portal: add onboarding stepper + budget section

This commit is contained in:
Brad Stein 2026-01-22 01:38:41 -03:00
parent a7a50619f3
commit ae79b74bf0
30 changed files with 1083 additions and 1149 deletions

View File

@ -4,7 +4,6 @@ node_modules
frontend/node_modules frontend/node_modules
frontend/dist frontend/dist
frontend/.vite frontend/.vite
media
docs docs
__pycache__ __pycache__
.venv .venv

View File

@ -6,6 +6,7 @@ COPY frontend/package*.json ./
RUN npm ci --ignore-scripts RUN npm ci --ignore-scripts
COPY frontend/ ./ COPY frontend/ ./
COPY media/ ./public/media/
RUN npm run build RUN npm run build
# Runtime stage # Runtime stage

View File

@ -148,66 +148,81 @@ def _verify_request(conn, code: str, token: str) -> str:
ONBOARDING_STEPS: tuple[str, ...] = ( ONBOARDING_STEPS: tuple[str, ...] = (
"vaultwarden_master_password", "vaultwarden_master_password",
"vaultwarden_browser_extension",
"vaultwarden_mobile_app",
"keycloak_password_rotated", "keycloak_password_rotated",
"element_recovery_key", "element_recovery_key",
"element_recovery_key_stored", "element_recovery_key_stored",
"firefly_login", "element_mobile_app",
"health_data_notice",
"wger_login",
"vaultwarden_browser_extension",
"vaultwarden_desktop_app",
"vaultwarden_mobile_app",
"elementx_setup",
"jellyfin_login",
"mail_client_setup", "mail_client_setup",
"actual_login", "nextcloud_web_access",
"outline_login", "nextcloud_mail_integration",
"planka_login", "nextcloud_desktop_app",
"keycloak_mfa_optional", "nextcloud_mobile_app",
"budget_encryption_ack",
"firefly_password_rotated",
"wger_password_rotated",
"jellyfin_web_access",
"jellyfin_mobile_app",
"jellyfin_tv_setup",
) )
ONBOARDING_OPTIONAL_STEPS: set[str] = { ONBOARDING_OPTIONAL_STEPS: set[str] = {
"vaultwarden_browser_extension", "element_mobile_app",
"vaultwarden_desktop_app", "nextcloud_desktop_app",
"vaultwarden_mobile_app", "nextcloud_mobile_app",
"elementx_setup", "jellyfin_web_access",
"jellyfin_login", "jellyfin_mobile_app",
"mail_client_setup", "jellyfin_tv_setup",
"actual_login",
"outline_login",
"planka_login",
"keycloak_mfa_optional",
} }
ONBOARDING_REQUIRED_STEPS: tuple[str, ...] = tuple( ONBOARDING_REQUIRED_STEPS: tuple[str, ...] = (
step for step in ONBOARDING_STEPS if step not in ONBOARDING_OPTIONAL_STEPS "vaultwarden_master_password",
"vaultwarden_browser_extension",
"vaultwarden_mobile_app",
"keycloak_password_rotated",
"element_recovery_key",
"element_recovery_key_stored",
"mail_client_setup",
"nextcloud_web_access",
"nextcloud_mail_integration",
"budget_encryption_ack",
"firefly_password_rotated",
"wger_password_rotated",
) )
KEYCLOAK_MANAGED_STEPS: set[str] = {"keycloak_password_rotated"} KEYCLOAK_MANAGED_STEPS: set[str] = {
_KEYCLOAK_MFA_OPTIONAL_STATE_ARTIFACT = "keycloak_mfa_optional_state" "keycloak_password_rotated",
_KEYCLOAK_MFA_OPTIONAL_VALID_STATES = {"done", "skipped"} "vaultwarden_master_password",
"nextcloud_mail_integration",
"firefly_password_rotated",
"wger_password_rotated",
}
_KEYCLOAK_PASSWORD_ROTATION_REQUESTED_ARTIFACT = "keycloak_password_rotation_requested_at" _KEYCLOAK_PASSWORD_ROTATION_REQUESTED_ARTIFACT = "keycloak_password_rotation_requested_at"
ONBOARDING_STEP_PREREQUISITES: dict[str, set[str]] = {
def _sequential_prerequisites( "vaultwarden_master_password": set(),
steps: tuple[str, ...], "vaultwarden_browser_extension": {"vaultwarden_master_password"},
optional_steps: set[str], "vaultwarden_mobile_app": {"vaultwarden_master_password"},
) -> dict[str, set[str]]: "keycloak_password_rotated": {"vaultwarden_master_password"},
completed: list[str] = [] "element_recovery_key": {"keycloak_password_rotated"},
prerequisites: dict[str, set[str]] = {} "element_recovery_key_stored": {"element_recovery_key"},
for step in steps: "element_mobile_app": {"element_recovery_key"},
prerequisites[step] = set(completed) "mail_client_setup": {"vaultwarden_master_password"},
if step not in optional_steps: "nextcloud_web_access": {"vaultwarden_master_password"},
completed.append(step) "nextcloud_mail_integration": {"nextcloud_web_access"},
return prerequisites "nextcloud_desktop_app": {"nextcloud_web_access"},
"nextcloud_mobile_app": {"nextcloud_web_access"},
"budget_encryption_ack": {"nextcloud_mail_integration"},
ONBOARDING_STEP_PREREQUISITES: dict[str, set[str]] = _sequential_prerequisites( "firefly_password_rotated": {"element_recovery_key_stored"},
ONBOARDING_STEPS, "wger_password_rotated": {"firefly_password_rotated"},
ONBOARDING_OPTIONAL_STEPS, "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" _ELEMENT_RECOVERY_ARTIFACT = "element_recovery_key_sha256"
_SHA256_HEX_RE = re.compile(r"^[0-9a-f]{64}$") _SHA256_HEX_RE = re.compile(r"^[0-9a-f]{64}$")
_VAULTWARDEN_READY_STATUSES = {"already_present", "active", "ready"}
def _normalize_status(status: str) -> str: def _normalize_status(status: str) -> str:
@ -243,6 +258,45 @@ def _password_rotation_requested(conn, request_code: str) -> bool:
return bool(row) return bool(row)
def _extract_attr(attrs: Any, key: str) -> str:
if not isinstance(attrs, dict):
return ""
raw = attrs.get(key)
if isinstance(raw, list):
for item in raw:
if isinstance(item, str) and item.strip():
return item.strip()
return ""
if isinstance(raw, str) and raw.strip():
return raw.strip()
return ""
def _auto_completed_service_steps(attrs: Any) -> set[str]:
completed: set[str] = set()
if not isinstance(attrs, dict):
return completed
vaultwarden_status = _extract_attr(attrs, "vaultwarden_status")
vaultwarden_master = _extract_attr(attrs, "vaultwarden_master_password_set_at")
if vaultwarden_master or vaultwarden_status in _VAULTWARDEN_READY_STATUSES:
completed.add("vaultwarden_master_password")
nextcloud_synced_at = _extract_attr(attrs, "nextcloud_mail_synced_at")
if nextcloud_synced_at:
completed.add("nextcloud_mail_integration")
firefly_rotated_at = _extract_attr(attrs, "firefly_password_rotated_at")
if firefly_rotated_at:
completed.add("firefly_password_rotated")
wger_rotated_at = _extract_attr(attrs, "wger_password_rotated_at")
if wger_rotated_at:
completed.add("wger_password_rotated")
return completed
def _auto_completed_keycloak_steps(conn, request_code: str, username: str) -> set[str]: def _auto_completed_keycloak_steps(conn, request_code: str, username: str) -> set[str]:
if not username: if not username:
return set() return set()
@ -264,6 +318,9 @@ def _auto_completed_keycloak_steps(conn, request_code: str, username: str) -> se
except Exception: except Exception:
full = user if isinstance(user, dict) else {} full = user if isinstance(user, dict) else {}
attrs = full.get("attributes") if isinstance(full, dict) else {}
completed |= _auto_completed_service_steps(attrs)
actions = full.get("requiredActions") actions = full.get("requiredActions")
required_actions: set[str] = set() required_actions: set[str] = set()
actions_list: list[str] = [] actions_list: list[str] = []
@ -352,26 +409,6 @@ def _advance_status(conn, request_code: str, username: str, status: str) -> str:
return status return status
def _fetch_optional_mfa_state(conn, request_code: str) -> str:
row = conn.execute(
"""
SELECT value_hash
FROM access_request_onboarding_artifacts
WHERE request_code = %s AND artifact = %s
""",
(request_code, _KEYCLOAK_MFA_OPTIONAL_STATE_ARTIFACT),
).fetchone()
if not row:
return "pending"
value = row.get("value_hash") if isinstance(row, dict) else None
if not isinstance(value, str):
return "pending"
cleaned = value.strip().lower()
if cleaned in _KEYCLOAK_MFA_OPTIONAL_VALID_STATES:
return cleaned
return "pending"
def _onboarding_payload(conn, request_code: str, username: str) -> dict[str, Any]: def _onboarding_payload(conn, request_code: str, username: str) -> dict[str, Any]:
completed_steps = sorted(_completed_onboarding_steps(conn, request_code, username)) completed_steps = sorted(_completed_onboarding_steps(conn, request_code, username))
password_rotation_requested = _password_rotation_requested(conn, request_code) password_rotation_requested = _password_rotation_requested(conn, request_code)
@ -382,11 +419,6 @@ def _onboarding_payload(conn, request_code: str, username: str) -> dict[str, Any
"keycloak": { "keycloak": {
"password_rotation_requested": password_rotation_requested, "password_rotation_requested": password_rotation_requested,
}, },
"optional": {
"keycloak_mfa_optional": {
"state": _fetch_optional_mfa_state(conn, request_code),
}
},
} }
@ -1066,70 +1098,3 @@ def register(app) -> None:
return jsonify({"error": "failed to request password rotation"}), 502 return jsonify({"error": "failed to request password rotation"}), 502
return jsonify({"ok": True, "status": status, "onboarding": onboarding_payload}) return jsonify({"ok": True, "status": status, "onboarding": onboarding_payload})
@app.route("/api/access/request/onboarding/mfa", methods=["POST"])
@require_auth
def request_access_onboarding_mfa_optional() -> 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()
state = (payload.get("state") or "").strip().lower()
if not code:
return jsonify({"error": "request_code is required"}), 400
if state not in _KEYCLOAK_MFA_OPTIONAL_VALID_STATES:
return jsonify({"error": "invalid state"}), 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("keycloak_mfa_optional", 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, _KEYCLOAK_MFA_OPTIONAL_STATE_ARTIFACT, state),
)
conn.execute(
"""
INSERT INTO access_request_onboarding_steps (request_code, step)
VALUES (%s, %s)
ON CONFLICT (request_code, step) DO NOTHING
""",
(code, "keycloak_mfa_optional"),
)
status = _advance_status(conn, code, username, status)
onboarding_payload = _onboarding_payload(conn, code, username)
except Exception:
return jsonify({"error": "failed to update onboarding"}), 502
return jsonify({"ok": True, "status": status, "onboarding": onboarding_payload})

