From 9735cfed6a1cb10f1c9b732ed3d1f91165ffebe9 Mon Sep 17 00:00:00 2001 From: Brad Stein Date: Wed, 21 Jan 2026 16:57:40 -0300 Subject: [PATCH] portal: integrate ariadne onboarding flow --- backend/atlas_portal/keycloak.py | 25 ++ .../atlas_portal/routes/access_requests.py | 93 ++++- backend/atlas_portal/routes/admin_access.py | 22 +- frontend/src/views/AccountView.vue | 141 ++++++- frontend/src/views/OnboardingView.vue | 359 +++++++++--------- frontend/src/views/RequestAccessView.vue | 141 ++++++- 6 files changed, 562 insertions(+), 219 deletions(-) diff --git a/backend/atlas_portal/keycloak.py b/backend/atlas_portal/keycloak.py index e0f204f..1aed9c4 100644 --- a/backend/atlas_portal/keycloak.py +++ b/backend/atlas_portal/keycloak.py @@ -251,6 +251,31 @@ class KeycloakAdminClient: return gid return None + def list_group_names(self) -> list[str]: + url = f"{settings.KEYCLOAK_ADMIN_URL}/admin/realms/{settings.KEYCLOAK_REALM}/groups" + with httpx.Client(timeout=settings.HTTP_CHECK_TIMEOUT_SEC) as client: + resp = client.get(url, headers=self._headers()) + resp.raise_for_status() + items = resp.json() + if not isinstance(items, list): + return [] + + names: set[str] = set() + + def walk(groups: list[Any]) -> None: + for group in groups: + if not isinstance(group, dict): + continue + name = group.get("name") + if isinstance(name, str) and name: + names.add(name) + sub = group.get("subGroups") + if isinstance(sub, list) and sub: + walk(sub) + + walk(items) + return sorted(names) + def add_user_to_group(self, user_id: str, group_id: str) -> None: url = ( f"{settings.KEYCLOAK_ADMIN_URL}/admin/realms/{settings.KEYCLOAK_REALM}" diff --git a/backend/atlas_portal/routes/access_requests.py b/backend/atlas_portal/routes/access_requests.py index 6a764c7..b09221a 100644 --- a/backend/atlas_portal/routes/access_requests.py +++ b/backend/atlas_portal/routes/access_requests.py @@ -30,6 +30,16 @@ def _extract_request_payload() -> tuple[str, str, str]: return username, email, note +def _validate_username(username: str) -> str | None: + if not username: + return "username is required" + if len(username) < 3 or len(username) > 32: + return "username must be 3-32 characters" + if not re.fullmatch(r"[a-zA-Z0-9._-]+", username): + return "username contains invalid characters" + return None + + def _random_request_code(username: str) -> str: suffix = "".join(secrets.choice(string.ascii_uppercase + string.digits) for _ in range(10)) return f"{username}~{suffix}" @@ -59,23 +69,36 @@ def _verify_url(request_code: str, token: str) -> str: ONBOARDING_STEPS: tuple[str, ...] = ( "vaultwarden_master_password", + "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", - "health_data_notice", - "wger_login", - "actual_login", - "firefly_login", - "keycloak_password_rotated", - "keycloak_mfa_optional", - "element_recovery_key", - "element_recovery_key_stored", "elementx_setup", "jellyfin_login", "mail_client_setup", + "actual_login", + "outline_login", + "planka_login", + "keycloak_mfa_optional", ) -ONBOARDING_OPTIONAL_STEPS: set[str] = {"keycloak_mfa_optional"} +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", +} ONBOARDING_REQUIRED_STEPS: tuple[str, ...] = tuple( step for step in ONBOARDING_STEPS if step not in ONBOARDING_OPTIONAL_STEPS ) @@ -289,6 +312,48 @@ def _onboarding_payload(conn, request_code: str, username: str) -> dict[str, Any def register(app) -> None: + @app.route("/api/access/request/availability", methods=["GET"]) + def request_access_availability() -> Any: + if not settings.ACCESS_REQUEST_ENABLED: + return jsonify({"error": "request access disabled"}), 503 + if not configured(): + return jsonify({"error": "server not configured"}), 503 + + username = (request.args.get("username") or "").strip() + error = _validate_username(username) + if error: + return jsonify({"available": False, "reason": "invalid", "detail": error}) + + if admin_client().ready() and admin_client().find_user(username): + return jsonify({"available": False, "reason": "exists", "detail": "username already exists"}) + + try: + with connect() as conn: + existing = conn.execute( + """ + SELECT status + FROM access_requests + WHERE username = %s + ORDER BY created_at DESC + LIMIT 1 + """, + (username,), + ).fetchone() + except Exception: + return jsonify({"error": "failed to check availability"}), 502 + + if existing: + status = str(existing.get("status") or "") + return jsonify( + { + "available": False, + "reason": "requested", + "status": _normalize_status(status), + } + ) + + return jsonify({"available": True}) + @app.route("/api/access/request", methods=["POST"]) def request_access() -> Any: if not settings.ACCESS_REQUEST_ENABLED: @@ -310,13 +375,9 @@ def register(app) -> None: ): return jsonify({"error": "rate limited"}), 429 - if not username: - return jsonify({"error": "username is required"}), 400 - - if len(username) < 3 or len(username) > 32: - return jsonify({"error": "username must be 3-32 characters"}), 400 - if not re.fullmatch(r"[a-zA-Z0-9._-]+", username): - return jsonify({"error": "username contains invalid characters"}), 400 + username_error = _validate_username(username) + if username_error: + return jsonify({"error": username_error}), 400 if not email: return jsonify({"error": "email is required"}), 400 if "@" not in email: diff --git a/backend/atlas_portal/routes/admin_access.py b/backend/atlas_portal/routes/admin_access.py index f342de0..b1223e3 100644 --- a/backend/atlas_portal/routes/admin_access.py +++ b/backend/atlas_portal/routes/admin_access.py @@ -5,9 +5,9 @@ from urllib.parse import quote from flask import jsonify, g, request -from .. import ariadne_client +from .. import ariadne_client, settings from ..db import connect, configured -from ..keycloak import require_auth, require_portal_admin +from ..keycloak import admin_client, require_auth, require_portal_admin from ..provisioning import provision_access_request @@ -51,6 +51,24 @@ def register(app) -> None: ) return jsonify({"requests": output}) + @app.route("/api/admin/access/flags", methods=["GET"]) + @require_auth + def admin_list_flags() -> Any: + ok, resp = require_portal_admin() + if not ok: + return resp + if ariadne_client.enabled(): + return ariadne_client.proxy("GET", "/api/admin/access/flags") + if not admin_client().ready(): + return jsonify({"error": "keycloak admin unavailable"}), 503 + try: + groups = admin_client().list_group_names() + except Exception: + return jsonify({"error": "failed to list flags"}), 502 + excluded = set(settings.PORTAL_ADMIN_GROUPS) + flags = sorted([name for name in groups if name not in excluded]) + return jsonify({"flags": flags}) + @app.route("/api/admin/access/requests//approve", methods=["POST"]) @require_auth def admin_approve_request(username: str) -> Any: diff --git a/frontend/src/views/AccountView.vue b/frontend/src/views/AccountView.vue index f9fe89f..fd201d2 100644 --- a/frontend/src/views/AccountView.vue +++ b/frontend/src/views/AccountView.vue @@ -356,12 +356,36 @@ User Email Note + Flags + Decision note
{{ req.username }}
{{ req.email }}
{{ req.note }}
+
+
loading flags...
+ +
no flags
+
+
+ +
- - Open Keycloak - + Open Element

- After Vaultwarden is set up, rotate your Keycloak password to a strong one and store it in Vaultwarden. - Atlas verifies this once Keycloak no longer requires you to update your password. + After Vaultwarden is ready, sign in to Element with the temporary password. Keycloak will prompt you to set + a new password and your name. Store the new password in Vaultwarden. Atlas will mark this step complete once + Keycloak no longer requires a password update.

@@ -403,9 +259,9 @@

- In Element, create a recovery key so you can restore encrypted history if you lose a device. Atlas stores only a SHA-256 hash so the - recovery key itself is never saved server-side. - Open Element settings → Encryption. + In Element, create a recovery key so you can restore encrypted history if you lose a device. Atlas stores only a + SHA-256 hash so the recovery key itself is never saved server-side. Open + Element settings → Encryption.

@@ -422,7 +278,143 @@ {{ stepPillLabel("element_recovery_key_stored") }} -

Save the recovery key in Vaultwarden so it doesn't get lost.

+

Add the Element recovery key to Vaultwarden so it's stored safely.

+ + +
  • + +

    + Open money.bstein.dev and sign in + with the credentials from your Account page. Change the password immediately and + save it in Vaultwarden. In the Abacus app, set the server URL to money.bstein.dev and log in once. +

    +
  • + +
  • + +

    + Wger is a personal wellness tool, not medical advice. Use it at your own risk. Your health data belongs to + you and will never be sold or used beyond providing the service. We apply best practices to protect it, + but no system is risk-free. +

    +
  • + +
  • + +

    + Open health.bstein.dev and sign in + with the credentials shown on your Account page. Change the password immediately and + store it in Vaultwarden. In the mobile app, set the server to health.bstein.dev and sign in once. +

    +
  • + +
  • + +

    + Install Bitwarden in your browser and point it at vault.bstein.dev (Settings → Account → Environment → Self-hosted). + Bitwarden downloads. +

    +
  • + +
  • + +

    + Install the Bitwarden desktop app and set the server to vault.bstein.dev (Settings → Account → Environment → Self-hosted). + Bitwarden downloads. +

    +
  • + +
  • + +

    + Install the mobile app, set the server to vault.bstein.dev, and enable biometrics for fast unlock. + Bitwarden downloads. +

    +
  • + +
  • + +

    + Open budget.bstein.dev and sign in + with your Keycloak account. +

  • @@ -434,23 +426,19 @@ @change="toggleStep(step.id, $event)" /> {{ step.title }} - {{ stepPillLabel(step.id) }} + + {{ stepPillLabel(step.id) }} +

    {{ step.description }} - - + {{ + step.primaryLink.text + }} + .

  • -

    Mobile app guides

    @@ -560,6 +548,12 @@ const extraSteps = [ "Use the IMAP/SMTP details on your Account page to add mail to your phone or desktop client (FairEmail on Android, Apple Mail on iOS, Thunderbird on desktop).", primaryLink: { href: "/account", text: "Account" }, }, + { + id: "jellyfin_login", + title: "Sign in to Jellyfin", + description: "Sign in with your Atlas username/password (LDAP-backed).", + primaryLink: { href: "https://stream.bstein.dev", text: "Jellyfin" }, + }, { id: "outline_login", title: "Open Outline and create your first doc", @@ -572,12 +566,6 @@ const extraSteps = [ description: "Spin up a project board and invite teammates to collaborate.", primaryLink: { href: "https://tasks.bstein.dev", text: "Planka" }, }, - { - id: "jellyfin_login", - title: "Sign in to Jellyfin", - description: "Sign in with your Atlas username/password (LDAP-backed).", - primaryLink: { href: "https://stream.bstein.dev", text: "Jellyfin" }, - }, ]; // Guide images: drop files into src/assets/onboarding//01-step.png to auto-load and order. @@ -719,21 +707,12 @@ function requiredStepOrder() { } return [ "vaultwarden_master_password", - "vaultwarden_browser_extension", - "vaultwarden_desktop_app", - "vaultwarden_mobile_app", - "health_data_notice", - "wger_login", - "actual_login", - "firefly_login", "keycloak_password_rotated", "element_recovery_key", "element_recovery_key_stored", - "elementx_setup", - "mail_client_setup", - "outline_login", - "planka_login", - "jellyfin_login", + "firefly_login", + "health_data_notice", + "wger_login", ]; } @@ -789,7 +768,7 @@ function mfaPillClass() { function keycloakRotationPillLabel() { if (isStepDone("keycloak_password_rotated")) return "done"; if (isStepBlocked("keycloak_password_rotated")) return "blocked"; - if (keycloakPasswordRotationRequested.value) return "rotate now"; + if (keycloakPasswordRotationRequested.value) return "update now"; return "ready"; } diff --git a/frontend/src/views/RequestAccessView.vue b/frontend/src/views/RequestAccessView.vue index 9ebc3b1..506837c 100644 --- a/frontend/src/views/RequestAccessView.vue +++ b/frontend/src/views/RequestAccessView.vue @@ -5,7 +5,7 @@

    Atlas

    Request Access

    - Request access and an admin can approve your account. + Request access to Atlas. Approved accounts are provisioned from this form only.

    @@ -20,11 +20,12 @@

    Requests require a verified external email so Keycloak can support account recovery. After verification, an admin can approve your account. + Your lab username becomes your Atlas identity (including your @{{ mailDomain }} mailbox).

    - Requests are rate-limited. @@ -153,7 +158,7 @@