bstein-dev-home/frontend/src/views/OnboardingView.vue

1667 lines
49 KiB
Vue
Raw Normal View History

2026-01-02 01:34:18 -03:00
<template>
<div class="page">
<section class="card hero glass">
<div>
<p class="eyebrow">Atlas</p>
<h1>Onboarding</h1>
<p class="lede">Use your request code to view status and next steps.</p>
</div>
</section>
<section class="card module">
<div class="module-head">
<h2>Request Code</h2>
<span class="pill mono" :class="statusPillClass(status)">
{{ statusLabel(status) }}
2026-01-02 01:34:18 -03:00
</span>
</div>
<div class="status-form">
<input
v-model="requestCode"
class="input mono"
type="text"
placeholder="username~XXXXXXXXXX"
:disabled="loading"
/>
2026-01-02 01:34:18 -03:00
<button class="primary" type="button" @click="check" :disabled="loading || !requestCode.trim()">
{{ loading ? "Checking..." : "Check" }}
</button>
</div>
<div v-if="status === 'pending_email_verification'" class="steps">
<h3>Confirm your email</h3>
<p class="muted">
Open the verification email from Atlas and click the link to confirm your address. After verification, an admin can
approve your request.
</p>
<p class="muted">
If you did not receive an email, return to
<a href="/request-access">Request Access</a>
and submit again using a reachable external address.
</p>
</div>
<div v-if="status === 'pending'" class="steps">
<h3>Awaiting approval</h3>
<p class="muted">An Atlas admin has to approve this request before an account can be provisioned.</p>
</div>
<div v-if="status === 'accounts_building'" class="steps">
<h3>Accounts building</h3>
<p class="muted">Your request is approved. Atlas is now preparing accounts and credentials. Check back in a minute.</p>
<div v-if="tasks.length" class="task-box">
<div class="module-head" style="margin-bottom: 10px;">
<h3>Automation</h3>
<span class="pill mono" :class="blocked ? 'pill-bad' : 'pill-ok'">
{{ blocked ? "blocked" : "running" }}
</span>
</div>
<ul class="task-list">
<li v-for="item in tasks" :key="item.task" class="task-row">
<span class="mono task-name">{{ item.task }}</span>
<span class="pill mono" :class="taskPillClass(item.status)">{{ item.status }}</span>
<span v-if="item.detail" class="mono task-detail">{{ item.detail }}</span>
</li>
</ul>
<p v-if="blocked" class="muted" style="margin-top: 10px;">
One or more automation steps failed. Fix the error above, then check again.
</p>
</div>
</div>
<div v-if="showOnboarding" class="steps">
<div class="onboarding-head">
<div>
<h3>Onboarding</h3>
<p class="muted">
Onboarding is fully self-service. Work through each section in order; you can pause and return later. Vaultwarden
comes first because it stores every credential that follows.
</p>
</div>
<span v-if="status !== 'ready'" class="pill mono pill-info">active</span>
</div>
<ol class="section-stepper">
<li v-for="(section, index) in sections" :key="section.id" class="stepper-item">
<button
class="stepper-card"
:class="sectionCardClass(section)"
:disabled="isSectionLocked(section)"
type="button"
@click="selectSection(section.id)"
>
<span class="stepper-dot" aria-hidden="true"></span>
<div class="stepper-body">
<div class="stepper-title">{{ index + 1 }}. {{ section.title }}</div>
<div class="stepper-meta">
<span v-if="sectionStatusLabel(section)" class="pill mono" :class="sectionPillClass(section)">
{{ sectionStatusLabel(section) }}
</span>
<span class="pill mono pill-compact">{{ sectionProgress(section) }}</span>
</div>
</div>
</button>
</li>
</ol>
<div v-if="requestUsername || initialPassword" class="credential-card">
<div class="credential-grid">
<div class="credential-field">
<span class="label mono">Username</span>
<div class="password-row">
<input class="input mono" type="text" :value="requestUsername || ''" readonly />
<button class="secondary" type="button" @click="copyUsername" :disabled="!requestUsername">
{{ usernameCopied ? "Copied" : "Copy" }}
</button>
</div>
</div>
<div class="credential-field" v-if="showPasswordCard">
<span class="label mono">Temporary password</span>
<div class="password-row">
<input
class="input mono"
:type="revealPassword ? 'text' : 'password'"
:value="initialPassword || '********'"
readonly
/>
<span class="tooltip-wrap" :title="passwordRevealHint">
<button class="secondary" type="button" @click="togglePassword" :disabled="!initialPassword">
{{ revealPassword ? "Hide" : "Reveal" }}
</button>
</span>
<span class="tooltip-wrap" :title="passwordRevealHint">
<button class="secondary" type="button" @click="copyInitialPassword" :disabled="!initialPassword">
{{ passwordCopied ? "Copied" : "Copy" }}
</button>
</span>
</div>
</div>
</div>
<p class="muted">
Use the temporary password to sign in the first time (Nextcloud, Element). You will rotate it after Vaultwarden is
ready. Store the new password in Vaultwarden.
</p>
</div>
<div class="section-shell" v-if="activeSection">
<div class="section-header">
<div>
<h3>{{ activeSection.title }}</h3>
<p class="muted">{{ activeSection.description }}</p>
2026-01-04 21:57:31 -03:00
</div>
</div>
2026-01-04 21:57:31 -03:00
<div class="step-grid">
<article v-for="step in activeSection.steps" :key="step.id" class="step-card" :class="stepCardClass(step)">
<div class="step-head">
<div class="step-title">
<label v-if="step.action === 'checkbox'" class="step-label">
<input
type="checkbox"
:checked="isStepDone(step.id)"
:disabled="loading || isStepBlocked(step.id)"
@change="toggleStep(step.id, $event)"
/>
<span>{{ step.title }}</span>
</label>
<div v-else class="step-label">
<span>{{ step.title }}</span>
</div>
2026-01-04 21:57:31 -03:00
</div>
<span class="pill mono auto-pill" :class="stepPillClass(step)">
{{ stepPillLabel(step) }}
</span>
2026-01-04 21:57:31 -03:00
</div>
<p class="muted" v-if="step.description">{{ step.description }}</p>
<ul v-if="step.bullets && step.bullets.length" class="step-bullets">
<li v-for="bullet in step.bullets" :key="bullet">{{ bullet }}</li>
</ul>
<div v-if="step.links && step.links.length" class="step-links">
<a v-for="link in step.links" :key="link.href" :href="link.href" target="_blank" rel="noreferrer">
{{ link.text }}
</a>
</div>
<details v-if="step.guide" class="guide-details">
<summary class="mono">Photo guide</summary>
<div v-if="guideGroups(step).length" class="guide-groups">
<div v-for="group in guideGroups(step)" :key="group.id" class="guide-group">
<h4 v-if="group.title" class="mono guide-title">{{ group.title }}</h4>
<div v-if="group.shots.length" class="guide-images">
<figure class="guide-shot" @click="openLightbox(guideShot(step, group))">
<figcaption v-if="guideShot(step, group).label" class="mono">{{ guideShot(step, group).label }}</figcaption>
<img :src="guideShot(step, group).url" :alt="guideShot(step, group).label || step.title" loading="lazy" />
</figure>
<div v-if="group.shots.length > 1" class="guide-pagination">
<button
class="secondary"
type="button"
@click="guidePrev(step, group)"
:disabled="guideIndex(step, group) === 0"
>
Prev
</button>
<div class="guide-dots">
<button
v-for="(shot, index) in group.shots"
:key="shot.url"
class="guide-dot mono"
type="button"
:class="{ active: guideIndex(step, group) === index }"
@click="guideSet(step, group, index)"
>
{{ index + 1 }}
</button>
</div>
<button
class="secondary"
type="button"
@click="guideNext(step, group)"
:disabled="guideIndex(step, group) >= group.shots.length - 1"
>
Next
</button>
</div>
</div>
</div>
</div>
<p v-else class="muted">Guide coming soon.</p>
</details>
<div class="step-actions">
<button
class="secondary"
type="button"
@click="confirmStep(step)"
:disabled="loading || isConfirming(step) || isStepDone(step.id) || isStepBlocked(step.id)"
>
{{ confirmLabel(step) }}
</button>
</div>
</article>
</div>
<div class="section-actions">
<button class="secondary" type="button" @click="prevSection" :disabled="!hasPrevSection">
Previous
</button>
<button
class="secondary"
type="button"
@click="nextSection"
:disabled="!hasNextSection || isSectionLocked(nextSectionItem) || !sectionGateComplete(activeSection)"
>
Next
</button>
</div>
</div>
<div v-if="status === 'ready'" class="ready-box">
<h3>You're ready</h3>
<p class="muted">
Your Atlas account is provisioned and onboarding is complete. You can log in at
<a href="https://cloud.bstein.dev" target="_blank" rel="noreferrer">cloud.bstein.dev</a>.
</p>
</div>
2026-01-02 01:34:18 -03:00
</div>
<div v-if="status === 'denied'" class="steps">
<h3>Denied</h3>
<p class="muted">This request was denied. Contact the Atlas admin if you think this is a mistake.</p>
</div>
<div v-if="error" class="error-box">
<div class="mono">{{ error }}</div>
</div>
</section>
<div v-if="lightboxShot" class="lightbox" @click.self="closeLightbox">
<div class="lightbox-card">
<div class="lightbox-head">
<span class="mono lightbox-label">{{ lightboxShot.label || "Guide image" }}</span>
<button class="secondary" type="button" @click="closeLightbox">Close</button>
</div>
<img :src="lightboxShot.url" :alt="lightboxShot.label || 'Guide image'" />
</div>
</div>
2026-01-02 01:34:18 -03:00
</div>
</template>
<script setup>
import { computed, onMounted, ref } from "vue";
2026-01-02 01:34:18 -03:00
import { useRoute } from "vue-router";
import { auth, authFetch } from "../auth";
2026-01-02 01:34:18 -03:00
const route = useRoute();
const requestCode = ref("");
const requestUsername = ref("");
2026-01-02 01:34:18 -03:00
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);
2026-01-04 08:44:25 -03:00
const usernameCopied = ref(false);
const tasks = ref([]);
const blocked = ref(false);
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 STEP_PREREQS = {
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"],
jellyfin_web_access: ["vaultwarden_master_password"],
jellyfin_mobile_app: ["jellyfin_web_access"],
jellyfin_tv_setup: ["jellyfin_web_access"],
};
const SECTION_DEFS = [
2026-01-12 23:29:32 -03:00
{
id: "vaultwarden",
title: "Vaultwarden",
description: "Unlock your vault and install the tools that will store every other credential.",
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: "vault.bstein.dev" },
],
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" },
},
],
2026-01-12 23:29:32 -03:00
},
{
id: "element",
title: "Element",
description: "Secure chat, calls, and video for the lab.",
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" },
},
],
2026-01-12 23:29:32 -03:00
},
{
id: "mail",
title: "Mail",
description: "Add your @bstein.dev mailbox to your preferred mail apps.",
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",
description: "Access files and confirm your Atlas mail integration inside Nextcloud.",
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: "cloud.bstein.dev" }],
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",
description: "Encrypt financial data inside Actual Budget and store the key safely.",
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: "budget.bstein.dev" },
{ href: "https://vault.bstein.dev", text: "Vaultwarden" },
],
guide: { service: "budget", step: "step1_encrypt_data" },
},
],
},
{
id: "firefly",
title: "Firefly III",
description: "Change your initial Firefly password and store it in Vaultwarden.",
steps: [
{
id: "firefly_password_rotated",
title: "Change your Firefly password",
action: "auto",
description:
"Sign in to money.bstein.dev with the credentials on your Account page, then change the password. This step completes once the original password no longer works.",
links: [
{ href: "https://money.bstein.dev", text: "money.bstein.dev" },
{ 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",
description: "Change your initial Wger password and store it in Vaultwarden.",
steps: [
{
id: "wger_password_rotated",
title: "Change your Wger password",
action: "auto",
description:
"Sign in to health.bstein.dev with the credentials on your Account page, then change the password. This step completes once the original password no longer works.",
links: [
{ href: "https://health.bstein.dev", text: "health.bstein.dev" },
{ href: "/account", text: "Account credentials" },
],
guide: { service: "wger", step: "step1_web_access" },
},
],
},
{
id: "jellyfin",
title: "Jellyfin",
description: "Optional media access across web, mobile, and TV clients.",
steps: [
{
id: "jellyfin_web_access",
title: "Optional: sign in to Jellyfin",
action: "checkbox",
description:
"Sign in with your Atlas username/password (LDAP-backed).",
links: [{ href: "https://stream.bstein.dev", text: "stream.bstein.dev" }],
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" }],
guide: { service: "jellyfin", step: "step3_tv_integrations" },
},
],
},
];
const sections = computed(() => SECTION_DEFS);
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;
}
2026-01-02 01:34:18 -03:00
function isStepRequired(stepId) {
const required = onboarding.value?.required_steps || [];
return Array.isArray(required) && required.includes(stepId);
2026-01-04 23:34:21 -03:00
}
function isStepBlocked(stepId) {
const prereqs = STEP_PREREQS[stepId] || [];
if (!prereqs.length) return false;
return prereqs.some((req) => !isStepDone(req));
2026-01-04 21:57:31 -03:00
}
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";
2026-01-04 21:57:31 -03:00
}
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";
2026-01-04 21:57:31 -03:00
}
function isConfirming(step) {
return confirmingStepId.value === step.id;
}
function confirmLabel(step) {
return isConfirming(step) ? "Confirming..." : "Confirm";
}
function stepCardClass(step) {
2026-01-04 23:34:21 -03:00
return {
done: isStepDone(step.id),
blocked: isStepBlocked(step.id),
optional: !isStepRequired(step.id),
2026-01-04 23:34:21 -03:00
};
}
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`;
2026-01-04 21:57:31 -03:00
}
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);
2026-01-04 21:57:31 -03:00
}
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),
};
2026-01-04 23:34:21 -03:00
}
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] || {};
return Object.values(stepShots);
}
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 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";
}
2026-01-02 01:34:18 -03:00
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,
}),
2026-01-02 01:34:18 -03:00
});
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();
}
2026-01-02 01:34:18 -03:00
} catch (err) {
error.value = err?.message || "Failed to check status";
tasks.value = [];
blocked.value = false;
keycloakPasswordRotationRequested.value = false;
2026-01-02 01:34:18 -03:00
} finally {
loading.value = false;
}
}
function togglePassword() {
revealPassword.value = !revealPassword.value;
}
async function copyText(text, setFlag) {
if (!text) return;
2026-01-04 08:44:25 -03:00
try {
if (navigator?.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
2026-01-04 08:44:25 -03:00
} 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);
2026-01-04 08:44:25 -03:00
document.execCommand("copy");
document.body.removeChild(fallback);
2026-01-04 08:44:25 -03:00
}
setFlag(true);
setTimeout(() => setFlag(false), 1500);
2026-01-04 08:44:25 -03:00
} catch (err) {
error.value = err?.message || "Copy failed";
2026-01-04 08:44:25 -03:00
}
}
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) {
if (!requestCode.value.trim()) {
error.value = "Request code is missing.";
2026-01-04 21:57:31 -03:00
return;
}
if (isStepBlocked(stepId)) {
return;
}
2026-01-04 21:57:31 -03:00
loading.value = true;
error.value = "";
2026-01-04 21:57:31 -03:00
try {
const requester = auth.authenticated ? authFetch : fetch;
let resp = await requester("/api/access/request/onboarding/attest", {
2026-01-04 21:57:31 -03:00
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ request_code: requestCode.value.trim(), step: stepId, completed }),
2026-01-04 21:57:31 -03:00
});
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 }),
});
}
2026-01-04 21:57:31 -03:00
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";
2026-01-04 21:57:31 -03:00
} 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") {
await runRotationCheck("firefly");
}
if (step.id === "wger_password_rotated") {
await runRotationCheck("wger");
}
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 = {};
2026-01-02 01:34:18 -03:00
}
}
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();
2026-01-02 01:34:18 -03:00
});
</script>
<style scoped>
.page {
max-width: 1080px;
2026-01-02 01:34:18 -03:00
margin: 0 auto;
padding: 32px 22px 72px;
}
.hero {
margin-bottom: 12px;
padding: 18px;
}
.eyebrow {
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-muted);
margin: 0 0 6px;
font-size: 13px;
}
h1 {
margin: 0 0 6px;
font-size: 32px;
}
.lede {
margin: 0;
color: var(--text-muted);
max-width: 640px;
}
.module {
padding: 18px;
}
.module-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
}
.status-form {
display: flex;
gap: 10px;
margin-top: 12px;
}
.status-meta {
margin-top: 12px;
padding: 12px;
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(0, 0, 0, 0.18);
}
.meta-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
}
.meta-row .label {
color: var(--text-muted);
}
2026-01-02 01:34:18 -03:00
.input {
flex: 1;
padding: 10px 12px;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.14);
background: rgba(0, 0, 0, 0.25);
color: var(--text-primary);
}
button.primary {
background: linear-gradient(90deg, #4f8bff, #7dd0ff);
color: #0b1222;
padding: 10px 14px;
border: none;
border-radius: 10px;
cursor: pointer;
font-weight: 700;
}
button.secondary {
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.16);
color: var(--text-primary);
padding: 8px 12px;
border-radius: 10px;
cursor: pointer;
font-weight: 600;
2026-01-02 01:34:18 -03:00
}
button.primary:disabled,
button.secondary:disabled,
button.copy:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.steps {
margin-top: 16px;
2026-01-02 01:34:18 -03:00
}
.onboarding-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 14px;
margin-bottom: 8px;
}
.section-stepper {
margin: 16px 0 18px;
list-style: none;
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
padding: 0;
}
.stepper-item {
min-width: 0;
}
.stepper-card {
width: 100%;
text-align: left;
padding: 12px 12px 12px 36px;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(0, 0, 0, 0.2);
color: var(--text-primary);
display: flex;
gap: 8px;
position: relative;
}
.stepper-card::after {
display: none;
2026-01-04 23:34:21 -03:00
}
.stepper-dot {
position: absolute;
left: 12px;
top: 12px;
width: 10px;
height: 10px;
border-radius: 999px;
border: 2px solid rgba(255, 255, 255, 0.22);
background: rgba(255, 255, 255, 0.16);
z-index: 1;
2026-01-04 23:34:21 -03:00
}
.stepper-body {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
2026-01-04 23:34:21 -03:00
}
.stepper-title {
font-weight: 700;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
2026-01-04 23:34:21 -03:00
}
.stepper-meta {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
flex-wrap: nowrap;
color: var(--text-muted);
width: 100%;
}
.stepper-meta .pill {
padding: 4px 6px;
font-size: 11px;
white-space: nowrap;
}
.pill-compact {
padding: 4px 6px;
font-size: 11px;
white-space: nowrap;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
}
.stepper-card.active {
border-color: rgba(125, 208, 255, 0.5);
box-shadow: 0 0 0 1px rgba(79, 139, 255, 0.3);
}
.stepper-card.active .stepper-dot {
background: rgba(125, 208, 255, 0.8);
border-color: rgba(125, 208, 255, 0.85);
}
.stepper-card.done {
border-color: rgba(92, 214, 167, 0.35);
background: rgba(92, 214, 167, 0.08);
}
.stepper-card.done .stepper-dot {
background: rgba(92, 214, 167, 0.9);
border-color: rgba(92, 214, 167, 0.95);
}
.stepper-card.locked {
opacity: 0.6;
}
@media (max-width: 1200px) {
.section-stepper {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (max-width: 860px) {
.section-stepper {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 520px) {
.section-stepper {
grid-template-columns: 1fr;
}
2026-01-04 21:57:31 -03:00
}
.credential-card {
margin-top: 14px;
padding: 14px;
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(0, 0, 0, 0.2);
}
.credential-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 14px;
2026-01-04 21:57:31 -03:00
}
.credential-field .label {
display: block;
margin-bottom: 6px;
color: var(--text-muted);
2026-01-04 21:57:31 -03:00
}
.credential-field .input[readonly] {
opacity: 0.8;
}
.password-row {
2026-01-04 21:57:31 -03:00
display: flex;
gap: 8px;
align-items: center;
2026-01-04 21:57:31 -03:00
}
.password-row .input {
flex: 1;
2026-01-04 21:57:31 -03:00
}
.section-shell {
margin-top: 16px;
padding-top: 12px;
border-top: 1px solid rgba(255, 255, 255, 0.08);
2026-01-04 21:57:31 -03:00
}
.section-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
2026-01-04 21:57:31 -03:00
gap: 12px;
margin-bottom: 12px;
2026-01-04 21:57:31 -03:00
}
.section-actions {
display: flex;
align-items: center;
justify-content: space-between;
2026-01-04 21:57:31 -03:00
gap: 8px;
width: 100%;
margin-top: 12px;
}
.step-grid {
display: grid;
gap: 12px;
}
.step-card {
2026-01-04 21:57:31 -03:00
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 14px;
padding: 12px 12px 10px;
2026-01-04 21:57:31 -03:00
background: rgba(255, 255, 255, 0.02);
display: flex;
flex-direction: column;
2026-01-04 21:57:31 -03:00
}
.step-card.blocked {
opacity: 0.55;
pointer-events: none;
2026-01-04 21:57:31 -03:00
}
.step-card.done {
border-color: rgba(92, 214, 167, 0.35);
background: rgba(92, 214, 167, 0.05);
2026-01-04 21:57:31 -03:00
}
.step-head {
display: flex;
align-items: center;
gap: 12px;
}
.auto-pill {
margin-left: auto;
2026-01-04 21:57:31 -03:00
font-size: 12px;
padding: 3px 10px;
border-radius: 999px;
2026-01-04 21:57:31 -03:00
}
.step-title {
font-weight: 650;
color: var(--text-strong);
2026-01-04 21:57:31 -03:00
}
.step-label {
display: flex;
align-items: center;
gap: 10px;
}
.step-label input {
width: 18px;
height: 18px;
}
.step-bullets {
margin: 8px 0 0;
padding-left: 18px;
color: var(--text-muted);
}
.step-links {
margin-top: 8px;
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.step-links a {
color: var(--accent-cyan);
text-decoration: none;
font-weight: 600;
}
.step-links a:hover {
text-decoration: underline;
}
.step-actions {
display: flex;
align-items: center;
gap: 12px;
justify-content: flex-end;
margin-top: auto;
padding-top: 10px;
}
.recovery-verify {
display: flex;
gap: 10px;
margin-top: 10px;
align-items: stretch;
}
.recovery-verify .input {
flex: 1;
}
.guide-details {
margin-top: 10px;
}
.guide-groups {
display: grid;
gap: 12px;
margin-top: 8px;
}
.guide-title {
margin: 0 0 6px;
}
.guide-images {
display: grid;
gap: 10px;
}
.guide-shot {
border-radius: 10px;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(0, 0, 0, 0.2);
padding: 0;
cursor: zoom-in;
display: flex;
flex-direction: column;
gap: 6px;
}
.guide-shot figcaption {
margin: 0;
padding: 10px 12px 6px;
font-size: 17px;
font-weight: 600;
color: var(--text-strong);
}
.guide-shot img {
display: block;
border-radius: 10px;
max-width: 100%;
width: auto;
height: auto;
max-height: min(60vh, 520px);
margin: 0 auto 10px;
}
.guide-pagination {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
flex-wrap: wrap;
}
.guide-dots {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.guide-dot {
border: 1px solid rgba(255, 255, 255, 0.18);
background: rgba(0, 0, 0, 0.3);
color: var(--text-muted);
border-radius: 999px;
padding: 4px 8px;
cursor: pointer;
font-size: 12px;
}
.guide-dot.active {
border-color: rgba(120, 180, 255, 0.5);
color: var(--text-strong);
}
.ready-box {
margin-top: 18px;
padding: 14px;
border-radius: 14px;
border: 1px solid rgba(92, 214, 167, 0.3);
background: rgba(92, 214, 167, 0.08);
}
.task-list {
margin: 0;
padding: 0;
list-style: none;
display: grid;
gap: 8px;
}
.task-row {
display: flex;
align-items: center;
gap: 12px;
}
.task-name {
min-width: 180px;
}
.task-detail {
color: var(--text-muted);
}
.error-box {
margin-top: 12px;
padding: 12px;
border-radius: 14px;
border: 1px solid rgba(255, 120, 120, 0.4);
background: rgba(255, 70, 70, 0.1);
}
.tooltip-wrap {
display: inline-flex;
}
.lightbox {
position: fixed;
inset: 0;
background: rgba(6, 8, 12, 0.82);
display: flex;
align-items: center;
justify-content: center;
padding: 28px;
z-index: 2000;
}
.lightbox-card {
width: min(1400px, 96vw);
max-height: 94vh;
background: rgba(10, 14, 24, 0.96);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 16px;
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
}
.lightbox-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.lightbox-label {
color: var(--text-muted);
}
.lightbox-card img {
width: 100%;
max-height: 82vh;
object-fit: contain;
border-radius: 12px;
background: rgba(0, 0, 0, 0.35);
}
@media (max-width: 720px) {
.status-form {
flex-direction: column;
}
2026-01-02 01:34:18 -03:00
.section-actions {
width: 100%;
justify-content: flex-start;
}
2026-01-02 01:34:18 -03:00
.password-row {
flex-direction: column;
align-items: stretch;
}
2026-01-02 01:34:18 -03:00
}
</style>