onboarding: relax confirm flow and add element guides

This commit is contained in:
Brad Stein 2026-01-23 03:10:54 -03:00
parent 8a3377959c
commit 1b6e58f782
20 changed files with 85 additions and 238 deletions

View File

@ -9,13 +9,13 @@ import string
from typing import Any
from urllib.parse import quote
from flask import jsonify, request, g, redirect
from flask import jsonify, request, redirect
import psycopg
from .. import ariadne_client
from ..db import connect, configured
from ..keycloak import admin_client, oidc_client, require_auth
from ..keycloak import admin_client, oidc_client
from ..mailer import MailerError, access_request_verification_body, send_text_email
from ..rate_limit import rate_limit_allow
from ..provisioning import provision_access_request, provision_tasks_complete
@ -152,7 +152,6 @@ ONBOARDING_STEPS: tuple[str, ...] = (
"vaultwarden_mobile_app",
"keycloak_password_rotated",
"element_recovery_key",
"element_recovery_key_stored",
"element_mobile_app",
"mail_client_setup",
"nextcloud_web_access",
@ -181,7 +180,6 @@ ONBOARDING_REQUIRED_STEPS: tuple[str, ...] = (
"vaultwarden_mobile_app",
"keycloak_password_rotated",
"element_recovery_key",
"element_recovery_key_stored",
"mail_client_setup",
"nextcloud_web_access",
"nextcloud_mail_integration",
@ -204,7 +202,6 @@ ONBOARDING_STEP_PREREQUISITES: dict[str, set[str]] = {
"vaultwarden_mobile_app": {"vaultwarden_master_password"},
"keycloak_password_rotated": {"vaultwarden_master_password"},
"element_recovery_key": {"keycloak_password_rotated"},
"element_recovery_key_stored": {"element_recovery_key"},
"element_mobile_app": {"element_recovery_key"},
"mail_client_setup": {"vaultwarden_master_password"},
"nextcloud_web_access": {"vaultwarden_master_password"},
@ -212,15 +209,13 @@ ONBOARDING_STEP_PREREQUISITES: dict[str, set[str]] = {
"nextcloud_desktop_app": {"nextcloud_web_access"},
"nextcloud_mobile_app": {"nextcloud_web_access"},
"budget_encryption_ack": {"nextcloud_mail_integration"},
"firefly_password_rotated": {"element_recovery_key_stored"},
"firefly_password_rotated": {"element_recovery_key"},
"wger_password_rotated": {"firefly_password_rotated"},
"jellyfin_web_access": {"vaultwarden_master_password"},
"jellyfin_mobile_app": {"jellyfin_web_access"},
"jellyfin_tv_setup": {"jellyfin_web_access"},
}
_ELEMENT_RECOVERY_ARTIFACT = "element_recovery_key_sha256"
_SHA256_HEX_RE = re.compile(r"^[0-9a-f]{64}$")
_VAULTWARDEN_READY_STATUSES = {"already_present", "active", "ready"}
@ -959,14 +954,9 @@ def register(app) -> None:
mark_done = completed
if mark_done:
if step == "element_recovery_key":
return (
jsonify({"error": "step requires verification"}),
400,
)
prerequisites = ONBOARDING_STEP_PREREQUISITES.get(step, set())
if prerequisites:
current_completed = _completed_onboarding_steps(conn, code, username)
current_completed = _completed_onboarding_steps(conn, code, row.get("username") or "")
missing = sorted(prerequisites - current_completed)
if missing:
return jsonify({"error": "step is blocked", "blocked_by": missing}), 409
@ -989,15 +979,11 @@ def register(app) -> None:
"DELETE FROM access_request_onboarding_steps WHERE request_code = %s AND step = %s",
(code, step),
)
if step == "element_recovery_key":
conn.execute(
"DELETE FROM access_request_onboarding_artifacts WHERE request_code = %s AND artifact = %s",
(code, _ELEMENT_RECOVERY_ARTIFACT),
)
# Re-evaluate completion to update request status to ready if applicable.
status = _advance_status(conn, code, username, status)
onboarding_payload = _onboarding_payload(conn, code, username)
request_username = row.get("username") or ""
status = _advance_status(conn, code, request_username, status)
onboarding_payload = _onboarding_payload(conn, code, request_username)
except Exception:
return jsonify({"error": "failed to update onboarding"}), 502
@ -1009,83 +995,7 @@ def register(app) -> None:
}
)
@app.route("/api/access/request/onboarding/element-recovery", methods=["POST"])
@require_auth
def request_access_onboarding_element_recovery() -> 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()
sha256_hex = (payload.get("sha256") or payload.get("sha256_hex") or "").strip().lower()
if not code:
return jsonify({"error": "request_code is required"}), 400
if not sha256_hex:
return jsonify({"error": "sha256 is required"}), 400
if not _SHA256_HEX_RE.fullmatch(sha256_hex):
return jsonify({"error": "invalid sha256"}), 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
prerequisites = ONBOARDING_STEP_PREREQUISITES.get("element_recovery_key", set())
if prerequisites:
current_completed = _completed_onboarding_steps(conn, code, username)
missing = sorted(prerequisites - current_completed)
if missing:
return jsonify({"error": "step is blocked", "blocked_by": missing}), 409
conn.execute(
"""
INSERT INTO access_request_onboarding_artifacts (request_code, artifact, value_hash)
VALUES (%s, %s, %s)
ON CONFLICT (request_code, artifact) DO UPDATE
SET value_hash = EXCLUDED.value_hash,
created_at = NOW()
""",
(code, _ELEMENT_RECOVERY_ARTIFACT, sha256_hex),
)
conn.execute(
"""
INSERT INTO access_request_onboarding_steps (request_code, step)
VALUES (%s, %s)
ON CONFLICT (request_code, step) DO NOTHING
""",
(code, "element_recovery_key"),
)
status = _advance_status(conn, code, username, status)
onboarding_payload = _onboarding_payload(conn, code, username)
except Exception:
return jsonify({"error": "failed to verify element recovery key"}), 502
return jsonify(
{
"ok": True,
"status": status,
"onboarding": onboarding_payload,
}
)
@app.route("/api/access/request/onboarding/keycloak-password-rotate", methods=["POST"])
@require_auth
def request_access_onboarding_keycloak_password_rotate() -> Any:
if not configured():
return jsonify({"error": "server not configured"}), 503
@ -1095,9 +1005,20 @@ def register(app) -> None:
if not code:
return jsonify({"error": "request_code is required"}), 400
username = getattr(g, "keycloak_username", "") or ""
if not username:
return jsonify({"error": "invalid token"}), 401
token_username = ""
bearer = request.headers.get("Authorization", "")
if bearer:
parts = bearer.split(None, 1)
if len(parts) != 2 or parts[0].lower() != "bearer":
return jsonify({"error": "invalid token"}), 401
token = parts[1].strip()
if not token:
return jsonify({"error": "invalid token"}), 401
try:
claims = oidc_client().verify(token)
except Exception:
return jsonify({"error": "invalid token"}), 401
token_username = claims.get("preferred_username") or ""
if not admin_client().ready():
return jsonify({"error": "keycloak admin unavailable"}), 503
@ -1110,7 +1031,8 @@ def register(app) -> None:
).fetchone()
if not row:
return jsonify({"error": "not found"}), 404
if (row.get("username") or "") != username:
request_username = row.get("username") or ""
if token_username and request_username != token_username:
return jsonify({"error": "forbidden"}), 403
status = _normalize_status(row.get("status") or "")
@ -1119,12 +1041,12 @@ def register(app) -> None:
prerequisites = ONBOARDING_STEP_PREREQUISITES.get("keycloak_password_rotated", set())
if prerequisites:
current_completed = _completed_onboarding_steps(conn, code, username)
current_completed = _completed_onboarding_steps(conn, code, request_username)
missing = sorted(prerequisites - current_completed)
if missing:
return jsonify({"error": "step is blocked", "blocked_by": missing}), 409
user = admin_client().find_user(username) or {}
user = admin_client().find_user(request_username) or {}
user_id = user.get("id") if isinstance(user, dict) else None
if not isinstance(user_id, str) or not user_id:
return jsonify({"error": "keycloak user not found"}), 409
@ -1147,7 +1069,7 @@ def register(app) -> None:
(code, _KEYCLOAK_PASSWORD_ROTATION_REQUESTED_ARTIFACT),
)
onboarding_payload = _onboarding_payload(conn, code, username)
onboarding_payload = _onboarding_payload(conn, code, request_username)
except Exception:
return jsonify({"error": "failed to request password rotation"}), 502