View File

@ -5,6 +5,7 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"prebuild": "node scripts/build_media_manifest.mjs",
"build": "vite build", "build": "vite build",
"preview": "vite preview" "preview": "vite preview"
}, },

View File

@ -0,0 +1,42 @@
import { promises as fs } from "fs";
import path from "path";
const ROOT = path.resolve("public", "media", "onboarding");
const MANIFEST = path.join(ROOT, "manifest.json");
const EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".webp"]);
async function walk(dir, base = "") {
const entries = await fs.readdir(dir, { withFileTypes: true });
const files = [];
for (const entry of entries) {
const full = path.join(dir, entry.name);
const rel = path.join(base, entry.name);
if (entry.isDirectory()) {
files.push(...(await walk(full, rel)));
} else if (EXTENSIONS.has(path.extname(entry.name).toLowerCase())) {
files.push(rel.replace(/\\/g, "/"));
}
}
return files;
}
async function ensureDir(dir) {
await fs.mkdir(dir, { recursive: true });
}
async function main() {
try {
await ensureDir(ROOT);
const files = await walk(ROOT).catch(() => []);
const payload = {
generated_at: new Date().toISOString(),
files: files.sort(),
};
await fs.writeFile(MANIFEST, JSON.stringify(payload, null, 2));
} catch (err) {
console.error("Failed to build onboarding media manifest", err);
process.exitCode = 1;
}
}
await main();

File diff suppressed because it is too large Load Diff