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 >
2026-01-24 07:12:35 -03:00
< div v-if = "blocked" class="actions" style="margin-top: 10px;" >
< button class = "pill mono" type = "button" :disabled = "retrying" @click ="retryProvisioning" >
{ { retrying ? "Retrying..." : "Retry failed steps" } }
< / button >
< span v-if = "retryMessage" class="hint mono" > {{ retryMessage }} < / span >
< / div >
< p v-if = "blocked" class="muted" style="margin-top: 8px;" >
If the error mentions rate limiting or a temporary outage , wait a few minutes and retry . If it keeps failing ,
contact an admin .
< / p >
2026-01-03 04:55:03 -03:00
< / 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" >
2026-01-24 11:27:45 -03:00
Onboarding is fully self - service . There are 8 steps for 8 services below . Each step has smaller tasks ; press
Confirm when you finish a task . You can pause and return later .
2026-01-22 01:38:41 -03:00
< / 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" >
2026-01-24 21:02:30 -03:00
< div class = "credential-head" >
< h4 > Keycloak temporary credentials < / h4 >
< p class = "muted" > Use these to sign in to Nextcloud , Element , and any Keycloak - protected services . < / p >
< / div >
2026-01-22 01:38:41 -03:00
< 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 >
2026-01-23 23:01:44 -03:00
< p v-if = "activeSection.summary" class="muted" > {{ activeSection.summary }} < / p >
< p v-if = "activeSection.benefit" class="muted" > {{ activeSection.benefit }} < / p >
2026-01-04 21:57:31 -03:00
< / div >
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-24 11:41:00 -03:00
< p class = "step-note" v-if = "stepNote(step)" > {{ stepNote ( step ) }} < / 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" >
2026-01-24 11:27:45 -03:00
< a
v - for = "link in step.links"
: key = "link.href"
: href = "link.href"
: title = "link.href"
target = "_blank"
rel = "noreferrer"
>
2026-01-22 01:38:41 -03:00
{ { link . text } }
< / a >
2026-01-18 00:25:03 -03:00
< / div >
2026-01-22 01:38:41 -03:00
2026-01-24 11:27:45 -03:00
< details v-if = "step.guide" class="guide-details" :open="shouldOpenGuide(step, activeSection)" >
2026-01-24 21:02:30 -03:00
< summary class = "mono guide-summary" > 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" >
2026-01-23 23:01:44 -03:00
Your Atlas account is provisioned and onboarding is complete . Visit the Apps page to access the full suite .
< a href = "/apps" > Atlas Apps < / a > .
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-24 07:12:35 -03:00
const retrying = ref ( false ) ;
const retryMessage = ref ( "" ) ;
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-23 22:30:21 -03:00
const vaultwardenRecoveryEmail = computed ( ( ) => onboarding . value ? . vaultwarden ? . recovery _email || "" ) ;
2026-01-24 11:27:45 -03:00
const vaultwardenMatched = computed ( ( ) => Boolean ( onboarding . value ? . vaultwarden ? . matched ) ) ;
2026-01-23 23:01:44 -03:00
const vaultwardenLoginEmail = computed ( ( ) => {
2026-01-24 11:27:45 -03:00
if ( vaultwardenMatched . value ) {
2026-01-23 23:01:44 -03:00
return vaultwardenRecoveryEmail . value || "your recovery email" ;
}
if ( requestUsername . value ) {
return ` ${ requestUsername . value } @bstein.dev ` ;
}
return "your @bstein.dev address" ;
} ) ;
2026-01-24 21:02:30 -03:00
const vaultwardenLoginEmailLower = computed ( ( ) => ( vaultwardenLoginEmail . value || "" ) . toLowerCase ( ) ) ;
2026-01-23 23:01:44 -03:00
const mailAddress = computed ( ( ) => ( requestUsername . value ? ` ${ requestUsername . value } @bstein.dev ` : "your @bstein.dev address" ) ) ;
2026-01-24 21:02:30 -03:00
const mailAddressLower = computed ( ( ) => ( mailAddress . value || "" ) . toLowerCase ( ) ) ;
2026-01-22 01:38:41 -03:00
const STEP _PREREQS = {
vaultwarden _master _password : [ ] ,
2026-01-24 11:27:45 -03:00
vaultwarden _store _temp _password : [ "vaultwarden_master_password" ] ,
2026-01-22 01:38:41 -03:00
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" ] ,
2026-01-23 20:16:21 -03:00
wger _mobile _app : [ "wger_password_rotated" ] ,
2026-01-22 01:38:41 -03:00
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" ,
2026-01-23 23:01:44 -03:00
summary : "Self-hosted password manager for Atlas credentials." ,
benefit : "Keeps every lab password encrypted and synced across devices." ,
2026-01-22 01:38:41 -03:00
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" } ,
2026-01-24 11:27:45 -03:00
{ href : "https://vault.bstein.dev" , text : "Vaultwarden" } ,
2026-01-22 01:38:41 -03:00
] ,
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 23:01:44 -03:00
summary : "Secure chat, calls, and video for the lab." ,
benefit : "Private messaging with encryption and recovery controls you own." ,
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" ,
2026-01-23 23:01:44 -03:00
summary : "Your @bstein.dev inbox for lab notifications and contact." ,
benefit : "One address for every Atlas service and shared communication." ,
2026-01-22 01:38:41 -03:00
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" ,
2026-01-23 23:01:44 -03:00
summary : "File storage, calendar, and mail hub for the lab." ,
benefit : "Central workspace for docs, sharing, and your mailbox." ,
2026-01-22 01:38:41 -03:00
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." ,
2026-01-24 11:27:45 -03:00
links : [ { href : "https://cloud.bstein.dev" , text : "Nextcloud" } ] ,
2026-01-22 01:38:41 -03:00
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 23:01:44 -03:00
summary : "Actual Budget for private personal finance." ,
benefit : "Encryption keeps your budget data safe and portable." ,
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-24 11:27:45 -03:00
{ href : "https://budget.bstein.dev" , text : "Actual Budget" } ,
2026-01-23 16:06:06 -03:00
{ 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-23 23:01:44 -03:00
summary : "Personal finance tracker for transactions and reporting." ,
benefit : "Detailed insights, budgets, and exports under your control." ,
2026-01-22 01:38:41 -03:00
steps : [
{
id : "firefly_password_rotated" ,
title : "Change your Firefly password" ,
2026-01-23 19:37:06 -03:00
action : "confirm" ,
2026-01-22 01:38:41 -03:00
description :
2026-01-23 19:37:06 -03:00
"Sign in to money.bstein.dev with the credentials on your Account page, change the password, then confirm here." ,
2026-01-22 01:38:41 -03:00
links : [
2026-01-24 11:27:45 -03:00
{ href : "https://money.bstein.dev" , text : "Firefly III" } ,
2026-01-22 01:38:41 -03:00
{ 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" ,
2026-01-23 23:01:44 -03:00
summary : "Fitness tracking for workouts and nutrition." ,
benefit : "Keeps training plans and progress in one place." ,
2026-01-22 01:38:41 -03:00
steps : [
{
id : "wger_password_rotated" ,
title : "Change your Wger password" ,
2026-01-23 19:37:06 -03:00
action : "confirm" ,
2026-01-22 01:38:41 -03:00
description :
2026-01-23 19:37:06 -03:00
"Sign in to health.bstein.dev with the credentials on your Account page, change the password, then confirm here." ,
2026-01-22 01:38:41 -03:00
links : [
2026-01-24 11:27:45 -03:00
{ href : "https://health.bstein.dev" , text : "Wger" } ,
2026-01-22 01:38:41 -03:00
{ href : "/account" , text : "Account credentials" } ,
] ,
guide : { service : "wger" , step : "step1_web_access" } ,
} ,
2026-01-23 20:16:21 -03:00
{
id : "wger_mobile_app" ,
title : "Optional: set up the mobile app" ,
action : "checkbox" ,
description :
"Install the Wger mobile app, sign in with your updated credentials, and store the password in Vaultwarden." ,
links : [
{ href : "https://github.com/wger-project/wger" , text : "Wger project" } ,
{ href : "/account" , text : "Account credentials" } ,
] ,
guide : { service : "wger" , step : "step2_mobile_app" } ,
} ,
2026-01-18 00:25:03 -03:00
] ,
} ,
{
id : "jellyfin" ,
title : "Jellyfin" ,
2026-01-23 23:01:44 -03:00
summary : "Self-hosted media streaming for the lab." ,
benefit : "Watch your media anywhere without third-party accounts." ,
2026-01-22 01:38:41 -03:00
steps : [
{
id : "jellyfin_web_access" ,
2026-01-23 20:16:21 -03:00
title : "Sign in to Jellyfin" ,
2026-01-22 01:38:41 -03:00
action : "checkbox" ,
description :
"Sign in with your Atlas username/password (LDAP-backed)." ,
2026-01-24 11:27:45 -03:00
links : [ { href : "https://stream.bstein.dev" , text : "Jellyfin" } ] ,
2026-01-22 01:38:41 -03:00
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" } ] ,
} ,
2026-01-18 00:25:03 -03:00
] ,
} ,
] ;
2026-01-24 11:27:45 -03:00
const VAULTWARDEN _TEMP _STEP = {
id : "vaultwarden_store_temp_password" ,
title : "Store the temporary Keycloak password" ,
action : "confirm" ,
description :
"Save the temporary Keycloak password in Vaultwarden so you can rotate it later without losing access." ,
links : [ { href : "https://vault.bstein.dev" , text : "Vaultwarden" } ] ,
guide : { service : "vaultwarden" , step : "step1_website" , tail : 4 } ,
} ;
const sections = computed ( ( ) =>
SECTION _DEFS . map ( ( section ) => {
if ( section . id !== "vaultwarden" ) return section ;
const steps = [ ... section . steps ] ;
if ( vaultwardenMatched . value ) {
steps . splice ( 1 , 0 , VAULTWARDEN _TEMP _STEP ) ;
}
return { ... section , steps } ;
} ) ,
) ;
2026-01-22 01:38:41 -03:00
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-23 23:01:44 -03:00
function stepNote ( step ) {
if ( step . id === "vaultwarden_master_password" ) {
2026-01-24 21:02:30 -03:00
return ` Vaultwarden uses an email login. Use ${ vaultwardenLoginEmailLower . value } to sign in. ` ;
2026-01-23 23:01:44 -03:00
}
2026-01-24 11:27:45 -03:00
if ( step . id === "vaultwarden_store_temp_password" ) {
return "Store the temporary Keycloak password in Vaultwarden so you can rotate it safely later." ;
}
2026-01-23 23:01:44 -03:00
if ( step . id === "firefly_password_rotated" ) {
2026-01-24 21:02:30 -03:00
return ` Firefly uses an email login. Use ${ mailAddressLower . value } to sign in. ` ;
2026-01-23 23:01:44 -03:00
}
if ( step . id === "mail_client_setup" ) {
2026-01-24 21:02:30 -03:00
return ` Your mailbox address is ${ mailAddressLower . value } . ` ;
2026-01-23 23:01:44 -03:00
}
return "" ;
}
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 ] || { } ;
2026-01-24 11:27:45 -03:00
const groups = Object . values ( stepShots ) ;
const take = step . guide . take || step . guide . tail || 0 ;
if ( ! take ) return groups ;
const useTail = Boolean ( step . guide . tail ) ;
return groups . map ( ( group ) => {
const shots = useTail ? group . shots . slice ( - take ) : group . shots . slice ( 0 , take ) ;
return { ... group , shots } ;
} ) ;
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-24 11:27:45 -03:00
function shouldOpenGuide ( step , section ) {
if ( ! step || ! step . guide || ! section ) return false ;
const first = section . steps . find (
( item ) => item . guide && ! isStepDone ( item . id ) && ! isStepBlocked ( item . id ) ,
) ;
return Boolean ( first && first . id === step . id ) ;
}
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-24 07:12:35 -03:00
async function retryProvisioning ( ) {
if ( retrying . value ) return ;
retryMessage . value = "" ;
const code = requestCode . value . trim ( ) ;
if ( ! code ) return ;
retrying . value = true ;
try {
const retryTasks = tasks . value
. filter ( ( item ) => item . status === "error" )
. map ( ( item ) => item . task )
. filter ( Boolean ) ;
const resp = await fetch ( "/api/access/request/retry" , {
method : "POST" ,
headers : { "Content-Type" : "application/json" } ,
cache : "no-store" ,
body : JSON . stringify ( { request _code : code , tasks : retryTasks } ) ,
} ) ;
const data = await resp . json ( ) . catch ( ( ) => ( { } ) ) ;
if ( ! resp . ok ) throw new Error ( data . error || resp . statusText || ` status ${ resp . status } ` ) ;
retryMessage . value = "Retry requested. Check again in a moment." ;
await check ( ) ;
} catch ( err ) {
retryMessage . value = err ? . message || "Retry request failed." ;
} finally {
retrying . value = false ;
}
}
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 ) ;
}
2026-01-23 22:30:21 -03:00
async function setStepCompletion ( stepId , completed , extra = { } ) {
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-23 22:30:21 -03:00
body : JSON . stringify ( { request _code : requestCode . value . trim ( ) , step : stepId , completed , ... extra } ) ,
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" } ,
2026-01-23 22:30:21 -03:00
body : JSON . stringify ( { request _code : requestCode . value . trim ( ) , step : stepId , completed , ... extra } ) ,
2026-01-23 03:10:54 -03:00
} ) ;
}
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" ) {
2026-01-23 19:20:14 -03:00
const result = await runRotationCheck ( "firefly" ) ;
if ( result && result . rotated === false ) {
throw new Error ( "Firefly still uses the initial password. Change it in Firefly, then confirm again." ) ;
}
2026-01-23 18:23:06 -03:00
}
if ( step . id === "wger_password_rotated" ) {
2026-01-23 19:20:14 -03:00
const result = await runRotationCheck ( "wger" ) ;
if ( result && result . rotated === false ) {
throw new Error ( "Wger still uses the initial password. Change it in Wger, then confirm again." ) ;
}
2026-01-23 18:23:06 -03:00
}
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 >
2026-04-21 06:51:35 -03:00
< style scoped src = "../styles/onboarding.css" > < / style >