View File

@ -161,7 +161,7 @@
<input
type="checkbox"
:checked="isStepDone(step.id)"
:disabled="!auth.authenticated || loading || isStepBlocked(step.id)"
:disabled="loading || isStepBlocked(step.id)"
@change="toggleStep(step.id, $event)"
/>
<span>{{ step.title }}</span>
@ -233,56 +233,7 @@
<p v-else class="muted">Guide coming soon.</p>
</details>
<div v-if="step.action === 'element_recovery'" class="recovery-verify">
<input
v-model="elementRecoveryKey"
class="input mono"
type="text"
placeholder="Paste recovery key (hashed locally)"
:disabled="!auth.authenticated || loading || isStepDone(step.id) || isStepBlocked(step.id)"
/>
<button
class="primary verify"
type="button"
@click="verifyElementRecoveryKey"
:disabled="
!auth.authenticated ||
loading ||
isStepDone(step.id) ||
isStepBlocked(step.id) ||
!elementRecoveryKey.trim()
"
>
Verify
</button>
<button
class="secondary"
type="button"
@click="confirmStep(step)"
:disabled="loading || isStepDone(step.id) || isStepBlocked(step.id) || !elementRecoveryKey.trim()"
>
{{ confirmLabel(step) }}
</button>
</div>
<div v-else class="step-actions">
<template v-if="step.action === 'keycloak_rotate'">
<button
class="secondary"
type="button"
@click="requestKeycloakPasswordRotation"
:disabled="
!auth.authenticated ||
loading ||
isStepDone('keycloak_password_rotated') ||
isStepBlocked('keycloak_password_rotated') ||
keycloakPasswordRotationRequested
"
>
Start Keycloak update
</button>
<a class="mono" href="https://live.bstein.dev" target="_blank" rel="noreferrer">Open Element</a>
</template>
<div class="step-actions">
<button
class="secondary"
type="button"
@ -361,7 +312,6 @@ const passwordCopied = ref(false);
const usernameCopied = ref(false);
const tasks = ref([]);
const blocked = ref(false);
const elementRecoveryKey = ref("");
const keycloakPasswordRotationRequested = ref(false);
const activeSectionId = ref("vaultwarden");
const guideShots = ref({});
@ -383,7 +333,6 @@ const STEP_PREREQS = {
vaultwarden_mobile_app: ["vaultwarden_master_password"],
keycloak_password_rotated: ["vaultwarden_master_password"],
element_recovery_key: ["keycloak_password_rotated"],
element_recovery_key_stored: ["element_recovery_key"],
element_mobile_app: ["element_recovery_key"],
mail_client_setup: ["vaultwarden_master_password"],
nextcloud_web_access: ["vaultwarden_master_password"],
@ -391,7 +340,7 @@ const STEP_PREREQS = {
nextcloud_desktop_app: ["nextcloud_web_access"],
nextcloud_mobile_app: ["nextcloud_web_access"],
budget_encryption_ack: ["nextcloud_mail_integration"],
firefly_password_rotated: ["element_recovery_key_stored"],
firefly_password_rotated: ["element_recovery_key"],
wger_password_rotated: ["firefly_password_rotated"],
jellyfin_web_access: ["vaultwarden_master_password"],
jellyfin_mobile_app: ["jellyfin_web_access"],
@ -448,12 +397,12 @@ const SECTION_DEFS = [
{
id: "element",
title: "Element",
description: "Rotate your Keycloak password, create a recovery key, and store it safely.",
description: "Secure chat, calls, and video for the lab.",
steps: [
{
id: "keycloak_password_rotated",
title: "Rotate your Keycloak password",
action: "keycloak_rotate",
title: "Connect to Element web",
action: "confirm",
description:
"Sign in to Element with the temporary password. Keycloak will prompt you to set a new password. Store the new password in Vaultwarden.",
links: [
@ -464,18 +413,12 @@ const SECTION_DEFS = [
},
{
id: "element_recovery_key",
title: "Create and verify your recovery key",
action: "element_recovery",
title: "Create your recovery key",
action: "confirm",
description:
"In Element settings → Encryption, create a recovery key so you can restore encrypted history if you lose a device.",
"In Element settings → Encryption, create a recovery key and store it in Vaultwarden.",
guide: { service: "element", step: "step2_record_recovery_key" },
},
{
id: "element_recovery_key_stored",
title: "Store the recovery key in Vaultwarden",
action: "checkbox",
description: "Save the recovery key in Vaultwarden so it never gets lost.",
},
{
id: "element_mobile_app",
title: "Optional: install Element X on mobile",
@ -759,7 +702,8 @@ function stepCardClass(step) {
function sectionProgress(section) {
const requiredSteps = section.steps.filter((step) => isStepRequired(step.id));
if (!requiredSteps.length) return "optional";
const doneCount = requiredSteps.filter((step) => isStepDone(step.id)).length;
if (isSectionLocked(section)) return `0/${requiredSteps.length} done`;
const doneCount = requiredSteps.filter((step) => isStepDone(step.id) && !isStepBlocked(step.id)).length;
return `${doneCount}/${requiredSteps.length} done`;
}
@ -951,18 +895,22 @@ async function setStepCompletion(stepId, completed) {
if (isStepBlocked(stepId)) {
return;
}
if (stepId === "element_recovery_key") {
return;
}
loading.value = true;
error.value = "";
try {
const requester = auth.authenticated ? authFetch : fetch;
const resp = await requester("/api/access/request/onboarding/attest", {
let resp = await requester("/api/access/request/onboarding/attest", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ request_code: requestCode.value.trim(), step: stepId, completed }),
});
if (resp.status === 401 && requester === authFetch) {
resp = await fetch("/api/access/request/onboarding/attest", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ request_code: requestCode.value.trim(), step: stepId, completed }),
});
}
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;
@ -978,18 +926,15 @@ async function confirmStep(step) {
if (!step || isStepBlocked(step.id) || isStepDone(step.id)) return;
confirmingStepId.value = step.id;
try {
if (step.id === "keycloak_password_rotated") {
await requestKeycloakPasswordRotation();
await check();
return;
}
if (step.action === "auto") {
await check();
return;
}
if (step.action === "keycloak_rotate") {
await check();
return;
}
if (step.action === "element_recovery") {
await verifyElementRecoveryKey();
return;
}
if (step.action === "confirm") {
await check();
if (!isStepDone(step.id)) {
@ -1003,42 +948,9 @@ async function confirmStep(step) {
}
}
async function verifyElementRecoveryKey() {
if (!auth.authenticated) {
error.value = "Log in to verify your recovery key.";
return;
}
if (isStepBlocked("element_recovery_key")) {
error.value = "Complete earlier onboarding steps first.";
return;
}
const raw = elementRecoveryKey.value.trim();
if (!raw) return;
error.value = "";
loading.value = true;
try {
const sha256 = await sha256Hex(raw);
const resp = await authFetch("/api/access/request/onboarding/element-recovery", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ request_code: requestCode.value.trim(), sha256 }),
});
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;
elementRecoveryKey.value = "";
} catch (err) {
error.value = err?.message || "Failed to verify recovery key";
} finally {
loading.value = false;
}
}
async function requestKeycloakPasswordRotation() {
if (!auth.authenticated) {
error.value = "Log in to request password rotation.";
if (!requestCode.value.trim()) {
error.value = "Request code is missing.";
return;
}
if (isStepBlocked("keycloak_password_rotated")) {
@ -1050,11 +962,19 @@ async function requestKeycloakPasswordRotation() {
loading.value = true;
error.value = "";
try {
const resp = await authFetch("/api/access/request/onboarding/keycloak-password-rotate", {
const requester = auth.authenticated ? authFetch : fetch;
let resp = await requester("/api/access/request/onboarding/keycloak-password-rotate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ request_code: requestCode.value.trim() }),
});
if (resp.status === 401 && requester === authFetch) {
resp = await fetch("/api/access/request/onboarding/keycloak-password-rotate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ request_code: requestCode.value.trim() }),
});
}
const data = await resp.json().catch(() => ({}));
if (!resp.ok) throw new Error(data.error || resp.statusText || `status ${resp.status}`);
onboarding.value = data.onboarding || onboarding.value;
@ -1067,14 +987,6 @@ async function requestKeycloakPasswordRotation() {
}
}
async function sha256Hex(value) {
const data = new TextEncoder().encode(value);
const hash = await crypto.subtle.digest("SHA-256", data);
return Array.from(new Uint8Array(hash))
.map((byte) => byte.toString(16).padStart(2, "0"))
.join("");
}
function parseManifest(files) {
const grouped = {};
for (const path of files) {
@ -1507,7 +1419,13 @@ button.copy:disabled {
}
.step-links a {
color: var(--text-link);
color: var(--accent-cyan);
text-decoration: none;
font-weight: 600;
}
.step-links a:hover {
text-decoration: underline;
}
.step-actions {
@ -1554,21 +1472,28 @@ button.copy:disabled {
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(0, 0, 0, 0.2);
padding: 6px;
display: grid;
gap: 6px;
padding: 0;
position: relative;
cursor: zoom-in;
}
.guide-shot figcaption {
position: absolute;
top: 8px;
left: 8px;
right: 8px;
font-size: 12px;
color: var(--text-muted);
margin: 0 4px;
color: var(--text-strong);
background: rgba(0, 0, 0, 0.65);
padding: 4px 8px;
border-radius: 8px;
z-index: 2;
}
.guide-shot img {
width: 100%;
border-radius: 8px;
display: block;
border-radius: 10px;
}
.guide-pagination {
@ -1654,12 +1579,12 @@ button.copy:disabled {
}
.lightbox-card {
width: min(1100px, 92vw);
max-height: 92vh;
width: min(1400px, 96vw);
max-height: 94vh;
background: rgba(10, 14, 24, 0.96);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 16px;
padding: 14px;
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
@ -1678,7 +1603,7 @@ button.copy:disabled {
.lightbox-card img {
width: 100%;
max-height: 74vh;
max-height: 82vh;
object-fit: contain;
border-radius: 12px;
background: rgba(0, 0, 0, 0.35);

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB