portal: improve onboarding confirmations and guides
@ -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",
|
||||
|
||||
@ -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;
|
||||
|
||||
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 294 KiB After Width: | Height: | Size: 294 KiB |
|
Before Width: | Height: | Size: 141 KiB After Width: | Height: | Size: 141 KiB |
|
Before Width: | Height: | Size: 121 KiB After Width: | Height: | Size: 121 KiB |
|
Before Width: | Height: | Size: 125 KiB After Width: | Height: | Size: 125 KiB |
|
Before Width: | Height: | Size: 133 KiB After Width: | Height: | Size: 133 KiB |