1667 lines
49 KiB
Vue
1667 lines
49 KiB
Vue
<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) }}
|
|
</span>
|
|
</div>
|
|
|
|
<div class="status-form">
|
|
<input
|
|
v-model="requestCode"
|
|
class="input mono"
|
|
type="text"
|
|
placeholder="username~XXXXXXXXXX"
|
|
:disabled="loading"
|
|
/>
|
|
<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>
|
|
</div>
|
|
</div>
|
|
|
|
<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>
|
|
</div>
|
|
<span class="pill mono auto-pill" :class="stepPillClass(step)">
|
|
{{ stepPillLabel(step) }}
|
|
</span>
|
|
</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>
|
|
</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>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { computed, onMounted, ref } from "vue";
|
|
import { useRoute } from "vue-router";
|
|
import { auth, authFetch } from "../auth";
|
|
|
|
const route = useRoute();
|
|
|
|
const requestCode = ref("");
|
|
const requestUsername = ref("");
|
|
const status = ref("");
|
|
const loading = ref(false);
|
|
const error = ref("");
|
|
const onboarding = ref({ required_steps: [], optional_steps: [], completed_steps: [] });
|
|
const initialPassword = ref("");
|
|
const initialPasswordRevealedAt = ref("");
|
|
const revealPassword = ref(false);
|
|
const passwordCopied = ref(false);
|
|
const usernameCopied = ref(false);
|
|
const tasks = ref([]);
|
|
const blocked = ref(false);
|
|
const 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 = [
|
|
{
|
|
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" },
|
|
},
|
|
],
|
|
},
|
|
{
|
|
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" },
|
|
},
|
|
],
|
|
},
|
|
{
|
|
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;
|
|
}
|
|
|
|
function isStepRequired(stepId) {
|
|
const required = onboarding.value?.required_steps || [];
|
|
return Array.isArray(required) && required.includes(stepId);
|
|
}
|
|
|
|
function isStepBlocked(stepId) {
|
|
const prereqs = STEP_PREREQS[stepId] || [];
|
|
if (!prereqs.length) return false;
|
|
return prereqs.some((req) => !isStepDone(req));
|
|
}
|
|
|
|
function stepPillLabel(step) {
|
|
if (isStepDone(step.id)) return "done";
|
|
if (isStepBlocked(step.id)) return "blocked";
|
|
if (step.action === "auto") return "pending";
|
|
if (!isStepRequired(step.id)) return "optional";
|
|
if (step.id === "keycloak_password_rotated") {
|
|
return keycloakPasswordRotationRequested.value ? "rotate now" : "ready";
|
|
}
|
|
return "pending";
|
|
}
|
|
|
|
function stepPillClass(step) {
|
|
if (isStepDone(step.id)) return "pill-ok";
|
|
if (isStepBlocked(step.id)) return "pill-wait";
|
|
if (!isStepRequired(step.id)) return "pill-info";
|
|
if (step.id === "keycloak_password_rotated" && !keycloakPasswordRotationRequested.value) {
|
|
return "pill-info";
|
|
}
|
|
return "pill-warn";
|
|
}
|
|
|
|
function isConfirming(step) {
|
|
return confirmingStepId.value === step.id;
|
|
}
|
|
|
|
function confirmLabel(step) {
|
|
return isConfirming(step) ? "Confirming..." : "Confirm";
|
|
}
|
|
|
|
function stepCardClass(step) {
|
|
return {
|
|
done: isStepDone(step.id),
|
|
blocked: isStepBlocked(step.id),
|
|
optional: !isStepRequired(step.id),
|
|
};
|
|
}
|
|
|
|
function sectionProgress(section) {
|
|
const requiredSteps = section.steps.filter((step) => isStepRequired(step.id));
|
|
if (!requiredSteps.length) return "optional";
|
|
if (isSectionLocked(section)) return `0/${requiredSteps.length} done`;
|
|
const doneCount = requiredSteps.filter((step) => isStepDone(step.id) && !isStepBlocked(step.id)).length;
|
|
return `${doneCount}/${requiredSteps.length} done`;
|
|
}
|
|
|
|
function sectionStatusLabel(section) {
|
|
if (isSectionDone(section)) return "";
|
|
if (isSectionLocked(section)) return "locked";
|
|
return "active";
|
|
}
|
|
|
|
function sectionPillClass(section) {
|
|
if (isSectionLocked(section)) return "pill-wait";
|
|
return "pill-info";
|
|
}
|
|
|
|
function isSectionLocked(section) {
|
|
const list = sections.value;
|
|
const index = list.findIndex((item) => item.id === section.id);
|
|
if (index <= 0) return false;
|
|
const previous = list[index - 1];
|
|
return !sectionGateComplete(previous);
|
|
}
|
|
|
|
function isSectionDone(section) {
|
|
const requiredSteps = section.steps.filter((step) => isStepRequired(step.id));
|
|
const stepsToCheck = requiredSteps.length ? requiredSteps : section.steps;
|
|
if (!stepsToCheck.length) return false;
|
|
return stepsToCheck.every((step) => isStepDone(step.id));
|
|
}
|
|
|
|
function sectionCardClass(section) {
|
|
return {
|
|
active: section.id === activeSectionId.value,
|
|
done: isSectionDone(section),
|
|
locked: isSectionLocked(section),
|
|
};
|
|
}
|
|
|
|
function sectionGateComplete(section) {
|
|
const requiredSteps = section.steps.filter((step) => isStepRequired(step.id));
|
|
if (!requiredSteps.length) return true;
|
|
return requiredSteps.every((step) => isStepDone(step.id));
|
|
}
|
|
|
|
function guideGroups(step) {
|
|
if (!step.guide) return [];
|
|
const service = step.guide.service;
|
|
const stepKey = step.guide.step;
|
|
const serviceShots = guideShots.value?.[service] || {};
|
|
const stepShots = serviceShots?.[stepKey] || {};
|
|
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";
|
|
}
|
|
|
|
async function check() {
|
|
if (loading.value) return;
|
|
error.value = "";
|
|
loading.value = true;
|
|
try {
|
|
const resp = await fetch("/api/access/request/status", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
request_code: requestCode.value.trim(),
|
|
reveal_initial_password: true,
|
|
}),
|
|
});
|
|
const data = await resp.json().catch(() => ({}));
|
|
if (!resp.ok) throw new Error(data.error || resp.statusText || `status ${resp.status}`);
|
|
status.value = data.status || "unknown";
|
|
requestUsername.value = data.username || "";
|
|
onboarding.value = data.onboarding || { required_steps: [], optional_steps: [], completed_steps: [] };
|
|
keycloakPasswordRotationRequested.value = Boolean(data.onboarding?.keycloak?.password_rotation_requested);
|
|
tasks.value = Array.isArray(data.tasks) ? data.tasks : [];
|
|
blocked.value = Boolean(data.blocked);
|
|
initialPassword.value = data.initial_password || "";
|
|
initialPasswordRevealedAt.value = data.initial_password_revealed_at || "";
|
|
if (showOnboarding.value) {
|
|
selectDefaultSection();
|
|
}
|
|
} catch (err) {
|
|
error.value = err?.message || "Failed to check status";
|
|
tasks.value = [];
|
|
blocked.value = false;
|
|
keycloakPasswordRotationRequested.value = false;
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
}
|
|
|
|
function togglePassword() {
|
|
revealPassword.value = !revealPassword.value;
|
|
}
|
|
|
|
async function copyText(text, setFlag) {
|
|
if (!text) return;
|
|
try {
|
|
if (navigator?.clipboard?.writeText) {
|
|
await navigator.clipboard.writeText(text);
|
|
} else {
|
|
const fallback = document.createElement("textarea");
|
|
fallback.value = text;
|
|
fallback.setAttribute("readonly", "");
|
|
fallback.style.position = "fixed";
|
|
fallback.style.top = "-9999px";
|
|
fallback.style.left = "-9999px";
|
|
document.body.appendChild(fallback);
|
|
fallback.select();
|
|
fallback.setSelectionRange(0, fallback.value.length);
|
|
document.execCommand("copy");
|
|
document.body.removeChild(fallback);
|
|
}
|
|
setFlag(true);
|
|
setTimeout(() => setFlag(false), 1500);
|
|
} catch (err) {
|
|
error.value = err?.message || "Copy failed";
|
|
}
|
|
}
|
|
|
|
function copyInitialPassword() {
|
|
copyText(initialPassword.value, (value) => (passwordCopied.value = value));
|
|
}
|
|
|
|
function copyUsername() {
|
|
copyText(requestUsername.value, (value) => (usernameCopied.value = value));
|
|
}
|
|
|
|
async function toggleStep(stepId, event) {
|
|
const checked = Boolean(event?.target?.checked);
|
|
await setStepCompletion(stepId, checked);
|
|
}
|
|
|
|
async function setStepCompletion(stepId, completed) {
|
|
if (!requestCode.value.trim()) {
|
|
error.value = "Request code is missing.";
|
|
return;
|
|
}
|
|
if (isStepBlocked(stepId)) {
|
|
return;
|
|
}
|
|
loading.value = true;
|
|
error.value = "";
|
|
try {
|
|
const requester = auth.authenticated ? authFetch : fetch;
|
|
let resp = await requester("/api/access/request/onboarding/attest", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ request_code: requestCode.value.trim(), step: stepId, completed }),
|
|
});
|
|
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 }),
|
|
});
|
|
}
|
|
const data = await resp.json().catch(() => ({}));
|
|
if (!resp.ok) throw new Error(data.error || resp.statusText || `status ${resp.status}`);
|
|
status.value = data.status || status.value;
|
|
onboarding.value = data.onboarding || onboarding.value;
|
|
} catch (err) {
|
|
error.value = err?.message || "Failed to update onboarding";
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
}
|
|
|
|
async function confirmStep(step) {
|
|
if (!step || isStepBlocked(step.id) || isStepDone(step.id)) return;
|
|
confirmingStepId.value = step.id;
|
|
try {
|
|
if (step.id === "keycloak_password_rotated") {
|
|
await requestKeycloakPasswordRotation();
|
|
await check();
|
|
return;
|
|
}
|
|
if (step.action === "auto") {
|
|
if (step.id === "firefly_password_rotated") {
|
|
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 = {};
|
|
}
|
|
}
|
|
|
|
onMounted(async () => {
|
|
const code = route.query.code || route.query.request_code || "";
|
|
if (typeof code === "string" && code.trim()) {
|
|
requestCode.value = code.trim();
|
|
await check();
|
|
}
|
|
await loadGuideShots();
|
|
});
|
|
</script>
|
|
|
|
<style scoped>
|
|
.page {
|
|
max-width: 1080px;
|
|
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);
|
|
}
|
|
|
|
.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;
|
|
}
|
|
|
|
button.primary:disabled,
|
|
button.secondary:disabled,
|
|
button.copy:disabled {
|
|
opacity: 0.45;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
.steps {
|
|
margin-top: 16px;
|
|
}
|
|
|
|
.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;
|
|
}
|
|
|
|
.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;
|
|
}
|
|
|
|
.stepper-body {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 6px;
|
|
min-width: 0;
|
|
}
|
|
|
|
.stepper-title {
|
|
font-weight: 700;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
|
|
.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;
|
|
}
|
|
}
|
|
|
|
.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;
|
|
}
|
|
|
|
.credential-field .label {
|
|
display: block;
|
|
margin-bottom: 6px;
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
.credential-field .input[readonly] {
|
|
opacity: 0.8;
|
|
}
|
|
|
|
.password-row {
|
|
display: flex;
|
|
gap: 8px;
|
|
align-items: center;
|
|
}
|
|
|
|
.password-row .input {
|
|
flex: 1;
|
|
}
|
|
|
|
.section-shell {
|
|
margin-top: 16px;
|
|
padding-top: 12px;
|
|
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
|
}
|
|
|
|
.section-header {
|
|
display: flex;
|
|
align-items: flex-start;
|
|
justify-content: space-between;
|
|
gap: 12px;
|
|
margin-bottom: 12px;
|
|
}
|
|
|
|
.section-actions {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 8px;
|
|
width: 100%;
|
|
margin-top: 12px;
|
|
}
|
|
|
|
.step-grid {
|
|
display: grid;
|
|
gap: 12px;
|
|
}
|
|
|
|
.step-card {
|
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
|
border-radius: 14px;
|
|
padding: 12px 12px 10px;
|
|
background: rgba(255, 255, 255, 0.02);
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.step-card.blocked {
|
|
opacity: 0.55;
|
|
pointer-events: none;
|
|
}
|
|
|
|
.step-card.done {
|
|
border-color: rgba(92, 214, 167, 0.35);
|
|
background: rgba(92, 214, 167, 0.05);
|
|
}
|
|
|
|
.step-head {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
}
|
|
|
|
.auto-pill {
|
|
margin-left: auto;
|
|
font-size: 12px;
|
|
padding: 3px 10px;
|
|
border-radius: 999px;
|
|
}
|
|
|
|
.step-title {
|
|
font-weight: 650;
|
|
color: var(--text-strong);
|
|
}
|
|
|
|
.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;
|
|
}
|
|
|
|
.section-actions {
|
|
width: 100%;
|
|
justify-content: flex-start;
|
|
}
|
|
|
|
.password-row {
|
|
flex-direction: column;
|
|
align-items: stretch;
|
|
}
|
|
}
|
|
</style>
|