portal: onboarding statuses + checklist

This commit is contained in:
Brad Stein 2026-01-02 09:42:06 -03:00
parent 1cb12dd6c6
commit 2c52a23d8f
6 changed files with 391 additions and 19 deletions

View File

@ -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(
"""
CREATE INDEX IF NOT EXISTS 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(
"""
CREATE UNIQUE INDEX IF NOT EXISTS access_requests_username_pending

View File

@ -5,12 +5,12 @@ import secrets
import string
from typing import Any
from flask import jsonify, request
from flask import jsonify, request, g
import psycopg
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 .. import settings
@ -38,6 +38,66 @@ def _client_ip() -> str:
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:
@app.route("/api/access/request", methods=["POST"])
def request_access() -> Any:
@ -157,14 +217,88 @@ def register(app) -> None:
).fetchone()
if not row:
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] = {
"ok": True,
"status": status,
"username": row.get("username") or "",
}
if status == "approved":
if status in {"accounts_building", "awaiting_onboarding", "ready"}:
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)
except Exception:
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},
}
)

View File

@ -61,7 +61,7 @@ def register(app) -> None:
row = conn.execute(
"""
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'
RETURNING request_code
""",

View File

@ -34,11 +34,26 @@
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 {
border-color: rgba(255, 220, 120, 0.35);
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 {
background: var(--bg-panel);
border: 1px solid var(--border);

View File

@ -11,8 +11,8 @@
<section class="card module">
<div class="module-head">
<h2>Request Code</h2>
<span class="pill mono" :class="status ? 'pill-ok' : 'pill-warn'">
{{ status || "unknown" }}
<span class="pill mono" :class="statusPillClass(status)">
{{ statusLabel(status) }}
</span>
</div>
@ -23,13 +23,88 @@
</button>
</div>
<div v-if="status === 'approved'" class="steps">
<h3>Next steps</h3>
<ol>
<li>Log in at <a href="https://cloud.bstein.dev" target="_blank" rel="noreferrer">cloud.bstein.dev</a>.</li>
<li>Use your Keycloak username/password to access services.</li>
<li>If something doesn't work, contact the Atlas admin.</li>
</ol>
<div v-if="status === 'pending'" class="steps">
<h3>Awaiting approval</h3>
<p class="muted">An Atlas admin has to approve this request before an account can be provisioned.</p>
</div>
<div v-if="status === 'accounts_building'" class="steps">
<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 v-if="status === 'denied'" class="steps">
@ -47,6 +122,7 @@
<script setup>
import { onMounted, ref } from "vue";
import { useRoute } from "vue-router";
import { auth, authFetch, login } from "../auth";
const route = useRoute();
@ -54,6 +130,32 @@ const requestCode = ref("");
const status = ref("");
const loading = ref(false);
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() {
if (loading.value) return;
@ -68,6 +170,7 @@ async function check() {
const data = await resp.json().catch(() => ({}));
if (!resp.ok) throw new Error(data.error || resp.statusText || `status ${resp.status}`);
status.value = data.status || "unknown";
onboarding.value = data.onboarding || { required_steps: [], completed_steps: [] };
} catch (err) {
error.value = err.message || "Failed to check status";
} 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 () => {
const code = route.query.code || route.query.request_code || "";
if (typeof code === "string" && code.trim()) {
@ -159,6 +291,62 @@ button.primary {
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 {
margin: 0;
padding-left: 18px;
@ -177,4 +365,3 @@ button.primary {
padding: 10px 12px;
}
</style>

View File

@ -84,13 +84,13 @@
<div class="card module status-module">
<div class="module-head">
<h2>Check status</h2>
<span class="pill mono" :class="status ? 'pill-ok' : 'pill-warn'">
{{ status || "unknown" }}
<span class="pill mono" :class="statusPillClass(status)">
{{ statusLabel(status) }}
</span>
</div>
<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>
<div class="status-form">
@ -106,7 +106,7 @@
</button>
</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>
</div>
</div>
@ -121,6 +121,26 @@
<script setup>
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({
username: "",
email: "",