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 @@