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 >
2026-01-02 09:42:06 -03:00
< span class = "pill mono" :class = "statusPillClass(status)" >
{ { statusLabel ( status ) } }
2026-01-02 01:34:18 -03:00
< / span >
< / div >
< div class = "status-form" >
2026-01-22 01:38:41 -03:00
< 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 >
2026-01-03 02:36:29 -03:00
< div v-if = "status === 'pending_email_verification'" class="steps" >
< h3 > Confirm your email < / h3 >
< p class = "muted" >
2026-01-22 01:38:41 -03:00
Open the verification email from Atlas and click the link to confirm your address . After verification , an admin can
approve your request .
2026-01-03 02:36:29 -03:00
< / 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 >
2026-01-02 09:42:06 -03:00
< 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 >
2026-01-22 01:38:41 -03:00
< p class = "muted" > Your request is approved . Atlas is now preparing accounts and credentials . Check back in a minute . < / p >
2026-01-03 04:55:03 -03:00
< 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 >
2026-01-02 09:42:06 -03:00
< / div >
2026-01-22 01:38:41 -03:00
< div v-if = "showOnboarding" class="steps" >
2026-01-02 09:42:06 -03:00
< div class = "onboarding-head" >
2026-01-22 01:38:41 -03:00
< 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 >
2026-01-22 23:44:16 -03:00
< span v-if = "status !== 'ready'" class="pill mono pill-info" > active < / span >
2026-01-02 09:42:06 -03:00
< / div >
2026-01-22 01:38:41 -03:00
< 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" >
2026-01-22 23:44:16 -03:00
< span v-if = "sectionStatusLabel(section)" class="pill mono" :class="sectionPillClass(section)" >
{ { sectionStatusLabel ( section ) } }
< / span >
2026-01-22 18:48:19 -03:00
< span class = "pill mono pill-compact" > { { sectionProgress ( section ) } } < / span >
2026-01-22 01:38:41 -03:00
< / div >
< / div >
2026-01-02 11:12:43 -03:00
< / button >
2026-01-22 01:38:41 -03:00
< / li >
< / ol >
< div v-if = "requestUsername || initialPassword" class="credential-card" >
< div class = "credential-grid" >
< div class = "credential-field" >
< span class = "label mono" > Username < / span >
2026-01-22 23:44:16 -03:00
< 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 >
2026-01-22 01:38:41 -03:00
< / div >
2026-01-22 22:03:09 -03:00
< div class = "credential-field" v-if = "showPasswordCard" >
2026-01-22 01:38:41 -03:00
< span class = "label mono" > Temporary password < / span >
< div class = "password-row" >
< input
class = "input mono"
: type = "revealPassword ? 'text' : 'password'"
2026-01-22 22:03:09 -03:00
: value = "initialPassword || '********'"
2026-01-22 01:38:41 -03:00
readonly
/ >
2026-01-22 23:44:16 -03:00
< 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 >
2026-01-22 01:38:41 -03:00
< / div >
< / div >
2026-01-02 11:12:43 -03:00
< / div >
< p class = "muted" >
2026-01-22 01:38:41 -03:00
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 .
2026-01-02 11:12:43 -03:00
< / p >
< / div >
2026-01-22 01:38:41 -03:00
< div class = "section-shell" v-if = "activeSection" >
< div class = "section-header" >
< div >
< h3 > { { activeSection . title } } < / h3 >
< p class = "muted" > { { activeSection . description } } < / p >
2026-01-04 21:57:31 -03:00
< / div >
2026-01-22 01:38:41 -03:00
< / div >
2026-01-04 21:57:31 -03:00
2026-01-22 01:38:41 -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)"
2026-01-23 03:10:54 -03:00
: disabled = "loading || isStepBlocked(step.id)"
2026-01-22 01:38:41 -03:00
@ 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 >
2026-01-22 01:38:41 -03:00
< span class = "pill mono auto-pill" :class = "stepPillClass(step)" >
{ { stepPillLabel ( step ) } }
< / span >
2026-01-04 21:57:31 -03:00
< / div >
2026-01-21 16:57:40 -03:00
2026-01-22 01:38:41 -03:00
< p class = "muted" v-if = "step.description" > {{ step.description }} < / p >
2026-01-21 16:57:40 -03:00
2026-01-22 01:38:41 -03:00
< ul v-if = "step.bullets && step.bullets.length" class="step-bullets" >
< li v-for ="bullet in step.bullets" :key ="bullet" > {{ bullet }} < / li >
< / ul >
2026-01-04 13:00:42 -03:00
2026-01-22 01:38:41 -03:00
< 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 >
2026-01-18 00:25:03 -03:00
< / div >
2026-01-22 01:38:41 -03:00
< details v-if = "step.guide" class="guide-details" >
2026-01-18 00:25:03 -03:00
< summary class = "mono" > Photo guide < / summary >
2026-01-22 01:38:41 -03:00
< 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 >
2026-01-22 22:03:09 -03:00
< div v-if = "group.shots.length" class="guide-images" >
2026-01-22 23:44:16 -03:00
< figure class = "guide-shot" @ click = "openLightbox(guideShot(step, group))" >
2026-01-22 22:03:09 -03:00
< figcaption v-if = "guideShot(step, group).label" class="mono" > {{ guideShot ( step , group ) .label }} < / figcaption >
2026-01-22 23:44:16 -03:00
< img : src = "guideShot(step, group).url" : alt = "guideShot(step, group).label || step.title" loading = "lazy" / >
2026-01-22 01:38:41 -03:00
< / figure >
2026-01-22 22:03:09 -03:00
< 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 >
2026-01-22 01:38:41 -03:00
< / div >
< / div >
2026-01-18 00:25:03 -03:00
< / div >
< p v -else class = "muted" > Guide coming soon . < / p >
< / details >
2026-01-22 23:44:16 -03:00
2026-01-23 03:10:54 -03:00
< div class = "step-actions" >
2026-01-22 23:44:16 -03:00
< button
class = "secondary"
type = "button"
@ click = "confirmStep(step)"
2026-01-22 23:59:02 -03:00
: disabled = "loading || isConfirming(step) || isStepDone(step.id) || isStepBlocked(step.id)"
2026-01-22 23:44:16 -03:00
>
2026-01-22 23:59:02 -03:00
{ { confirmLabel ( step ) } }
2026-01-22 23:44:16 -03:00
< / button >
< / div >
2026-01-22 01:38:41 -03:00
< / article >
2026-01-18 00:25:03 -03:00
< / div >
2026-01-22 23:44:16 -03:00
< 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 >
2026-01-18 00:25:03 -03:00
< / div >
2026-01-02 09:42:06 -03:00
< 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
2026-01-22 01:38:41 -03:00
< a href = "https://cloud.bstein.dev" target = "_blank" rel = "noreferrer" > cloud . bstein . dev < / a > .
2026-01-02 09:42:06 -03:00
< / 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 >
2026-01-22 23:44:16 -03:00
< 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 >
2026-01-22 01:38:41 -03:00
import { computed , onMounted , ref } from "vue" ;
2026-01-02 01:34:18 -03:00
import { useRoute } from "vue-router" ;
2026-01-22 18:48:19 -03:00
import { auth , authFetch } from "../auth" ;
2026-01-02 01:34:18 -03:00
const route = useRoute ( ) ;
const requestCode = ref ( "" ) ;
2026-01-02 10:27:02 -03:00
const requestUsername = ref ( "" ) ;
2026-01-02 01:34:18 -03:00
const status = ref ( "" ) ;
const loading = ref ( false ) ;
const error = ref ( "" ) ;
2026-01-22 01:38:41 -03:00
const onboarding = ref ( { required _steps : [ ] , optional _steps : [ ] , completed _steps : [ ] } ) ;
2026-01-02 11:12:43 -03:00
const initialPassword = ref ( "" ) ;
2026-01-22 22:03:09 -03:00
const initialPasswordRevealedAt = ref ( "" ) ;
2026-01-22 01:38:41 -03:00
const revealPassword = ref ( false ) ;
const passwordCopied = ref ( false ) ;
2026-01-04 08:44:25 -03:00
const usernameCopied = ref ( false ) ;
2026-01-03 04:55:03 -03:00
const tasks = ref ( [ ] ) ;
const blocked = ref ( false ) ;
2026-01-04 22:49:34 -03:00
const keycloakPasswordRotationRequested = ref ( false ) ;
2026-01-22 01:38:41 -03:00
const activeSectionId = ref ( "vaultwarden" ) ;
const guideShots = ref ( { } ) ;
2026-01-22 22:03:09 -03:00
const guidePage = ref ( { } ) ;
2026-01-22 23:44:16 -03:00
const lightboxShot = ref ( null ) ;
2026-01-22 23:59:02 -03:00
const confirmingStepId = ref ( "" ) ;
2026-01-22 22:03:09 -03:00
const showPasswordCard = computed ( ( ) => Boolean ( initialPassword . value || initialPasswordRevealedAt . value ) ) ;
const passwordRevealLocked = computed ( ( ) => Boolean ( ! initialPassword . value && initialPasswordRevealedAt . value ) ) ;
2026-01-22 23:44:16 -03:00
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."
: "" ,
) ;
2026-01-22 01:38:41 -03:00
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" ] ,
2026-01-23 03:10:54 -03:00
firefly _password _rotated : [ "element_recovery_key" ] ,
2026-01-23 16:06:06 -03:00
firefly _mobile _app : [ "firefly_password_rotated" ] ,
2026-01-22 01:38:41 -03:00
wger _password _rotated : [ "firefly_password_rotated" ] ,
jellyfin _web _access : [ "vaultwarden_master_password" ] ,
jellyfin _mobile _app : [ "jellyfin_web_access" ] ,
jellyfin _tv _setup : [ "jellyfin_web_access" ] ,
} ;
const SECTION _DEFS = [
2026-01-12 23:29:32 -03:00
{
2026-01-22 01:38:41 -03:00
id : "vaultwarden" ,
title : "Vaultwarden" ,
description : "Unlock your vault and install the tools that will store every other credential." ,
steps : [
{
id : "vaultwarden_master_password" ,
title : "Set your Vaultwarden master password" ,
2026-01-22 23:44:16 -03:00
action : "confirm" ,
2026-01-22 01:38:41 -03:00
description :
"Open Nextcloud Mail to find the invite, then visit vault.bstein.dev and create your master password. Use the temporary Keycloak password to sign in to Nextcloud for the first time." ,
bullets : [
"Prefer a long (64+ character) multi word phrase over a single word. Length is stronger than complexity." ,
"Never share, write, or store your password with anyone or anywhere for any reason. Your password must only live between your ears." ,
"Pick something you will not forget, probably something you already know, something easy to remember, maybe something close to you." ,
] ,
links : [
{ href : "https://cloud.bstein.dev" , text : "Nextcloud Mail" } ,
{ href : "https://vault.bstein.dev" , text : "vault.bstein.dev" } ,
] ,
guide : { service : "vaultwarden" , step : "step1_website" } ,
} ,
{
id : "vaultwarden_browser_extension" ,
title : "Install the browser extension" ,
action : "checkbox" ,
description :
"Install Bitwarden in your browser and point it at vault.bstein.dev (Settings → Account → Environment → Self-hosted)." ,
links : [
{ href : "https://addons.mozilla.org/en-US/firefox/addon/bitwarden-password-manager/" , text : "Firefox" } ,
{ href : "https://chromewebstore.google.com/detail/bitwarden-free-password-m/nngceckbapebfimnlniiiahkandclblb" , text : "Chrome" } ,
{ href : "https://apps.apple.com/app/bitwarden/id1352778147" , text : "Safari" } ,
{ href : "https://www.mozilla.org/firefox/new/" , text : "Need a browser? Get Firefox" } ,
] ,
guide : { service : "vaultwarden" , step : "step2_browser_extension" } ,
} ,
{
id : "vaultwarden_mobile_app" ,
title : "Install the mobile app" ,
action : "checkbox" ,
description : "Install Bitwarden on your phone, set the server to vault.bstein.dev, and enable biometrics." ,
links : [ { href : "https://bitwarden.com/download/" , text : "Bitwarden downloads" } ] ,
guide : { service : "vaultwarden" , step : "step3_mobile_app" } ,
} ,
] ,
2026-01-12 23:29:32 -03:00
} ,
{
2026-01-22 01:38:41 -03:00
id : "element" ,
title : "Element" ,
2026-01-23 03:10:54 -03:00
description : "Secure chat, calls, and video for the lab." ,
2026-01-22 01:38:41 -03:00
steps : [
{
id : "keycloak_password_rotated" ,
2026-01-23 03:10:54 -03:00
title : "Connect to Element web" ,
action : "confirm" ,
2026-01-22 01:38:41 -03:00
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" ,
2026-01-23 03:10:54 -03:00
title : "Create your recovery key" ,
action : "confirm" ,
2026-01-22 01:38:41 -03:00
description :
2026-01-23 03:10:54 -03:00
"In Element settings → Encryption, create a recovery key and store it in Vaultwarden." ,
2026-01-22 01:38:41 -03:00
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
} ,
2026-01-18 00:25:03 -03:00
{
2026-01-22 01:38:41 -03:00
id : "mail" ,
title : "Mail" ,
description : "Add your @bstein.dev mailbox to your preferred mail apps." ,
steps : [
{
id : "mail_client_setup" ,
title : "Set up mail on a device" ,
action : "checkbox" ,
description :
"Use the IMAP/SMTP details on your Account page to add mail to your phone or desktop client (Thunderbird, Apple Mail, FairEmail)." ,
links : [ { href : "/account" , text : "Open Account details" } ] ,
2026-01-22 23:44:16 -03:00
guide : { service : "mail" , step : "step1_mail_app" } ,
2026-01-22 01:38:41 -03:00
} ,
2026-01-18 00:25:03 -03:00
] ,
} ,
{
2026-01-22 01:38:41 -03:00
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" } ,
} ,
2026-01-18 00:25:03 -03:00
] ,
} ,
{
2026-01-22 01:38:41 -03:00
id : "budget" ,
2026-01-22 23:44:16 -03:00
title : "Budget Encryption" ,
2026-01-23 16:06:06 -03:00
description : "Encrypt financial data inside Actual Budget and store the key safely." ,
2026-01-22 01:38:41 -03:00
steps : [
{
id : "budget_encryption_ack" ,
2026-01-23 16:06:06 -03:00
title : "Enable encryption inside Actual Budget" ,
2026-01-22 01:38:41 -03:00
action : "checkbox" ,
description :
2026-01-23 16:06:06 -03:00
"Actual Budget does not encrypt by default. Open Settings → Encryption, enable it, and store the key in Vaultwarden." ,
2026-01-22 01:38:41 -03:00
bullets : [
2026-01-23 16:06:06 -03:00
"Keep the encryption key only in Vaultwarden." ,
"If you lose the key, your budget data cannot be recovered." ,
2026-01-22 01:38:41 -03:00
] ,
links : [
2026-01-23 16:06:06 -03:00
{ href : "https://budget.bstein.dev" , text : "budget.bstein.dev" } ,
{ href : "https://vault.bstein.dev" , text : "Vaultwarden" } ,
2026-01-22 01:38:41 -03:00
] ,
guide : { service : "budget" , step : "step1_encrypt_data" } ,
} ,
2026-01-18 00:25:03 -03:00
] ,
} ,
{
id : "firefly" ,
title : "Firefly III" ,
2026-01-22 01:38:41 -03:00
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" } ,
} ,
2026-01-23 16:06:06 -03:00
{
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" } ,
} ,
2026-01-18 00:25:03 -03:00
] ,
} ,
{
2026-01-22 01:38:41 -03:00
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" } ,
} ,
2026-01-18 00:25:03 -03:00
] ,
} ,
{
id : "jellyfin" ,
title : "Jellyfin" ,
2026-01-22 01:38:41 -03:00
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" } ,
} ,
2026-01-18 00:25:03 -03:00
] ,
} ,
] ;
2026-01-22 01:38:41 -03:00
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 ;
2026-01-18 00:25:03 -03:00
}
2026-01-22 01:38:41 -03:00
function prevSection ( ) {
const list = sections . value ;
const index = list . findIndex ( ( item ) => item . id === activeSectionId . value ) ;
if ( index > 0 ) {
activeSectionId . value = list [ index - 1 ] . id ;
}
2026-01-18 00:25:03 -03:00
}
2026-01-22 01:38:41 -03:00
function nextSection ( ) {
const nextItem = nextSectionItem . value ;
if ( nextItem && ! isSectionLocked ( nextItem ) ) {
activeSectionId . value = nextItem . id ;
}
2026-01-18 00:25:03 -03:00
}
2026-01-02 09:42:06 -03:00
function statusLabel ( value ) {
const key = ( value || "" ) . trim ( ) ;
2026-01-03 02:36:29 -03:00
if ( key === "pending_email_verification" ) return "confirm email" ;
2026-01-02 09:42:06 -03:00
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 ( ) ;
2026-01-03 02:36:29 -03:00
if ( key === "pending_email_verification" ) return "pill-warn" ;
2026-01-02 09:42:06 -03:00
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" ;
}
2026-01-22 01:38:41 -03:00
function isStepDone ( stepId ) {
2026-01-02 09:42:06 -03:00
const steps = onboarding . value ? . completed _steps || [ ] ;
2026-01-22 01:38:41 -03:00
return Array . isArray ( steps ) ? steps . includes ( stepId ) : false ;
2026-01-02 09:42:06 -03:00
}
2026-01-02 01:34:18 -03:00
2026-01-22 01:38:41 -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
}
2026-01-22 01:38:41 -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-22 01:38:41 -03:00
function stepPillLabel ( step ) {
if ( isStepDone ( step . id ) ) return "done" ;
if ( isStepBlocked ( step . id ) ) return "blocked" ;
if ( step . action === "auto" ) return "pending" ;
if ( ! isStepRequired ( step . id ) ) return "optional" ;
if ( step . id === "keycloak_password_rotated" ) {
return keycloakPasswordRotationRequested . value ? "rotate now" : "ready" ;
}
return "pending" ;
2026-01-04 21:57:31 -03:00
}
2026-01-22 01:38:41 -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
}
2026-01-22 23:59:02 -03:00
function isConfirming ( step ) {
return confirmingStepId . value === step . id ;
}
function confirmLabel ( step ) {
return isConfirming ( step ) ? "Confirming..." : "Confirm" ;
}
2026-01-22 01:38:41 -03:00
function stepCardClass ( step ) {
2026-01-04 23:34:21 -03:00
return {
2026-01-22 01:38:41 -03:00
done : isStepDone ( step . id ) ,
blocked : isStepBlocked ( step . id ) ,
optional : ! isStepRequired ( step . id ) ,
2026-01-04 23:34:21 -03:00
} ;
}
2026-01-22 01:38:41 -03:00
function sectionProgress ( section ) {
const requiredSteps = section . steps . filter ( ( step ) => isStepRequired ( step . id ) ) ;
if ( ! requiredSteps . length ) return "optional" ;
2026-01-23 03:10:54 -03:00
if ( isSectionLocked ( section ) ) return ` 0/ ${ requiredSteps . length } done ` ;
const doneCount = requiredSteps . filter ( ( step ) => isStepDone ( step . id ) && ! isStepBlocked ( step . id ) ) . length ;
2026-01-22 23:44:16 -03:00
return ` ${ doneCount } / ${ requiredSteps . length } done ` ;
2026-01-04 21:57:31 -03:00
}
2026-01-22 01:38:41 -03:00
function sectionStatusLabel ( section ) {
2026-01-22 23:44:16 -03:00
if ( isSectionDone ( section ) ) return "" ;
2026-01-22 01:38:41 -03:00
if ( isSectionLocked ( section ) ) return "locked" ;
2026-01-22 23:44:16 -03:00
return "active" ;
2026-01-04 22:49:34 -03:00
}
2026-01-22 01:38:41 -03:00
function sectionPillClass ( section ) {
if ( isSectionLocked ( section ) ) return "pill-wait" ;
2026-01-04 22:49:34 -03:00
return "pill-info" ;
}
2026-01-22 01:38:41 -03:00
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
}
2026-01-22 01:38:41 -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 ) ) ;
2026-01-04 12:30:30 -03:00
}
2026-01-22 01:38:41 -03:00
function sectionCardClass ( section ) {
return {
active : section . id === activeSectionId . value ,
done : isSectionDone ( section ) ,
locked : isSectionLocked ( section ) ,
} ;
2026-01-04 23:34:21 -03:00
}
2026-01-22 01:38:41 -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 ) ) ;
2026-01-04 12:30:30 -03:00
}
2026-01-22 01:38:41 -03:00
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 ) ;
2026-01-04 12:30:30 -03:00
}
2026-01-22 22:03:09 -03:00
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 ) ] || { } ;
}
2026-01-22 23:44:16 -03:00
function openLightbox ( shot ) {
if ( ! shot || ! shot . url ) return ;
lightboxShot . value = shot ;
}
function closeLightbox ( ) {
lightboxShot . value = null ;
}
2026-01-22 01:38:41 -03:00
function taskPillClass ( value ) {
const key = ( value || "" ) . trim ( ) ;
2026-01-03 04:55:03 -03:00
if ( key === "ok" ) return "pill-ok" ;
if ( key === "error" ) return "pill-bad" ;
return "pill-warn" ;
}
2026-01-22 01:38:41 -03:00
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" } ,
2026-01-22 18:48:19 -03:00
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" ;
2026-01-02 10:27:02 -03:00
requestUsername . value = data . username || "" ;
2026-01-22 01:38:41 -03:00
onboarding . value = data . onboarding || { required _steps : [ ] , optional _steps : [ ] , completed _steps : [ ] } ;
2026-01-04 22:49:34 -03:00
keycloakPasswordRotationRequested . value = Boolean ( data . onboarding ? . keycloak ? . password _rotation _requested ) ;
2026-01-03 04:55:03 -03:00
tasks . value = Array . isArray ( data . tasks ) ? data . tasks : [ ] ;
blocked . value = Boolean ( data . blocked ) ;
2026-01-04 22:49:34 -03:00
initialPassword . value = data . initial _password || "" ;
2026-01-22 22:03:09 -03:00
initialPasswordRevealedAt . value = data . initial _password _revealed _at || "" ;
2026-01-22 01:38:41 -03:00
if ( showOnboarding . value ) {
selectDefaultSection ( ) ;
}
2026-01-02 01:34:18 -03:00
} catch ( err ) {
2026-01-22 01:38:41 -03:00
error . value = err ? . message || "Failed to check status" ;
2026-01-03 04:55:03 -03:00
tasks . value = [ ] ;
blocked . value = false ;
2026-01-04 22:49:34 -03:00
keycloakPasswordRotationRequested . value = false ;
2026-01-02 01:34:18 -03:00
} finally {
loading . value = false ;
}
}
2026-01-22 01:38:41 -03:00
function togglePassword ( ) {
revealPassword . value = ! revealPassword . value ;
2026-01-02 11:12:43 -03:00
}
2026-01-22 01:38:41 -03:00
async function copyText ( text , setFlag ) {
if ( ! text ) return ;
2026-01-04 08:44:25 -03:00
try {
if ( navigator ? . clipboard ? . writeText ) {
2026-01-22 01:38:41 -03:00
await navigator . clipboard . writeText ( text ) ;
2026-01-04 08:44:25 -03:00
} else {
2026-01-22 01:38:41 -03:00
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" ) ;
2026-01-22 01:38:41 -03:00
document . body . removeChild ( fallback ) ;
2026-01-04 08:44:25 -03:00
}
2026-01-22 01:38:41 -03:00
setFlag ( true ) ;
setTimeout ( ( ) => setFlag ( false ) , 1500 ) ;
2026-01-04 08:44:25 -03:00
} catch ( err ) {
2026-01-22 01:38:41 -03:00
error . value = err ? . message || "Copy failed" ;
2026-01-04 08:44:25 -03:00
}
}
2026-01-22 01:38:41 -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 ) ;
2026-01-22 23:44:16 -03:00
await setStepCompletion ( stepId , checked ) ;
}
async function setStepCompletion ( stepId , completed ) {
2026-01-23 00:08:13 -03:00
if ( ! requestCode . value . trim ( ) ) {
error . value = "Request code is missing." ;
2026-01-04 21:57:31 -03:00
return ;
}
2026-01-22 01:38:41 -03:00
if ( isStepBlocked ( stepId ) ) {
return ;
}
2026-01-04 21:57:31 -03:00
loading . value = true ;
2026-01-22 01:38:41 -03:00
error . value = "" ;
2026-01-04 21:57:31 -03:00
try {
2026-01-23 00:08:13 -03:00
const requester = auth . authenticated ? authFetch : fetch ;
2026-01-23 03:10:54 -03:00
let resp = await requester ( "/api/access/request/onboarding/attest" , {
2026-01-04 21:57:31 -03:00
method : "POST" ,
headers : { "Content-Type" : "application/json" } ,
2026-01-22 23:44:16 -03:00
body : JSON . stringify ( { request _code : requestCode . value . trim ( ) , step : stepId , completed } ) ,
2026-01-04 21:57:31 -03:00
} ) ;
2026-01-23 16:51:10 -03:00
if ( [ 401 , 403 ] . includes ( resp . status ) && requester === authFetch ) {
2026-01-23 03:10:54 -03:00
resp = await fetch ( "/api/access/request/onboarding/attest" , {
method : "POST" ,
headers : { "Content-Type" : "application/json" } ,
body : JSON . stringify ( { request _code : requestCode . value . trim ( ) , step : stepId , completed } ) ,
} ) ;
}
2026-01-04 21:57:31 -03:00
const data = await resp . json ( ) . catch ( ( ) => ( { } ) ) ;
if ( ! resp . ok ) throw new Error ( data . error || resp . statusText || ` status ${ resp . status } ` ) ;
status . value = data . status || status . value ;
onboarding . value = data . onboarding || onboarding . value ;
} catch ( err ) {
2026-01-22 01:38:41 -03:00
error . value = err ? . message || "Failed to update onboarding" ;
2026-01-04 21:57:31 -03:00
} finally {
loading . value = false ;
}
}
2026-01-22 23:44:16 -03:00
async function confirmStep ( step ) {
if ( ! step || isStepBlocked ( step . id ) || isStepDone ( step . id ) ) return ;
2026-01-22 23:59:02 -03:00
confirmingStepId . value = step . id ;
try {
2026-01-23 03:10:54 -03:00
if ( step . id === "keycloak_password_rotated" ) {
await requestKeycloakPasswordRotation ( ) ;
2026-01-22 23:59:02 -03:00
await check ( ) ;
return ;
}
2026-01-23 03:10:54 -03:00
if ( step . action === "auto" ) {
2026-01-23 18:23:06 -03:00
if ( step . id === "firefly_password_rotated" ) {
await runRotationCheck ( "firefly" ) ;
}
if ( step . id === "wger_password_rotated" ) {
await runRotationCheck ( "wger" ) ;
}
2026-01-22 23:59:02 -03:00
await check ( ) ;
return ;
}
if ( step . action === "confirm" ) {
await check ( ) ;
if ( ! isStepDone ( step . id ) ) {
await setStepCompletion ( step . id , true ) ;
}
return ;
}
await setStepCompletion ( step . id , true ) ;
2026-01-23 18:23:06 -03:00
} catch ( err ) {
error . value = err ? . message || "Failed to confirm step" ;
2026-01-22 23:59:02 -03:00
} finally {
confirmingStepId . value = "" ;
2026-01-22 23:44:16 -03:00
}
}
2026-01-23 18:23:06 -03:00
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 ;
}
2026-01-22 01:38:41 -03:00
async function requestKeycloakPasswordRotation ( ) {
2026-01-23 03:10:54 -03:00
if ( ! requestCode . value . trim ( ) ) {
error . value = "Request code is missing." ;
2026-01-03 00:57:14 -03:00
return ;
}
2026-01-22 01:38:41 -03:00
if ( isStepBlocked ( "keycloak_password_rotated" ) ) {
error . value = "Complete earlier onboarding steps first." ;
2026-01-04 13:00:42 -03:00
return ;
}
2026-01-22 01:38:41 -03:00
if ( keycloakPasswordRotationRequested . value ) return ;
2026-01-02 09:42:06 -03:00
loading . value = true ;
2026-01-22 01:38:41 -03:00
error . value = "" ;
2026-01-02 09:42:06 -03:00
try {
2026-01-23 03:10:54 -03:00
const requester = auth . authenticated ? authFetch : fetch ;
let resp = await requester ( "/api/access/request/onboarding/keycloak-password-rotate" , {
2026-01-02 09:42:06 -03:00
method : "POST" ,
headers : { "Content-Type" : "application/json" } ,
2026-01-22 01:38:41 -03:00
body : JSON . stringify ( { request _code : requestCode . value . trim ( ) } ) ,
2026-01-02 09:42:06 -03:00
} ) ;
2026-01-23 16:51:10 -03:00
if ( [ 401 , 403 ] . includes ( resp . status ) && requester === authFetch ) {
2026-01-23 03:10:54 -03:00
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 ( ) } ) ,
} ) ;
}
2026-01-02 09:42:06 -03:00
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 ;
2026-01-22 01:38:41 -03:00
status . value = data . status || status . value ;
keycloakPasswordRotationRequested . value = Boolean ( data . onboarding ? . keycloak ? . password _rotation _requested ) ;
2026-01-02 09:42:06 -03:00
} catch ( err ) {
2026-01-22 01:38:41 -03:00
error . value = err ? . message || "Failed to request password rotation" ;
2026-01-02 09:42:06 -03:00
} finally {
loading . value = false ;
}
}
2026-01-22 01:38:41 -03:00
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 } ) ;
2026-01-04 13:00:42 -03:00
}
2026-01-22 01:38:41 -03:00
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 ) ) ;
} ) ;
2026-01-04 13:00:42 -03:00
} ) ;
2026-01-22 01:38:41 -03:00
} ) ;
return grouped ;
2026-01-04 13:00:42 -03:00
}
2026-01-22 01:38:41 -03:00
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
}
2026-01-22 01:38:41 -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 {
2026-01-22 01:38:41 -03:00
max - width : 1080 px ;
2026-01-02 01:34:18 -03:00
margin : 0 auto ;
padding : 32 px 22 px 72 px ;
}
. hero {
margin - bottom : 12 px ;
padding : 18 px ;
}
. eyebrow {
text - transform : uppercase ;
letter - spacing : 0.08 em ;
color : var ( -- text - muted ) ;
margin : 0 0 6 px ;
font - size : 13 px ;
}
h1 {
margin : 0 0 6 px ;
font - size : 32 px ;
}
. lede {
margin : 0 ;
color : var ( -- text - muted ) ;
max - width : 640 px ;
}
. module {
padding : 18 px ;
}
. module - head {
display : flex ;
align - items : center ;
justify - content : space - between ;
gap : 18 px ;
}
. status - form {
display : flex ;
gap : 10 px ;
margin - top : 12 px ;
}
2026-01-02 10:27:02 -03:00
. status - meta {
margin - top : 12 px ;
padding : 12 px ;
border - radius : 14 px ;
border : 1 px 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 : 14 px ;
}
. meta - row . label {
color : var ( -- text - muted ) ;
}
2026-01-02 01:34:18 -03:00
. input {
flex : 1 ;
padding : 10 px 12 px ;
border - radius : 10 px ;
border : 1 px solid rgba ( 255 , 255 , 255 , 0.14 ) ;
background : rgba ( 0 , 0 , 0 , 0.25 ) ;
color : var ( -- text - primary ) ;
}
button . primary {
background : linear - gradient ( 90 deg , # 4 f8bff , # 7 dd0ff ) ;
color : # 0b1 222 ;
padding : 10 px 14 px ;
border : none ;
border - radius : 10 px ;
cursor : pointer ;
font - weight : 700 ;
}
2026-01-22 01:38:41 -03:00
button . secondary {
background : rgba ( 255 , 255 , 255 , 0.08 ) ;
border : 1 px solid rgba ( 255 , 255 , 255 , 0.16 ) ;
color : var ( -- text - primary ) ;
padding : 8 px 12 px ;
border - radius : 10 px ;
cursor : pointer ;
font - weight : 600 ;
2026-01-02 01:34:18 -03:00
}
2026-01-22 22:28:31 -03:00
button . primary : disabled ,
button . secondary : disabled ,
button . copy : disabled {
opacity : 0.45 ;
cursor : not - allowed ;
}
2026-01-22 01:38:41 -03:00
. steps {
margin - top : 16 px ;
2026-01-02 01:34:18 -03:00
}
2026-01-02 09:42:06 -03:00
. onboarding - head {
display : flex ;
2026-01-22 01:38:41 -03:00
align - items : flex - start ;
2026-01-02 09:42:06 -03:00
justify - content : space - between ;
2026-01-22 01:38:41 -03:00
gap : 14 px ;
2026-01-02 09:42:06 -03:00
margin - bottom : 8 px ;
}
2026-01-22 01:38:41 -03:00
. section - stepper {
margin : 16 px 0 18 px ;
list - style : none ;
2026-01-22 18:48:19 -03:00
display : grid ;
grid - template - columns : repeat ( 4 , minmax ( 0 , 1 fr ) ) ;
2026-01-22 01:38:41 -03:00
gap : 10 px ;
padding : 0 ;
2026-01-02 09:42:06 -03:00
}
2026-01-22 01:38:41 -03:00
. stepper - item {
2026-01-22 18:48:19 -03:00
min - width : 0 ;
2026-01-02 09:42:06 -03:00
}
2026-01-22 01:38:41 -03:00
. stepper - card {
width : 100 % ;
text - align : left ;
padding : 12 px 12 px 12 px 36 px ;
border - radius : 12 px ;
border : 1 px solid rgba ( 255 , 255 , 255 , 0.12 ) ;
background : rgba ( 0 , 0 , 0 , 0.2 ) ;
color : var ( -- text - primary ) ;
display : flex ;
gap : 8 px ;
position : relative ;
2026-01-02 09:42:06 -03:00
}
2026-01-22 01:38:41 -03:00
. stepper - card : : after {
display : none ;
2026-01-04 23:34:21 -03:00
}
2026-01-22 01:38:41 -03:00
. stepper - dot {
position : absolute ;
left : 12 px ;
top : 12 px ;
width : 10 px ;
height : 10 px ;
border - radius : 999 px ;
border : 2 px 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
}
2026-01-22 01:38:41 -03:00
. stepper - body {
display : flex ;
flex - direction : column ;
gap : 6 px ;
2026-01-22 22:28:31 -03:00
min - width : 0 ;
2026-01-04 23:34:21 -03:00
}
2026-01-22 01:38:41 -03:00
. stepper - title {
font - weight : 700 ;
2026-01-22 22:28:31 -03:00
white - space : nowrap ;
overflow : hidden ;
text - overflow : ellipsis ;
2026-01-04 23:34:21 -03:00
}
2026-01-22 01:38:41 -03:00
. stepper - meta {
2026-01-02 09:42:06 -03:00
display : flex ;
align - items : center ;
2026-01-23 16:06:06 -03:00
justify - content : center ;
2026-01-23 16:51:10 -03:00
gap : 10 px ;
2026-01-22 22:28:31 -03:00
flex - wrap : nowrap ;
2026-01-22 01:38:41 -03:00
color : var ( -- text - muted ) ;
2026-01-23 16:51:10 -03:00
width : 100 % ;
2026-01-02 09:42:06 -03:00
}
2026-01-22 23:44:16 -03:00
. stepper - meta . pill {
padding : 4 px 6 px ;
font - size : 11 px ;
white - space : nowrap ;
}
2026-01-22 18:48:19 -03:00
. pill - compact {
2026-01-22 23:44:16 -03:00
padding : 4 px 6 px ;
font - size : 11 px ;
2026-01-22 18:48:19 -03:00
white - space : nowrap ;
max - width : 100 % ;
overflow : hidden ;
text - overflow : ellipsis ;
}
2026-01-22 01:38:41 -03:00
. stepper - card . active {
border - color : rgba ( 125 , 208 , 255 , 0.5 ) ;
box - shadow : 0 0 0 1 px rgba ( 79 , 139 , 255 , 0.3 ) ;
2026-01-03 00:57:14 -03:00
}
2026-01-22 01:38:41 -03:00
. stepper - card . active . stepper - dot {
background : rgba ( 125 , 208 , 255 , 0.8 ) ;
border - color : rgba ( 125 , 208 , 255 , 0.85 ) ;
2026-01-02 09:42:06 -03:00
}
2026-01-22 01:38:41 -03:00
. stepper - card . done {
border - color : rgba ( 92 , 214 , 167 , 0.35 ) ;
background : rgba ( 92 , 214 , 167 , 0.08 ) ;
2026-01-04 13:00:42 -03:00
}
2026-01-22 01:38:41 -03:00
. stepper - card . done . stepper - dot {
background : rgba ( 92 , 214 , 167 , 0.9 ) ;
border - color : rgba ( 92 , 214 , 167 , 0.95 ) ;
2026-01-04 13:00:42 -03:00
}
2026-01-22 01:38:41 -03:00
. stepper - card . locked {
opacity : 0.6 ;
2026-01-04 13:00:42 -03:00
}
2026-01-22 18:48:19 -03:00
@ media ( max - width : 1200 px ) {
. section - stepper {
grid - template - columns : repeat ( 3 , minmax ( 0 , 1 fr ) ) ;
}
}
2026-01-22 01:38:41 -03:00
@ media ( max - width : 860 px ) {
2026-01-22 18:48:19 -03:00
. section - stepper {
grid - template - columns : repeat ( 2 , minmax ( 0 , 1 fr ) ) ;
}
}
@ media ( max - width : 520 px ) {
. section - stepper {
grid - template - columns : 1 fr ;
2026-01-22 01:38:41 -03:00
}
2026-01-04 21:57:31 -03:00
}
2026-01-22 01:38:41 -03:00
. credential - card {
margin - top : 14 px ;
padding : 14 px ;
border - radius : 14 px ;
border : 1 px 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 ( 220 px , 1 fr ) ) ;
gap : 14 px ;
2026-01-04 21:57:31 -03:00
}
2026-01-22 01:38:41 -03:00
. credential - field . label {
display : block ;
margin - bottom : 6 px ;
color : var ( -- text - muted ) ;
2026-01-04 21:57:31 -03:00
}
2026-01-22 23:44:16 -03:00
. credential - field . input [ readonly ] {
opacity : 0.8 ;
}
2026-01-22 01:38:41 -03:00
. password - row {
2026-01-04 21:57:31 -03:00
display : flex ;
2026-01-22 01:38:41 -03:00
gap : 8 px ;
align - items : center ;
2026-01-04 21:57:31 -03:00
}
2026-01-22 01:38:41 -03:00
. password - row . input {
flex : 1 ;
2026-01-04 21:57:31 -03:00
}
2026-01-22 01:38:41 -03:00
. section - shell {
margin - top : 16 px ;
padding - top : 12 px ;
border - top : 1 px solid rgba ( 255 , 255 , 255 , 0.08 ) ;
2026-01-04 21:57:31 -03:00
}
2026-01-22 01:38:41 -03:00
. section - header {
display : flex ;
align - items : flex - start ;
justify - content : space - between ;
2026-01-04 21:57:31 -03:00
gap : 12 px ;
2026-01-22 01:38:41 -03:00
margin - bottom : 12 px ;
2026-01-04 21:57:31 -03:00
}
2026-01-22 01:38:41 -03:00
. section - actions {
display : flex ;
2026-01-22 23:44:16 -03:00
align - items : center ;
justify - content : space - between ;
2026-01-04 21:57:31 -03:00
gap : 8 px ;
2026-01-22 23:44:16 -03:00
width : 100 % ;
margin - top : 12 px ;
2026-01-22 01:38:41 -03:00
}
. step - grid {
display : grid ;
gap : 12 px ;
}
. step - card {
2026-01-04 21:57:31 -03:00
border : 1 px solid rgba ( 255 , 255 , 255 , 0.08 ) ;
2026-01-22 01:38:41 -03:00
border - radius : 14 px ;
padding : 12 px 12 px 10 px ;
2026-01-04 21:57:31 -03:00
background : rgba ( 255 , 255 , 255 , 0.02 ) ;
2026-01-22 22:28:31 -03:00
display : flex ;
flex - direction : column ;
2026-01-04 21:57:31 -03:00
}
2026-01-22 01:38:41 -03:00
. step - card . blocked {
opacity : 0.55 ;
2026-01-22 23:59:02 -03:00
pointer - events : none ;
2026-01-04 21:57:31 -03:00
}
2026-01-22 01:38:41 -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
}
2026-01-22 01:38:41 -03:00
. step - head {
display : flex ;
align - items : center ;
gap : 12 px ;
}
. auto - pill {
margin - left : auto ;
2026-01-04 21:57:31 -03:00
font - size : 12 px ;
2026-01-22 01:38:41 -03:00
padding : 3 px 10 px ;
border - radius : 999 px ;
2026-01-04 21:57:31 -03:00
}
2026-01-22 01:38:41 -03:00
. step - title {
font - weight : 650 ;
color : var ( -- text - strong ) ;
2026-01-04 21:57:31 -03:00
}
2026-01-22 01:38:41 -03:00
. step - label {
display : flex ;
align - items : center ;
gap : 10 px ;
2026-01-05 00:06:41 -03:00
}
2026-01-22 01:38:41 -03:00
. step - label input {
width : 18 px ;
height : 18 px ;
2026-01-05 00:06:41 -03:00
}
2026-01-22 01:38:41 -03:00
. step - bullets {
margin : 8 px 0 0 ;
2026-01-05 00:06:41 -03:00
padding - left : 18 px ;
2026-01-22 01:38:41 -03:00
color : var ( -- text - muted ) ;
2026-01-05 00:06:41 -03:00
}
2026-01-22 01:38:41 -03:00
. step - links {
margin - top : 8 px ;
display : flex ;
flex - wrap : wrap ;
gap : 10 px ;
2026-01-18 00:25:03 -03:00
}
2026-01-22 01:38:41 -03:00
. step - links a {
2026-01-23 03:10:54 -03:00
color : var ( -- accent - cyan ) ;
text - decoration : none ;
font - weight : 600 ;
}
. step - links a : hover {
text - decoration : underline ;
2026-01-18 00:25:03 -03:00
}
2026-01-22 01:38:41 -03:00
. step - actions {
2026-01-18 00:25:03 -03:00
display : flex ;
2026-01-22 01:38:41 -03:00
align - items : center ;
gap : 12 px ;
2026-01-22 22:28:31 -03:00
justify - content : flex - end ;
margin - top : auto ;
2026-01-22 23:44:16 -03:00
padding - top : 10 px ;
2026-01-18 00:25:03 -03:00
}
2026-01-22 01:38:41 -03:00
. recovery - verify {
2026-01-18 00:25:03 -03:00
display : flex ;
gap : 10 px ;
2026-01-22 01:38:41 -03:00
margin - top : 10 px ;
align - items : stretch ;
2026-01-18 00:25:03 -03:00
}
2026-01-22 01:38:41 -03:00
. recovery - verify . input {
flex : 1 ;
2026-01-18 00:25:03 -03:00
}
. guide - details {
margin - top : 10 px ;
}
2026-01-22 01:38:41 -03:00
. guide - groups {
display : grid ;
gap : 12 px ;
margin - top : 8 px ;
}
. guide - title {
margin : 0 0 6 px ;
2026-01-18 00:25:03 -03:00
}
. guide - images {
display : grid ;
gap : 10 px ;
}
. guide - shot {
2026-01-22 01:38:41 -03:00
border - radius : 10 px ;
overflow : hidden ;
border : 1 px solid rgba ( 255 , 255 , 255 , 0.1 ) ;
background : rgba ( 0 , 0 , 0 , 0.2 ) ;
2026-01-23 03:10:54 -03:00
padding : 0 ;
2026-01-22 23:44:16 -03:00
cursor : zoom - in ;
2026-01-23 16:51:10 -03:00
display : flex ;
flex - direction : column ;
gap : 6 px ;
2026-01-22 23:44:16 -03:00
}
. guide - shot figcaption {
2026-01-23 16:06:06 -03:00
margin : 0 ;
padding : 10 px 12 px 6 px ;
2026-01-23 16:51:10 -03:00
font - size : 17 px ;
2026-01-23 16:06:06 -03:00
font - weight : 600 ;
2026-01-23 03:10:54 -03:00
color : var ( -- text - strong ) ;
2026-01-18 00:25:03 -03:00
}
. guide - shot img {
2026-01-23 03:10:54 -03:00
display : block ;
border - radius : 10 px ;
2026-01-23 16:51:10 -03:00
max - width : 100 % ;
width : auto ;
height : auto ;
max - height : min ( 60 vh , 520 px ) ;
margin : 0 auto 10 px ;
2026-01-04 13:00:42 -03:00
}
2026-01-22 22:03:09 -03:00
. guide - pagination {
display : flex ;
align - items : center ;
justify - content : space - between ;
gap : 8 px ;
flex - wrap : wrap ;
}
. guide - dots {
display : flex ;
gap : 6 px ;
flex - wrap : wrap ;
}
. guide - dot {
border : 1 px solid rgba ( 255 , 255 , 255 , 0.18 ) ;
background : rgba ( 0 , 0 , 0 , 0.3 ) ;
color : var ( -- text - muted ) ;
border - radius : 999 px ;
padding : 4 px 8 px ;
cursor : pointer ;
font - size : 12 px ;
}
. guide - dot . active {
border - color : rgba ( 120 , 180 , 255 , 0.5 ) ;
color : var ( -- text - strong ) ;
}
2026-01-02 09:42:06 -03:00
. ready - box {
2026-01-22 01:38:41 -03:00
margin - top : 18 px ;
2026-01-03 04:55:03 -03:00
padding : 14 px ;
border - radius : 14 px ;
2026-01-22 01:38:41 -03:00
border : 1 px solid rgba ( 92 , 214 , 167 , 0.3 ) ;
background : rgba ( 92 , 214 , 167 , 0.08 ) ;
2026-01-03 04:55:03 -03:00
}
. task - list {
margin : 0 ;
2026-01-22 01:38:41 -03:00
padding : 0 ;
list - style : none ;
2026-01-03 04:55:03 -03:00
display : grid ;
2026-01-22 01:38:41 -03:00
gap : 8 px ;
2026-01-03 04:55:03 -03:00
}
. task - row {
2026-01-22 01:38:41 -03:00
display : flex ;
2026-01-03 04:55:03 -03:00
align - items : center ;
2026-01-22 01:38:41 -03:00
gap : 12 px ;
2026-01-03 04:55:03 -03:00
}
. task - name {
2026-01-22 01:38:41 -03:00
min - width : 180 px ;
2026-01-03 04:55:03 -03:00
}
. task - detail {
color : var ( -- text - muted ) ;
}
2026-01-22 01:38:41 -03:00
. error - box {
2026-01-02 11:12:43 -03:00
margin - top : 12 px ;
2026-01-22 01:38:41 -03:00
padding : 12 px ;
border - radius : 14 px ;
border : 1 px solid rgba ( 255 , 120 , 120 , 0.4 ) ;
background : rgba ( 255 , 70 , 70 , 0.1 ) ;
2026-01-02 11:12:43 -03:00
}
2026-01-22 23:44:16 -03:00
. 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 : 28 px ;
z - index : 2000 ;
}
. lightbox - card {
2026-01-23 03:10:54 -03:00
width : min ( 1400 px , 96 vw ) ;
max - height : 94 vh ;
2026-01-22 23:44:16 -03:00
background : rgba ( 10 , 14 , 24 , 0.96 ) ;
border : 1 px solid rgba ( 255 , 255 , 255 , 0.12 ) ;
border - radius : 16 px ;
2026-01-23 03:10:54 -03:00
padding : 16 px ;
2026-01-22 23:44:16 -03:00
display : flex ;
flex - direction : column ;
gap : 12 px ;
}
. lightbox - head {
display : flex ;
align - items : center ;
justify - content : space - between ;
gap : 12 px ;
}
. lightbox - label {
color : var ( -- text - muted ) ;
}
. lightbox - card img {
width : 100 % ;
2026-01-23 03:10:54 -03:00
max - height : 82 vh ;
2026-01-22 23:44:16 -03:00
object - fit : contain ;
border - radius : 12 px ;
background : rgba ( 0 , 0 , 0 , 0.35 ) ;
}
2026-01-22 01:38:41 -03:00
@ media ( max - width : 720 px ) {
. status - form {
flex - direction : column ;
}
2026-01-02 01:34:18 -03:00
2026-01-22 01:38:41 -03:00
. section - actions {
width : 100 % ;
justify - content : flex - start ;
}
2026-01-02 01:34:18 -03:00
2026-01-22 01:38:41 -03:00
. password - row {
flex - direction : column ;
align - items : stretch ;
}
2026-01-02 01:34:18 -03:00
}
< / style >