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

1897 lines
56 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>
2026-01-24 07:12:35 -03:00
<div v-if="blocked" class="actions" style="margin-top: 10px;">
<button class="pill mono" type="button" :disabled="retrying" @click="retryProvisioning">
{{ retrying ? "Retrying..." : "Retry failed steps" }}
</button>
<span v-if="retryMessage" class="hint mono">{{ retryMessage }}</span>
</div>
<p v-if="blocked" class="muted" style="margin-top: 8px;">
If the error mentions rate limiting or a temporary outage, wait a few minutes and retry. If it keeps failing,
contact an admin.
</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. There are 8 steps for 8 services below. Each step has smaller tasks; press
Confirm when you finish a task. You can pause and return later.
</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-head">
<h4>Keycloak temporary credentials</h4>
<p class="muted">Use these to sign in to Nextcloud, Element, and any Keycloak-protected services.</p>
</div>
<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>
2026-01-23 23:01:44 -03:00
<p v-if="activeSection.summary" class="muted">{{ activeSection.summary }}</p>
<p v-if="activeSection.benefit" class="muted">{{ activeSection.benefit }}</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>
2026-01-24 11:41:00 -03:00
<p class="step-note" v-if="stepNote(step)">{{ stepNote(step) }}</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"
:title="link.href"
target="_blank"
rel="noreferrer"
>
{{ link.text }}
</a>
</div>
<details v-if="step.guide" class="guide-details" :open="shouldOpenGuide(step, activeSection)">
<summary class="mono guide-summary">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">
2026-01-23 23:01:44 -03:00
Your Atlas account is provisioned and onboarding is complete. Visit the Apps page to access the full suite.
<a href="/apps">Atlas Apps</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);
2026-01-24 07:12:35 -03:00
const retrying = ref(false);
const retryMessage = ref("");
const keycloakPasswordRotationRequested = ref(false);
const activeSectionId = ref("vaultwarden");
const guideShots = ref({});
const guidePage = ref({});
const lightboxShot = ref(null);
const confirmingStepId = ref("");
const showPasswordCard = computed(() => Boolean(initialPassword.value || initialPasswordRevealedAt.value));
const passwordRevealLocked = computed(() => Boolean(!initialPassword.value && initialPasswordRevealedAt.value));
const passwordRevealHint = computed(() =>
passwordRevealLocked.value
? "This password was already revealed and cannot be shown again. Ask an admin to reset it if you missed it."
: "",
);
const vaultwardenRecoveryEmail = computed(() => onboarding.value?.vaultwarden?.recovery_email || "");
const vaultwardenMatched = computed(() => Boolean(onboarding.value?.vaultwarden?.matched));
2026-01-23 23:01:44 -03:00
const vaultwardenLoginEmail = computed(() => {
if (vaultwardenMatched.value) {
2026-01-23 23:01:44 -03:00
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());
2026-01-23 23:01:44 -03:00
const mailAddress = computed(() => (requestUsername.value ? `${requestUsername.value}@bstein.dev` : "your @bstein.dev address"));
const mailAddressLower = computed(() => (mailAddress.value || "").toLowerCase());
const STEP_PREREQS = {
vaultwarden_master_password: [],
vaultwarden_store_temp_password: ["vaultwarden_master_password"],
vaultwarden_browser_extension: ["vaultwarden_master_password"],
vaultwarden_mobile_app: ["vaultwarden_master_password"],
keycloak_password_rotated: ["vaultwarden_master_password"],
element_recovery_key: ["keycloak_password_rotated"],
element_mobile_app: ["element_recovery_key"],
mail_client_setup: ["vaultwarden_master_password"],
nextcloud_web_access: ["vaultwarden_master_password"],
nextcloud_mail_integration: ["nextcloud_web_access"],
nextcloud_desktop_app: ["nextcloud_web_access"],
nextcloud_mobile_app: ["nextcloud_web_access"],
budget_encryption_ack: ["nextcloud_mail_integration"],
firefly_password_rotated: ["element_recovery_key"],
firefly_mobile_app: ["firefly_password_rotated"],
wger_password_rotated: ["firefly_password_rotated"],
wger_mobile_app: ["wger_password_rotated"],
jellyfin_web_access: ["vaultwarden_master_password"],
jellyfin_mobile_app: ["jellyfin_web_access"],
jellyfin_tv_setup: ["jellyfin_web_access"],
};
const SECTION_DEFS = [
2026-01-12 23:29:32 -03:00
{
id: "vaultwarden",
title: "Vaultwarden",
2026-01-23 23:01:44 -03:00
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" },
},
],
2026-01-12 23:29:32 -03:00
},
{
id: "element",
title: "Element",
2026-01-23 23:01:44 -03:00
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" },
},
],
2026-01-12 23:29:32 -03:00
},
{
id: "mail",
title: "Mail",
2026-01-23 23:01:44 -03:00
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",
2026-01-23 23:01:44 -03:00
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",
2026-01-23 23:01:44 -03:00
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",
2026-01-23 23:01:44 -03:00
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",
2026-01-23 23:01:44 -03:00
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",
2026-01-23 23:01:44 -03:00
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;
}
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
}
2026-01-23 23:01:44 -03:00
function stepNote(step) {
if (step.id === "vaultwarden_master_password") {
return `Vaultwarden uses an email login. Use ${vaultwardenLoginEmailLower.value} to sign in.`;
2026-01-23 23:01:44 -03:00
}
if (step.id === "vaultwarden_store_temp_password") {
return "Store the temporary Keycloak password in Vaultwarden so you can rotate it safely later.";
}
2026-01-23 23:01:44 -03:00
if (step.id === "firefly_password_rotated") {
return `Firefly uses an email login. Use ${mailAddressLower.value} to sign in.`;
2026-01-23 23:01:44 -03:00
}
if (step.id === "mail_client_setup") {
return `Your mailbox address is ${mailAddressLower.value}.`;
2026-01-23 23:01:44 -03:00
}
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";
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] || {};
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";
}
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;
}
}
2026-01-24 07:12:35 -03:00
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;
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, extra = {}) {
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, ...extra }),
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, ...extra }),
});
}
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") {
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 = {};
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: 6px 10px;
flex-wrap: wrap;
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-head {
margin-bottom: 10px;
}
.credential-head h4 {
margin: 0 0 4px;
font-size: 18px;
}
.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
}
2026-01-23 23:01:44 -03:00
.step-note {
2026-01-24 11:41:00 -03:00
margin-top: 8px;
padding: 8px 10px;
border-radius: 10px;
border: 1px solid rgba(0, 229, 197, 0.35);
background: rgba(0, 229, 197, 0.08);
color: var(--text-strong);
font-weight: 600;
2026-01-23 23:01:44 -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: 10px;
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 10px;
}
.step-links a {
color: rgba(92, 214, 167, 0.95);
text-decoration: none;
font-weight: 600;
border-radius: 999px;
padding: 6px 14px;
border: 1px solid rgba(92, 214, 167, 0.35);
background: rgba(92, 214, 167, 0.12);
display: inline-flex;
align-items: center;
gap: 6px;
}
.step-links a:hover {
text-decoration: none;
background: rgba(92, 214, 167, 0.2);
}
.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-summary {
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 10px;
padding: 6px 12px;
border-radius: 999px;
border: 1px solid rgba(92, 214, 167, 0.5);
background: rgba(92, 214, 167, 0.15);
color: var(--text-strong);
font-weight: 600;
}
.guide-summary::after {
content: "Tap to open";
font-size: 12px;
color: var(--text-muted);
}
.guide-details[open] .guide-summary::after {
content: "Tap to close";
}
.guide-summary::-webkit-details-marker {
display: none;
}
.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: 18px;
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) {
.page {
padding: 24px 16px 56px;
}
.status-form {
flex-direction: column;
}
2026-01-02 01:34:18 -03:00
.onboarding-head {
flex-direction: column;
align-items: flex-start;
}
.credential-grid {
grid-template-columns: 1fr;
}
.step-head {
flex-direction: column;
align-items: flex-start;
}
.auto-pill {
margin-left: 0;
}
.section-actions {
flex-direction: column;
align-items: stretch;
}
.step-actions {
justify-content: flex-start;
}
.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>