portal: improve onboarding confirmations and guides

This commit is contained in:
Brad Stein 2026-01-22 23:44:16 -03:00
parent 23a69c212f
commit 87c3cb35ab
8 changed files with 196 additions and 50 deletions

View File

@ -192,7 +192,6 @@ ONBOARDING_REQUIRED_STEPS: tuple[str, ...] = (
KEYCLOAK_MANAGED_STEPS: set[str] = {
"keycloak_password_rotated",
"vaultwarden_master_password",
"nextcloud_mail_integration",
"firefly_password_rotated",
"wger_password_rotated",

View File

@ -79,9 +79,7 @@
comes first because it stores every credential that follows.
</p>
</div>
<span class="pill mono" :class="status === 'ready' ? 'pill-info' : 'pill-ok'">
{{ status === "ready" ? "ready" : "in progress" }}
</span>
<span v-if="status !== 'ready'" class="pill mono pill-info">active</span>
</div>
<ol class="section-stepper">
@ -97,7 +95,9 @@
<div class="stepper-body">
<div class="stepper-title">{{ index + 1 }}. {{ section.title }}</div>
<div class="stepper-meta">
<span class="pill mono" :class="sectionPillClass(section)">{{ sectionStatusLabel(section) }}</span>
<span v-if="sectionStatusLabel(section)" class="pill mono" :class="sectionPillClass(section)">
{{ sectionStatusLabel(section) }}
</span>
<span class="pill mono pill-compact">{{ sectionProgress(section) }}</span>
</div>
</div>
@ -109,10 +109,12 @@
<div class="credential-grid">
<div class="credential-field">
<span class="label mono">Username</span>
<button class="copy mono" type="button" @click="copyUsername" :disabled="!requestUsername">
{{ requestUsername || "" }}
<span v-if="usernameCopied" class="copied">copied</span>
</button>
<div class="password-row">
<input class="input mono" type="text" :value="requestUsername || ''" readonly />
<button class="secondary" type="button" @click="copyUsername" :disabled="!requestUsername">
{{ usernameCopied ? "Copied" : "Copy" }}
</button>
</div>
</div>
<div class="credential-field" v-if="showPasswordCard">
@ -124,16 +126,17 @@
:value="initialPassword || '********'"
readonly
/>
<button class="secondary" type="button" @click="togglePassword" :disabled="!initialPassword">
{{ revealPassword ? "Hide" : "Reveal" }}
</button>
<button class="secondary" type="button" @click="copyInitialPassword" :disabled="!initialPassword">
{{ passwordCopied ? "Copied" : "Copy" }}
</button>
<span class="tooltip-wrap" :title="passwordRevealHint">
<button class="secondary" type="button" @click="togglePassword" :disabled="!initialPassword">
{{ revealPassword ? "Hide" : "Reveal" }}
</button>
</span>
<span class="tooltip-wrap" :title="passwordRevealHint">
<button class="secondary" type="button" @click="copyInitialPassword" :disabled="!initialPassword">
{{ passwordCopied ? "Copied" : "Copy" }}
</button>
</span>
</div>
<p v-if="passwordRevealLocked" class="muted">
This password was already revealed and cannot be shown again. Ask an admin to reset it if you missed it.
</p>
</div>
</div>
<p class="muted">
@ -148,19 +151,6 @@
<h3>{{ activeSection.title }}</h3>
<p class="muted">{{ activeSection.description }}</p>
</div>
<div class="section-actions">
<button class="secondary" type="button" @click="prevSection" :disabled="!hasPrevSection">
Previous
</button>
<button
class="secondary"
type="button"
@click="nextSection"
:disabled="!hasNextSection || isSectionLocked(nextSectionItem) || !sectionGateComplete(activeSection)"
>
Next
</button>
</div>
</div>
<div class="step-grid">
@ -197,8 +187,13 @@
</a>
</div>
<div v-if="step.action === 'auto'" class="step-actions">
<button class="secondary" type="button" @click="check" :disabled="loading">
<div v-if="step.action === 'auto' || step.action === 'confirm'" class="step-actions">
<button
class="secondary"
type="button"
@click="confirmStep(step)"
:disabled="loading || isStepDone(step.id) || isStepBlocked(step.id)"
>
Confirm
</button>
</div>
@ -219,6 +214,14 @@
Start Keycloak update
</button>
<a class="mono" href="https://live.bstein.dev" target="_blank" rel="noreferrer">Open Element</a>
<button
class="secondary"
type="button"
@click="confirmStep(step)"
:disabled="loading || isStepDone(step.id) || isStepBlocked(step.id)"
>
Confirm
</button>
</div>
<div v-if="step.action === 'element_recovery'" class="recovery-verify">
@ -243,6 +246,14 @@
>
Verify
</button>
<button
class="secondary"
type="button"
@click="confirmStep(step)"
:disabled="loading || isStepDone(step.id) || isStepBlocked(step.id) || !elementRecoveryKey.trim()"
>
Confirm
</button>
</div>
<details v-if="step.guide" class="guide-details">
@ -251,9 +262,9 @@
<div v-for="group in guideGroups(step)" :key="group.id" class="guide-group">
<h4 v-if="group.title" class="mono guide-title">{{ group.title }}</h4>
<div v-if="group.shots.length" class="guide-images">
<figure class="guide-shot">
<img :src="guideShot(step, group).url" :alt="guideShot(step, group).label || step.title" loading="lazy" />
<figure class="guide-shot" @click="openLightbox(guideShot(step, group))">
<figcaption v-if="guideShot(step, group).label" class="mono">{{ guideShot(step, group).label }}</figcaption>
<img :src="guideShot(step, group).url" :alt="guideShot(step, group).label || step.title" loading="lazy" />
</figure>
<div v-if="group.shots.length > 1" class="guide-pagination">
<button
@ -290,8 +301,33 @@
</div>
<p v-else class="muted">Guide coming soon.</p>
</details>
<div v-if="step.action === 'checkbox'" class="step-actions">
<button
class="secondary"
type="button"
@click="confirmStep(step)"
:disabled="loading || isStepDone(step.id) || isStepBlocked(step.id)"
>
Confirm
</button>
</div>
</article>
</div>
<div class="section-actions">
<button class="secondary" type="button" @click="prevSection" :disabled="!hasPrevSection">
Previous
</button>
<button
class="secondary"
type="button"
@click="nextSection"
:disabled="!hasNextSection || isSectionLocked(nextSectionItem) || !sectionGateComplete(activeSection)"
>
Next
</button>
</div>
</div>
<div v-if="status === 'ready'" class="ready-box">
@ -312,6 +348,16 @@
<div class="mono">{{ error }}</div>
</div>
</section>
<div v-if="lightboxShot" class="lightbox" @click.self="closeLightbox">
<div class="lightbox-card">
<div class="lightbox-head">
<span class="mono lightbox-label">{{ lightboxShot.label || "Guide image" }}</span>
<button class="secondary" type="button" @click="closeLightbox">Close</button>
</div>
<img :src="lightboxShot.url" :alt="lightboxShot.label || 'Guide image'" />
</div>
</div>
</div>
</template>
@ -340,9 +386,15 @@ const keycloakPasswordRotationRequested = ref(false);
const activeSectionId = ref("vaultwarden");
const guideShots = ref({});
const guidePage = ref({});
const lightboxShot = ref(null);
const showPasswordCard = computed(() => Boolean(initialPassword.value || initialPasswordRevealedAt.value));
const passwordRevealLocked = computed(() => Boolean(!initialPassword.value && initialPasswordRevealedAt.value));
const passwordRevealHint = computed(() =>
passwordRevealLocked.value
? "This password was already revealed and cannot be shown again. Ask an admin to reset it if you missed it."
: "",
);
const STEP_PREREQS = {
vaultwarden_master_password: [],
@ -374,7 +426,7 @@ const SECTION_DEFS = [
{
id: "vaultwarden_master_password",
title: "Set your Vaultwarden master password",
action: "auto",
action: "confirm",
description:
"Open Nextcloud Mail to find the invite, then visit vault.bstein.dev and create your master password. Use the temporary Keycloak password to sign in to Nextcloud for the first time.",
bullets: [
@ -466,7 +518,7 @@ const SECTION_DEFS = [
description:
"Use the IMAP/SMTP details on your Account page to add mail to your phone or desktop client (Thunderbird, Apple Mail, FairEmail).",
links: [{ href: "/account", text: "Open Account details" }],
guide: { service: "nextcloud", step: "step2_mail_integration" },
guide: { service: "mail", step: "step1_mail_app" },
},
],
},
@ -512,7 +564,7 @@ const SECTION_DEFS = [
},
{
id: "budget",
title: "Budget & Encryption",
title: "Budget Encryption",
description: "Protect sensitive data and keep the shared storage budget predictable.",
steps: [
{
@ -719,17 +771,16 @@ function sectionProgress(section) {
const requiredSteps = section.steps.filter((step) => isStepRequired(step.id));
if (!requiredSteps.length) return "optional";
const doneCount = requiredSteps.filter((step) => isStepDone(step.id)).length;
return `${doneCount}/${requiredSteps.length} required`;
return `${doneCount}/${requiredSteps.length} done`;
}
function sectionStatusLabel(section) {
if (isSectionDone(section)) return "done";
if (isSectionDone(section)) return "";
if (isSectionLocked(section)) return "locked";
return "in progress";
return "active";
}
function sectionPillClass(section) {
if (isSectionDone(section)) return "pill-ok";
if (isSectionLocked(section)) return "pill-wait";
return "pill-info";
}
@ -803,6 +854,15 @@ function guideShot(step, group) {
return group.shots[guideIndex(step, group)] || {};
}
function openLightbox(shot) {
if (!shot || !shot.url) return;
lightboxShot.value = shot;
}
function closeLightbox() {
lightboxShot.value = null;
}
function taskPillClass(value) {
const key = (value || "").trim();
if (key === "ok") return "pill-ok";
@ -891,17 +951,18 @@ function copyUsername() {
async function toggleStep(stepId, event) {
const checked = Boolean(event?.target?.checked);
await setStepCompletion(stepId, checked);
}
async function setStepCompletion(stepId, completed) {
if (!auth.authenticated) {
event?.preventDefault?.();
error.value = "Log in to update onboarding steps.";
return;
}
if (isStepBlocked(stepId)) {
event?.preventDefault?.();
return;
}
if (stepId === "element_recovery_key") {
event?.preventDefault?.();
return;
}
loading.value = true;
@ -910,7 +971,7 @@ async function toggleStep(stepId, event) {
const resp = await authFetch("/api/access/request/onboarding/attest", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ request_code: requestCode.value.trim(), step: stepId, completed: checked }),
body: JSON.stringify({ request_code: requestCode.value.trim(), step: stepId, completed }),
});
const data = await resp.json().catch(() => ({}));
if (!resp.ok) throw new Error(data.error || resp.statusText || `status ${resp.status}`);
@ -923,6 +984,23 @@ async function toggleStep(stepId, event) {
}
}
async function confirmStep(step) {
if (!step || isStepBlocked(step.id) || isStepDone(step.id)) return;
if (step.action === "auto") {
await check();
return;
}
if (step.action === "keycloak_rotate") {
await requestKeycloakPasswordRotation();
return;
}
if (step.action === "element_recovery") {
await verifyElementRecoveryKey();
return;
}
await setStepCompletion(step.id, true);
}
async function verifyElementRecoveryKey() {
if (!auth.authenticated) {
error.value = "Log in to verify your recovery key.";
@ -1241,14 +1319,20 @@ button.copy:disabled {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 6px;
gap: 4px;
flex-wrap: nowrap;
color: var(--text-muted);
}
.stepper-meta .pill {
padding: 4px 6px;
font-size: 11px;
white-space: nowrap;
}
.pill-compact {
padding: 6px 8px;
font-size: 12px;
padding: 4px 6px;
font-size: 11px;
white-space: nowrap;
max-width: 100%;
overflow: hidden;
@ -1317,6 +1401,10 @@ button.copy:disabled {
color: var(--text-muted);
}
.credential-field .input[readonly] {
opacity: 0.8;
}
.password-row {
display: flex;
gap: 8px;
@ -1343,7 +1431,11 @@ button.copy:disabled {
.section-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
width: 100%;
margin-top: 12px;
}
.step-grid {
@ -1416,12 +1508,12 @@ button.copy:disabled {
}
.step-actions {
margin-top: 10px;
display: flex;
align-items: center;
gap: 12px;
justify-content: flex-end;
margin-top: auto;
padding-top: 10px;
}
.recovery-verify {
@ -1460,6 +1552,15 @@ button.copy:disabled {
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(0, 0, 0, 0.2);
padding: 6px;
display: grid;
gap: 6px;
cursor: zoom-in;
}
.guide-shot figcaption {
font-size: 12px;
color: var(--text-muted);
margin: 0 4px;
}
.guide-shot img {
@ -1534,6 +1635,52 @@ button.copy:disabled {
background: rgba(255, 70, 70, 0.1);
}
.tooltip-wrap {
display: inline-flex;
}
.lightbox {
position: fixed;
inset: 0;
background: rgba(6, 8, 12, 0.82);
display: flex;
align-items: center;
justify-content: center;
padding: 28px;
z-index: 2000;
}
.lightbox-card {
width: min(1100px, 92vw);
max-height: 92vh;
background: rgba(10, 14, 24, 0.96);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 16px;
padding: 14px;
display: flex;
flex-direction: column;
gap: 12px;
}
.lightbox-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.lightbox-label {
color: var(--text-muted);
}
.lightbox-card img {
width: 100%;
max-height: 74vh;
object-fit: contain;
border-radius: 12px;
background: rgba(0, 0, 0, 0.35);
}
@media (max-width: 720px) {
.status-form {
flex-direction: column;

View File

Before

Width:  |  Height:  |  Size: 121 KiB

After

Width:  |  Height:  |  Size: 121 KiB