diff --git a/backend/atlas_portal/routes/access_requests.py b/backend/atlas_portal/routes/access_requests.py index ede55f2..edd83e5 100644 --- a/backend/atlas_portal/routes/access_requests.py +++ b/backend/atlas_portal/routes/access_requests.py @@ -148,6 +148,7 @@ def _verify_request(conn, code: str, token: str) -> str: ONBOARDING_STEPS: tuple[str, ...] = ( "vaultwarden_master_password", + "vaultwarden_store_temp_password", "vaultwarden_browser_extension", "vaultwarden_mobile_app", "keycloak_password_rotated", @@ -199,6 +200,7 @@ _KEYCLOAK_PASSWORD_ROTATION_REQUESTED_ARTIFACT = "keycloak_password_rotation_req ONBOARDING_STEP_PREREQUISITES: dict[str, set[str]] = { "vaultwarden_master_password": set(), + "vaultwarden_store_temp_password": {"vaultwarden_master_password"}, "vaultwarden_browser_extension": {"vaultwarden_master_password"}, "vaultwarden_mobile_app": {"vaultwarden_master_password"}, "keycloak_password_rotated": {"vaultwarden_master_password"}, @@ -359,6 +361,23 @@ def _extract_attr(attrs: Any, key: str) -> str: return "" +def _vaultwarden_status_for_user(username: str) -> str: + if not username: + return "" + if not admin_client().ready(): + return "" + try: + user = admin_client().find_user(username) or {} + user_id = user.get("id") if isinstance(user, dict) else None + if not isinstance(user_id, str) or not user_id: + return "" + full = admin_client().get_user(user_id) + attrs = full.get("attributes") if isinstance(full, dict) else {} + return _extract_attr(attrs, "vaultwarden_status") + except Exception: + return "" + + def _auto_completed_service_steps(attrs: Any) -> set[str]: completed: set[str] = set() if not isinstance(attrs, dict): @@ -486,7 +505,12 @@ def _advance_status(conn, request_code: str, username: str, status: str) -> str: if status == "awaiting_onboarding": completed = _completed_onboarding_steps(conn, request_code, username) - if set(ONBOARDING_REQUIRED_STEPS).issubset(completed): + required_steps = set(ONBOARDING_REQUIRED_STEPS) + grandfathered, _ = _vaultwarden_grandfathered(conn, request_code, username) + vaultwarden_status = _vaultwarden_status_for_user(username) + if grandfathered and vaultwarden_status == "grandfathered": + required_steps.add("vaultwarden_store_temp_password") + if required_steps.issubset(completed): conn.execute( "UPDATE access_requests SET status = 'ready' WHERE request_code = %s AND status = 'awaiting_onboarding'", (request_code,), @@ -501,8 +525,13 @@ def _onboarding_payload(conn, request_code: str, username: str) -> dict[str, Any password_rotation_requested = _password_rotation_requested(conn, request_code) grandfathered, contact_email = _vaultwarden_grandfathered(conn, request_code, username) recovery_email = _resolve_recovery_email(username, contact_email) if grandfathered else "" + vaultwarden_status = _vaultwarden_status_for_user(username) + vaultwarden_matched = grandfathered and vaultwarden_status == "grandfathered" + required_steps = list(ONBOARDING_REQUIRED_STEPS) + if vaultwarden_matched: + required_steps.append("vaultwarden_store_temp_password") return { - "required_steps": list(ONBOARDING_REQUIRED_STEPS), + "required_steps": required_steps, "optional_steps": sorted(ONBOARDING_OPTIONAL_STEPS), "completed_steps": completed_steps, "keycloak": { @@ -511,6 +540,7 @@ def _onboarding_payload(conn, request_code: str, username: str) -> dict[str, Any "vaultwarden": { "grandfathered": grandfathered, "recovery_email": recovery_email, + "matched": vaultwarden_matched, }, } diff --git a/frontend/src/views/OnboardingView.vue b/frontend/src/views/OnboardingView.vue index 09eb7b4..7691932 100644 --- a/frontend/src/views/OnboardingView.vue +++ b/frontend/src/views/OnboardingView.vue @@ -85,8 +85,8 @@

Onboarding

- 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. + Onboarding is fully self-service. There are 8 steps for 8 services below. Each step has smaller tasks; press + Confirm when you finish a task. You can pause and return later.

active @@ -189,38 +189,24 @@

{{ step.description }}

{{ stepNote(step) }}

-
-

- Already have a Vaultwarden account? Claim it with - {{ vaultwardenRecoveryEmail || "your recovery email" }}. - This skips the invite flow and keeps your existing vault. -

- - - -
- -
+
Photo guide
@@ -353,7 +339,6 @@ const guideShots = ref({}); const guidePage = ref({}); const lightboxShot = ref(null); const confirmingStepId = ref(""); -const vaultwardenClaiming = ref(false); const showPasswordCard = computed(() => Boolean(initialPassword.value || initialPasswordRevealedAt.value)); const passwordRevealLocked = computed(() => Boolean(!initialPassword.value && initialPasswordRevealedAt.value)); @@ -362,10 +347,10 @@ const passwordRevealHint = computed(() => ? "This password was already revealed and cannot be shown again. Ask an admin to reset it if you missed it." : "", ); -const vaultwardenGrandfathered = computed(() => Boolean(onboarding.value?.vaultwarden?.grandfathered)); const vaultwardenRecoveryEmail = computed(() => onboarding.value?.vaultwarden?.recovery_email || ""); +const vaultwardenMatched = computed(() => Boolean(onboarding.value?.vaultwarden?.matched)); const vaultwardenLoginEmail = computed(() => { - if (vaultwardenGrandfathered.value) { + if (vaultwardenMatched.value) { return vaultwardenRecoveryEmail.value || "your recovery email"; } if (requestUsername.value) { @@ -374,20 +359,10 @@ const vaultwardenLoginEmail = computed(() => { return "your @bstein.dev address"; }); const mailAddress = computed(() => (requestUsername.value ? `${requestUsername.value}@bstein.dev` : "your @bstein.dev address")); -const vaultwardenClaimDisabled = computed( - () => - loading.value || - vaultwardenClaiming.value || - !auth.authenticated || - isStepDone("vaultwarden_master_password") || - isStepBlocked("vaultwarden_master_password"), -); -const vaultwardenClaimHint = computed(() => - !auth.authenticated ? "Log in to claim an existing Vaultwarden account." : "", -); const STEP_PREREQS = { vaultwarden_master_password: [], + vaultwarden_store_temp_password: ["vaultwarden_master_password"], vaultwarden_browser_extension: ["vaultwarden_master_password"], vaultwarden_mobile_app: ["vaultwarden_master_password"], keycloak_password_rotated: ["vaultwarden_master_password"], @@ -428,7 +403,7 @@ const SECTION_DEFS = [ ], links: [ { href: "https://cloud.bstein.dev", text: "Nextcloud Mail" }, - { href: "https://vault.bstein.dev", text: "vault.bstein.dev" }, + { href: "https://vault.bstein.dev", text: "Vaultwarden" }, ], guide: { service: "vaultwarden", step: "step1_website" }, }, @@ -522,7 +497,7 @@ const SECTION_DEFS = [ action: "checkbox", description: "Open Nextcloud, confirm you can access Files, Calendar, and Mail, and keep the tab handy during onboarding.", - links: [{ href: "https://cloud.bstein.dev", text: "cloud.bstein.dev" }], + links: [{ href: "https://cloud.bstein.dev", text: "Nextcloud" }], guide: { service: "nextcloud", step: "step1_web_access" }, }, { @@ -568,7 +543,7 @@ const SECTION_DEFS = [ "If you lose the key, your budget data cannot be recovered.", ], links: [ - { href: "https://budget.bstein.dev", text: "budget.bstein.dev" }, + { href: "https://budget.bstein.dev", text: "Actual Budget" }, { href: "https://vault.bstein.dev", text: "Vaultwarden" }, ], guide: { service: "budget", step: "step1_encrypt_data" }, @@ -588,7 +563,7 @@ const SECTION_DEFS = [ description: "Sign in to money.bstein.dev with the credentials on your Account page, change the password, then confirm here.", links: [ - { href: "https://money.bstein.dev", text: "money.bstein.dev" }, + { href: "https://money.bstein.dev", text: "Firefly III" }, { href: "/account", text: "Account credentials" }, ], guide: { service: "firefly", step: "step1_web_access" }, @@ -620,7 +595,7 @@ const SECTION_DEFS = [ description: "Sign in to health.bstein.dev with the credentials on your Account page, change the password, then confirm here.", links: [ - { href: "https://health.bstein.dev", text: "health.bstein.dev" }, + { href: "https://health.bstein.dev", text: "Wger" }, { href: "/account", text: "Account credentials" }, ], guide: { service: "wger", step: "step1_web_access" }, @@ -651,7 +626,7 @@ const SECTION_DEFS = [ action: "checkbox", description: "Sign in with your Atlas username/password (LDAP-backed).", - links: [{ href: "https://stream.bstein.dev", text: "stream.bstein.dev" }], + links: [{ href: "https://stream.bstein.dev", text: "Jellyfin" }], guide: { service: "jellyfin", step: "step1_web_access" }, }, { @@ -674,7 +649,26 @@ const SECTION_DEFS = [ }, ]; -const sections = computed(() => SECTION_DEFS); +const VAULTWARDEN_TEMP_STEP = { + id: "vaultwarden_store_temp_password", + title: "Store the temporary Keycloak password", + action: "confirm", + description: + "Save the temporary Keycloak password in Vaultwarden so you can rotate it later without losing access.", + links: [{ href: "https://vault.bstein.dev", text: "Vaultwarden" }], + guide: { service: "vaultwarden", step: "step1_website", tail: 4 }, +}; + +const sections = computed(() => + SECTION_DEFS.map((section) => { + if (section.id !== "vaultwarden") return section; + const steps = [...section.steps]; + if (vaultwardenMatched.value) { + steps.splice(1, 0, VAULTWARDEN_TEMP_STEP); + } + return { ...section, steps }; + }), +); const activeSection = computed(() => sections.value.find((item) => item.id === activeSectionId.value)); const nextSectionItem = computed(() => { @@ -758,6 +752,9 @@ function stepNote(step) { if (step.id === "vaultwarden_master_password") { return `Vaultwarden uses an email login. Use ${vaultwardenLoginEmail.value} to sign in.`; } + if (step.id === "vaultwarden_store_temp_password") { + return "Store the temporary Keycloak password in Vaultwarden so you can rotate it safely later."; + } if (step.id === "firefly_password_rotated") { return `Firefly uses an email login. Use ${mailAddress.value} to sign in.`; } @@ -858,7 +855,14 @@ function guideGroups(step) { const stepKey = step.guide.step; const serviceShots = guideShots.value?.[service] || {}; const stepShots = serviceShots?.[stepKey] || {}; - return Object.values(stepShots); + const groups = Object.values(stepShots); + const take = step.guide.take || step.guide.tail || 0; + if (!take) return groups; + const useTail = Boolean(step.guide.tail); + return groups.map((group) => { + const shots = useTail ? group.shots.slice(-take) : group.shots.slice(0, take); + return { ...group, shots }; + }); } function guideKey(step, group) { @@ -892,6 +896,14 @@ function guideShot(step, group) { return group.shots[guideIndex(step, group)] || {}; } +function shouldOpenGuide(step, section) { + if (!step || !step.guide || !section) return false; + const first = section.steps.find( + (item) => item.guide && !isStepDone(item.id) && !isStepBlocked(item.id), + ); + return Boolean(first && first.id === step.id); +} + function openLightbox(shot) { if (!shot || !shot.url) return; lightboxShot.value = shot; @@ -1095,18 +1107,6 @@ async function confirmStep(step) { } } -async function claimVaultwarden() { - if (isStepDone("vaultwarden_master_password") || isStepBlocked("vaultwarden_master_password")) return; - vaultwardenClaiming.value = true; - try { - await setStepCompletion("vaultwarden_master_password", true, { vaultwarden_claim: true }); - } catch (err) { - error.value = err?.message || "Failed to claim Vaultwarden account"; - } finally { - vaultwardenClaiming.value = false; - } -} - async function runRotationCheck(service) { if (!auth.authenticated) { throw new Error("Log in to update onboarding steps."); @@ -1408,8 +1408,8 @@ button.copy:disabled { display: flex; align-items: center; justify-content: center; - gap: 10px; - flex-wrap: nowrap; + gap: 6px 10px; + flex-wrap: wrap; color: var(--text-muted); width: 100%; } @@ -1592,30 +1592,29 @@ button.copy:disabled { } .step-links { - margin-top: 8px; + margin-top: 10px; display: flex; flex-wrap: wrap; + justify-content: center; gap: 10px; } .step-links a { - color: var(--accent-cyan); + color: rgba(92, 214, 167, 0.95); text-decoration: none; font-weight: 600; + border-radius: 999px; + padding: 6px 14px; + border: 1px solid rgba(92, 214, 167, 0.35); + background: rgba(92, 214, 167, 0.12); + display: inline-flex; + align-items: center; + gap: 6px; } .step-links a:hover { - text-decoration: underline; -} - -.claim-box { - margin-top: 12px; - padding: 10px 12px; - border-radius: 12px; - border: 1px dashed rgba(255, 255, 255, 0.18); - background: rgba(0, 0, 0, 0.2); - display: grid; - gap: 8px; + text-decoration: none; + background: rgba(92, 214, 167, 0.2); } .step-actions { @@ -1672,7 +1671,7 @@ button.copy:disabled { .guide-shot figcaption { margin: 0; padding: 10px 12px 6px; - font-size: 17px; + font-size: 18px; font-weight: 600; color: var(--text-strong); }