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/dist
frontend/.vite
media
docs
__pycache__
.venv

View File

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

View File

@ -148,66 +148,81 @@ def _verify_request(conn, code: str, token: str) -> str:
ONBOARDING_STEPS: tuple[str, ...] = (
"vaultwarden_master_password",
"vaultwarden_browser_extension",
"vaultwarden_mobile_app",
"keycloak_password_rotated",
"element_recovery_key",
"element_recovery_key_stored",
"firefly_login",
"health_data_notice",
"wger_login",
"vaultwarden_browser_extension",
"vaultwarden_desktop_app",
"vaultwarden_mobile_app",
"elementx_setup",
"jellyfin_login",
"element_mobile_app",
"mail_client_setup",
"actual_login",
"outline_login",
"planka_login",
"keycloak_mfa_optional",
"nextcloud_web_access",
"nextcloud_mail_integration",
"nextcloud_desktop_app",
"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] = {
"vaultwarden_browser_extension",
"vaultwarden_desktop_app",
"vaultwarden_mobile_app",
"elementx_setup",
"jellyfin_login",
"mail_client_setup",
"actual_login",
"outline_login",
"planka_login",
"keycloak_mfa_optional",
"element_mobile_app",
"nextcloud_desktop_app",
"nextcloud_mobile_app",
"jellyfin_web_access",
"jellyfin_mobile_app",
"jellyfin_tv_setup",
}
ONBOARDING_REQUIRED_STEPS: tuple[str, ...] = tuple(
step for step in ONBOARDING_STEPS if step not in ONBOARDING_OPTIONAL_STEPS
ONBOARDING_REQUIRED_STEPS: tuple[str, ...] = (
"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_MFA_OPTIONAL_STATE_ARTIFACT = "keycloak_mfa_optional_state"
_KEYCLOAK_MFA_OPTIONAL_VALID_STATES = {"done", "skipped"}
KEYCLOAK_MANAGED_STEPS: set[str] = {
"keycloak_password_rotated",
"vaultwarden_master_password",
"nextcloud_mail_integration",
"firefly_password_rotated",
"wger_password_rotated",
}
_KEYCLOAK_PASSWORD_ROTATION_REQUESTED_ARTIFACT = "keycloak_password_rotation_requested_at"
def _sequential_prerequisites(
steps: tuple[str, ...],
optional_steps: set[str],
) -> dict[str, set[str]]:
completed: list[str] = []
prerequisites: dict[str, set[str]] = {}
for step in steps:
prerequisites[step] = set(completed)
if step not in optional_steps:
completed.append(step)
return prerequisites
ONBOARDING_STEP_PREREQUISITES: dict[str, set[str]] = _sequential_prerequisites(
ONBOARDING_STEPS,
ONBOARDING_OPTIONAL_STEPS,
)
ONBOARDING_STEP_PREREQUISITES: dict[str, set[str]] = {
"vaultwarden_master_password": set(),
"vaultwarden_browser_extension": {"vaultwarden_master_password"},
"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"},
"nextcloud_mail_integration": {"nextcloud_web_access"},
"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"},
"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"}
def _normalize_status(status: str) -> str:
@ -243,6 +258,45 @@ def _password_rotation_requested(conn, request_code: str) -> bool:
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]:
if not username:
return set()
@ -264,6 +318,9 @@ def _auto_completed_keycloak_steps(conn, request_code: str, username: str) -> se
except Exception:
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")
required_actions: set[str] = set()
actions_list: list[str] = []
@ -352,26 +409,6 @@ def _advance_status(conn, request_code: str, username: str, status: str) -> str:
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]:
completed_steps = sorted(_completed_onboarding_steps(conn, request_code, username))
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": {
"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({"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",
"scripts": {
"dev": "vite",
"prebuild": "node scripts/build_media_manifest.mjs",
"build": "vite build",
"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