portal: onboarding statuses + checklist
This commit is contained in:
parent
1cb12dd6c6
commit
2c52a23d8f
@ -39,12 +39,28 @@ def ensure_schema() -> None:
|
|||||||
)
|
)
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS access_request_onboarding_steps (
|
||||||
|
request_code TEXT NOT NULL REFERENCES access_requests(request_code) ON DELETE CASCADE,
|
||||||
|
step TEXT NOT NULL,
|
||||||
|
completed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
PRIMARY KEY (request_code, step)
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""
|
"""
|
||||||
CREATE INDEX IF NOT EXISTS access_requests_status_created_at
|
CREATE INDEX IF NOT EXISTS access_requests_status_created_at
|
||||||
ON access_requests (status, created_at)
|
ON access_requests (status, created_at)
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS access_request_onboarding_steps_request_code
|
||||||
|
ON access_request_onboarding_steps (request_code)
|
||||||
|
"""
|
||||||
|
)
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""
|
"""
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS access_requests_username_pending
|
CREATE UNIQUE INDEX IF NOT EXISTS access_requests_username_pending
|
||||||
|
|||||||
@ -5,12 +5,12 @@ import secrets
|
|||||||
import string
|
import string
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from flask import jsonify, request
|
from flask import jsonify, request, g
|
||||||
|
|
||||||
import psycopg
|
import psycopg
|
||||||
|
|
||||||
from ..db import connect, configured
|
from ..db import connect, configured
|
||||||
from ..keycloak import admin_client
|
from ..keycloak import admin_client, require_auth
|
||||||
from ..rate_limit import rate_limit_allow
|
from ..rate_limit import rate_limit_allow
|
||||||
from .. import settings
|
from .. import settings
|
||||||
|
|
||||||
@ -38,6 +38,66 @@ def _client_ip() -> str:
|
|||||||
return request.remote_addr or "unknown"
|
return request.remote_addr or "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
ONBOARDING_STEPS: tuple[str, ...] = (
|
||||||
|
"vaultwarden_master_password",
|
||||||
|
"element_recovery_key",
|
||||||
|
"element_recovery_key_stored",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_status(status: str) -> str:
|
||||||
|
cleaned = (status or "").strip().lower()
|
||||||
|
if cleaned == "approved":
|
||||||
|
return "accounts_building"
|
||||||
|
return cleaned or "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_completed_onboarding_steps(conn, request_code: str) -> set[str]:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT step FROM access_request_onboarding_steps WHERE request_code = %s",
|
||||||
|
(request_code,),
|
||||||
|
).fetchall()
|
||||||
|
completed: set[str] = set()
|
||||||
|
for row in rows:
|
||||||
|
step = row.get("step") if isinstance(row, dict) else None
|
||||||
|
if isinstance(step, str) and step:
|
||||||
|
completed.add(step)
|
||||||
|
return completed
|
||||||
|
|
||||||
|
|
||||||
|
def _automation_ready(username: str) -> bool:
|
||||||
|
if not username:
|
||||||
|
return False
|
||||||
|
if not admin_client().ready():
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
return bool(admin_client().find_user(username))
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _advance_status(conn, request_code: str, username: str, status: str) -> str:
|
||||||
|
status = _normalize_status(status)
|
||||||
|
|
||||||
|
if status == "accounts_building" and _automation_ready(username):
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE access_requests SET status = 'awaiting_onboarding' WHERE request_code = %s AND status = 'accounts_building'",
|
||||||
|
(request_code,),
|
||||||
|
)
|
||||||
|
return "awaiting_onboarding"
|
||||||
|
|
||||||
|
if status == "awaiting_onboarding":
|
||||||
|
completed = _fetch_completed_onboarding_steps(conn, request_code)
|
||||||
|
if set(ONBOARDING_STEPS).issubset(completed):
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE access_requests SET status = 'ready' WHERE request_code = %s AND status = 'awaiting_onboarding'",
|
||||||
|
(request_code,),
|
||||||
|
)
|
||||||
|
return "ready"
|
||||||
|
|
||||||
|
return status
|
||||||
|
|
||||||
|
|
||||||
def register(app) -> None:
|
def register(app) -> None:
|
||||||
@app.route("/api/access/request", methods=["POST"])
|
@app.route("/api/access/request", methods=["POST"])
|
||||||
def request_access() -> Any:
|
def request_access() -> Any:
|
||||||
@ -157,14 +217,88 @@ def register(app) -> None:
|
|||||||
).fetchone()
|
).fetchone()
|
||||||
if not row:
|
if not row:
|
||||||
return jsonify({"error": "not found"}), 404
|
return jsonify({"error": "not found"}), 404
|
||||||
status = row["status"] or "unknown"
|
status = _advance_status(conn, code, row.get("username") or "", row.get("status") or "")
|
||||||
response: dict[str, Any] = {
|
response: dict[str, Any] = {
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"status": status,
|
"status": status,
|
||||||
"username": row.get("username") or "",
|
"username": row.get("username") or "",
|
||||||
}
|
}
|
||||||
if status == "approved":
|
if status in {"accounts_building", "awaiting_onboarding", "ready"}:
|
||||||
response["onboarding_url"] = f"/onboarding?code={code}"
|
response["onboarding_url"] = f"/onboarding?code={code}"
|
||||||
|
if status in {"awaiting_onboarding", "ready"}:
|
||||||
|
completed = sorted(_fetch_completed_onboarding_steps(conn, code))
|
||||||
|
response["onboarding"] = {
|
||||||
|
"required_steps": list(ONBOARDING_STEPS),
|
||||||
|
"completed_steps": completed,
|
||||||
|
}
|
||||||
return jsonify(response)
|
return jsonify(response)
|
||||||
except Exception:
|
except Exception:
|
||||||
return jsonify({"error": "failed to load status"}), 502
|
return jsonify({"error": "failed to load status"}), 502
|
||||||
|
|
||||||
|
@app.route("/api/access/request/onboarding/attest", methods=["POST"])
|
||||||
|
@require_auth
|
||||||
|
def request_access_onboarding_attest() -> Any:
|
||||||
|
if not configured():
|
||||||
|
return jsonify({"error": "server not configured"}), 503
|
||||||
|
|
||||||
|
payload = request.get_json(silent=True) or {}
|
||||||
|
code = (payload.get("request_code") or payload.get("code") or "").strip()
|
||||||
|
step = (payload.get("step") or "").strip()
|
||||||
|
completed = payload.get("completed")
|
||||||
|
|
||||||
|
if not code:
|
||||||
|
return jsonify({"error": "request_code is required"}), 400
|
||||||
|
if step not in ONBOARDING_STEPS:
|
||||||
|
return jsonify({"error": "invalid step"}), 400
|
||||||
|
|
||||||
|
username = getattr(g, "keycloak_username", "") or ""
|
||||||
|
if not username:
|
||||||
|
return jsonify({"error": "invalid token"}), 401
|
||||||
|
|
||||||
|
try:
|
||||||
|
with connect() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT username, status FROM access_requests WHERE request_code = %s",
|
||||||
|
(code,),
|
||||||
|
).fetchone()
|
||||||
|
if not row:
|
||||||
|
return jsonify({"error": "not found"}), 404
|
||||||
|
if (row.get("username") or "") != username:
|
||||||
|
return jsonify({"error": "forbidden"}), 403
|
||||||
|
|
||||||
|
status = _normalize_status(row.get("status") or "")
|
||||||
|
if status not in {"awaiting_onboarding", "ready"}:
|
||||||
|
return jsonify({"error": "onboarding not available"}), 409
|
||||||
|
|
||||||
|
mark_done = True
|
||||||
|
if isinstance(completed, bool):
|
||||||
|
mark_done = completed
|
||||||
|
|
||||||
|
if mark_done:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO access_request_onboarding_steps (request_code, step)
|
||||||
|
VALUES (%s, %s)
|
||||||
|
ON CONFLICT (request_code, step) DO NOTHING
|
||||||
|
""",
|
||||||
|
(code, step),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
conn.execute(
|
||||||
|
"DELETE FROM access_request_onboarding_steps WHERE request_code = %s AND step = %s",
|
||||||
|
(code, step),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Re-evaluate completion to update request status to ready if applicable.
|
||||||
|
status = _advance_status(conn, code, username, status)
|
||||||
|
completed_steps = sorted(_fetch_completed_onboarding_steps(conn, code))
|
||||||
|
except Exception:
|
||||||
|
return jsonify({"error": "failed to update onboarding"}), 502
|
||||||
|
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"ok": True,
|
||||||
|
"status": status,
|
||||||
|
"onboarding": {"required_steps": list(ONBOARDING_STEPS), "completed_steps": completed_steps},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|||||||
@ -61,7 +61,7 @@ def register(app) -> None:
|
|||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
"""
|
"""
|
||||||
UPDATE access_requests
|
UPDATE access_requests
|
||||||
SET status = 'approved', decided_at = NOW(), decided_by = %s
|
SET status = 'accounts_building', decided_at = NOW(), decided_by = %s
|
||||||
WHERE username = %s AND status = 'pending'
|
WHERE username = %s AND status = 'pending'
|
||||||
RETURNING request_code
|
RETURNING request_code
|
||||||
""",
|
""",
|
||||||
|
|||||||
@ -34,11 +34,26 @@
|
|||||||
color: rgba(170, 255, 215, 0.92);
|
color: rgba(170, 255, 215, 0.92);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pill-info {
|
||||||
|
border-color: rgba(120, 180, 255, 0.42);
|
||||||
|
color: rgba(185, 225, 255, 0.92);
|
||||||
|
}
|
||||||
|
|
||||||
.pill-warn {
|
.pill-warn {
|
||||||
border-color: rgba(255, 220, 120, 0.35);
|
border-color: rgba(255, 220, 120, 0.35);
|
||||||
color: rgba(255, 230, 170, 0.92);
|
color: rgba(255, 230, 170, 0.92);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pill-wait {
|
||||||
|
border-color: rgba(255, 170, 80, 0.42);
|
||||||
|
color: rgba(255, 210, 170, 0.92);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pill-bad {
|
||||||
|
border-color: rgba(255, 96, 96, 0.45);
|
||||||
|
color: rgba(255, 170, 170, 0.92);
|
||||||
|
}
|
||||||
|
|
||||||
.card {
|
.card {
|
||||||
background: var(--bg-panel);
|
background: var(--bg-panel);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
|
|||||||
@ -11,8 +11,8 @@
|
|||||||
<section class="card module">
|
<section class="card module">
|
||||||
<div class="module-head">
|
<div class="module-head">
|
||||||
<h2>Request Code</h2>
|
<h2>Request Code</h2>
|
||||||
<span class="pill mono" :class="status ? 'pill-ok' : 'pill-warn'">
|
<span class="pill mono" :class="statusPillClass(status)">
|
||||||
{{ status || "unknown" }}
|
{{ statusLabel(status) }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -23,13 +23,88 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="status === 'approved'" class="steps">
|
<div v-if="status === 'pending'" class="steps">
|
||||||
<h3>Next steps</h3>
|
<h3>Awaiting approval</h3>
|
||||||
<ol>
|
<p class="muted">An Atlas admin has to approve this request before an account can be provisioned.</p>
|
||||||
<li>Log in at <a href="https://cloud.bstein.dev" target="_blank" rel="noreferrer">cloud.bstein.dev</a>.</li>
|
</div>
|
||||||
<li>Use your Keycloak username/password to access services.</li>
|
|
||||||
<li>If something doesn't work, contact the Atlas admin.</li>
|
<div v-if="status === 'accounts_building'" class="steps">
|
||||||
</ol>
|
<h3>Accounts building</h3>
|
||||||
|
<p class="muted">
|
||||||
|
Your request is approved. Atlas is now preparing accounts and credentials. Check back in a minute.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="status === 'awaiting_onboarding' || status === 'ready'" class="steps">
|
||||||
|
<div class="onboarding-head">
|
||||||
|
<h3>Onboarding checklist</h3>
|
||||||
|
<span class="pill mono" :class="status === 'ready' ? 'pill-info' : 'pill-ok'">
|
||||||
|
{{ status === "ready" ? "ready" : "in progress" }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="muted">
|
||||||
|
Atlas can't reliably verify these steps automatically yet. Mark them complete once you're done.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div v-if="!auth.authenticated" class="login-callout">
|
||||||
|
<p class="muted">Log in to check off onboarding steps.</p>
|
||||||
|
<button class="primary" type="button" @click="loginToContinue" :disabled="loading">Log in</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul class="checklist">
|
||||||
|
<li class="check-item">
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
:checked="isStepDone('vaultwarden_master_password')"
|
||||||
|
:disabled="!auth.authenticated || loading"
|
||||||
|
@change="toggleStep('vaultwarden_master_password', $event)"
|
||||||
|
/>
|
||||||
|
<span>Set a Vaultwarden master password</span>
|
||||||
|
</label>
|
||||||
|
<p class="muted">
|
||||||
|
Open <a href="https://vault.bstein.dev" target="_blank" rel="noreferrer">Passwords</a> and set a strong master
|
||||||
|
password you won't forget.
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li class="check-item">
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
:checked="isStepDone('element_recovery_key')"
|
||||||
|
:disabled="!auth.authenticated || loading"
|
||||||
|
@change="toggleStep('element_recovery_key', $event)"
|
||||||
|
/>
|
||||||
|
<span>Create an Element recovery key</span>
|
||||||
|
</label>
|
||||||
|
<p class="muted">
|
||||||
|
In Element, create a recovery key so you can restore encrypted history if you lose a device.
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li class="check-item">
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
:checked="isStepDone('element_recovery_key_stored')"
|
||||||
|
:disabled="!auth.authenticated || loading"
|
||||||
|
@change="toggleStep('element_recovery_key_stored', $event)"
|
||||||
|
/>
|
||||||
|
<span>Store the recovery key in Vaultwarden</span>
|
||||||
|
</label>
|
||||||
|
<p class="muted">Save the recovery key in Vaultwarden so it doesn't get lost.</p>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div v-if="status === 'ready'" class="ready-box">
|
||||||
|
<h3>You're ready</h3>
|
||||||
|
<p class="muted">
|
||||||
|
Your Atlas account is provisioned and onboarding is complete. You can log in at
|
||||||
|
<a href="https://cloud.bstein.dev" target="_blank" rel="noreferrer">cloud.bstein.dev</a>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="status === 'denied'" class="steps">
|
<div v-if="status === 'denied'" class="steps">
|
||||||
@ -47,6 +122,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { onMounted, ref } from "vue";
|
import { onMounted, ref } from "vue";
|
||||||
import { useRoute } from "vue-router";
|
import { useRoute } from "vue-router";
|
||||||
|
import { auth, authFetch, login } from "../auth";
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
|
|
||||||
@ -54,6 +130,32 @@ const requestCode = ref("");
|
|||||||
const status = ref("");
|
const status = ref("");
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const error = ref("");
|
const error = ref("");
|
||||||
|
const onboarding = ref({ required_steps: [], completed_steps: [] });
|
||||||
|
|
||||||
|
function statusLabel(value) {
|
||||||
|
const key = (value || "").trim();
|
||||||
|
if (key === "pending") return "awaiting approval";
|
||||||
|
if (key === "accounts_building") return "accounts building";
|
||||||
|
if (key === "awaiting_onboarding") return "awaiting onboarding";
|
||||||
|
if (key === "ready") return "ready";
|
||||||
|
if (key === "denied") return "rejected";
|
||||||
|
return key || "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusPillClass(value) {
|
||||||
|
const key = (value || "").trim();
|
||||||
|
if (key === "pending") return "pill-wait";
|
||||||
|
if (key === "accounts_building") return "pill-warn";
|
||||||
|
if (key === "awaiting_onboarding") return "pill-ok";
|
||||||
|
if (key === "ready") return "pill-info";
|
||||||
|
if (key === "denied") return "pill-bad";
|
||||||
|
return "pill-warn";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isStepDone(step) {
|
||||||
|
const steps = onboarding.value?.completed_steps || [];
|
||||||
|
return Array.isArray(steps) ? steps.includes(step) : false;
|
||||||
|
}
|
||||||
|
|
||||||
async function check() {
|
async function check() {
|
||||||
if (loading.value) return;
|
if (loading.value) return;
|
||||||
@ -68,6 +170,7 @@ async function check() {
|
|||||||
const data = await resp.json().catch(() => ({}));
|
const data = await resp.json().catch(() => ({}));
|
||||||
if (!resp.ok) throw new Error(data.error || resp.statusText || `status ${resp.status}`);
|
if (!resp.ok) throw new Error(data.error || resp.statusText || `status ${resp.status}`);
|
||||||
status.value = data.status || "unknown";
|
status.value = data.status || "unknown";
|
||||||
|
onboarding.value = data.onboarding || { required_steps: [], completed_steps: [] };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error.value = err.message || "Failed to check status";
|
error.value = err.message || "Failed to check status";
|
||||||
} finally {
|
} finally {
|
||||||
@ -75,6 +178,35 @@ async function check() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loginToContinue() {
|
||||||
|
await login(`/onboarding?code=${encodeURIComponent(requestCode.value.trim())}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleStep(step, event) {
|
||||||
|
const checked = Boolean(event?.target?.checked);
|
||||||
|
if (!auth.authenticated) {
|
||||||
|
event?.preventDefault?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
error.value = "";
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const resp = await authFetch("/api/access/request/onboarding/attest", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ request_code: requestCode.value.trim(), step, completed: checked }),
|
||||||
|
});
|
||||||
|
const data = await resp.json().catch(() => ({}));
|
||||||
|
if (!resp.ok) throw new Error(data.error || resp.statusText || `status ${resp.status}`);
|
||||||
|
status.value = data.status || status.value;
|
||||||
|
onboarding.value = data.onboarding || onboarding.value;
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err.message || "Failed to update onboarding";
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
const code = route.query.code || route.query.request_code || "";
|
const code = route.query.code || route.query.request_code || "";
|
||||||
if (typeof code === "string" && code.trim()) {
|
if (typeof code === "string" && code.trim()) {
|
||||||
@ -159,6 +291,62 @@ button.primary {
|
|||||||
margin: 0 0 8px;
|
margin: 0 0 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.onboarding-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-callout {
|
||||||
|
margin-top: 10px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 14px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
background: rgba(0, 0, 0, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.checklist {
|
||||||
|
margin: 14px 0 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-item {
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 12px 12px 10px;
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-item label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
font-weight: 650;
|
||||||
|
color: var(--text-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-item input[type="checkbox"] {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ready-box {
|
||||||
|
margin-top: 14px;
|
||||||
|
padding: 14px;
|
||||||
|
border-radius: 14px;
|
||||||
|
border: 1px solid rgba(120, 180, 255, 0.25);
|
||||||
|
background: rgba(120, 180, 255, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
.steps ol {
|
.steps ol {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding-left: 18px;
|
padding-left: 18px;
|
||||||
@ -177,4 +365,3 @@ button.primary {
|
|||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
|||||||
@ -84,13 +84,13 @@
|
|||||||
<div class="card module status-module">
|
<div class="card module status-module">
|
||||||
<div class="module-head">
|
<div class="module-head">
|
||||||
<h2>Check status</h2>
|
<h2>Check status</h2>
|
||||||
<span class="pill mono" :class="status ? 'pill-ok' : 'pill-warn'">
|
<span class="pill mono" :class="statusPillClass(status)">
|
||||||
{{ status || "unknown" }}
|
{{ statusLabel(status) }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="muted">
|
<p class="muted">
|
||||||
Enter your request code to see whether it is pending, approved, or denied.
|
Enter your request code to see whether it is awaiting approval, building accounts, awaiting onboarding, ready, or rejected.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="status-form">
|
<div class="status-form">
|
||||||
@ -106,7 +106,7 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="status === 'approved' && onboardingUrl" class="actions" style="margin-top: 12px;">
|
<div v-if="onboardingUrl" class="actions" style="margin-top: 12px;">
|
||||||
<a class="primary" :href="onboardingUrl">Continue onboarding</a>
|
<a class="primary" :href="onboardingUrl">Continue onboarding</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -121,6 +121,26 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { reactive, ref } from "vue";
|
import { reactive, ref } from "vue";
|
||||||
|
|
||||||
|
function statusLabel(value) {
|
||||||
|
const key = (value || "").trim();
|
||||||
|
if (key === "pending") return "awaiting approval";
|
||||||
|
if (key === "accounts_building") return "accounts building";
|
||||||
|
if (key === "awaiting_onboarding") return "awaiting onboarding";
|
||||||
|
if (key === "ready") return "ready";
|
||||||
|
if (key === "denied") return "rejected";
|
||||||
|
return key || "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusPillClass(value) {
|
||||||
|
const key = (value || "").trim();
|
||||||
|
if (key === "pending") return "pill-wait";
|
||||||
|
if (key === "accounts_building") return "pill-warn";
|
||||||
|
if (key === "awaiting_onboarding") return "pill-ok";
|
||||||
|
if (key === "ready") return "pill-info";
|
||||||
|
if (key === "denied") return "pill-bad";
|
||||||
|
return "pill-warn";
|
||||||
|
}
|
||||||
|
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
username: "",
|
username: "",
|
||||||
email: "",
|
email: "",
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user