portal: improve onboarding guidance and rotation

This commit is contained in:
Brad Stein 2026-01-24 21:02:30 -03:00
parent 27fbad1f05
commit 647c954739
7 changed files with 138 additions and 14 deletions

View File

@ -1147,6 +1147,13 @@ def register(app) -> None:
missing = sorted(prerequisites - current_completed)
if missing:
return jsonify({"error": "step is blocked", "blocked_by": missing}), 409
if step in {"vaultwarden_master_password", "vaultwarden_store_temp_password"}:
if not _password_rotation_requested(conn, code):
try:
_request_keycloak_password_rotation(conn, code, request_username)
except Exception:
return jsonify({"error": "failed to request keycloak password rotation"}), 502
if step == "vaultwarden_master_password":
if vaultwarden_claim and not username:
return jsonify({"error": "login required"}), 401
@ -1159,10 +1166,6 @@ def register(app) -> None:
return jsonify({"error": "vaultwarden claim not allowed"}), 403
if vaultwarden_claim and not admin_client().ready():
return jsonify({"error": "keycloak admin unavailable"}), 503
try:
_request_keycloak_password_rotation(conn, code, request_username)
except Exception:
return jsonify({"error": "failed to request keycloak password rotation"}), 502
if request_username and admin_client().ready():
try:
now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")

View File

