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_MANAGED_STEPS: set[str] = {
"keycloak_password_rotated", "keycloak_password_rotated",
"vaultwarden_master_password",
"nextcloud_mail_integration", "nextcloud_mail_integration",
"firefly_password_rotated", "firefly_password_rotated",
"wger_password_rotated", "wger_password_rotated",

View File

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

View File

Before

Width:  |  Height:  |  Size: 121 KiB

After

Width:  |  Height:  |  Size: 121 KiB