refactor(bstein-home): extract onboarding flow modules
This commit is contained in:
parent
0273da9e79
commit
aae51f26e1
57
frontend/src/onboarding/onboardingGuides.js
Normal file
57
frontend/src/onboarding/onboardingGuides.js
Normal 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();
|
||||
}
|
||||
29
frontend/src/onboarding/onboardingLabels.js
Normal file
29
frontend/src/onboarding/onboardingLabels.js
Normal 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";
|
||||
}
|
||||
299
frontend/src/onboarding/onboardingSections.js
Normal file
299
frontend/src/onboarding/onboardingSections.js
Normal 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 },
|
||||
};
|
||||
500
frontend/src/onboarding/useOnboardingFlow.js
Normal file
500
frontend/src/onboarding/useOnboardingFlow.js
Normal 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,
|
||||
};
|
||||
}
|
||||
108
frontend/src/onboarding/useOnboardingGuides.js
Normal file
108
frontend/src/onboarding/useOnboardingGuides.js
Normal 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,
|
||||
};
|
||||
}
|
||||
123
frontend/src/onboarding/useOnboardingNavigation.js
Normal file
123
frontend/src/onboarding/useOnboardingNavigation.js
Normal 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,
|
||||
};
|
||||
}
|
||||
@ -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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user