diff --git a/.dockerignore b/.dockerignore index 4ab6ed1..3b21c46 100644 --- a/.dockerignore +++ b/.dockerignore @@ -4,7 +4,6 @@ node_modules frontend/node_modules frontend/dist frontend/.vite -media docs __pycache__ .venv diff --git a/Dockerfile.frontend b/Dockerfile.frontend index 9e7c60f..a2610e7 100644 --- a/Dockerfile.frontend +++ b/Dockerfile.frontend @@ -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 diff --git a/backend/atlas_portal/routes/access_requests.py b/backend/atlas_portal/routes/access_requests.py index 4e0b5f5..9740b05 100644 --- a/backend/atlas_portal/routes/access_requests.py +++ b/backend/atlas_portal/routes/access_requests.py @@ -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}) diff --git a/frontend/package.json b/frontend/package.json index 55050ef..1231c14 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -5,6 +5,7 @@ "type": "module", "scripts": { "dev": "vite", + "prebuild": "node scripts/build_media_manifest.mjs", "build": "vite build", "preview": "vite preview" }, diff --git a/frontend/scripts/build_media_manifest.mjs b/frontend/scripts/build_media_manifest.mjs new file mode 100644 index 0000000..8994835 --- /dev/null +++ b/frontend/scripts/build_media_manifest.mjs @@ -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(); diff --git a/frontend/src/views/OnboardingView.vue b/frontend/src/views/OnboardingView.vue index 95f5039..77a11cf 100644 --- a/frontend/src/views/OnboardingView.vue +++ b/frontend/src/views/OnboardingView.vue @@ -17,7 +17,13 @@
- Open the verification email from Atlas and click the link to confirm your address. After verification, an admin can approve your request. + Open the verification email from Atlas and click the link to confirm your address. After verification, an admin can + approve your request.
If you did not receive an email, return to @@ -52,9 +59,7 @@
- Your request is approved. Atlas is now preparing accounts and credentials. Check back in a minute. -
+Your request is approved. Atlas is now preparing accounts and credentials. Check back in a minute.
+ Onboarding is fully self-service. Work through each section in order; you can pause and return later. Vaultwarden + comes first because it stores every credential that follows. +
+- Some steps are verified automatically from Keycloak (password). Others can't be verified yet — mark them complete once you're done. -
- -- Use this password to log in for the first time (Nextcloud, Element). You won't be forced to change it - immediately — you'll rotate it after Vaultwarden is set up. This password is shown once — copy it now. If you - refresh this page, it may disappear. -
-