onboarding: rotate Keycloak after Vaultwarden
This commit is contained in:
parent
f708bee4bf
commit
698ed49a9b
@ -202,17 +202,21 @@ def provision_access_request(request_code: str) -> ProvisionResult:
|
||||
existing_email_user = admin_client().find_user_by_email(email)
|
||||
if existing_email_user and (existing_email_user.get("username") or "") != username:
|
||||
raise RuntimeError("email is already associated with an existing Atlas account")
|
||||
# Always enforce email verification in Keycloak itself (even if the portal
|
||||
# already verified an external email before approval).
|
||||
# Do not force MFA enrollment during initial login: some users prefer to
|
||||
# enable MFA later and some clients are too friction-heavy when MFA is
|
||||
# mandatory for every service.
|
||||
required_actions = ["UPDATE_PASSWORD", "VERIFY_EMAIL"]
|
||||
# The portal already verified the external contact email before approval,
|
||||
# so mark it as verified in Keycloak.
|
||||
#
|
||||
# Do not force password rotation on first login: the onboarding flow
|
||||
# intentionally guides users through Vaultwarden first, then triggers a
|
||||
# Keycloak password change step later.
|
||||
#
|
||||
# Do not force MFA enrollment during initial login: users can opt into MFA
|
||||
# later.
|
||||
required_actions: list[str] = []
|
||||
payload = {
|
||||
"username": username,
|
||||
"enabled": True,
|
||||
"email": email,
|
||||
"emailVerified": False,
|
||||
"emailVerified": True,
|
||||
"requiredActions": required_actions,
|
||||
"attributes": {MAILU_EMAIL_ATTR: [mailu_email]},
|
||||
}
|
||||
@ -256,7 +260,7 @@ def provision_access_request(request_code: str) -> ProvisionResult:
|
||||
if not user_id:
|
||||
return ProvisionResult(ok=False, status="accounts_building")
|
||||
|
||||
# Task: set initial temporary password and store it for "show once" onboarding.
|
||||
# Task: set initial password and store it for "show once" onboarding.
|
||||
try:
|
||||
if not user_id:
|
||||
raise RuntimeError("missing user id")
|
||||
@ -280,7 +284,7 @@ def provision_access_request(request_code: str) -> ProvisionResult:
|
||||
initial_password = password_value
|
||||
|
||||
if password_value:
|
||||
admin_client().reset_password(user_id, password_value, temporary=True)
|
||||
admin_client().reset_password(user_id, password_value, temporary=False)
|
||||
|
||||
if isinstance(initial_password, str) and initial_password:
|
||||
_upsert_task(conn, request_code, "keycloak_password", "ok", None)
|
||||
|
||||
@ -57,13 +57,13 @@ def _verify_url(request_code: str, token: str) -> str:
|
||||
|
||||
|
||||
ONBOARDING_STEPS: tuple[str, ...] = (
|
||||
"keycloak_password_changed",
|
||||
"keycloak_mfa_optional",
|
||||
"vaultwarden_master_password",
|
||||
"element_recovery_key",
|
||||
"element_recovery_key_stored",
|
||||
"vaultwarden_browser_extension",
|
||||
"vaultwarden_mobile_app",
|
||||
"keycloak_password_rotated",
|
||||
"keycloak_mfa_optional",
|
||||
"element_recovery_key",
|
||||
"element_recovery_key_stored",
|
||||
"elementx_setup",
|
||||
"jellyfin_login",
|
||||
"mail_client_setup",
|
||||
@ -74,22 +74,19 @@ ONBOARDING_REQUIRED_STEPS: tuple[str, ...] = tuple(
|
||||
step for step in ONBOARDING_STEPS if step not in ONBOARDING_OPTIONAL_STEPS
|
||||
)
|
||||
|
||||
KEYCLOAK_MANAGED_STEPS: set[str] = {"keycloak_password_changed"}
|
||||
KEYCLOAK_MANAGED_STEPS: set[str] = {"keycloak_password_rotated"}
|
||||
_KEYCLOAK_MFA_OPTIONAL_STATE_ARTIFACT = "keycloak_mfa_optional_state"
|
||||
_KEYCLOAK_MFA_OPTIONAL_VALID_STATES = {"done", "skipped"}
|
||||
_KEYCLOAK_PASSWORD_ROTATION_REQUESTED_ARTIFACT = "keycloak_password_rotation_requested_at"
|
||||
|
||||
|
||||
def _sequential_prerequisites(
|
||||
steps: tuple[str, ...],
|
||||
keycloak_managed_steps: set[str],
|
||||
optional_steps: set[str],
|
||||
) -> dict[str, set[str]]:
|
||||
completed: list[str] = []
|
||||
prerequisites: dict[str, set[str]] = {}
|
||||
for step in steps:
|
||||
if step in keycloak_managed_steps:
|
||||
completed.append(step)
|
||||
continue
|
||||
prerequisites[step] = set(completed)
|
||||
if step not in optional_steps:
|
||||
completed.append(step)
|
||||
@ -98,7 +95,6 @@ def _sequential_prerequisites(
|
||||
|
||||
ONBOARDING_STEP_PREREQUISITES: dict[str, set[str]] = _sequential_prerequisites(
|
||||
ONBOARDING_STEPS,
|
||||
KEYCLOAK_MANAGED_STEPS,
|
||||
ONBOARDING_OPTIONAL_STEPS,
|
||||
)
|
||||
|
||||
@ -126,11 +122,26 @@ def _fetch_completed_onboarding_steps(conn, request_code: str) -> set[str]:
|
||||
return completed
|
||||
|
||||
|
||||
def _auto_completed_keycloak_steps(username: str) -> set[str]:
|
||||
def _password_rotation_requested(conn, request_code: str) -> bool:
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT 1
|
||||
FROM access_request_onboarding_artifacts
|
||||
WHERE request_code = %s AND artifact = %s
|
||||
LIMIT 1
|
||||
""",
|
||||
(request_code, _KEYCLOAK_PASSWORD_ROTATION_REQUESTED_ARTIFACT),
|
||||
).fetchone()
|
||||
return bool(row)
|
||||
|
||||
|
||||
def _auto_completed_keycloak_steps(conn, request_code: str, username: str) -> set[str]:
|
||||
if not username:
|
||||
return set()
|
||||
if not admin_client().ready():
|
||||
return set()
|
||||
if not request_code:
|
||||
return set()
|
||||
|
||||
completed: set[str] = set()
|
||||
try:
|
||||
@ -152,8 +163,8 @@ def _auto_completed_keycloak_steps(username: str) -> set[str]:
|
||||
actions_list = [a for a in actions if isinstance(a, str)]
|
||||
required_actions = set(actions_list)
|
||||
|
||||
if "UPDATE_PASSWORD" not in required_actions:
|
||||
completed.add("keycloak_password_changed")
|
||||
if _password_rotation_requested(conn, request_code) and "UPDATE_PASSWORD" not in required_actions:
|
||||
completed.add("keycloak_password_rotated")
|
||||
|
||||
# Backfill: earlier accounts were created with CONFIGURE_TOTP as a required action,
|
||||
# which forces users to enroll MFA at first login. We no longer require that, so
|
||||
@ -174,7 +185,7 @@ def _auto_completed_keycloak_steps(username: str) -> set[str]:
|
||||
|
||||
def _completed_onboarding_steps(conn, request_code: str, username: str) -> set[str]:
|
||||
completed = _fetch_completed_onboarding_steps(conn, request_code)
|
||||
return completed | _auto_completed_keycloak_steps(username)
|
||||
return completed | _auto_completed_keycloak_steps(conn, request_code, username)
|
||||
|
||||
|
||||
def _automation_ready(conn, request_code: str, username: str) -> bool:
|
||||
@ -255,10 +266,14 @@ def _fetch_optional_mfa_state(conn, request_code: str) -> str:
|
||||
|
||||
def _onboarding_payload(conn, request_code: str, username: str) -> dict[str, Any]:
|
||||
completed_steps = sorted(_completed_onboarding_steps(conn, request_code, username))
|
||||
password_rotation_requested = _password_rotation_requested(conn, request_code)
|
||||
return {
|
||||
"required_steps": list(ONBOARDING_REQUIRED_STEPS),
|
||||
"optional_steps": sorted(ONBOARDING_OPTIONAL_STEPS),
|
||||
"completed_steps": completed_steps,
|
||||
"keycloak": {
|
||||
"password_rotation_requested": password_rotation_requested,
|
||||
},
|
||||
"optional": {
|
||||
"keycloak_mfa_optional": {
|
||||
"state": _fetch_optional_mfa_state(conn, request_code),
|
||||
@ -751,6 +766,75 @@ def register(app) -> None:
|
||||
}
|
||||
)
|
||||
|
||||
@app.route("/api/access/request/onboarding/keycloak-password-rotate", methods=["POST"])
|
||||
@require_auth
|
||||
def request_access_onboarding_keycloak_password_rotate() -> Any:
|
||||
if not configured():
|
||||
return jsonify({"error": "server not configured"}), 503
|
||||
|
||||
payload = request.get_json(silent=True) or {}
|
||||
code = (payload.get("request_code") or payload.get("code") or "").strip()
|
||||
if not code:
|
||||
return jsonify({"error": "request_code is required"}), 400
|
||||
|
||||
username = getattr(g, "keycloak_username", "") or ""
|
||||
if not username:
|
||||
return jsonify({"error": "invalid token"}), 401
|
||||
|
||||
if not admin_client().ready():
|
||||
return jsonify({"error": "keycloak admin unavailable"}), 503
|
||||
|
||||
try:
|
||||
with connect() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT username, status FROM access_requests WHERE request_code = %s",
|
||||
(code,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return jsonify({"error": "not found"}), 404
|
||||
if (row.get("username") or "") != username:
|
||||
return jsonify({"error": "forbidden"}), 403
|
||||
|
||||
status = _normalize_status(row.get("status") or "")
|
||||
if status not in {"awaiting_onboarding", "ready"}:
|
||||
return jsonify({"error": "onboarding not available"}), 409
|
||||
|
||||
prerequisites = ONBOARDING_STEP_PREREQUISITES.get("keycloak_password_rotated", set())
|
||||
if prerequisites:
|
||||
current_completed = _completed_onboarding_steps(conn, code, username)
|
||||
missing = sorted(prerequisites - current_completed)
|
||||
if missing:
|
||||
return jsonify({"error": "step is blocked", "blocked_by": missing}), 409
|
||||
|
||||
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 jsonify({"error": "keycloak user not found"}), 409
|
||||
|
||||
full = admin_client().get_user(user_id)
|
||||
actions = full.get("requiredActions")
|
||||
actions_list: list[str] = []
|
||||
if isinstance(actions, list):
|
||||
actions_list = [a for a in actions if isinstance(a, str)]
|
||||
if "UPDATE_PASSWORD" not in actions_list:
|
||||
actions_list.append("UPDATE_PASSWORD")
|
||||
admin_client().update_user(user_id, {"requiredActions": actions_list})
|
||||
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO access_request_onboarding_artifacts (request_code, artifact, value_hash)
|
||||
VALUES (%s, %s, NOW()::text)
|
||||
ON CONFLICT (request_code, artifact) DO NOTHING
|
||||
""",
|
||||
(code, _KEYCLOAK_PASSWORD_ROTATION_REQUESTED_ARTIFACT),
|
||||
)
|
||||
|
||||
onboarding_payload = _onboarding_payload(conn, code, username)
|
||||
except Exception:
|
||||
return jsonify({"error": "failed to request password rotation"}), 502
|
||||
|
||||
return jsonify({"ok": True, "status": status, "onboarding": onboarding_payload})
|
||||
|
||||
@app.route("/api/access/request/onboarding/mfa", methods=["POST"])
|
||||
@require_auth
|
||||
def request_access_onboarding_mfa_optional() -> Any:
|
||||
|
||||
@ -90,8 +90,8 @@
|
||||
<div v-if="initialPassword" class="initial-password">
|
||||
<h3>Temporary password</h3>
|
||||
<p class="muted">
|
||||
Use this password to log in for the first time. Keycloak will prompt you to change it. This password is shown
|
||||
once — copy it now.
|
||||
Use this password to log in for the first time. You won't be forced to change it immediately — you'll rotate
|
||||
it later after Vaultwarden is set up. This password is shown once — copy it now.
|
||||
</p>
|
||||
<div class="request-code-row">
|
||||
<span class="label mono">Password</span>
|
||||
@ -118,20 +118,88 @@
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="isStepDone('keycloak_password_changed')"
|
||||
disabled
|
||||
:checked="isStepDone('vaultwarden_master_password')"
|
||||
:disabled="!auth.authenticated || loading || isStepBlocked('vaultwarden_master_password')"
|
||||
@change="toggleStep('vaultwarden_master_password', $event)"
|
||||
/>
|
||||
<span>Change your Keycloak password</span>
|
||||
<span
|
||||
class="pill mono auto-pill"
|
||||
:class="isStepDone('keycloak_password_changed') ? 'pill-ok' : 'pill-warn'"
|
||||
>
|
||||
{{ isStepDone("keycloak_password_changed") ? "verified" : "pending" }}
|
||||
<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">
|
||||
Keycloak marks this complete once it no longer requires you to update your password.
|
||||
Use the <a href="https://sso.bstein.dev/realms/atlas/account" target="_blank" rel="noreferrer">account console</a>.
|
||||
Open <a href="https://vault.bstein.dev" target="_blank" rel="noreferrer">Passwords</a> and set a strong master
|
||||
password you won't forget. 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>
|
||||
</li>
|
||||
|
||||
<li class="check-item">
|
||||
<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>
|
||||
|
||||
<li class="check-item">
|
||||
<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>
|
||||
|
||||
<li class="check-item">
|
||||
<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>
|
||||
|
||||
@ -183,26 +251,6 @@
|
||||
</details>
|
||||
</li>
|
||||
|
||||
<li class="check-item">
|
||||
<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. 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>
|
||||
</li>
|
||||
|
||||
<li class="check-item">
|
||||
<label>
|
||||
<input type="checkbox" :checked="isStepDone('element_recovery_key')" disabled />
|
||||
@ -239,6 +287,7 @@
|
||||
<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>
|
||||
|
||||
@ -332,22 +381,8 @@ const aegisQr = ref("");
|
||||
const freeOtpQr = ref("");
|
||||
const mfaQrError = ref("");
|
||||
const mfaQrReady = ref(false);
|
||||
const keycloakPasswordRotationRequested = ref(false);
|
||||
const extraSteps = [
|
||||
{
|
||||
id: "vaultwarden_browser_extension",
|
||||
title: "Install the Vaultwarden browser extension",
|
||||
description:
|
||||
"Install Bitwarden in your browser and point it at vault.bstein.dev (Settings → Account → Environment → Self-hosted).",
|
||||
primaryLink: { href: "https://bitwarden.com/download", text: "Bitwarden downloads" },
|
||||
secondaryLink: { href: "https://vault.bstein.dev", text: "Passwords" },
|
||||
},
|
||||
{
|
||||
id: "vaultwarden_mobile_app",
|
||||
title: "Install Bitwarden on your phone",
|
||||
description: "Install the mobile app, set the server to vault.bstein.dev, and enable biometrics for fast unlock.",
|
||||
primaryLink: { href: "https://bitwarden.com/download", text: "Bitwarden downloads" },
|
||||
secondaryLink: { href: "https://vault.bstein.dev", text: "Passwords" },
|
||||
},
|
||||
{
|
||||
id: "elementx_setup",
|
||||
title: "Install Element X and sign in",
|
||||
@ -364,7 +399,7 @@ const extraSteps = [
|
||||
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 (Thunderbird, Apple Mail, Outlook, etc).",
|
||||
"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" },
|
||||
},
|
||||
];
|
||||
@ -408,7 +443,7 @@ function isMfaDecided() {
|
||||
}
|
||||
|
||||
function isMfaBlocked() {
|
||||
return !isStepDone("keycloak_password_changed");
|
||||
return !isStepDone("keycloak_password_rotated");
|
||||
}
|
||||
|
||||
function mfaPillLabel() {
|
||||
@ -427,6 +462,20 @@ function mfaPillClass() {
|
||||
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";
|
||||
}
|
||||
|
||||
async function maybeGenerateMfaQrs(event) {
|
||||
if (mfaQrReady.value) return;
|
||||
const details = event?.target;
|
||||
@ -446,12 +495,12 @@ function isStepBlocked(step) {
|
||||
Array.isArray(onboarding.value?.required_steps) && onboarding.value.required_steps.length
|
||||
? onboarding.value.required_steps
|
||||
: [
|
||||
"keycloak_password_changed",
|
||||
"vaultwarden_master_password",
|
||||
"element_recovery_key",
|
||||
"element_recovery_key_stored",
|
||||
"vaultwarden_browser_extension",
|
||||
"vaultwarden_mobile_app",
|
||||
"keycloak_password_rotated",
|
||||
"element_recovery_key",
|
||||
"element_recovery_key_stored",
|
||||
"elementx_setup",
|
||||
"jellyfin_login",
|
||||
"mail_client_setup",
|
||||
@ -499,15 +548,15 @@ async function check() {
|
||||
status.value = data.status || "unknown";
|
||||
requestUsername.value = data.username || "";
|
||||
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);
|
||||
if (data.initial_password) {
|
||||
initialPassword.value = data.initial_password;
|
||||
}
|
||||
initialPassword.value = data.initial_password || "";
|
||||
} catch (err) {
|
||||
error.value = err.message || "Failed to check status";
|
||||
tasks.value = [];
|
||||
blocked.value = false;
|
||||
keycloakPasswordRotationRequested.value = false;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
@ -575,7 +624,7 @@ async function setMfaOptional(state) {
|
||||
return;
|
||||
}
|
||||
if (isMfaBlocked()) {
|
||||
error.value = "Change your Keycloak password first.";
|
||||
error.value = "Rotate your Keycloak password first.";
|
||||
return;
|
||||
}
|
||||
if (state !== "done" && state !== "skipped") return;
|
||||
@ -598,13 +647,43 @@ async function setMfaOptional(state) {
|
||||
}
|
||||
}
|
||||
|
||||
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_changed") {
|
||||
if (step === "keycloak_password_rotated") {
|
||||
event?.preventDefault?.();
|
||||
return;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user