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, ...] = (
"vaultwarden_master_password",
"vaultwarden_store_temp_password",
"vaultwarden_browser_extension",
"vaultwarden_mobile_app",
"keycloak_password_rotated",
@ -199,6 +200,7 @@ _KEYCLOAK_PASSWORD_ROTATION_REQUESTED_ARTIFACT = "keycloak_password_rotation_req
ONBOARDING_STEP_PREREQUISITES: dict[str, set[str]] = {
"vaultwarden_master_password": set(),
"vaultwarden_store_temp_password": {"vaultwarden_master_password"},
"vaultwarden_browser_extension": {"vaultwarden_master_password"},
"vaultwarden_mobile_app": {"vaultwarden_master_password"},
"keycloak_password_rotated": {"vaultwarden_master_password"},
@ -359,6 +361,23 @@ def _extract_attr(attrs: Any, key: str) -> str:
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]:
completed: set[str] = set()
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":
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(
"UPDATE access_requests SET status = 'ready' WHERE request_code = %s AND status = 'awaiting_onboarding'",
(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)
grandfathered, contact_email = _vaultwarden_grandfathered(conn, request_code, username)
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 {
"required_steps": list(ONBOARDING_REQUIRED_STEPS),
"required_steps": required_steps,
"optional_steps": sorted(ONBOARDING_OPTIONAL_STEPS),
"completed_steps": completed_steps,
"keycloak": {
@ -511,6 +540,7 @@ def _onboarding_payload(conn, request_code: str, username: str) -> dict[str, Any
"vaultwarden": {
"grandfathered": grandfathered,
"recovery_email": recovery_email,
"matched": vaultwarden_matched,
},
}

View File

@ -85,8 +85,8 @@
<div>
<h3>Onboarding</h3>
<p class="muted">
Onboarding is fully self-service. Work through each section in order; you can pause and return later. Vaultwarden
comes first because it stores every credential that follows.
Onboarding is fully self-service. There are 8 steps for 8 services below. Each step has smaller tasks; press
Confirm when you finish a task. You can pause and return later.
</p>
</div>
<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 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">
<li v-for="bullet in step.bullets" :key="bullet">{{ bullet }}</li>
</ul>
<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 }}
</a>
</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>
<div v-if="guideGroups(step).length" class="guide-groups">
<div v-for="group in guideGroups(step)" :key="group.id" class="guide-group">
@ -353,7 +339,6 @@ const guideShots = ref({});
const guidePage = ref({});
const lightboxShot = ref(null);
const confirmingStepId = ref("");
const vaultwardenClaiming = ref(false);
const showPasswordCard = 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."
: "",
);
const vaultwardenGrandfathered = computed(() => Boolean(onboarding.value?.vaultwarden?.grandfathered));
const vaultwardenRecoveryEmail = computed(() => onboarding.value?.vaultwarden?.recovery_email || "");
const vaultwardenMatched = computed(() => Boolean(onboarding.value?.vaultwarden?.matched));
const vaultwardenLoginEmail = computed(() => {
if (vaultwardenGrandfathered.value) {
if (vaultwardenMatched.value) {
return vaultwardenRecoveryEmail.value || "your recovery email";
}
if (requestUsername.value) {
@ -374,20 +359,10 @@ const vaultwardenLoginEmail = computed(() => {
return "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 = {
vaultwarden_master_password: [],
vaultwarden_store_temp_password: ["vaultwarden_master_password"],
vaultwarden_browser_extension: ["vaultwarden_master_password"],
vaultwarden_mobile_app: ["vaultwarden_master_password"],
keycloak_password_rotated: ["vaultwarden_master_password"],
@ -428,7 +403,7 @@ const SECTION_DEFS = [
],
links: [
{ 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" },
},
@ -522,7 +497,7 @@ const SECTION_DEFS = [
action: "checkbox",
description:
"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" },
},
{
@ -568,7 +543,7 @@ const SECTION_DEFS = [
"If you lose the key, your budget data cannot be recovered.",
],
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" },
],
guide: { service: "budget", step: "step1_encrypt_data" },
@ -588,7 +563,7 @@ const SECTION_DEFS = [
description:
"Sign in to money.bstein.dev with the credentials on your Account page, change the password, then confirm here.",
links: [
{ href: "https://money.bstein.dev", text: "money.bstein.dev" },
{ href: "https://money.bstein.dev", text: "Firefly III" },
{ href: "/account", text: "Account credentials" },
],
guide: { service: "firefly", step: "step1_web_access" },
@ -620,7 +595,7 @@ const SECTION_DEFS = [
description:
"Sign in to health.bstein.dev with the credentials on your Account page, change the password, then confirm here.",
links: [
{ href: "https://health.bstein.dev", text: "health.bstein.dev" },
{ href: "https://health.bstein.dev", text: "Wger" },
{ href: "/account", text: "Account credentials" },
],
guide: { service: "wger", step: "step1_web_access" },
@ -651,7 +626,7 @@ const SECTION_DEFS = [
action: "checkbox",
description:
"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" },
},
{
@ -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 nextSectionItem = computed(() => {
@ -758,6 +752,9 @@ function stepNote(step) {
if (step.id === "vaultwarden_master_password") {
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") {
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 serviceShots = guideShots.value?.[service] || {};
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) {
@ -892,6 +896,14 @@ function guideShot(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) {
if (!shot || !shot.url) return;
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) {
if (!auth.authenticated) {
throw new Error("Log in to update onboarding steps.");
@ -1408,8 +1408,8 @@ button.copy:disabled {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
flex-wrap: nowrap;
gap: 6px 10px;
flex-wrap: wrap;
color: var(--text-muted);
width: 100%;
}
@ -1592,30 +1592,29 @@ button.copy:disabled {
}
.step-links {
margin-top: 8px;
margin-top: 10px;
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 10px;
}
.step-links a {
color: var(--accent-cyan);
color: rgba(92, 214, 167, 0.95);
text-decoration: none;
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 {
text-decoration: underline;
}
.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;
text-decoration: none;
background: rgba(92, 214, 167, 0.2);
}
.step-actions {
@ -1672,7 +1671,7 @@ button.copy:disabled {
.guide-shot figcaption {
margin: 0;
padding: 10px 12px 6px;
font-size: 17px;
font-size: 18px;
font-weight: 600;
color: var(--text-strong);
}