refactor(bstein-home): extract onboarding flow modules

This commit is contained in:
codex 2026-04-21 07:03:21 -03:00
parent 0273da9e79
commit aae51f26e1
7 changed files with 1177 additions and 919 deletions

View File

@ -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();
}

View File

@ -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";
}

View File

@ -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 },
};

View File

@ -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,
};
}

View File

@ -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,
};
}

View File

@ -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,
};
}

View File

@ -316,927 +316,69 @@
</template>
<script setup>
import { computed, onMounted, ref } from "vue";
import { useRoute } from "vue-router";
import { auth, authFetch } from "../auth";
import { useOnboardingFlow } from "../onboarding/useOnboardingFlow";
const route = useRoute();
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 = ref({});
const guidePage = ref({});
const lightboxShot = ref(null);
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 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"],
};
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" }],
},
],
},
];
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(() => {
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));
const showOnboarding = computed(() => status.value === "awaiting_onboarding" || status.value === "ready");
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 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";
}
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";
}
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 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));
}
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;
}
function taskPillClass(value) {
const key = (value || "").trim();
if (key === "ok") return "pill-ok";
if (key === "error") return "pill-bad";
return "pill-warn";
}
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;
}
}
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();
}
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 = {};
}
}
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();
});
const {
requestCode,
requestUsername,
status,
loading,
error,
onboarding,
initialPassword,
revealPassword,
passwordCopied,
usernameCopied,
tasks,
blocked,
retrying,
retryMessage,
lightboxShot,
showPasswordCard,
passwordRevealHint,
sections,
activeSection,
nextSectionItem,
hasPrevSection,
hasNextSection,
showOnboarding,
selectSection,
prevSection,
nextSection,
statusLabel,
statusPillClass,
isStepDone,
isStepBlocked,
stepNote,
stepPillLabel,
stepPillClass,
isConfirming,
confirmLabel,
stepCardClass,
sectionProgress,
sectionStatusLabel,
sectionPillClass,
isSectionLocked,
sectionCardClass,
guideGroups,
guideIndex,
guideSet,
guidePrev,
guideNext,
guideShot,
shouldOpenGuide,
openLightbox,
closeLightbox,
taskPillClass,
check,
retryProvisioning,
togglePassword,
copyInitialPassword,
copyUsername,
toggleStep,
confirmStep,
} = useOnboardingFlow(useRoute());
</script>
<style scoped src="../styles/onboarding.css"></style>