diff --git a/frontend/src/onboarding/onboardingGuides.js b/frontend/src/onboarding/onboardingGuides.js
new file mode 100644
index 0000000..b0c69de
--- /dev/null
+++ b/frontend/src/onboarding/onboardingGuides.js
@@ -0,0 +1,57 @@
+/**
+ * Parse onboarding media manifests into guide groups.
+ *
+ * WHY: keeping manifest shaping outside the view makes guide behavior
+ * testable without mounting the whole onboarding page.
+ *
+ * @param {string[]} files - Manifest file paths relative to the onboarding media root.
+ * @returns {object} Guide groups keyed by service, step, and variant.
+ */
+export function parseManifest(files) {
+ const grouped = {};
+ for (const path of files) {
+ if (typeof path !== "string") continue;
+ const cleaned = path.replace(/^\/+/, "").replace(/\\/g, "/");
+ const parts = cleaned.split("/");
+ if (parts.length < 3) continue;
+ const service = parts[0];
+ const step = parts[1];
+ const rest = parts.slice(2);
+ let variant = "default";
+ let filename = rest.join("/");
+ if (rest.length > 1) {
+ variant = rest[0];
+ filename = rest.slice(1).join("/");
+ }
+ const order = guideOrder(filename);
+ const label = guideLabel(filename);
+ const url = `/media/onboarding/${cleaned}`;
+ grouped[service] = grouped[service] || {};
+ grouped[service][step] = grouped[service][step] || {};
+ grouped[service][step][variant] = grouped[service][step][variant] || { id: variant, title: variant === "default" ? "" : variant, shots: [] };
+ grouped[service][step][variant].shots.push({ url, order, label, file: filename });
+ }
+
+ Object.values(grouped).forEach((serviceSteps) => {
+ Object.values(serviceSteps).forEach((variants) => {
+ Object.values(variants).forEach((group) => {
+ group.shots.sort((a, b) => (a.order - b.order) || a.file.localeCompare(b.file));
+ });
+ });
+ });
+
+ return grouped;
+}
+
+function guideOrder(filename) {
+ const prefix = filename.match(/^(\d{1,3})/);
+ if (prefix) return Number(prefix[1]);
+ const step = filename.match(/step[-_ ]?(\d{1,3})/i);
+ if (step) return Number(step[1]);
+ return Number.MAX_SAFE_INTEGER;
+}
+
+function guideLabel(filename) {
+ const base = filename.replace(/\.(png|jpe?g|webp)$/i, "");
+ return base.replace(/^\d+[-_]?/, "").replace(/[-_]/g, " ").trim();
+}
diff --git a/frontend/src/onboarding/onboardingLabels.js b/frontend/src/onboarding/onboardingLabels.js
new file mode 100644
index 0000000..9476f32
--- /dev/null
+++ b/frontend/src/onboarding/onboardingLabels.js
@@ -0,0 +1,29 @@
+/** Display labels and pill classes for onboarding status values. */
+export function statusLabel(value) {
+ const key = (value || "").trim();
+ if (key === "pending_email_verification") return "confirm email";
+ if (key === "pending") return "awaiting approval";
+ if (key === "accounts_building") return "accounts building";
+ if (key === "awaiting_onboarding") return "awaiting onboarding";
+ if (key === "ready") return "ready";
+ if (key === "denied") return "rejected";
+ return key || "unknown";
+ }
+
+export function statusPillClass(value) {
+ const key = (value || "").trim();
+ if (key === "pending_email_verification") return "pill-warn";
+ if (key === "pending") return "pill-wait";
+ if (key === "accounts_building") return "pill-warn";
+ if (key === "awaiting_onboarding") return "pill-ok";
+ if (key === "ready") return "pill-info";
+ if (key === "denied") return "pill-bad";
+ return "pill-warn";
+ }
+
+export function taskPillClass(value) {
+ const key = (value || "").trim();
+ if (key === "ok") return "pill-ok";
+ if (key === "error") return "pill-bad";
+ return "pill-warn";
+ }
diff --git a/frontend/src/onboarding/onboardingSections.js b/frontend/src/onboarding/onboardingSections.js
new file mode 100644
index 0000000..0fcb4cd
--- /dev/null
+++ b/frontend/src/onboarding/onboardingSections.js
@@ -0,0 +1,299 @@
+/** Static onboarding section and prerequisite definitions. */
+export 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"],
+ element_recovery_key: ["keycloak_password_rotated"],
+ 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"],
+ firefly_mobile_app: ["firefly_password_rotated"],
+ wger_password_rotated: ["firefly_password_rotated"],
+ wger_mobile_app: ["wger_password_rotated"],
+ jellyfin_web_access: ["vaultwarden_master_password"],
+ jellyfin_mobile_app: ["jellyfin_web_access"],
+ jellyfin_tv_setup: ["jellyfin_web_access"],
+};
+
+export const SECTION_DEFS = [
+ {
+ id: "vaultwarden",
+ title: "Vaultwarden",
+ summary: "Self-hosted password manager for Atlas credentials.",
+ benefit: "Keeps every lab password encrypted and synced across devices.",
+ steps: [
+ {
+ id: "vaultwarden_master_password",
+ title: "Set your Vaultwarden master password",
+ action: "confirm",
+ description:
+ "Open Nextcloud Mail to find the invite, then visit vault.bstein.dev and create your master password. Use the temporary Keycloak password to sign in to Nextcloud for the first time.",
+ bullets: [
+ "Prefer a long (64+ character) multi word phrase over a single word. Length is stronger than complexity.",
+ "Never share, write, or store your password with anyone or anywhere for any reason. Your password must only live between your ears.",
+ "Pick something you will not forget, probably something you already know, something easy to remember, maybe something close to you.",
+ ],
+ links: [
+ { href: "https://cloud.bstein.dev", text: "Nextcloud Mail" },
+ { href: "https://vault.bstein.dev", text: "Vaultwarden" },
+ ],
+ guide: { service: "vaultwarden", step: "step1_website" },
+ },
+ {
+ id: "vaultwarden_browser_extension",
+ title: "Install the browser extension",
+ action: "checkbox",
+ description:
+ "Install Bitwarden in your browser and point it at vault.bstein.dev (Settings → Account → Environment → Self-hosted).",
+ links: [
+ { href: "https://addons.mozilla.org/en-US/firefox/addon/bitwarden-password-manager/", text: "Firefox" },
+ { href: "https://chromewebstore.google.com/detail/bitwarden-free-password-m/nngceckbapebfimnlniiiahkandclblb", text: "Chrome" },
+ { href: "https://apps.apple.com/app/bitwarden/id1352778147", text: "Safari" },
+ { href: "https://www.mozilla.org/firefox/new/", text: "Need a browser? Get Firefox" },
+ ],
+ guide: { service: "vaultwarden", step: "step2_browser_extension" },
+ },
+ {
+ id: "vaultwarden_mobile_app",
+ title: "Install the mobile app",
+ action: "checkbox",
+ description: "Install Bitwarden on your phone, set the server to vault.bstein.dev, and enable biometrics.",
+ links: [{ href: "https://bitwarden.com/download/", text: "Bitwarden downloads" }],
+ guide: { service: "vaultwarden", step: "step3_mobile_app" },
+ },
+ ],
+ },
+ {
+ id: "element",
+ title: "Element",
+ summary: "Secure chat, calls, and video for the lab.",
+ benefit: "Private messaging with encryption and recovery controls you own.",
+ steps: [
+ {
+ id: "keycloak_password_rotated",
+ title: "Connect to Element web",
+ action: "confirm",
+ description:
+ "Sign in to Element with the temporary password. Keycloak will prompt you to set a new password. Store the new password in Vaultwarden.",
+ links: [
+ { href: "https://live.bstein.dev", text: "Element" },
+ { href: "https://sso.bstein.dev/realms/atlas/account", text: "Keycloak account" },
+ ],
+ guide: { service: "element", step: "step1_web_access" },
+ },
+ {
+ id: "element_recovery_key",
+ title: "Create your recovery key",
+ action: "confirm",
+ description:
+ "In Element settings → Encryption, create a recovery key and store it in Vaultwarden.",
+ guide: { service: "element", step: "step2_record_recovery_key" },
+ },
+ {
+ id: "element_mobile_app",
+ title: "Optional: install Element X on mobile",
+ action: "checkbox",
+ description:
+ "Install Element X and sign in. Use Element Web → Settings → Sessions to connect your phone via QR.",
+ links: [{ href: "https://element.io/download", text: "Element X downloads" }],
+ guide: { service: "element", step: "step3_mobile_app_and_qr_code_login" },
+ },
+ ],
+ },
+ {
+ id: "mail",
+ title: "Mail",
+ summary: "Your @bstein.dev inbox for lab notifications and contact.",
+ benefit: "One address for every Atlas service and shared communication.",
+ steps: [
+ {
+ id: "mail_client_setup",
+ title: "Set up mail on a device",
+ action: "checkbox",
+ description:
+ "Use the IMAP/SMTP details on your Account page to add mail to your phone or desktop client (Thunderbird, Apple Mail, FairEmail).",
+ links: [{ href: "/account", text: "Open Account details" }],
+ guide: { service: "mail", step: "step1_mail_app" },
+ },
+ ],
+ },
+ {
+ id: "nextcloud",
+ title: "Nextcloud",
+ summary: "File storage, calendar, and mail hub for the lab.",
+ benefit: "Central workspace for docs, sharing, and your mailbox.",
+ steps: [
+ {
+ id: "nextcloud_web_access",
+ title: "Sign in to Nextcloud",
+ 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: "Nextcloud" }],
+ guide: { service: "nextcloud", step: "step1_web_access" },
+ },
+ {
+ id: "nextcloud_mail_integration",
+ title: "Mail integration ready",
+ action: "auto",
+ description:
+ "Atlas configures your mailbox inside Nextcloud automatically. If this stays pending, use Accounts → Sync Mail and retry.",
+ guide: { service: "nextcloud", step: "step2_mail_integration" },
+ },
+ {
+ id: "nextcloud_desktop_app",
+ title: "Optional: install the desktop sync app",
+ action: "checkbox",
+ description: "Install the Nextcloud desktop app to sync files locally.",
+ links: [{ href: "https://nextcloud.com/install/", text: "Nextcloud desktop" }],
+ guide: { service: "nextcloud", step: "step3_desktop_storage_app" },
+ },
+ {
+ id: "nextcloud_mobile_app",
+ title: "Optional: install the mobile app",
+ action: "checkbox",
+ description: "Install the Nextcloud mobile app for files and photos on the go.",
+ links: [{ href: "https://nextcloud.com/install/", text: "Nextcloud mobile" }],
+ guide: { service: "nextcloud", step: "step4_mobile_app" },
+ },
+ ],
+ },
+ {
+ id: "budget",
+ title: "Budget Encryption",
+ summary: "Actual Budget for private personal finance.",
+ benefit: "Encryption keeps your budget data safe and portable.",
+ steps: [
+ {
+ id: "budget_encryption_ack",
+ title: "Enable encryption inside Actual Budget",
+ action: "checkbox",
+ description:
+ "Actual Budget does not encrypt by default. Open Settings → Encryption, enable it, and store the key in Vaultwarden.",
+ bullets: [
+ "Keep the encryption key only in Vaultwarden.",
+ "If you lose the key, your budget data cannot be recovered.",
+ ],
+ links: [
+ { href: "https://budget.bstein.dev", text: "Actual Budget" },
+ { href: "https://vault.bstein.dev", text: "Vaultwarden" },
+ ],
+ guide: { service: "budget", step: "step1_encrypt_data" },
+ },
+ ],
+ },
+ {
+ id: "firefly",
+ title: "Firefly III",
+ summary: "Personal finance tracker for transactions and reporting.",
+ benefit: "Detailed insights, budgets, and exports under your control.",
+ steps: [
+ {
+ id: "firefly_password_rotated",
+ title: "Change your Firefly password",
+ action: "confirm",
+ 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: "Firefly III" },
+ { href: "/account", text: "Account credentials" },
+ ],
+ guide: { service: "firefly", step: "step1_web_access" },
+ },
+ {
+ id: "firefly_mobile_app",
+ title: "Optional: set up the mobile app",
+ action: "checkbox",
+ description:
+ "Install Abacus (Firefly III), connect to money.bstein.dev, and keep the OAuth credentials in Vaultwarden.",
+ links: [
+ { href: "https://github.com/vgsmar/Abacus/releases", text: "Abacus releases" },
+ { href: "/account", text: "Account credentials" },
+ ],
+ guide: { service: "firefly", step: "step2_mobile_app" },
+ },
+ ],
+ },
+ {
+ id: "wger",
+ title: "Wger",
+ summary: "Fitness tracking for workouts and nutrition.",
+ benefit: "Keeps training plans and progress in one place.",
+ steps: [
+ {
+ id: "wger_password_rotated",
+ title: "Change your Wger password",
+ action: "confirm",
+ 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: "Wger" },
+ { href: "/account", text: "Account credentials" },
+ ],
+ guide: { service: "wger", step: "step1_web_access" },
+ },
+ {
+ id: "wger_mobile_app",
+ title: "Optional: set up the mobile app",
+ action: "checkbox",
+ description:
+ "Install the Wger mobile app, sign in with your updated credentials, and store the password in Vaultwarden.",
+ links: [
+ { href: "https://github.com/wger-project/wger", text: "Wger project" },
+ { href: "/account", text: "Account credentials" },
+ ],
+ guide: { service: "wger", step: "step2_mobile_app" },
+ },
+ ],
+ },
+ {
+ id: "jellyfin",
+ title: "Jellyfin",
+ summary: "Self-hosted media streaming for the lab.",
+ benefit: "Watch your media anywhere without third-party accounts.",
+ steps: [
+ {
+ id: "jellyfin_web_access",
+ title: "Sign in to Jellyfin",
+ action: "checkbox",
+ description:
+ "Sign in with your Atlas username/password (LDAP-backed).",
+ links: [{ href: "https://stream.bstein.dev", text: "Jellyfin" }],
+ guide: { service: "jellyfin", step: "step1_web_access" },
+ },
+ {
+ id: "jellyfin_mobile_app",
+ title: "Optional: install the mobile app",
+ action: "checkbox",
+ description: "Install Jellyfin on mobile and connect to stream.bstein.dev.",
+ links: [{ href: "https://jellyfin.org/downloads/", text: "Jellyfin downloads" }],
+ guide: { service: "jellyfin", step: "step2_mobile_app" },
+ },
+ {
+ id: "jellyfin_tv_setup",
+ title: "Optional: connect a TV client",
+ action: "checkbox",
+ description:
+ "Use the Jellyfin app on your TV or streaming device (LG, Samsung, Roku, Apple TV, Xbox).",
+ links: [{ href: "https://jellyfin.org/downloads/", text: "Jellyfin TV apps" }],
+ },
+ ],
+ },
+];
+
+export 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 },
+};
diff --git a/frontend/src/onboarding/useOnboardingFlow.js b/frontend/src/onboarding/useOnboardingFlow.js
new file mode 100644
index 0000000..49b40d4
--- /dev/null
+++ b/frontend/src/onboarding/useOnboardingFlow.js
@@ -0,0 +1,500 @@
+import { computed, onMounted, ref } from "vue";
+import { auth, authFetch } from "../auth";
+import { useOnboardingGuides } from "./useOnboardingGuides";
+import { useOnboardingNavigation } from "./useOnboardingNavigation";
+import { statusLabel, statusPillClass, taskPillClass } from "./onboardingLabels";
+import { SECTION_DEFS, STEP_PREREQS, VAULTWARDEN_TEMP_STEP } from "./onboardingSections";
+
+/**
+ * Build the Onboarding page state machine.
+ *
+ * WHY: onboarding coordinates request status, guide media, password reveal,
+ * and service attestation flow; isolating that state keeps the view focused
+ * on layout and makes the workflow independently testable.
+ *
+ * @param {import("vue-router").RouteLocationNormalizedLoaded} route - active route with optional request code query params.
+ * @returns {object} reactive onboarding state and event handlers.
+ */
+export function useOnboardingFlow(route) {
+ const requestCode = ref("");
+ const requestUsername = ref("");
+ const status = ref("");
+ const loading = ref(false);
+ const error = ref("");
+ const onboarding = ref({ required_steps: [], optional_steps: [], completed_steps: [] });
+ const initialPassword = ref("");
+ const initialPasswordRevealedAt = ref("");
+ const revealPassword = ref(false);
+ const passwordCopied = ref(false);
+ const usernameCopied = ref(false);
+ const tasks = ref([]);
+ const blocked = ref(false);
+ const retrying = ref(false);
+ const retryMessage = ref("");
+ const keycloakPasswordRotationRequested = ref(false);
+ const activeSectionId = ref("vaultwarden");
+ const {
+ guideShots,
+ guidePage,
+ lightboxShot,
+ guideGroups,
+ guideKey,
+ guideIndex,
+ guideSet,
+ guidePrev,
+ guideNext,
+ guideShot,
+ shouldOpenGuide,
+ openLightbox,
+ closeLightbox,
+ loadGuideShots,
+ } = useOnboardingGuides({ isStepDone, isStepBlocked });
+ const confirmingStepId = ref("");
+
+ const showPasswordCard = computed(() => Boolean(initialPassword.value || initialPasswordRevealedAt.value));
+ const passwordRevealLocked = computed(() => Boolean(!initialPassword.value && initialPasswordRevealedAt.value));
+ const passwordRevealHint = computed(() =>
+ passwordRevealLocked.value
+ ? "This password was already revealed and cannot be shown again. Ask an admin to reset it if you missed it."
+ : "",
+ );
+ const vaultwardenRecoveryEmail = computed(() => onboarding.value?.vaultwarden?.recovery_email || "");
+ const vaultwardenMatched = computed(() => Boolean(onboarding.value?.vaultwarden?.matched));
+ const vaultwardenLoginEmail = computed(() => {
+ if (vaultwardenMatched.value) {
+ return vaultwardenRecoveryEmail.value || "your recovery email";
+ }
+ if (requestUsername.value) {
+ return `${requestUsername.value}@bstein.dev`;
+ }
+ return "your @bstein.dev address";
+ });
+ const vaultwardenLoginEmailLower = computed(() => (vaultwardenLoginEmail.value || "").toLowerCase());
+ const mailAddress = computed(() => (requestUsername.value ? `${requestUsername.value}@bstein.dev` : "your @bstein.dev address"));
+ const mailAddressLower = computed(() => (mailAddress.value || "").toLowerCase());
+
+ 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,
+ nextSectionItem,
+ hasPrevSection,
+ hasNextSection,
+ selectSection,
+ prevSection,
+ nextSection,
+ stepCardClass,
+ sectionProgress,
+ sectionStatusLabel,
+ sectionPillClass,
+ isSectionLocked,
+ isSectionDone,
+ sectionCardClass,
+ sectionGateComplete,
+ } = useOnboardingNavigation({ sections, activeSectionId, isStepDone, isStepRequired, isStepBlocked });
+
+ const showOnboarding = computed(() => status.value === "awaiting_onboarding" || status.value === "ready");
+ function isStepDone(stepId) {
+ const steps = onboarding.value?.completed_steps || [];
+ return Array.isArray(steps) ? steps.includes(stepId) : false;
+ }
+
+ function isStepRequired(stepId) {
+ const required = onboarding.value?.required_steps || [];
+ return Array.isArray(required) && required.includes(stepId);
+ }
+
+ function isStepBlocked(stepId) {
+ const prereqs = STEP_PREREQS[stepId] || [];
+ if (!prereqs.length) return false;
+ return prereqs.some((req) => !isStepDone(req));
+ }
+
+ function stepNote(step) {
+ if (step.id === "vaultwarden_master_password") {
+ return `Vaultwarden uses an email login. Use ${vaultwardenLoginEmailLower.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 ${mailAddressLower.value} to sign in.`;
+ }
+ if (step.id === "mail_client_setup") {
+ return `Your mailbox address is ${mailAddressLower.value}.`;
+ }
+ return "";
+ }
+
+ function stepPillLabel(step) {
+ if (isStepDone(step.id)) return "done";
+ if (isStepBlocked(step.id)) return "blocked";
+ if (step.action === "auto") return "pending";
+ if (!isStepRequired(step.id)) return "optional";
+ if (step.id === "keycloak_password_rotated") {
+ return keycloakPasswordRotationRequested.value ? "rotate now" : "ready";
+ }
+ return "pending";
+ }
+
+ function stepPillClass(step) {
+ if (isStepDone(step.id)) return "pill-ok";
+ if (isStepBlocked(step.id)) return "pill-wait";
+ if (!isStepRequired(step.id)) return "pill-info";
+ if (step.id === "keycloak_password_rotated" && !keycloakPasswordRotationRequested.value) {
+ return "pill-info";
+ }
+ return "pill-warn";
+ }
+
+ function isConfirming(step) {
+ return confirmingStepId.value === step.id;
+ }
+
+ function confirmLabel(step) {
+ return isConfirming(step) ? "Confirming..." : "Confirm";
+ }
+
+ function selectDefaultSection() {
+ const list = sections.value;
+ const firstIncomplete = list.find((section) => !isSectionDone(section) && !isSectionLocked(section));
+ activeSectionId.value = (firstIncomplete || list[0] || {}).id || "vaultwarden";
+ }
+
+ async function check() {
+ if (loading.value) return;
+ error.value = "";
+ loading.value = true;
+ try {
+ const resp = await fetch("/api/access/request/status", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ request_code: requestCode.value.trim(),
+ reveal_initial_password: true,
+ }),
+ });
+ const data = await resp.json().catch(() => ({}));
+ if (!resp.ok) throw new Error(data.error || resp.statusText || `status ${resp.status}`);
+ status.value = data.status || "unknown";
+ requestUsername.value = data.username || "";
+ onboarding.value = data.onboarding || { required_steps: [], optional_steps: [], completed_steps: [] };
+ keycloakPasswordRotationRequested.value = Boolean(data.onboarding?.keycloak?.password_rotation_requested);
+ tasks.value = Array.isArray(data.tasks) ? data.tasks : [];
+ blocked.value = Boolean(data.blocked);
+ initialPassword.value = data.initial_password || "";
+ initialPasswordRevealedAt.value = data.initial_password_revealed_at || "";
+ if (showOnboarding.value) {
+ selectDefaultSection();
+ }
+ } catch (err) {
+ error.value = err?.message || "Failed to check status";
+ tasks.value = [];
+ blocked.value = false;
+ keycloakPasswordRotationRequested.value = false;
+ } finally {
+ loading.value = false;
+ }
+ }
+
+ async function retryProvisioning() {
+ if (retrying.value) return;
+ retryMessage.value = "";
+ const code = requestCode.value.trim();
+ if (!code) return;
+ retrying.value = true;
+ try {
+ const retryTasks = tasks.value
+ .filter((item) => item.status === "error")
+ .map((item) => item.task)
+ .filter(Boolean);
+ const resp = await fetch("/api/access/request/retry", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ cache: "no-store",
+ body: JSON.stringify({ request_code: code, tasks: retryTasks }),
+ });
+ const data = await resp.json().catch(() => ({}));
+ if (!resp.ok) throw new Error(data.error || resp.statusText || `status ${resp.status}`);
+ retryMessage.value = "Retry requested. Check again in a moment.";
+ await check();
+ } catch (err) {
+ retryMessage.value = err?.message || "Retry request failed.";
+ } finally {
+ retrying.value = false;
+ }
+ }
+
+ function togglePassword() {
+ revealPassword.value = !revealPassword.value;
+ }
+
+ async function copyText(text, setFlag) {
+ if (!text) return;
+ try {
+ if (navigator?.clipboard?.writeText) {
+ await navigator.clipboard.writeText(text);
+ } else {
+ const fallback = document.createElement("textarea");
+ fallback.value = text;
+ fallback.setAttribute("readonly", "");
+ fallback.style.position = "fixed";
+ fallback.style.top = "-9999px";
+ fallback.style.left = "-9999px";
+ document.body.appendChild(fallback);
+ fallback.select();
+ fallback.setSelectionRange(0, fallback.value.length);
+ document.execCommand("copy");
+ document.body.removeChild(fallback);
+ }
+ setFlag(true);
+ setTimeout(() => setFlag(false), 1500);
+ } catch (err) {
+ error.value = err?.message || "Copy failed";
+ }
+ }
+
+ function copyInitialPassword() {
+ copyText(initialPassword.value, (value) => (passwordCopied.value = value));
+ }
+
+ function copyUsername() {
+ copyText(requestUsername.value, (value) => (usernameCopied.value = value));
+ }
+
+ async function toggleStep(stepId, event) {
+ const checked = Boolean(event?.target?.checked);
+ await setStepCompletion(stepId, checked);
+ }
+
+ async function setStepCompletion(stepId, completed, extra = {}) {
+ if (!requestCode.value.trim()) {
+ error.value = "Request code is missing.";
+ return;
+ }
+ if (isStepBlocked(stepId)) {
+ return;
+ }
+ loading.value = true;
+ error.value = "";
+ try {
+ const requester = auth.authenticated ? authFetch : fetch;
+ let resp = await requester("/api/access/request/onboarding/attest", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ request_code: requestCode.value.trim(), step: stepId, completed, ...extra }),
+ });
+ if ([401, 403].includes(resp.status) && requester === authFetch) {
+ resp = await fetch("/api/access/request/onboarding/attest", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ request_code: requestCode.value.trim(), step: stepId, completed, ...extra }),
+ });
+ }
+ const data = await resp.json().catch(() => ({}));
+ if (!resp.ok) throw new Error(data.error || resp.statusText || `status ${resp.status}`);
+ status.value = data.status || status.value;
+ onboarding.value = data.onboarding || onboarding.value;
+ } catch (err) {
+ error.value = err?.message || "Failed to update onboarding";
+ } finally {
+ loading.value = false;
+ }
+ }
+
+ async function confirmStep(step) {
+ if (!step || isStepBlocked(step.id) || isStepDone(step.id)) return;
+ confirmingStepId.value = step.id;
+ try {
+ if (step.id === "keycloak_password_rotated") {
+ await requestKeycloakPasswordRotation();
+ await check();
+ return;
+ }
+ if (step.action === "auto") {
+ if (step.id === "firefly_password_rotated") {
+ const result = await runRotationCheck("firefly");
+ if (result && result.rotated === false) {
+ throw new Error("Firefly still uses the initial password. Change it in Firefly, then confirm again.");
+ }
+ }
+ if (step.id === "wger_password_rotated") {
+ const result = await runRotationCheck("wger");
+ if (result && result.rotated === false) {
+ throw new Error("Wger still uses the initial password. Change it in Wger, then confirm again.");
+ }
+ }
+ await check();
+ return;
+ }
+ if (step.action === "confirm") {
+ await check();
+ if (!isStepDone(step.id)) {
+ await setStepCompletion(step.id, true);
+ }
+ return;
+ }
+ await setStepCompletion(step.id, true);
+ } catch (err) {
+ error.value = err?.message || "Failed to confirm step";
+ } finally {
+ confirmingStepId.value = "";
+ }
+ }
+
+ async function runRotationCheck(service) {
+ if (!auth.authenticated) {
+ throw new Error("Log in to update onboarding steps.");
+ }
+ const endpoint =
+ service === "firefly"
+ ? "/api/account/firefly/rotation/check"
+ : "/api/account/wger/rotation/check";
+ const resp = await authFetch(endpoint, { method: "POST" });
+ const data = await resp.json().catch(() => ({}));
+ if (!resp.ok) {
+ throw new Error(data.error || resp.statusText || `status ${resp.status}`);
+ }
+ return data;
+ }
+
+ async function requestKeycloakPasswordRotation() {
+ if (!requestCode.value.trim()) {
+ error.value = "Request code is missing.";
+ return;
+ }
+ if (isStepBlocked("keycloak_password_rotated")) {
+ error.value = "Complete earlier onboarding steps first.";
+ return;
+ }
+ if (keycloakPasswordRotationRequested.value) return;
+
+ loading.value = true;
+ error.value = "";
+ try {
+ const requester = auth.authenticated ? authFetch : fetch;
+ let resp = await requester("/api/access/request/onboarding/keycloak-password-rotate", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ request_code: requestCode.value.trim() }),
+ });
+ if ([401, 403].includes(resp.status) && requester === authFetch) {
+ resp = await fetch("/api/access/request/onboarding/keycloak-password-rotate", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ request_code: requestCode.value.trim() }),
+ });
+ }
+ const data = await resp.json().catch(() => ({}));
+ if (!resp.ok) throw new Error(data.error || resp.statusText || `status ${resp.status}`);
+ onboarding.value = data.onboarding || onboarding.value;
+ status.value = data.status || status.value;
+ keycloakPasswordRotationRequested.value = Boolean(data.onboarding?.keycloak?.password_rotation_requested);
+ } catch (err) {
+ error.value = err?.message || "Failed to request password rotation";
+ } finally {
+ loading.value = false;
+ }
+ }
+
+ onMounted(async () => {
+ const code = route.query.code || route.query.request_code || "";
+ if (typeof code === "string" && code.trim()) {
+ requestCode.value = code.trim();
+ await check();
+ }
+ await loadGuideShots();
+ });
+
+ return {
+ requestCode,
+ requestUsername,
+ status,
+ loading,
+ error,
+ onboarding,
+ initialPassword,
+ initialPasswordRevealedAt,
+ revealPassword,
+ passwordCopied,
+ usernameCopied,
+ tasks,
+ blocked,
+ retrying,
+ retryMessage,
+ keycloakPasswordRotationRequested,
+ activeSectionId,
+ guideShots,
+ guidePage,
+ lightboxShot,
+ confirmingStepId,
+ showPasswordCard,
+ passwordRevealLocked,
+ passwordRevealHint,
+ vaultwardenRecoveryEmail,
+ vaultwardenMatched,
+ vaultwardenLoginEmail,
+ vaultwardenLoginEmailLower,
+ mailAddress,
+ mailAddressLower,
+ sections,
+ activeSection,
+ nextSectionItem,
+ hasPrevSection,
+ hasNextSection,
+ showOnboarding,
+ selectSection,
+ prevSection,
+ nextSection,
+ statusLabel,
+ statusPillClass,
+ isStepDone,
+ isStepRequired,
+ isStepBlocked,
+ stepNote,
+ stepPillLabel,
+ stepPillClass,
+ isConfirming,
+ confirmLabel,
+ stepCardClass,
+ sectionProgress,
+ sectionStatusLabel,
+ sectionPillClass,
+ isSectionLocked,
+ isSectionDone,
+ sectionCardClass,
+ sectionGateComplete,
+ guideGroups,
+ guideKey,
+ guideIndex,
+ guideSet,
+ guidePrev,
+ guideNext,
+ guideShot,
+ shouldOpenGuide,
+ openLightbox,
+ closeLightbox,
+ taskPillClass,
+ selectDefaultSection,
+ check,
+ retryProvisioning,
+ togglePassword,
+ copyText,
+ copyInitialPassword,
+ copyUsername,
+ toggleStep,
+ setStepCompletion,
+ confirmStep,
+ runRotationCheck,
+ requestKeycloakPasswordRotation,
+ loadGuideShots,
+ };
+}
diff --git a/frontend/src/onboarding/useOnboardingGuides.js b/frontend/src/onboarding/useOnboardingGuides.js
new file mode 100644
index 0000000..967e4d0
--- /dev/null
+++ b/frontend/src/onboarding/useOnboardingGuides.js
@@ -0,0 +1,108 @@
+import { ref } from "vue";
+import { parseManifest } from "./onboardingGuides";
+
+/**
+ * Manage onboarding guide media, pagination, and lightbox state.
+ *
+ * @param {object} gates - Step completion/blocking predicates from the onboarding flow.
+ * @returns {object} guide state and guide UI helpers.
+ */
+export function useOnboardingGuides({ isStepDone, isStepBlocked }) {
+ const guideShots = ref({});
+ const guidePage = ref({});
+ const lightboxShot = ref(null);
+
+ function guideGroups(step) {
+ if (!step.guide) return [];
+ const service = step.guide.service;
+ const stepKey = step.guide.step;
+ const serviceShots = guideShots.value?.[service] || {};
+ const stepShots = serviceShots?.[stepKey] || {};
+ 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) {
+ const service = step.guide?.service || "unknown";
+ const stepKey = step.guide?.step || "unknown";
+ return `${service}:${stepKey}:${group.id}`;
+ }
+
+ function guideIndex(step, group) {
+ const key = guideKey(step, group);
+ const index = guidePage.value[key] ?? 0;
+ const maxIndex = Math.max(group.shots.length - 1, 0);
+ return Math.min(Math.max(index, 0), maxIndex);
+ }
+
+ function guideSet(step, group, index) {
+ const key = guideKey(step, group);
+ const next = Math.min(Math.max(index, 0), group.shots.length - 1);
+ guidePage.value = { ...guidePage.value, [key]: next };
+ }
+
+ function guidePrev(step, group) {
+ guideSet(step, group, guideIndex(step, group) - 1);
+ }
+
+ function guideNext(step, group) {
+ guideSet(step, group, guideIndex(step, group) + 1);
+ }
+
+ 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;
+ }
+
+ function closeLightbox() {
+ lightboxShot.value = null;
+ }
+
+
+ async function loadGuideShots() {
+ try {
+ const resp = await fetch("/media/onboarding/manifest.json", { headers: { Accept: "application/json" } });
+ if (!resp.ok) return;
+ const payload = await resp.json();
+ const files = Array.isArray(payload?.files) ? payload.files : [];
+ guideShots.value = parseManifest(files);
+ } catch {
+ guideShots.value = {};
+ }
+ }
+
+ return {
+ guideShots,
+ guidePage,
+ lightboxShot,
+ guideGroups,
+ guideKey,
+ guideIndex,
+ guideSet,
+ guidePrev,
+ guideNext,
+ guideShot,
+ shouldOpenGuide,
+ openLightbox,
+ closeLightbox,
+ loadGuideShots,
+ };
+}
diff --git a/frontend/src/onboarding/useOnboardingNavigation.js b/frontend/src/onboarding/useOnboardingNavigation.js
new file mode 100644
index 0000000..492e9b2
--- /dev/null
+++ b/frontend/src/onboarding/useOnboardingNavigation.js
@@ -0,0 +1,123 @@
+import { computed } from "vue";
+
+/**
+ * Manage onboarding section navigation and section completion state.
+ *
+ * @param {object} options - Section refs and step predicates from the flow.
+ * @returns {object} section navigation state and helpers.
+ */
+export function useOnboardingNavigation({ sections, activeSectionId, isStepDone, isStepRequired, isStepBlocked }) {
+ const activeSection = computed(() => sections.value.find((item) => item.id === activeSectionId.value));
+
+ const nextSectionItem = computed(() => {
+ const list = sections.value;
+ const index = list.findIndex((item) => item.id === activeSectionId.value);
+ return index >= 0 ? list[index + 1] : null;
+ });
+
+ const hasPrevSection = computed(() => {
+ const list = sections.value;
+ const index = list.findIndex((item) => item.id === activeSectionId.value);
+ return index > 0;
+ });
+
+ const hasNextSection = computed(() => Boolean(nextSectionItem.value));
+
+ function selectSection(sectionId) {
+ if (!sectionId) return;
+ const section = sections.value.find((item) => item.id === sectionId);
+ if (!section) return;
+ if (isSectionLocked(section)) return;
+ activeSectionId.value = sectionId;
+ }
+
+ function prevSection() {
+ const list = sections.value;
+ const index = list.findIndex((item) => item.id === activeSectionId.value);
+ if (index > 0) {
+ activeSectionId.value = list[index - 1].id;
+ }
+ }
+
+ function nextSection() {
+ const nextItem = nextSectionItem.value;
+ if (nextItem && !isSectionLocked(nextItem)) {
+ activeSectionId.value = nextItem.id;
+ }
+ }
+
+ function stepCardClass(step) {
+ return {
+ done: isStepDone(step.id),
+ blocked: isStepBlocked(step.id),
+ optional: !isStepRequired(step.id),
+ };
+ }
+
+ function sectionProgress(section) {
+ const requiredSteps = section.steps.filter((step) => isStepRequired(step.id));
+ if (!requiredSteps.length) return "optional";
+ if (isSectionLocked(section)) return `0/${requiredSteps.length} done`;
+ const doneCount = requiredSteps.filter((step) => isStepDone(step.id) && !isStepBlocked(step.id)).length;
+ return `${doneCount}/${requiredSteps.length} done`;
+ }
+
+ function sectionStatusLabel(section) {
+ if (isSectionDone(section)) return "";
+ if (isSectionLocked(section)) return "locked";
+ return "active";
+ }
+
+ function sectionPillClass(section) {
+ if (isSectionLocked(section)) return "pill-wait";
+ return "pill-info";
+ }
+
+ function isSectionLocked(section) {
+ const list = sections.value;
+ const index = list.findIndex((item) => item.id === section.id);
+ if (index <= 0) return false;
+ const previous = list[index - 1];
+ return !sectionGateComplete(previous);
+ }
+
+ function isSectionDone(section) {
+ const requiredSteps = section.steps.filter((step) => isStepRequired(step.id));
+ const stepsToCheck = requiredSteps.length ? requiredSteps : section.steps;
+ if (!stepsToCheck.length) return false;
+ return stepsToCheck.every((step) => isStepDone(step.id));
+ }
+
+ function sectionCardClass(section) {
+ return {
+ active: section.id === activeSectionId.value,
+ done: isSectionDone(section),
+ locked: isSectionLocked(section),
+ };
+ }
+
+ function sectionGateComplete(section) {
+ const requiredSteps = section.steps.filter((step) => isStepRequired(step.id));
+ if (!requiredSteps.length) return true;
+ return requiredSteps.every((step) => isStepDone(step.id));
+ }
+
+
+ return {
+ activeSection,
+ nextSectionItem,
+ hasPrevSection,
+ hasNextSection,
+ selectSection,
+ prevSection,
+ nextSection,
+ stepCardClass,
+ sectionProgress,
+ sectionStatusLabel,
+ sectionPillClass,
+ isSectionLocked,
+ isSectionDone,
+ sectionCardClass,
+ sectionGateComplete,
+ };
+}
diff --git a/frontend/src/views/OnboardingView.vue b/frontend/src/views/OnboardingView.vue
index bbf20ee..7ed25d9 100644
--- a/frontend/src/views/OnboardingView.vue
+++ b/frontend/src/views/OnboardingView.vue
@@ -316,927 +316,69 @@