ui: polish onboarding gating

This commit is contained in:
Brad Stein 2026-01-04 23:34:21 -03:00
parent 698ed49a9b
commit 16e69541e5
2 changed files with 79 additions and 24 deletions

View File

@ -1,7 +1,7 @@
<template> <template>
<header class="hero card glass"> <header class="hero card glass">
<div class="eyebrow"> <div class="eyebrow">
<span class="pill">Portfolio + Titan Lab</span> <span class="pill">Titan Lab</span>
<span class="mono accent">atlas · oceanus · nextcloud-ready</span> <span class="mono accent">atlas · oceanus · nextcloud-ready</span>
</div> </div>
<h1>{{ title }}</h1> <h1>{{ title }}</h1>

View File

@ -91,7 +91,8 @@
<h3>Temporary password</h3> <h3>Temporary password</h3>
<p class="muted"> <p class="muted">
Use this password to log in for the first time. You won't be forced to change it immediately — you'll rotate Use this password to log in for the first time. You won't be forced to change it immediately — you'll rotate
it later after Vaultwarden is set up. This password is shown once — copy it now. it later after Vaultwarden is set up. This password is shown once — copy it now. If you refresh this page,
it may disappear.
</p> </p>
<div class="request-code-row"> <div class="request-code-row">
<span class="label mono">Password</span> <span class="label mono">Password</span>
@ -114,7 +115,7 @@
</div> </div>
<ul class="checklist"> <ul class="checklist">
<li class="check-item"> <li class="check-item" :class="checkItemClass('vaultwarden_master_password')">
<label> <label>
<input <input
type="checkbox" type="checkbox"
@ -134,7 +135,7 @@
</p> </p>
</li> </li>
<li class="check-item"> <li class="check-item" :class="checkItemClass('vaultwarden_browser_extension')">
<label> <label>
<input <input
type="checkbox" type="checkbox"
@ -153,7 +154,7 @@
</p> </p>
</li> </li>
<li class="check-item"> <li class="check-item" :class="checkItemClass('vaultwarden_mobile_app')">
<label> <label>
<input <input
type="checkbox" type="checkbox"
@ -172,7 +173,7 @@
</p> </p>
</li> </li>
<li class="check-item"> <li class="check-item" :class="checkItemClass('keycloak_password_rotated')">
<label> <label>
<input type="checkbox" :checked="isStepDone('keycloak_password_rotated')" disabled /> <input type="checkbox" :checked="isStepDone('keycloak_password_rotated')" disabled />
<span>Rotate your Keycloak password</span> <span>Rotate your Keycloak password</span>
@ -203,7 +204,7 @@
</p> </p>
</li> </li>
<li class="check-item mfa-optional"> <li class="check-item mfa-optional" :class="mfaItemClass()">
<div class="mfa-row"> <div class="mfa-row">
<div class="mfa-text"> <div class="mfa-text">
<span class="mfa-title">Optional: enable MFA (TOTP) for Keycloak</span> <span class="mfa-title">Optional: enable MFA (TOTP) for Keycloak</span>
@ -251,7 +252,7 @@
</details> </details>
</li> </li>
<li class="check-item"> <li class="check-item" :class="checkItemClass('element_recovery_key')">
<label> <label>
<input type="checkbox" :checked="isStepDone('element_recovery_key')" disabled /> <input type="checkbox" :checked="isStepDone('element_recovery_key')" disabled />
<span>Create an Element recovery key</span> <span>Create an Element recovery key</span>
@ -291,7 +292,7 @@
</p> </p>
</li> </li>
<li class="check-item"> <li class="check-item" :class="checkItemClass('element_recovery_key_stored')">
<label> <label>
<input <input
type="checkbox" type="checkbox"
@ -307,7 +308,7 @@
<p class="muted">Save the recovery key in Vaultwarden so it doesn't get lost.</p> <p class="muted">Save the recovery key in Vaultwarden so it doesn't get lost.</p>
</li> </li>
<li v-for="step in extraSteps" :key="step.id" class="check-item"> <li v-for="step in extraSteps" :key="step.id" class="check-item" :class="checkItemClass(step.id)">
<label> <label>
<input <input
type="checkbox" type="checkbox"
@ -431,6 +432,31 @@ function isStepDone(step) {
return Array.isArray(steps) ? steps.includes(step) : false; return Array.isArray(steps) ? steps.includes(step) : false;
} }
function requiredStepOrder() {
if (Array.isArray(onboarding.value?.required_steps) && onboarding.value.required_steps.length) {
return onboarding.value.required_steps;
}
return [
"vaultwarden_master_password",
"vaultwarden_browser_extension",
"vaultwarden_mobile_app",
"keycloak_password_rotated",
"element_recovery_key",
"element_recovery_key_stored",
"elementx_setup",
"jellyfin_login",
"mail_client_setup",
];
}
function activeRequiredStep() {
const order = requiredStepOrder();
for (const step of order) {
if (!isStepDone(step)) return step;
}
return "";
}
function mfaOptionalState() { function mfaOptionalState() {
const state = onboarding.value?.optional?.keycloak_mfa_optional?.state; const state = onboarding.value?.optional?.keycloak_mfa_optional?.state;
if (state === "done" || state === "skipped") return state; if (state === "done" || state === "skipped") return state;
@ -446,6 +472,16 @@ function isMfaBlocked() {
return !isStepDone("keycloak_password_rotated"); return !isStepDone("keycloak_password_rotated");
} }
function mfaItemClass() {
const state = mfaOptionalState();
return {
blocked: isMfaBlocked(),
done: state === "done",
skipped: state === "skipped",
optional: true,
};
}
function mfaPillLabel() { function mfaPillLabel() {
if (isMfaBlocked()) return "blocked"; if (isMfaBlocked()) return "blocked";
const state = mfaOptionalState(); const state = mfaOptionalState();
@ -491,20 +527,7 @@ async function maybeGenerateMfaQrs(event) {
} }
function isStepBlocked(step) { function isStepBlocked(step) {
const order = const order = requiredStepOrder();
Array.isArray(onboarding.value?.required_steps) && onboarding.value.required_steps.length
? onboarding.value.required_steps
: [
"vaultwarden_master_password",
"vaultwarden_browser_extension",
"vaultwarden_mobile_app",
"keycloak_password_rotated",
"element_recovery_key",
"element_recovery_key_stored",
"elementx_setup",
"jellyfin_login",
"mail_client_setup",
];
const idx = order.indexOf(step); const idx = order.indexOf(step);
if (idx <= 0) return false; if (idx <= 0) return false;
for (let i = 0; i < idx; i += 1) { for (let i = 0; i < idx; i += 1) {
@ -513,6 +536,14 @@ function isStepBlocked(step) {
return false; return false;
} }
function checkItemClass(step) {
const activeStep = activeRequiredStep();
const done = isStepDone(step);
const blockedStep = isStepBlocked(step);
const active = !done && !blockedStep && activeStep === step;
return { done, blocked: blockedStep, active };
}
function stepPillLabel(step) { function stepPillLabel(step) {
if (isStepDone(step)) return "done"; if (isStepDone(step)) return "done";
if (isStepBlocked(step)) return "blocked"; if (isStepBlocked(step)) return "blocked";
@ -893,6 +924,30 @@ button.primary {
background: rgba(255, 255, 255, 0.02); background: rgba(255, 255, 255, 0.02);
} }
.check-item.blocked {
opacity: 0.55;
}
.check-item.active {
border-color: rgba(125, 208, 255, 0.45);
background: rgba(79, 139, 255, 0.08);
box-shadow: 0 0 0 1px rgba(79, 139, 255, 0.2);
}
.check-item.done {
border-color: rgba(92, 214, 167, 0.35);
background: rgba(92, 214, 167, 0.05);
}
.check-item.skipped {
border-color: rgba(146, 158, 182, 0.25);
}
.check-item.done label,
.check-item.active label {
color: var(--text-primary);
}
.check-item label { .check-item label {
display: flex; display: flex;
align-items: center; align-items: center;