bstein-dev-home/frontend/src/views/OnboardingView.vue

1205 lines
37 KiB
Vue
Raw Normal View History

2026-01-02 01:34:18 -03:00
<template>
<div class="page">
<section class="card hero glass">
<div>
<p class="eyebrow">Atlas</p>
<h1>Onboarding</h1>
<p class="lede">Use your request code to view status and next steps.</p>
</div>
</section>
<section class="card module">
<div class="module-head">
<h2>Request Code</h2>
<span class="pill mono" :class="statusPillClass(status)">
{{ statusLabel(status) }}
2026-01-02 01:34:18 -03:00
</span>
</div>
<div class="status-form">
<input v-model="requestCode" class="input mono" type="text" placeholder="username~XXXXXXXXXX" :disabled="loading" />
<button class="primary" type="button" @click="check" :disabled="loading || !requestCode.trim()">
{{ loading ? "Checking..." : "Check" }}
</button>
</div>
<div v-if="requestUsername" class="status-meta">
<div class="meta-row">
<span class="label mono">Username</span>
2026-01-04 08:44:25 -03:00
<button class="copy mono" type="button" @click="copyUsername">
{{ requestUsername }}
<span v-if="usernameCopied" class="copied">copied</span>
</button>
</div>
</div>
<div v-if="status === 'pending_email_verification'" class="steps">
<h3>Confirm your email</h3>
<p class="muted">
Open the verification email from Atlas and click the link to confirm your address. After verification, an admin can approve your request.
</p>
<p class="muted">
If you did not receive an email, return to
<a href="/request-access">Request Access</a>
and submit again using a reachable external address.
</p>
</div>
<div v-if="status === 'pending'" class="steps">
<h3>Awaiting approval</h3>
<p class="muted">An Atlas admin has to approve this request before an account can be provisioned.</p>
</div>
<div v-if="status === 'accounts_building'" class="steps">
<h3>Accounts building</h3>
<p class="muted">
Your request is approved. Atlas is now preparing accounts and credentials. Check back in a minute.
</p>
<div v-if="tasks.length" class="task-box">
<div class="module-head" style="margin-bottom: 10px;">
<h3>Automation</h3>
<span class="pill mono" :class="blocked ? 'pill-bad' : 'pill-ok'">
{{ blocked ? "blocked" : "running" }}
</span>
</div>
<ul class="task-list">
<li v-for="item in tasks" :key="item.task" class="task-row">
<span class="mono task-name">{{ item.task }}</span>
<span class="pill mono" :class="taskPillClass(item.status)">{{ item.status }}</span>
<span v-if="item.detail" class="mono task-detail">{{ item.detail }}</span>
</li>
</ul>
<p v-if="blocked" class="muted" style="margin-top: 10px;">
One or more automation steps failed. Fix the error above, then check again.
</p>
</div>
</div>
<div v-if="status === 'awaiting_onboarding' || status === 'ready'" class="steps">
<div class="onboarding-head">
<h3>Onboarding checklist</h3>
<span class="pill mono" :class="status === 'ready' ? 'pill-info' : 'pill-ok'">
{{ status === "ready" ? "ready" : "in progress" }}
</span>
</div>
<p class="muted">
Some steps are verified automatically from Keycloak (password). Others can't be verified yet — mark them complete once you're done.
</p>
<div v-if="initialPassword" class="initial-password">
<h3>Temporary password</h3>
<p class="muted">
Use this password to log in for the first time. You won't be forced to change it immediately — you'll rotate
2026-01-04 23:34:21 -03:00
it later after Vaultwarden is set up. This password is shown once copy it now. If you refresh this page,
it may disappear.
</p>
<div class="request-code-row">
<span class="label mono">Password</span>
<button class="copy mono" type="button" @click="copyInitialPassword">
{{ initialPassword }}
<span v-if="copied" class="copied">copied</span>
</button>
</div>
<p class="muted">
Log in at
<a href="https://sso.bstein.dev" target="_blank" rel="noreferrer">sso.bstein.dev</a>
or go directly to
<a href="https://cloud.bstein.dev" target="_blank" rel="noreferrer">cloud.bstein.dev</a>.
</p>
</div>
<div v-if="!auth.authenticated" class="login-callout">
<p class="muted">Log in to check off onboarding steps.</p>
<button class="primary" type="button" @click="loginToContinue" :disabled="loading">Log in</button>
</div>
<ul class="checklist">
2026-01-04 23:34:21 -03:00
<li class="check-item" :class="checkItemClass('vaultwarden_master_password')">
<label>
<input
type="checkbox"
:checked="isStepDone('vaultwarden_master_password')"
:disabled="!auth.authenticated || loading || isStepBlocked('vaultwarden_master_password')"
@change="toggleStep('vaultwarden_master_password', $event)"
/>
<span>Set a Vaultwarden master password</span>
<span class="pill mono auto-pill" :class="stepPillClass('vaultwarden_master_password')">
{{ stepPillLabel("vaultwarden_master_password") }}
</span>
</label>
<p class="muted">
Open <a href="https://vault.bstein.dev" target="_blank" rel="noreferrer">Passwords</a> and set a strong master
password you won't forget.
Your master password is the one password to rule all passwords: use a long passphrase (64+ characters is a good target), and never
write it down or share it with anyone.
If you lose it, Atlas can't recover your vault.
If you can't sign in yet, check your Atlas mailbox in
<a href="https://cloud.bstein.dev" target="_blank" rel="noreferrer">Nextcloud Mail</a> for the invite link.
</p>
<details class="howto">
<summary class="mono">Master password guidance</summary>
<ul class="muted howto-list">
<li>Prefer a multi-word passphrase over a single word.</li>
<li>Never store it in plaintext or share it with anyone.</li>
<li>If you forget it, Vaultwarden cant decrypt your data.</li>
</ul>
</details>
</li>
2026-01-04 23:34:21 -03:00
<li class="check-item" :class="checkItemClass('vaultwarden_browser_extension')">
<label>
<input
type="checkbox"
:checked="isStepDone('vaultwarden_browser_extension')"
:disabled="!auth.authenticated || loading || isStepBlocked('vaultwarden_browser_extension')"
@change="toggleStep('vaultwarden_browser_extension', $event)"
/>
<span>Install the Vaultwarden browser extension</span>
<span class="pill mono auto-pill" :class="stepPillClass('vaultwarden_browser_extension')">
{{ stepPillLabel("vaultwarden_browser_extension") }}
</span>
</label>
<p class="muted">
Install Bitwarden in your browser and point it at vault.bstein.dev (Settings Account Environment Self-hosted).
<a href="https://bitwarden.com/download" target="_blank" rel="noreferrer">Bitwarden downloads</a>.
</p>
</li>
2026-01-04 23:34:21 -03:00
<li class="check-item" :class="checkItemClass('vaultwarden_mobile_app')">
<label>
<input
type="checkbox"
:checked="isStepDone('vaultwarden_mobile_app')"
:disabled="!auth.authenticated || loading || isStepBlocked('vaultwarden_mobile_app')"
@change="toggleStep('vaultwarden_mobile_app', $event)"
/>
<span>Install Bitwarden on your phone</span>
<span class="pill mono auto-pill" :class="stepPillClass('vaultwarden_mobile_app')">
{{ stepPillLabel("vaultwarden_mobile_app") }}
</span>
</label>
<p class="muted">
Install the mobile app, set the server to vault.bstein.dev, and enable biometrics for fast unlock.
<a href="https://bitwarden.com/download" target="_blank" rel="noreferrer">Bitwarden downloads</a>.
</p>
</li>
2026-01-04 23:34:21 -03:00
<li class="check-item" :class="checkItemClass('keycloak_password_rotated')">
<label>
<input type="checkbox" :checked="isStepDone('keycloak_password_rotated')" disabled />
<span>Rotate your Keycloak password</span>
<span class="pill mono auto-pill" :class="keycloakRotationPillClass()">
{{ keycloakRotationPillLabel() }}
</span>
</label>
<div class="mfa-actions">
<button
class="secondary"
type="button"
@click="requestKeycloakPasswordRotation"
:disabled="
!auth.authenticated ||
loading ||
isStepDone('keycloak_password_rotated') ||
isStepBlocked('keycloak_password_rotated') ||
keycloakPasswordRotationRequested
"
>
Enable rotation
</button>
<a class="mono" href="https://sso.bstein.dev/realms/atlas/account" target="_blank" rel="noreferrer">Open Keycloak</a>
</div>
<p class="muted">
After Vaultwarden is set up, rotate your Keycloak password to a strong one and store it in Vaultwarden.
Atlas verifies this once Keycloak no longer requires you to update your password.
</p>
</li>
2026-01-04 23:34:21 -03:00
<li class="check-item mfa-optional" :class="mfaItemClass()">
2026-01-04 21:57:31 -03:00
<div class="mfa-row">
<div class="mfa-text">
<span class="mfa-title">Optional: enable MFA (TOTP) for Keycloak</span>
<p class="muted mfa-description">
Add a second factor with a mobile authenticator app. This is optional and won't block the rest of onboarding.
</p>
</div>
<span class="pill mono auto-pill" :class="mfaPillClass()">{{ mfaPillLabel() }}</span>
</div>
<div class="mfa-actions">
<button
class="secondary"
type="button"
@click="setMfaOptional('skipped')"
:disabled="!auth.authenticated || loading || isMfaBlocked() || isMfaDecided()"
>
Skip
</button>
<button
class="primary"
type="button"
@click="setMfaOptional('done')"
:disabled="!auth.authenticated || loading || isMfaBlocked() || isMfaDecided()"
>
Mark complete
</button>
</div>
<details class="mfa-qr" @toggle="maybeGenerateMfaQrs">
<summary class="mono">Show app install QR codes</summary>
<p v-if="mfaQrError" class="muted mfa-error">{{ mfaQrError }}</p>
<div v-else class="mfa-qr-grid">
<div class="mfa-qr-card">
<span class="mono mfa-qr-label">Aegis (Android)</span>
<img v-if="aegisQr" class="mfa-qr-img" :src="aegisQr" alt="Aegis Android app QR code" />
<a class="mono mfa-qr-link" :href="AEGIS_URL" target="_blank" rel="noreferrer">Open store</a>
</div>
<div class="mfa-qr-card">
<span class="mono mfa-qr-label">FreeOTP (iPhone)</span>
<img v-if="freeOtpQr" class="mfa-qr-img" :src="freeOtpQr" alt="FreeOTP iPhone app QR code" />
<a class="mono mfa-qr-link" :href="FREEOTP_URL" target="_blank" rel="noreferrer">Open store</a>
</div>
</div>
</details>
</li>
2026-01-04 23:34:21 -03:00
<li class="check-item" :class="checkItemClass('element_recovery_key')">
<label>
<input type="checkbox" :checked="isStepDone('element_recovery_key')" disabled />
<span>Create an Element recovery key</span>
<span class="pill mono auto-pill" :class="stepPillClass('element_recovery_key')">
{{ stepPillLabel("element_recovery_key") }}
</span>
</label>
<div class="recovery-verify">
<input
v-model="elementRecoveryKey"
class="input mono"
type="text"
placeholder="Paste recovery key (hashed locally)"
:disabled="
!auth.authenticated || loading || isStepDone('element_recovery_key') || isStepBlocked('element_recovery_key')
"
/>
<button
class="primary verify"
type="button"
@click="verifyElementRecoveryKey"
:disabled="
!auth.authenticated ||
loading ||
isStepDone('element_recovery_key') ||
isStepBlocked('element_recovery_key') ||
!elementRecoveryKey.trim()
"
>
Verify
</button>
</div>
<p class="muted">
In Element, create a recovery key so you can restore encrypted history if you lose a device. Atlas stores only a SHA-256 hash so the
recovery key itself is never saved server-side.
Open <a href="https://live.bstein.dev/#/settings" target="_blank" rel="noreferrer">Element settings</a> Encryption.
</p>
</li>
2026-01-04 23:34:21 -03:00
<li class="check-item" :class="checkItemClass('element_recovery_key_stored')">
<label>
<input
type="checkbox"
:checked="isStepDone('element_recovery_key_stored')"
:disabled="!auth.authenticated || loading || isStepBlocked('element_recovery_key_stored')"
@change="toggleStep('element_recovery_key_stored', $event)"
/>
<span>Store the recovery key in Vaultwarden</span>
<span class="pill mono auto-pill" :class="stepPillClass('element_recovery_key_stored')">
{{ stepPillLabel("element_recovery_key_stored") }}
</span>
</label>
<p class="muted">Save the recovery key in Vaultwarden so it doesn't get lost.</p>
</li>
2026-01-04 23:34:21 -03:00
<li v-for="step in extraSteps" :key="step.id" class="check-item" :class="checkItemClass(step.id)">
<label>
<input
type="checkbox"
:checked="isStepDone(step.id)"
:disabled="!auth.authenticated || loading || isStepBlocked(step.id)"
@change="toggleStep(step.id, $event)"
/>
<span>{{ step.title }}</span>
<span class="pill mono auto-pill" :class="stepPillClass(step.id)">{{ stepPillLabel(step.id) }}</span>
</label>
<p class="muted">
{{ step.description }}
<template v-if="step.primaryLink">
<a :href="step.primaryLink.href" target="_blank" rel="noreferrer">{{ step.primaryLink.text }}</a
>.
</template>
<template v-if="step.secondaryLink">
<span> </span>
<a :href="step.secondaryLink.href" target="_blank" rel="noreferrer">{{ step.secondaryLink.text }}</a
>.
</template>
</p>
</li>
</ul>
<div v-if="status === 'ready'" class="ready-box">
<h3>You're ready</h3>
<p class="muted">
Your Atlas account is provisioned and onboarding is complete. You can log in at
<a href="https://cloud.bstein.dev" target="_blank" rel="noreferrer">cloud.bstein.dev</a>.
</p>
</div>
2026-01-02 01:34:18 -03:00
</div>
<div v-if="status === 'denied'" class="steps">
<h3>Denied</h3>
<p class="muted">This request was denied. Contact the Atlas admin if you think this is a mistake.</p>
</div>
<div v-if="error" class="error-box">
<div class="mono">{{ error }}</div>
</div>
</section>
</div>
</template>
<script setup>
import { onMounted, ref } from "vue";
2026-01-04 21:57:31 -03:00
import QRCode from "qrcode";
2026-01-02 01:34:18 -03:00
import { useRoute } from "vue-router";
import { auth, authFetch, login } from "../auth";
2026-01-02 01:34:18 -03:00
const route = useRoute();
2026-01-04 21:57:31 -03:00
const AEGIS_URL = "https://play.google.com/store/apps/details?id=com.beemdevelopment.aegis";
const FREEOTP_URL = "https://apps.apple.com/app/freeotp-authenticator/id872559395";
2026-01-02 01:34:18 -03:00
const requestCode = ref("");
const requestUsername = ref("");
2026-01-02 01:34:18 -03:00
const status = ref("");
const loading = ref(false);
const error = ref("");
2026-01-04 21:57:31 -03:00
const onboarding = ref({ required_steps: [], optional_steps: [], completed_steps: [], optional: {} });
const initialPassword = ref("");
const copied = ref(false);
2026-01-04 08:44:25 -03:00
const usernameCopied = ref(false);
const tasks = ref([]);
const blocked = ref(false);
const elementRecoveryKey = ref("");
2026-01-04 21:57:31 -03:00
const aegisQr = ref("");
const freeOtpQr = ref("");
const mfaQrError = ref("");
const mfaQrReady = ref(false);
const keycloakPasswordRotationRequested = ref(false);
const extraSteps = [
{
id: "elementx_setup",
title: "Install Element X and sign in",
description: "Install Element X on mobile and sign in with your Atlas username/password to join rooms and calls.",
primaryLink: { href: "https://live.bstein.dev", text: "Element" },
},
{
id: "jellyfin_login",
title: "Sign in to Jellyfin",
description: "Sign in with your Atlas username/password (LDAP-backed).",
primaryLink: { href: "https://stream.bstein.dev", text: "Jellyfin" },
},
{
id: "mail_client_setup",
title: "Set up mail on a device",
description:
"Use the IMAP/SMTP details on your Account page to add mail to your phone or desktop client (FairEmail, Thunderbird, Apple Mail, Outlook, etc).",
primaryLink: { href: "/account", text: "Account" },
},
];
function statusLabel(value) {
const key = (value || "").trim();
if (key === "pending_email_verification") return "confirm email";
if (key === "pending") return "awaiting approval";
if (key === "accounts_building") return "accounts building";
if (key === "awaiting_onboarding") return "awaiting onboarding";
if (key === "ready") return "ready";
if (key === "denied") return "rejected";
return key || "unknown";
}
function statusPillClass(value) {
const key = (value || "").trim();
if (key === "pending_email_verification") return "pill-warn";
if (key === "pending") return "pill-wait";
if (key === "accounts_building") return "pill-warn";
if (key === "awaiting_onboarding") return "pill-ok";
if (key === "ready") return "pill-info";
if (key === "denied") return "pill-bad";
return "pill-warn";
}
function isStepDone(step) {
const steps = onboarding.value?.completed_steps || [];
return Array.isArray(steps) ? steps.includes(step) : false;
}
2026-01-02 01:34:18 -03:00
2026-01-04 23:34:21 -03:00
function requiredStepOrder() {
if (Array.isArray(onboarding.value?.required_steps) && onboarding.value.required_steps.length) {
return onboarding.value.required_steps;
}
return [
"vaultwarden_master_password",
"vaultwarden_browser_extension",
"vaultwarden_mobile_app",
"keycloak_password_rotated",
"element_recovery_key",
"element_recovery_key_stored",
"elementx_setup",
"jellyfin_login",
"mail_client_setup",
];
}
function activeRequiredStep() {
const order = requiredStepOrder();
for (const step of order) {
if (!isStepDone(step)) return step;
}
return "";
}
2026-01-04 21:57:31 -03:00
function mfaOptionalState() {
const state = onboarding.value?.optional?.keycloak_mfa_optional?.state;
if (state === "done" || state === "skipped") return state;
return "pending";
}
function isMfaDecided() {
const state = mfaOptionalState();
return state === "done" || state === "skipped";
}
function isMfaBlocked() {
return !isStepDone("keycloak_password_rotated");
2026-01-04 21:57:31 -03:00
}
2026-01-04 23:34:21 -03:00
function mfaItemClass() {
const state = mfaOptionalState();
return {
blocked: isMfaBlocked(),
done: state === "done",
skipped: state === "skipped",
optional: true,
};
}
2026-01-04 21:57:31 -03:00
function mfaPillLabel() {
if (isMfaBlocked()) return "blocked";
const state = mfaOptionalState();
if (state === "done") return "done";
if (state === "skipped") return "skipped";
return "optional";
}
function mfaPillClass() {
if (isMfaBlocked()) return "pill-wait";
const state = mfaOptionalState();
if (state === "done") return "pill-ok";
if (state === "skipped") return "pill-info";
return "pill-warn";
}
function keycloakRotationPillLabel() {
if (isStepDone("keycloak_password_rotated")) return "done";
if (isStepBlocked("keycloak_password_rotated")) return "blocked";
if (keycloakPasswordRotationRequested.value) return "rotate now";
return "ready";
}
function keycloakRotationPillClass() {
if (isStepDone("keycloak_password_rotated")) return "pill-ok";
if (isStepBlocked("keycloak_password_rotated")) return "pill-wait";
if (keycloakPasswordRotationRequested.value) return "pill-warn";
return "pill-info";
}
2026-01-04 21:57:31 -03:00
async function maybeGenerateMfaQrs(event) {
if (mfaQrReady.value) return;
const details = event?.target;
if (details && details.tagName === "DETAILS" && !details.open) return;
mfaQrError.value = "";
try {
aegisQr.value = await QRCode.toDataURL(AEGIS_URL, { width: 220, margin: 2 });
freeOtpQr.value = await QRCode.toDataURL(FREEOTP_URL, { width: 220, margin: 2 });
mfaQrReady.value = true;
} catch (err) {
mfaQrError.value = err?.message || "Failed to generate QR codes";
}
}
function isStepBlocked(step) {
2026-01-04 23:34:21 -03:00
const order = requiredStepOrder();
const idx = order.indexOf(step);
if (idx <= 0) return false;
for (let i = 0; i < idx; i += 1) {
if (!isStepDone(order[i])) return true;
}
return false;
}
2026-01-04 23:34:21 -03:00
function checkItemClass(step) {
const activeStep = activeRequiredStep();
const done = isStepDone(step);
const blockedStep = isStepBlocked(step);
const active = !done && !blockedStep && activeStep === step;
return { done, blocked: blockedStep, active };
}
function stepPillLabel(step) {
if (isStepDone(step)) return "done";
if (isStepBlocked(step)) return "blocked";
return "pending";
}
function stepPillClass(step) {
if (isStepDone(step)) return "pill-ok";
if (isStepBlocked(step)) return "pill-wait";
return "pill-warn";
}
function taskPillClass(status) {
const key = (status || "").trim();
if (key === "ok") return "pill-ok";
if (key === "error") return "pill-bad";
if (key === "pending") return "pill-warn";
return "pill-warn";
}
2026-01-02 01:34:18 -03:00
async function check() {
if (loading.value) return;
error.value = "";
loading.value = true;
try {
const resp = await fetch("/api/access/request/status", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ request_code: requestCode.value.trim() }),
});
const data = await resp.json().catch(() => ({}));
if (!resp.ok) throw new Error(data.error || resp.statusText || `status ${resp.status}`);
status.value = data.status || "unknown";
requestUsername.value = data.username || "";
2026-01-04 21:57:31 -03:00
onboarding.value = data.onboarding || { required_steps: [], optional_steps: [], completed_steps: [], optional: {} };
keycloakPasswordRotationRequested.value = Boolean(data.onboarding?.keycloak?.password_rotation_requested);
tasks.value = Array.isArray(data.tasks) ? data.tasks : [];
blocked.value = Boolean(data.blocked);
initialPassword.value = data.initial_password || "";
2026-01-02 01:34:18 -03:00
} catch (err) {
error.value = err.message || "Failed to check status";
tasks.value = [];
blocked.value = false;
keycloakPasswordRotationRequested.value = false;
2026-01-02 01:34:18 -03:00
} finally {
loading.value = false;
}
}
async function copyInitialPassword() {
if (!initialPassword.value) return;
try {
if (navigator?.clipboard?.writeText) {
await navigator.clipboard.writeText(initialPassword.value);
} else {
const textarea = document.createElement("textarea");
textarea.value = initialPassword.value;
textarea.setAttribute("readonly", "");
textarea.style.position = "fixed";
textarea.style.top = "-9999px";
textarea.style.left = "-9999px";
document.body.appendChild(textarea);
textarea.select();
textarea.setSelectionRange(0, textarea.value.length);
document.execCommand("copy");
document.body.removeChild(textarea);
}
copied.value = true;
setTimeout(() => (copied.value = false), 1500);
} catch (err) {
error.value = err?.message || "Failed to copy password";
}
}
2026-01-04 08:44:25 -03:00
async function copyUsername() {
if (!requestUsername.value) return;
try {
if (navigator?.clipboard?.writeText) {
await navigator.clipboard.writeText(requestUsername.value);
} else {
const textarea = document.createElement("textarea");
textarea.value = requestUsername.value;
textarea.setAttribute("readonly", "");
textarea.style.position = "fixed";
textarea.style.top = "-9999px";
textarea.style.left = "-9999px";
document.body.appendChild(textarea);
textarea.select();
textarea.setSelectionRange(0, textarea.value.length);
document.execCommand("copy");
document.body.removeChild(textarea);
}
usernameCopied.value = true;
setTimeout(() => (usernameCopied.value = false), 1500);
} catch (err) {
error.value = err?.message || "Failed to copy username";
}
}
async function loginToContinue() {
2026-01-04 08:44:25 -03:00
const trimmedCode = requestCode.value.trim();
const hint = requestUsername.value.trim() || trimmedCode.split("~", 1)[0] || "";
await login(`/onboarding?code=${encodeURIComponent(trimmedCode)}`, hint);
}
2026-01-04 21:57:31 -03:00
async function setMfaOptional(state) {
if (!auth.authenticated) {
error.value = "Log in to update onboarding steps.";
return;
}
if (isMfaBlocked()) {
error.value = "Rotate your Keycloak password first.";
2026-01-04 21:57:31 -03:00
return;
}
if (state !== "done" && state !== "skipped") return;
error.value = "";
loading.value = true;
try {
const resp = await authFetch("/api/access/request/onboarding/mfa", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ request_code: requestCode.value.trim(), state }),
});
const data = await resp.json().catch(() => ({}));
if (!resp.ok) throw new Error(data.error || resp.statusText || `status ${resp.status}`);
status.value = data.status || status.value;
onboarding.value = data.onboarding || onboarding.value;
} catch (err) {
error.value = err.message || "Failed to update MFA step";
} finally {
loading.value = false;
}
}
async function requestKeycloakPasswordRotation() {
if (!auth.authenticated) {
error.value = "Log in to request password rotation.";
return;
}
if (isStepBlocked("keycloak_password_rotated")) {
error.value = "Complete earlier onboarding steps first.";
return;
}
if (keycloakPasswordRotationRequested.value) return;
error.value = "";
loading.value = true;
try {
const resp = await authFetch("/api/access/request/onboarding/keycloak-password-rotate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ request_code: requestCode.value.trim() }),
});
const data = await resp.json().catch(() => ({}));
if (!resp.ok) throw new Error(data.error || resp.statusText || `status ${resp.status}`);
status.value = data.status || status.value;
onboarding.value = data.onboarding || onboarding.value;
keycloakPasswordRotationRequested.value = Boolean(data.onboarding?.keycloak?.password_rotation_requested);
} catch (err) {
error.value = err.message || "Failed to request password rotation";
} finally {
loading.value = false;
}
}
async function toggleStep(step, event) {
const checked = Boolean(event?.target?.checked);
if (!auth.authenticated) {
event?.preventDefault?.();
return;
}
if (step === "keycloak_password_rotated") {
event?.preventDefault?.();
return;
}
if (step === "element_recovery_key") {
event?.preventDefault?.();
return;
}
error.value = "";
loading.value = true;
try {
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, completed: checked }),
});
const data = await resp.json().catch(() => ({}));
if (!resp.ok) throw new Error(data.error || resp.statusText || `status ${resp.status}`);
status.value = data.status || status.value;
onboarding.value = data.onboarding || onboarding.value;
} catch (err) {
error.value = err.message || "Failed to update onboarding";
} finally {
loading.value = false;
}
}
async function sha256Hex(text) {
const encoder = new TextEncoder();
const data = encoder.encode(text);
const digest = await crypto.subtle.digest("SHA-256", data);
return Array.from(new Uint8Array(digest))
.map((byte) => byte.toString(16).padStart(2, "0"))
.join("");
}
async function verifyElementRecoveryKey() {
if (!auth.authenticated) {
error.value = "Log in to verify your recovery key.";
return;
}
if (isStepBlocked("element_recovery_key")) {
error.value = "Complete earlier onboarding steps first.";
return;
}
const raw = elementRecoveryKey.value.trim();
if (!raw) return;
error.value = "";
loading.value = true;
try {
const hash = await sha256Hex(raw);
const resp = await authFetch("/api/access/request/onboarding/element-recovery", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ request_code: requestCode.value.trim(), sha256: hash }),
});
const data = await resp.json().catch(() => ({}));
if (!resp.ok) throw new Error(data.error || resp.statusText || `status ${resp.status}`);
status.value = data.status || status.value;
onboarding.value = data.onboarding || onboarding.value;
elementRecoveryKey.value = "";
} catch (err) {
error.value = err.message || "Failed to verify recovery key";
} finally {
loading.value = false;
}
}
2026-01-02 01:34:18 -03:00
onMounted(async () => {
const code = route.query.code || route.query.request_code || "";
if (typeof code === "string" && code.trim()) {
requestCode.value = code.trim();
await check();
}
});
</script>
<style scoped>
.page {
max-width: 960px;
margin: 0 auto;
padding: 32px 22px 72px;
}
.hero {
margin-bottom: 12px;
padding: 18px;
}
.eyebrow {
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-muted);
margin: 0 0 6px;
font-size: 13px;
}
h1 {
margin: 0 0 6px;
font-size: 32px;
}
.lede {
margin: 0;
color: var(--text-muted);
max-width: 640px;
}
.module {
padding: 18px;
}
.module-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
}
.status-form {
display: flex;
gap: 10px;
margin-top: 12px;
}
.status-meta {
margin-top: 12px;
padding: 12px;
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(0, 0, 0, 0.18);
}
.meta-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
}
.meta-row .label {
color: var(--text-muted);
}
.meta-row .value {
color: var(--text-strong);
}
2026-01-02 01:34:18 -03:00
.input {
flex: 1;
padding: 10px 12px;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.14);
background: rgba(0, 0, 0, 0.25);
color: var(--text-primary);
}
button.primary {
background: linear-gradient(90deg, #4f8bff, #7dd0ff);
color: #0b1222;
padding: 10px 14px;
border: none;
border-radius: 10px;
cursor: pointer;
font-weight: 700;
}
.steps {
margin-top: 16px;
}
.steps h3 {
margin: 0 0 8px;
}
.onboarding-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 8px;
}
.login-callout {
margin-top: 10px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
padding: 12px;
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(0, 0, 0, 0.18);
}
.checklist {
margin: 14px 0 0;
padding: 0;
list-style: none;
display: grid;
gap: 12px;
}
.check-item {
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 14px;
padding: 12px 12px 10px;
background: rgba(255, 255, 255, 0.02);
}
2026-01-04 23:34:21 -03:00
.check-item.blocked {
opacity: 0.55;
}
.check-item.active {
border-color: rgba(125, 208, 255, 0.45);
background: rgba(79, 139, 255, 0.08);
box-shadow: 0 0 0 1px rgba(79, 139, 255, 0.2);
}
.check-item.done {
border-color: rgba(92, 214, 167, 0.35);
background: rgba(92, 214, 167, 0.05);
}
.check-item.skipped {
border-color: rgba(146, 158, 182, 0.25);
}
.check-item.done label,
.check-item.active label {
color: var(--text-primary);
}
.check-item label {
display: flex;
align-items: center;
gap: 10px;
font-weight: 650;
color: var(--text-strong);
}
.auto-pill {
margin-left: auto;
font-size: 12px;
padding: 3px 10px;
border-radius: 999px;
}
.check-item input[type="checkbox"] {
width: 18px;
height: 18px;
}
.recovery-verify {
display: flex;
gap: 10px;
margin-top: 10px;
align-items: stretch;
}
.recovery-verify .input {
flex: 1;
}
.recovery-verify .verify {
min-width: 96px;
}
2026-01-04 21:57:31 -03:00
.mfa-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.mfa-title {
font-weight: 650;
color: var(--text-strong);
}
.mfa-description {
margin: 6px 0 0;
}
.mfa-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 10px;
}
button.secondary {
padding: 10px 14px;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.14);
background: rgba(0, 0, 0, 0.22);
color: var(--text-primary);
cursor: pointer;
font-weight: 650;
}
.mfa-qr {
margin-top: 12px;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(0, 0, 0, 0.16);
padding: 10px 12px;
}
.mfa-qr summary {
cursor: pointer;
color: var(--text-muted);
}
.mfa-qr-grid {
margin-top: 12px;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
gap: 12px;
}
.mfa-qr-card {
display: grid;
justify-items: center;
gap: 8px;
padding: 12px;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.02);
}
.mfa-qr-label {
color: var(--text-muted);
font-size: 12px;
}
.mfa-qr-img {
width: 180px;
height: 180px;
border-radius: 10px;
background: #ffffff;
padding: 6px;
}
.mfa-qr-link {
color: rgba(125, 208, 255, 0.9);
font-size: 12px;
text-decoration: none;
}
.mfa-error {
margin-top: 10px;
}
.howto {
margin-top: 10px;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(0, 0, 0, 0.16);
padding: 10px 12px;
}
.howto summary {
cursor: pointer;
color: var(--text-muted);
}
.howto-list {
margin: 10px 0 0;
padding-left: 18px;
display: grid;
gap: 6px;
}
@media (max-width: 560px) {
.recovery-verify {
flex-direction: column;
}
.recovery-verify .verify {
width: 100%;
}
}
.ready-box {
margin-top: 14px;
padding: 14px;
border-radius: 14px;
border: 1px solid rgba(120, 180, 255, 0.25);
background: rgba(120, 180, 255, 0.06);
}
.initial-password {
margin-top: 14px;
padding: 14px;
border-radius: 14px;
border: 1px solid rgba(255, 220, 120, 0.25);
background: rgba(255, 220, 120, 0.06);
}
.task-box {
margin-top: 14px;
padding: 14px;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 14px;
background: rgba(0, 0, 0, 0.25);
}
.task-list {
list-style: none;
padding: 0;
margin: 0;
display: grid;
gap: 10px;
}
.task-row {
display: grid;
gap: 6px;
grid-template-columns: 1fr auto;
align-items: center;
}
.task-name {
color: var(--text);
}
.task-detail {
grid-column: 1 / -1;
color: var(--text-muted);
font-size: 12px;
}
.request-code-row {
margin-top: 12px;
display: flex;
flex-direction: column;
gap: 6px;
}
.copy {
display: inline-flex;
align-items: center;
gap: 10px;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.14);
background: rgba(0, 0, 0, 0.22);
color: var(--text-primary);
padding: 10px 12px;
cursor: pointer;
}
.copied {
font-size: 12px;
color: rgba(120, 255, 160, 0.9);
}
2026-01-02 01:34:18 -03:00
.steps ol {
margin: 0;
padding-left: 18px;
color: var(--text-muted);
}
.muted {
color: var(--text-muted);
}
.error-box {
margin-top: 12px;
border-radius: 12px;
border: 1px solid rgba(255, 87, 87, 0.5);
background: rgba(255, 87, 87, 0.06);
padding: 10px 12px;
}
</style>