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>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, onMounted, ref } from "vue";
|
|
||||||
import { useRoute } from "vue-router";
|
import { useRoute } from "vue-router";
|
||||||
import { auth, authFetch } from "../auth";
|
import { useOnboardingFlow } from "../onboarding/useOnboardingFlow";
|
||||||
|
|
||||||
const route = useRoute();
|
const {
|
||||||
|
requestCode,
|
||||||
const requestCode = ref("");
|
requestUsername,
|
||||||
const requestUsername = ref("");
|
status,
|
||||||
const status = ref("");
|
loading,
|
||||||
const loading = ref(false);
|
error,
|
||||||
const error = ref("");
|
onboarding,
|
||||||
const onboarding = ref({ required_steps: [], optional_steps: [], completed_steps: [] });
|
initialPassword,
|
||||||
const initialPassword = ref("");
|
revealPassword,
|
||||||
const initialPasswordRevealedAt = ref("");
|
passwordCopied,
|
||||||
const revealPassword = ref(false);
|
usernameCopied,
|
||||||
const passwordCopied = ref(false);
|
tasks,
|
||||||
const usernameCopied = ref(false);
|
blocked,
|
||||||
const tasks = ref([]);
|
retrying,
|
||||||
const blocked = ref(false);
|
retryMessage,
|
||||||
const retrying = ref(false);
|
lightboxShot,
|
||||||
const retryMessage = ref("");
|
showPasswordCard,
|
||||||
const keycloakPasswordRotationRequested = ref(false);
|
passwordRevealHint,
|
||||||
const activeSectionId = ref("vaultwarden");
|
sections,
|
||||||
const guideShots = ref({});
|
activeSection,
|
||||||
const guidePage = ref({});
|
nextSectionItem,
|
||||||
const lightboxShot = ref(null);
|
hasPrevSection,
|
||||||
const confirmingStepId = ref("");
|
hasNextSection,
|
||||||
|
showOnboarding,
|
||||||
const showPasswordCard = computed(() => Boolean(initialPassword.value || initialPasswordRevealedAt.value));
|
selectSection,
|
||||||
const passwordRevealLocked = computed(() => Boolean(!initialPassword.value && initialPasswordRevealedAt.value));
|
prevSection,
|
||||||
const passwordRevealHint = computed(() =>
|
nextSection,
|
||||||
passwordRevealLocked.value
|
statusLabel,
|
||||||
? "This password was already revealed and cannot be shown again. Ask an admin to reset it if you missed it."
|
statusPillClass,
|
||||||
: "",
|
isStepDone,
|
||||||
);
|
isStepBlocked,
|
||||||
const vaultwardenRecoveryEmail = computed(() => onboarding.value?.vaultwarden?.recovery_email || "");
|
stepNote,
|
||||||
const vaultwardenMatched = computed(() => Boolean(onboarding.value?.vaultwarden?.matched));
|
stepPillLabel,
|
||||||
const vaultwardenLoginEmail = computed(() => {
|
stepPillClass,
|
||||||
if (vaultwardenMatched.value) {
|
isConfirming,
|
||||||
return vaultwardenRecoveryEmail.value || "your recovery email";
|
confirmLabel,
|
||||||
}
|
stepCardClass,
|
||||||
if (requestUsername.value) {
|
sectionProgress,
|
||||||
return `${requestUsername.value}@bstein.dev`;
|
sectionStatusLabel,
|
||||||
}
|
sectionPillClass,
|
||||||
return "your @bstein.dev address";
|
isSectionLocked,
|
||||||
});
|
sectionCardClass,
|
||||||
const vaultwardenLoginEmailLower = computed(() => (vaultwardenLoginEmail.value || "").toLowerCase());
|
guideGroups,
|
||||||
const mailAddress = computed(() => (requestUsername.value ? `${requestUsername.value}@bstein.dev` : "your @bstein.dev address"));
|
guideIndex,
|
||||||
const mailAddressLower = computed(() => (mailAddress.value || "").toLowerCase());
|
guideSet,
|
||||||
|
guidePrev,
|
||||||
const STEP_PREREQS = {
|
guideNext,
|
||||||
vaultwarden_master_password: [],
|
guideShot,
|
||||||
vaultwarden_store_temp_password: ["vaultwarden_master_password"],
|
shouldOpenGuide,
|
||||||
vaultwarden_browser_extension: ["vaultwarden_master_password"],
|
openLightbox,
|
||||||
vaultwarden_mobile_app: ["vaultwarden_master_password"],
|
closeLightbox,
|
||||||
keycloak_password_rotated: ["vaultwarden_master_password"],
|
taskPillClass,
|
||||||
element_recovery_key: ["keycloak_password_rotated"],
|
check,
|
||||||
element_mobile_app: ["element_recovery_key"],
|
retryProvisioning,
|
||||||
mail_client_setup: ["vaultwarden_master_password"],
|
togglePassword,
|
||||||
nextcloud_web_access: ["vaultwarden_master_password"],
|
copyInitialPassword,
|
||||||
nextcloud_mail_integration: ["nextcloud_web_access"],
|
copyUsername,
|
||||||
nextcloud_desktop_app: ["nextcloud_web_access"],
|
toggleStep,
|
||||||
nextcloud_mobile_app: ["nextcloud_web_access"],
|
confirmStep,
|
||||||
budget_encryption_ack: ["nextcloud_mail_integration"],
|
} = useOnboardingFlow(useRoute());
|
||||||
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();
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped src="../styles/onboarding.css"></style>
|
<style scoped src="../styles/onboarding.css"></style>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user