@ -52,3 +52,9 @@ p {
.page > section + section {
margin-top: 32px;
}
@media (max-width: 720px) {
.page {
padding: 24px 16px 56px;
}
}

View File

@ -498,6 +498,7 @@ const vaultwardenOrder = computed(() => (vaultwardenReady.value ? 3 : 0));
const doLogin = () => login("/account");
const copied = reactive({});
const normalizeEmail = (value) => (typeof value === "string" ? value.toLowerCase() : "");
onMounted(() => {
if (auth.ready && auth.authenticated) {
refreshOverview();
@ -555,22 +556,22 @@ async function refreshOverview() {
}
const data = await resp.json();
mailu.status = data.mailu?.status || "ready";
mailu.username = data.mailu?.username || auth.email || auth.username;
mailu.username = normalizeEmail(data.mailu?.username) || normalizeEmail(auth.email) || auth.username;
mailu.currentPassword = data.mailu?.app_password || "";
nextcloudMail.status = data.nextcloud_mail?.status || "unknown";
nextcloudMail.primaryEmail = data.nextcloud_mail?.primary_email || "";
nextcloudMail.primaryEmail = normalizeEmail(data.nextcloud_mail?.primary_email) || "";
nextcloudMail.accountCount = data.nextcloud_mail?.account_count || "";
nextcloudMail.syncedAt = data.nextcloud_mail?.synced_at || "";
wger.status = data.wger?.status || "unknown";
wger.username = data.wger?.username || mailu.username || auth.username;
wger.username = normalizeEmail(data.wger?.username) || mailu.username || auth.username;
wger.password = data.wger?.password || "";
wger.passwordUpdatedAt = data.wger?.password_updated_at || "";
firefly.status = data.firefly?.status || "unknown";
firefly.username = data.firefly?.username || mailu.username || auth.username;
firefly.username = normalizeEmail(data.firefly?.username) || mailu.username || auth.username;
firefly.password = data.firefly?.password || "";
firefly.passwordUpdatedAt = data.firefly?.password_updated_at || "";
vaultwarden.status = data.vaultwarden?.status || "unknown";
vaultwarden.username = data.vaultwarden?.username || mailu.username || auth.username;
vaultwarden.username = normalizeEmail(data.vaultwarden?.username) || mailu.username || auth.username;
vaultwarden.syncedAt = data.vaultwarden?.synced_at || "";
jellyfin.status = data.jellyfin?.status || "ready";
jellyfin.username = data.jellyfin?.username || auth.username;
@ -778,7 +779,12 @@ async function syncNextcloudMail() {
if (!resp.ok) throw new Error(data.error || `status ${resp.status}`);
await refreshOverview();
} catch (err) {
nextcloudMail.error = formatActionError(err, "Sync failed");
const message = formatActionError(err, "Sync failed");
if (message.toLowerCase().includes("ariadne is busy")) {
nextcloudMail.error = "Ariadne is busy. Refresh in a moment; the sync may have completed.";
} else {
nextcloudMail.error = message;
}
} finally {
nextcloudMail.syncing = false;
}
@ -1090,6 +1096,42 @@ button.primary {
}
}
@media (max-width: 720px) {
.page {
padding: 24px 16px 56px;
}
.hero {
flex-direction: column;
align-items: flex-start;
}
.hero-actions {
width: 100%;
justify-content: flex-start;
gap: 12px;
}
.row {
flex-direction: column;
align-items: flex-start;
}
.actions {
flex-direction: column;
align-items: stretch;
}
.secret-head {
flex-direction: column;
align-items: flex-start;
}
.req-row {
grid-template-columns: 1fr;
}
}
.admin {
margin-top: 12px;
}

View File

@ -116,6 +116,10 @@
</ol>
<div v-if="requestUsername || initialPassword" class="credential-card">
<div class="credential-head">
<h4>Keycloak temporary credentials</h4>
<p class="muted">Use these to sign in to Nextcloud, Element, and any Keycloak-protected services.</p>
</div>
<div class="credential-grid">
<div class="credential-field">
<span class="label mono">Username</span>
@ -207,7 +211,7 @@
</div>
<details v-if="step.guide" class="guide-details" :open="shouldOpenGuide(step, activeSection)">
<summary class="mono">Photo guide</summary>
<summary class="mono guide-summary">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">
<h4 v-if="group.title" class="mono guide-title">{{ group.title }}</h4>
@ -358,7 +362,9 @@ const vaultwardenLoginEmail = computed(() => {
}
return "your @bstein.dev address";
});
const vaultwardenLoginEmailLower = computed(() => (vaultwardenLoginEmail.value || "").toLowerCase());
const mailAddress = computed(() => (requestUsername.value ? `${requestUsername.value}@bstein.dev` : "your @bstein.dev address"));
const mailAddressLower = computed(() => (mailAddress.value || "").toLowerCase());
const STEP_PREREQS = {
vaultwarden_master_password: [],
@ -750,16 +756,16 @@ function isStepBlocked(stepId) {
function stepNote(step) {
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 ${vaultwardenLoginEmailLower.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.`;
return `Firefly uses an email login. Use ${mailAddressLower.value} to sign in.`;
}
if (step.id === "mail_client_setup") {
return `Your mailbox address is ${mailAddress.value}.`;
return `Your mailbox address is ${mailAddressLower.value}.`;
}
return "";
}
@ -1479,6 +1485,15 @@ button.copy:disabled {
background: rgba(0, 0, 0, 0.2);
}
.credential-head {
margin-bottom: 10px;
}
.credential-head h4 {
margin: 0 0 4px;
font-size: 18px;
}
.credential-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
@ -1647,6 +1662,33 @@ button.copy:disabled {
margin-top: 10px;
}
.guide-summary {
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 10px;
padding: 6px 12px;
border-radius: 999px;
border: 1px solid rgba(92, 214, 167, 0.5);
background: rgba(92, 214, 167, 0.15);
color: var(--text-strong);
font-weight: 600;
}
.guide-summary::after {
content: "Tap to open";
font-size: 12px;
color: var(--text-muted);
}
.guide-details[open] .guide-summary::after {
content: "Tap to close";
}
.guide-summary::-webkit-details-marker {
display: none;
}
.guide-groups {
display: grid;
gap: 12px;
@ -1806,10 +1848,41 @@ button.copy:disabled {
}
@media (max-width: 720px) {
.page {
padding: 24px 16px 56px;
}
.status-form {
flex-direction: column;
}
.onboarding-head {
flex-direction: column;
align-items: flex-start;
}
.credential-grid {
grid-template-columns: 1fr;
}
.step-head {
flex-direction: column;
align-items: flex-start;
}
.auto-pill {
margin-left: 0;
}
.section-actions {
flex-direction: column;
align-items: stretch;
}
.step-actions {
justify-content: flex-start;
}
.section-actions {
width: 100%;
justify-content: flex-start;

View File

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 54 KiB