portal: refine onboarding vaultwarden flow

This commit is contained in:
Brad Stein 2026-01-24 11:27:45 -03:00
parent 882a9ae513
commit f973b64ac6
2 changed files with 106 additions and 77 deletions

View File

@ -148,6 +148,7 @@ def _verify_request(conn, code: str, token: str) -> str:
ONBOARDING_STEPS: tuple[str, ...] = ( ONBOARDING_STEPS: tuple[str, ...] = (
"vaultwarden_master_password", "vaultwarden_master_password",
"vaultwarden_store_temp_password",
"vaultwarden_browser_extension", "vaultwarden_browser_extension",
"vaultwarden_mobile_app", "vaultwarden_mobile_app",
"keycloak_password_rotated", "keycloak_password_rotated",
@ -199,6 +200,7 @@ _KEYCLOAK_PASSWORD_ROTATION_REQUESTED_ARTIFACT = "keycloak_password_rotation_req
ONBOARDING_STEP_PREREQUISITES: dict[str, set[str]] = { ONBOARDING_STEP_PREREQUISITES: dict[str, set[str]] = {
"vaultwarden_master_password": set(), "vaultwarden_master_password": set(),
"vaultwarden_store_temp_password": {"vaultwarden_master_password"},
"vaultwarden_browser_extension": {"vaultwarden_master_password"}, "vaultwarden_browser_extension": {"vaultwarden_master_password"},
"vaultwarden_mobile_app": {"vaultwarden_master_password"}, "vaultwarden_mobile_app": {"vaultwarden_master_password"},
"keycloak_password_rotated": {"vaultwarden_master_password"}, "keycloak_password_rotated": {"vaultwarden_master_password"},
@ -359,6 +361,23 @@ def _extract_attr(attrs: Any, key: str) -> str:
return "" return ""
def _vaultwarden_status_for_user(username: str) -> str:
if not username:
return ""
if not admin_client().ready():
return ""
try:
user = admin_client().find_user(username) or {}
user_id = user.get("id") if isinstance(user, dict) else None
if not isinstance(user_id, str) or not user_id:
return ""
full = admin_client().get_user(user_id)
attrs = full.get("attributes") if isinstance(full, dict) else {}
return _extract_attr(attrs, "vaultwarden_status")
except Exception:
return ""
def _auto_completed_service_steps(attrs: Any) -> set[str]: def _auto_completed_service_steps(attrs: Any) -> set[str]:
completed: set[str] = set() completed: set[str] = set()
if not isinstance(attrs, dict): if not isinstance(attrs, dict):
@ -486,7 +505,12 @@ def _advance_status(conn, request_code: str, username: str, status: str) -> str:
if status == "awaiting_onboarding": if status == "awaiting_onboarding":
completed = _completed_onboarding_steps(conn, request_code, username) completed = _completed_onboarding_steps(conn, request_code, username)
if set(ONBOARDING_REQUIRED_STEPS).issubset(completed): required_steps = set(ONBOARDING_REQUIRED_STEPS)
grandfathered, _ = _vaultwarden_grandfathered(conn, request_code, username)
vaultwarden_status = _vaultwarden_status_for_user(username)
if grandfathered and vaultwarden_status == "grandfathered":
required_steps.add("vaultwarden_store_temp_password")
if required_steps.issubset(completed):
conn.execute( conn.execute(
"UPDATE access_requests SET status = 'ready' WHERE request_code = %s AND status = 'awaiting_onboarding'", "UPDATE access_requests SET status = 'ready' WHERE request_code = %s AND status = 'awaiting_onboarding'",
(request_code,), (request_code,),
@ -501,8 +525,13 @@ def _onboarding_payload(conn, request_code: str, username: str) -> dict[str, Any
password_rotation_requested = _password_rotation_requested(conn, request_code) password_rotation_requested = _password_rotation_requested(conn, request_code)
grandfathered, contact_email = _vaultwarden_grandfathered(conn, request_code, username) grandfathered, contact_email = _vaultwarden_grandfathered(conn, request_code, username)
recovery_email = _resolve_recovery_email(username, contact_email) if grandfathered else "" recovery_email = _resolve_recovery_email(username, contact_email) if grandfathered else ""
vaultwarden_status = _vaultwarden_status_for_user(username)
vaultwarden_matched = grandfathered and vaultwarden_status == "grandfathered"
required_steps = list(ONBOARDING_REQUIRED_STEPS)
if vaultwarden_matched:
required_steps.append("vaultwarden_store_temp_password")
return { return {
"required_steps": list(ONBOARDING_REQUIRED_STEPS), "required_steps": required_steps,
"optional_steps": sorted(ONBOARDING_OPTIONAL_STEPS), "optional_steps": sorted(ONBOARDING_OPTIONAL_STEPS),
"completed_steps": completed_steps, "completed_steps": completed_steps,
"keycloak": { "keycloak": {
@ -511,6 +540,7 @@ def _onboarding_payload(conn, request_code: str, username: str) -> dict[str, Any
"vaultwarden": { "vaultwarden": {
"grandfathered": grandfathered, "grandfathered": grandfathered,
"recovery_email": recovery_email, "recovery_email": recovery_email,
"matched": vaultwarden_matched,
}, },
} }

View File

@ -85,8 +85,8 @@
<div> <div>
<h3>Onboarding</h3> <h3>Onboarding</h3>
<p class="muted"> <p class="muted">
Onboarding is fully self-service. Work through each section in order; you can pause and return later. Vaultwarden Onboarding is fully self-service. There are 8 steps for 8 services below. Each step has smaller tasks; press
comes first because it stores every credential that follows. Confirm when you finish a task. You can pause and return later.
</p> </p>
</div> </div>
<span v-if="status !== 'ready'" class="pill mono pill-info">active</span> <span v-if="status !== 'ready'" class="pill mono pill-info">active</span>
@ -189,38 +189,24 @@
<p class="muted" v-if="step.description">{{ step.description }}</p> <p class="muted" v-if="step.description">{{ step.description }}</p>
<p class="muted step-note" v-if="stepNote(step)">{{ stepNote(step) }}</p> <p class="muted step-note" v-if="stepNote(step)">{{ stepNote(step) }}</p>
<div
v-if="step.id === 'vaultwarden_master_password' && vaultwardenGrandfathered"
class="claim-box"
>
<p class="muted">
Already have a Vaultwarden account? Claim it with
<span class="mono">{{ vaultwardenRecoveryEmail || "your recovery email" }}</span>.
This skips the invite flow and keeps your existing vault.
</p>
<span class="tooltip-wrap" :title="vaultwardenClaimHint">
<button
class="secondary"
type="button"
@click="claimVaultwarden"
:disabled="vaultwardenClaimDisabled"
>
{{ vaultwardenClaiming ? "Claiming..." : "Claim existing account" }}
</button>
</span>
</div>
<ul v-if="step.bullets && step.bullets.length" class="step-bullets"> <ul v-if="step.bullets && step.bullets.length" class="step-bullets">
<li v-for="bullet in step.bullets" :key="bullet">{{ bullet }}</li> <li v-for="bullet in step.bullets" :key="bullet">{{ bullet }}</li>
</ul> </ul>
<div v-if="step.links && step.links.length" class="step-links"> <div v-if="step.links && step.links.length" class="step-links">
<a v-for="link in step.links" :key="link.href" :href="link.href" target="_blank" rel="noreferrer"> <a
v-for="link in step.links"
:key="link.href"
:href="link.href"
:title="link.href"
target="_blank"
rel="noreferrer"
>
{{ link.text }} {{ link.text }}
</a> </a>
</div> </div>
<details v-if="step.guide" class="guide-details"> <details v-if="step.guide" class="guide-details" :open="shouldOpenGuide(step, activeSection)">
<summary class="mono">Photo guide</summary> <summary class="mono">Photo guide</summary>
<div v-if="guideGroups(step).length" class="guide-groups"> <div v-if="guideGroups(step).length" class="guide-groups">
<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">
@ -353,7 +339,6 @@ const guideShots = ref({});
const guidePage = ref({}); const guidePage = ref({});
const lightboxShot = ref(null); const lightboxShot = ref(null);
const confirmingStepId = ref(""); const confirmingStepId = ref("");
const vaultwardenClaiming = ref(false);
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));
@ -362,10 +347,10 @@ const passwordRevealHint = computed(() =>
? "This password was already revealed and cannot be shown again. Ask an admin to reset it if you missed it." ? "This password was already revealed and cannot be shown again. Ask an admin to reset it if you missed it."
: "", : "",
); );
const vaultwardenGrandfathered = computed(() => Boolean(onboarding.value?.vaultwarden?.grandfathered));
const vaultwardenRecoveryEmail = computed(() => onboarding.value?.vaultwarden?.recovery_email || ""); const vaultwardenRecoveryEmail = computed(() => onboarding.value?.vaultwarden?.recovery_email || "");
const vaultwardenMatched = computed(() => Boolean(onboarding.value?.vaultwarden?.matched));
const vaultwardenLoginEmail = computed(() => { const vaultwardenLoginEmail = computed(() => {
if (vaultwardenGrandfathered.value) { if (vaultwardenMatched.value) {
return vaultwardenRecoveryEmail.value || "your recovery email"; return vaultwardenRecoveryEmail.value || "your recovery email";
} }
if (requestUsername.value) { if (requestUsername.value) {
@ -374,20 +359,10 @@ const vaultwardenLoginEmail = computed(() => {
return "your @bstein.dev address"; return "your @bstein.dev address";
}); });
const mailAddress = computed(() => (requestUsername.value ? `${requestUsername.value}@bstein.dev` : "your @bstein.dev address")); const mailAddress = computed(() => (requestUsername.value ? `${requestUsername.value}@bstein.dev` : "your @bstein.dev address"));
const vaultwardenClaimDisabled = computed(
() =>
loading.value ||
vaultwardenClaiming.value ||
!auth.authenticated ||
isStepDone("vaultwarden_master_password") ||
isStepBlocked("vaultwarden_master_password"),
);
const vaultwardenClaimHint = computed(() =>
!auth.authenticated ? "Log in to claim an existing Vaultwarden account." : "",
);
const STEP_PREREQS = { const STEP_PREREQS = {
vaultwarden_master_password: [], vaultwarden_master_password: [],
vaultwarden_store_temp_password: ["vaultwarden_master_password"],
vaultwarden_browser_extension: ["vaultwarden_master_password"], vaultwarden_browser_extension: ["vaultwarden_master_password"],
vaultwarden_mobile_app: ["vaultwarden_master_password"], vaultwarden_mobile_app: ["vaultwarden_master_password"],
keycloak_password_rotated: ["vaultwarden_master_password"], keycloak_password_rotated: ["vaultwarden_master_password"],
@ -428,7 +403,7 @@ const SECTION_DEFS = [
], ],
links: [ links: [
{ href: "https://cloud.bstein.dev", text: "Nextcloud Mail" }, { href: "https://cloud.bstein.dev", text: "Nextcloud Mail" },
{ href: "https://vault.bstein.dev", text: "vault.bstein.dev" }, { href: "https://vault.bstein.dev", text: "Vaultwarden" },
], ],
guide: { service: "vaultwarden", step: "step1_website" }, guide: { service: "vaultwarden", step: "step1_website" },
}, },
@ -522,7 +497,7 @@ const SECTION_DEFS = [
action: "checkbox", action: "checkbox",
description: description:
"Open Nextcloud, confirm you can access Files, Calendar, and Mail, and keep the tab handy during onboarding.", "Open Nextcloud, confirm you can access Files, Calendar, and Mail, and keep the tab handy during onboarding.",
links: [{ href: "https://cloud.bstein.dev", text: "cloud.bstein.dev" }], links: [{ href: "https://cloud.bstein.dev", text: "Nextcloud" }],
guide: { service: "nextcloud", step: "step1_web_access" }, guide: { service: "nextcloud", step: "step1_web_access" },
}, },
{ {
@ -568,7 +543,7 @@ const SECTION_DEFS = [
"If you lose the key, your budget data cannot be recovered.", "If you lose the key, your budget data cannot be recovered.",
], ],
links: [ links: [
{ href: "https://budget.bstein.dev", text: "budget.bstein.dev" }, { href: "https://budget.bstein.dev", text: "Actual Budget" },
{ href: "https://vault.bstein.dev", text: "Vaultwarden" }, { href: "https://vault.bstein.dev", text: "Vaultwarden" },
], ],
guide: { service: "budget", step: "step1_encrypt_data" }, guide: { service: "budget", step: "step1_encrypt_data" },
@ -588,7 +563,7 @@ const SECTION_DEFS = [
description: description:
"Sign in to money.bstein.dev with the credentials on your Account page, change the password, then confirm here.", "Sign in to money.bstein.dev with the credentials on your Account page, change the password, then confirm here.",
links: [ links: [
{ href: "https://money.bstein.dev", text: "money.bstein.dev" }, { href: "https://money.bstein.dev", text: "Firefly III" },
{ href: "/account", text: "Account credentials" }, { href: "/account", text: "Account credentials" },
], ],
guide: { service: "firefly", step: "step1_web_access" }, guide: { service: "firefly", step: "step1_web_access" },
@ -620,7 +595,7 @@ const SECTION_DEFS = [
description: description:
"Sign in to health.bstein.dev with the credentials on your Account page, change the password, then confirm here.", "Sign in to health.bstein.dev with the credentials on your Account page, change the password, then confirm here.",
links: [ links: [
{ href: "https://health.bstein.dev", text: "health.bstein.dev" }, { href: "https://health.bstein.dev", text: "Wger" },
{ href: "/account", text: "Account credentials" }, { href: "/account", text: "Account credentials" },
], ],
guide: { service: "wger", step: "step1_web_access" }, guide: { service: "wger", step: "step1_web_access" },
@ -651,7 +626,7 @@ const SECTION_DEFS = [
action: "checkbox", action: "checkbox",
description: description:
"Sign in with your Atlas username/password (LDAP-backed).", "Sign in with your Atlas username/password (LDAP-backed).",
links: [{ href: "https://stream.bstein.dev", text: "stream.bstein.dev" }], links: [{ href: "https://stream.bstein.dev", text: "Jellyfin" }],
guide: { service: "jellyfin", step: "step1_web_access" }, guide: { service: "jellyfin", step: "step1_web_access" },
}, },
{ {
@ -674,7 +649,26 @@ const SECTION_DEFS = [
}, },
]; ];
const sections = computed(() => SECTION_DEFS); 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 };
}),
);
const activeSection = computed(() => sections.value.find((item) => item.id === activeSectionId.value)); const activeSection = computed(() => sections.value.find((item) => item.id === activeSectionId.value));
const nextSectionItem = computed(() => { const nextSectionItem = computed(() => {
@ -758,6 +752,9 @@ function stepNote(step) {
if (step.id === "vaultwarden_master_password") { if (step.id === "vaultwarden_master_password") {
return `Vaultwarden uses an email login. Use ${vaultwardenLoginEmail.value} to sign in.`; return `Vaultwarden uses an email login. Use ${vaultwardenLoginEmail.value} to sign in.`;
} }
if (step.id === "vaultwarden_store_temp_password") {
return "Store the temporary Keycloak password in Vaultwarden so you can rotate it safely later.";
}
if (step.id === "firefly_password_rotated") { if (step.id === "firefly_password_rotated") {
return `Firefly uses an email login. Use ${mailAddress.value} to sign in.`; return `Firefly uses an email login. Use ${mailAddress.value} to sign in.`;
} }
@ -858,7 +855,14 @@ function guideGroups(step) {
const stepKey = step.guide.step; const stepKey = step.guide.step;
const serviceShots = guideShots.value?.[service] || {}; const serviceShots = guideShots.value?.[service] || {};
const stepShots = serviceShots?.[stepKey] || {}; const stepShots = serviceShots?.[stepKey] || {};
return Object.values(stepShots); 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 };
});
} }
function guideKey(step, group) { function guideKey(step, group) {
@ -892,6 +896,14 @@ function guideShot(step, group) {
return group.shots[guideIndex(step, group)] || {}; return group.shots[guideIndex(step, group)] || {};
} }
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);
}
function openLightbox(shot) { function openLightbox(shot) {
if (!shot || !shot.url) return; if (!shot || !shot.url) return;
lightboxShot.value = shot; lightboxShot.value = shot;
@ -1095,18 +1107,6 @@ async function confirmStep(step) {
} }
} }
async function claimVaultwarden() {
if (isStepDone("vaultwarden_master_password") || isStepBlocked("vaultwarden_master_password")) return;
vaultwardenClaiming.value = true;
try {
await setStepCompletion("vaultwarden_master_password", true, { vaultwarden_claim: true });
} catch (err) {
error.value = err?.message || "Failed to claim Vaultwarden account";
} finally {
vaultwardenClaiming.value = false;
}
}
async function runRotationCheck(service) { async function runRotationCheck(service) {
if (!auth.authenticated) { if (!auth.authenticated) {
throw new Error("Log in to update onboarding steps."); throw new Error("Log in to update onboarding steps.");
@ -1408,8 +1408,8 @@ button.copy:disabled {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 10px; gap: 6px 10px;
flex-wrap: nowrap; flex-wrap: wrap;
color: var(--text-muted); color: var(--text-muted);
width: 100%; width: 100%;
} }
@ -1592,30 +1592,29 @@ button.copy:disabled {
} }
.step-links { .step-links {
margin-top: 8px; margin-top: 10px;
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
justify-content: center;
gap: 10px; gap: 10px;
} }
.step-links a { .step-links a {
color: var(--accent-cyan); color: rgba(92, 214, 167, 0.95);
text-decoration: none; text-decoration: none;
font-weight: 600; font-weight: 600;
border-radius: 999px;
padding: 6px 14px;
border: 1px solid rgba(92, 214, 167, 0.35);
background: rgba(92, 214, 167, 0.12);
display: inline-flex;
align-items: center;
gap: 6px;
} }
.step-links a:hover { .step-links a:hover {
text-decoration: underline; text-decoration: none;
} background: rgba(92, 214, 167, 0.2);
.claim-box {
margin-top: 12px;
padding: 10px 12px;
border-radius: 12px;
border: 1px dashed rgba(255, 255, 255, 0.18);
background: rgba(0, 0, 0, 0.2);
display: grid;
gap: 8px;
} }
.step-actions { .step-actions {
@ -1672,7 +1671,7 @@ button.copy:disabled {
.guide-shot figcaption { .guide-shot figcaption {
margin: 0; margin: 0;
padding: 10px 12px 6px; padding: 10px 12px 6px;
font-size: 17px; font-size: 18px;
font-weight: 600; font-weight: 600;
color: var(--text-strong); color: var(--text-strong);
} }