feat(access): add guarded local password lifecycle and recovery
This commit is contained in:
+2
-1
@@ -4,7 +4,8 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test:interface-patterns": "node scripts/test-interface-pattern-language.mjs"
|
||||
"test:interface-patterns": "node scripts/test-interface-pattern-language.mjs",
|
||||
"test:passwords": "node --test scripts/test-passwords.mjs"
|
||||
},
|
||||
"main": "src/index.ts",
|
||||
"module": "src/index.ts",
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import test from "node:test";
|
||||
import vm from "node:vm";
|
||||
|
||||
// Use the workspace's shared frontend compiler, without loading the application.
|
||||
const require = createRequire(new URL("../../../govoplan-core/webui/package.json", import.meta.url));
|
||||
const { transformSync } = require("esbuild");
|
||||
const calls = [];
|
||||
function load(relativePath, imports = {}) {
|
||||
const source = readFileSync(new URL(relativePath, import.meta.url), "utf8");
|
||||
const code = transformSync(source, { loader: "ts", format: "cjs", target: "es2022" }).code;
|
||||
const context = vm.createContext({ module: { exports: {} }, require: () => imports });
|
||||
context.exports = context.module.exports;
|
||||
vm.runInContext(code, context);
|
||||
return context.module.exports;
|
||||
}
|
||||
const api = load("../src/api/passwords.ts", {
|
||||
apiFetch: (...args) => { calls.push(args); return Promise.resolve({}); },
|
||||
isApiError: (error) => Boolean(error?.fixtureApiError)
|
||||
});
|
||||
const { passwordTranslations } = load("../src/i18n/passwordTranslations.ts");
|
||||
const settings = { apiBaseUrl: "https://fixture.invalid", apiKey: "fixture-key", accessToken: "legacy-fixture-token" };
|
||||
const current = "fixture-current-password";
|
||||
const next = "fixture-next-password";
|
||||
const code = "fixture-recovery-code";
|
||||
|
||||
test("password requests carry credentials only in POST bodies and public calls discard bearer settings", async () => {
|
||||
calls.length = 0;
|
||||
await api.fetchPasswordPolicy(settings);
|
||||
await api.changePassword(settings, current, next);
|
||||
await api.issuePasswordRecovery(settings, "account/1", current, true);
|
||||
await api.recoverPassword(settings, "person@example.test", code, next);
|
||||
assert.equal(calls[0][0].apiKey, "");
|
||||
assert.equal(calls[0][0].accessToken, "");
|
||||
assert.equal(calls[0][2].cache, "no-store");
|
||||
assert.equal(calls[1][0], settings);
|
||||
assert.deepEqual(JSON.parse(calls[1][2].body), { current_password: current, new_password: next });
|
||||
assert.equal(calls[2][1], "/api/v1/auth/password/recovery/account%2F1");
|
||||
assert.deepEqual(JSON.parse(calls[2][2].body), { current_password: current, identity_verified: true });
|
||||
assert.equal(calls[3][0].apiKey, "");
|
||||
assert.equal(calls[3][0].accessToken, "");
|
||||
assert.deepEqual(JSON.parse(calls[3][2].body), { email: "person@example.test", recovery_code: code, new_password: next });
|
||||
for (const [, path, options] of calls.slice(1)) {
|
||||
assert.equal(options.method, "POST");
|
||||
for (const secret of [current, next, code]) assert.equal(path.includes(secret), false);
|
||||
}
|
||||
});
|
||||
|
||||
test("all stable password errors have EN/DE messages, while arbitrary input never becomes display text", () => {
|
||||
const codes = ["current_password_invalid", "invalid_new_password", "password_unchanged", "password_recovery_disabled", "password_rate_limited", "local_password_unavailable", "recovery_invalid", "recovery_issuer_required", "recovery_membership_required", "password_changed_concurrently", "password_change_required"];
|
||||
for (const value of codes) {
|
||||
const key = api.passwordErrorMessage({ fixtureApiError: true, status: 400, body: JSON.stringify({ detail: { code: value, input: current } }) });
|
||||
assert.ok(passwordTranslations.en[key], value);
|
||||
assert.ok(passwordTranslations.de[key], value);
|
||||
assert.notEqual(key, "i18n:govoplan-access.password.request_failed");
|
||||
}
|
||||
for (const body of [current, JSON.stringify({ detail: [{ input: current }] }), JSON.stringify({ detail: { code: "toString", input: code } })]) {
|
||||
assert.equal(api.passwordErrorMessage({ fixtureApiError: true, status: 500, body }), "i18n:govoplan-access.password.request_failed");
|
||||
}
|
||||
assert.equal(api.passwordErrorMessage(new Error(current)), "i18n:govoplan-access.password.request_failed");
|
||||
for (const status of [401, 403, 422, 429]) {
|
||||
const key = api.passwordErrorMessage({ fixtureApiError: true, status, body: JSON.stringify({ detail: current }) });
|
||||
assert.ok(passwordTranslations.en[key]);
|
||||
assert.ok(passwordTranslations.de[key]);
|
||||
assert.equal(key.includes(current), false);
|
||||
}
|
||||
});
|
||||
|
||||
test("password translations keep the EN/DE workflow and placeholder contracts aligned", () => {
|
||||
assert.deepEqual(Object.keys(passwordTranslations.en).sort(), Object.keys(passwordTranslations.de).sort());
|
||||
for (const key of Object.keys(passwordTranslations.en)) {
|
||||
assert.deepEqual(passwordTranslations.en[key].match(/\{value\d+\}/g) ?? [], passwordTranslations.de[key].match(/\{value\d+\}/g) ?? [], key);
|
||||
}
|
||||
});
|
||||
@@ -4,8 +4,7 @@ import type {
|
||||
DeltaDeletedItem,
|
||||
PrivacyRetentionPolicy,
|
||||
ResourceAccessExplanationOptions,
|
||||
ResourceAccessExplanationResponse as CoreResourceAccessExplanationResponse,
|
||||
TenantAdminItem
|
||||
ResourceAccessExplanationResponse as CoreResourceAccessExplanationResponse
|
||||
} from "@govoplan/core-webui";
|
||||
import { apiFetch, apiGetList, apiPath, apiQuery, fetchResourceAccessExplanation as fetchCoreResourceAccessExplanation } from "@govoplan/core-webui";
|
||||
export { fetchAdminOverview, fetchPermissionCatalog, fetchTenants } from "@govoplan/core-webui";
|
||||
@@ -137,6 +136,7 @@ export type ResourceAccessExplanationResponse = CoreResourceAccessExplanationRes
|
||||
|
||||
export type SystemAccountItem = {
|
||||
account_id: string;
|
||||
local_password?: boolean;
|
||||
email: string;
|
||||
display_name?: string | null;
|
||||
is_active: boolean;
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { apiFetch, isApiError, type ApiSettings, type LoginResponse } from "@govoplan/core-webui";
|
||||
|
||||
export type PasswordPolicy = {
|
||||
recovery_enabled: boolean;
|
||||
min_length: number;
|
||||
max_length: number;
|
||||
recovery_minutes: number;
|
||||
};
|
||||
|
||||
export type PasswordRecoveryCode = { recovery_code: string; expires_at: string };
|
||||
|
||||
export function fetchPasswordPolicy(settings: ApiSettings): Promise<PasswordPolicy> {
|
||||
return apiFetch({ ...settings, apiKey: "", accessToken: "" }, "/api/v1/auth/password/policy", { cache: "no-store" });
|
||||
}
|
||||
|
||||
export function changePassword(settings: ApiSettings, currentPassword: string, newPassword: string): Promise<LoginResponse> {
|
||||
return apiFetch(settings, "/api/v1/auth/password/change", {
|
||||
method: "POST", body: JSON.stringify({ current_password: currentPassword, new_password: newPassword })
|
||||
});
|
||||
}
|
||||
|
||||
export function issuePasswordRecovery(settings: ApiSettings, accountId: string, currentPassword: string, identityVerified: true): Promise<PasswordRecoveryCode> {
|
||||
return apiFetch(settings, `/api/v1/auth/password/recovery/${encodeURIComponent(accountId)}`, {
|
||||
method: "POST", body: JSON.stringify({ current_password: currentPassword, identity_verified: identityVerified })
|
||||
});
|
||||
}
|
||||
|
||||
export function recoverPassword(settings: ApiSettings, email: string, recoveryCode: string, newPassword: string): Promise<{ ok: boolean }> {
|
||||
return apiFetch({ ...settings, apiKey: "", accessToken: "" }, "/api/v1/auth/password/recover", {
|
||||
method: "POST", body: JSON.stringify({ email, recovery_code: recoveryCode, new_password: newPassword })
|
||||
});
|
||||
}
|
||||
|
||||
const passwordErrors: Record<string, string> = {
|
||||
current_password_invalid: "i18n:govoplan-access.password.current_invalid",
|
||||
invalid_new_password: "i18n:govoplan-access.password.invalid_new",
|
||||
password_unchanged: "i18n:govoplan-access.password.unchanged",
|
||||
password_recovery_disabled: "i18n:govoplan-access.password.recovery_disabled",
|
||||
password_rate_limited: "i18n:govoplan-access.password.rate_limited",
|
||||
local_password_unavailable: "i18n:govoplan-access.password.local_only",
|
||||
recovery_invalid: "i18n:govoplan-access.password.recovery_invalid",
|
||||
recovery_issuer_required: "i18n:govoplan-access.password.issuer_required",
|
||||
recovery_membership_required: "i18n:govoplan-access.password.membership_required",
|
||||
password_changed_concurrently: "i18n:govoplan-access.password.changed_concurrently",
|
||||
password_change_required: "i18n:govoplan-access.password.required"
|
||||
};
|
||||
|
||||
export function passwordErrorMessage(error: unknown): string {
|
||||
if (isApiError(error)) {
|
||||
try {
|
||||
const code: unknown = JSON.parse(error.body)?.detail?.code;
|
||||
if (typeof code === "string" && Object.prototype.hasOwnProperty.call(passwordErrors, code)) return passwordErrors[code];
|
||||
} catch { /* Never display arbitrary response content from a secret-bearing request. */ }
|
||||
if (error.status === 401) return "i18n:govoplan-access.password.session_expired";
|
||||
if (error.status === 403) return "i18n:govoplan-access.password.not_allowed";
|
||||
if (error.status === 422) return "i18n:govoplan-access.password.invalid_fields";
|
||||
if (error.status === 429) return "i18n:govoplan-access.password.rate_limited";
|
||||
}
|
||||
return "i18n:govoplan-access.password.request_failed";
|
||||
}
|
||||
@@ -358,6 +358,7 @@ export default function AdminPage({
|
||||
{!contributedSection && active === "system-users" && (
|
||||
<SystemUsersPanel
|
||||
settings={settings}
|
||||
auth={auth}
|
||||
canCreate={hasScope(auth, "system:accounts:create")}
|
||||
canUpdate={hasScope(auth, "system:accounts:update")}
|
||||
canSuspend={hasScope(auth, "system:accounts:suspend")}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { ContentGrid, DescriptionItem, DescriptionList, FormGrid } from "@govoplan/core-webui";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Search, Pencil, Plus, Trash2 } from "lucide-react";
|
||||
import type { ApiSettings } from "@govoplan/core-webui";
|
||||
import { Search, Pencil, Plus, Trash2, KeyRound } from "lucide-react";
|
||||
import type { ApiSettings, AuthInfo } from "@govoplan/core-webui";
|
||||
import { hasScope } from "@govoplan/core-webui";
|
||||
import PasswordRecoveryIssueDialog from "../passwords/PasswordRecoveryIssueDialog";
|
||||
import { usePasswordPolicy } from "../passwords/usePasswordPolicy";
|
||||
import { Button } from "@govoplan/core-webui";
|
||||
import { ConfirmDialog } from "@govoplan/core-webui";
|
||||
import { DataGrid, type DataGridColumn } from "@govoplan/core-webui";
|
||||
@@ -36,6 +39,7 @@ const emptyDraft = {
|
||||
|
||||
export default function SystemUsersPanel({
|
||||
settings,
|
||||
auth,
|
||||
canCreate,
|
||||
canUpdate,
|
||||
canSuspend,
|
||||
@@ -50,7 +54,7 @@ export default function SystemUsersPanel({
|
||||
|
||||
|
||||
|
||||
}: {settings: ApiSettings;canCreate: boolean;canUpdate: boolean;canSuspend: boolean;canAssignRoles: boolean;canManageMemberships: boolean;onAuthRefresh: () => Promise<void>;}) {
|
||||
}: {settings: ApiSettings;auth: AuthInfo;canCreate: boolean;canUpdate: boolean;canSuspend: boolean;canAssignRoles: boolean;canManageMemberships: boolean;onAuthRefresh: () => Promise<void>;}) {
|
||||
const [accounts, setAccounts] = useState<SystemAccountItem[]>([]);
|
||||
const [roles, setRoles] = useState<RoleSummary[]>([]);
|
||||
const [tenants, setTenants] = useState<TenantAdminItem[]>([]);
|
||||
@@ -61,6 +65,11 @@ export default function SystemUsersPanel({
|
||||
const [viewing, setViewing] = useState<SystemAccountItem | null>(null);
|
||||
const [deactivating, setDeactivating] = useState<SystemAccountItem | null>(null);
|
||||
const [temporaryPassword, setTemporaryPassword] = useState<{email: string;value: string;} | null>(null);
|
||||
const [recovering, setRecovering] = useState<SystemAccountItem | null>(null);
|
||||
const { policy: passwordPolicy } = usePasswordPolicy(settings);
|
||||
const canIssueRecovery = Boolean(passwordPolicy?.recovery_enabled
|
||||
&& auth.principal?.auth_method === "session" && auth.user.local_password
|
||||
&& hasScope(auth, "system:*") && !auth.user.required_auth_action);
|
||||
const [draft, setDraft] = useState(emptyDraft);
|
||||
const [savedDraftKey, setSavedDraftKey] = useState(draftKey(emptyDraft));
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -216,13 +225,17 @@ export default function SystemUsersPanel({
|
||||
{ id: "last_login", header: "i18n:govoplan-access.last_login.43dab84f", width: 180, minWidth: 150, resizable: true, sortable: true, value: (row) => row.last_login_at || "", render: (row) => formatDateTime(row.last_login_at) },
|
||||
{ id: "actions", header: "i18n:govoplan-access.actions.c3cd636a", width: 150, sticky: "end", resizable: false, align: "right", render: (row) => <TableActionGroup actions={[
|
||||
{ id: "inspect", label: i18nMessage("i18n:govoplan-access.inspect_value.9d5d1071", { value0: row.email }), icon: <Search />, onClick: () => setViewing(row) },
|
||||
...(canIssueRecovery && row.local_password === true && row.is_active
|
||||
? [{ id: "recover-password", label: "i18n:govoplan-access.password.issue_title", icon: <KeyRound />, helpContextId: "access.password.issue-recovery", helpModuleId: "access", onClick: () => setRecovering(row) }]
|
||||
: []),
|
||||
{ id: "edit", label: i18nMessage("i18n:govoplan-access.edit_value.fad75899", { value0: row.email }), icon: <Pencil />, disabled: !(canUpdate || canSuspend || canAssignRoles || canManageMemberships), disabledReason: !(canUpdate || canSuspend || canAssignRoles || canManageMemberships) ? ACCESS_INTERFACE_I18N.updatePermissionRequired : undefined, onClick: () => openEdit(row) },
|
||||
{ id: "deactivate", label: i18nMessage("i18n:govoplan-access.deactivate_value.a276a667", { value0: row.email }), icon: <Trash2 />, variant: "danger", applicable: row.is_active, disabled: !canSuspend || row.memberships.some((membership) => membership.is_last_active_owner), disabledReason: !row.is_active ? "i18n:govoplan-access.inactive.09af574c" : !canSuspend ? ACCESS_INTERFACE_I18N.updatePermissionRequired : row.memberships.some((membership) => membership.is_last_active_owner) ? ACCESS_INTERFACE_I18N.lastOwnerCannotBeDeactivated : undefined, onClick: () => setDeactivating(row) }
|
||||
]} /> }],
|
||||
[canAssignRoles, canManageMemberships, canSuspend, canUpdate]);
|
||||
[canAssignRoles, canManageMemberships, canSuspend, canUpdate, canIssueRecovery]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{recovering && canIssueRecovery && <PasswordRecoveryIssueDialog key={recovering.account_id} settings={settings} account={recovering} onClose={() => setRecovering(null)} />}
|
||||
<AdminPageLayout
|
||||
title="i18n:govoplan-access.central_users.91ac1b51"
|
||||
description="i18n:govoplan-access.global_login_identities_tenant_memberships_and_s.8f963b7f"
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { useState, type FormEvent } from "react";
|
||||
import {
|
||||
Button, DismissibleAlert, FormField, FormLayout, PasswordField, i18nMessage, usePlatformLanguage,
|
||||
type ApiSettings, type AuthInfo, type AuthUpdate
|
||||
} from "@govoplan/core-webui";
|
||||
import { changePassword, passwordErrorMessage } from "../../api/passwords";
|
||||
import { usePasswordPolicy } from "./usePasswordPolicy";
|
||||
|
||||
export default function PasswordChangePanel({ settings, auth, onAuthChange }: {
|
||||
settings: ApiSettings;
|
||||
auth: AuthInfo;
|
||||
onAuthChange: (auth: AuthUpdate | null, accessToken?: string) => void;
|
||||
}) {
|
||||
const { translateText } = usePlatformLanguage();
|
||||
const { policy, error: policyError, reload } = usePasswordPolicy(settings);
|
||||
const [currentPassword, setCurrentPassword] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [confirmation, setConfirmation] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const required = auth.user.required_auth_action === "change_password";
|
||||
const localSession = auth.principal?.auth_method === "session" && auth.user.local_password === true;
|
||||
const complete = Boolean(policy && currentPassword && Array.from(currentPassword).length <= 1024 && newPassword === confirmation
|
||||
&& Array.from(newPassword).length >= policy.min_length
|
||||
&& Array.from(newPassword).length <= policy.max_length);
|
||||
|
||||
async function submit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
if (busy || !complete || !localSession) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
setSuccess(false);
|
||||
try {
|
||||
const response = await changePassword(settings, currentPassword, newPassword);
|
||||
onAuthChange(response, "");
|
||||
setSuccess(true);
|
||||
} catch (reason) {
|
||||
setError(passwordErrorMessage(reason));
|
||||
} finally {
|
||||
setCurrentPassword("");
|
||||
setNewPassword("");
|
||||
setConfirmation("");
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return <section>
|
||||
<h1>{required ? "i18n:govoplan-access.password.required_title" : "i18n:govoplan-access.password.change_title"}</h1>
|
||||
{required && <p>i18n:govoplan-access.password.required</p>}
|
||||
{!localSession ? <DismissibleAlert tone="info" dismissible={false}>i18n:govoplan-access.password.local_only</DismissibleAlert> : <>
|
||||
<p>i18n:govoplan-access.password.change_consequences</p>
|
||||
{policyError && <DismissibleAlert tone="warning" dismissible={false}>{policyError}<Button onClick={reload} helpContextId="access.password.change" helpModuleId="access">i18n:govoplan-access.reload.cce71553</Button></DismissibleAlert>}
|
||||
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
|
||||
{success && <DismissibleAlert tone="success">i18n:govoplan-access.password.changed</DismissibleAlert>}
|
||||
<FormLayout columns={1} collapseAt="standard" onSubmit={submit}>
|
||||
<FormField label="i18n:govoplan-access.current_password.5e551021" helpContextId="access.password.change" helpModuleId="access">
|
||||
<PasswordField aria-label={translateText("i18n:govoplan-access.current_password.5e551021")} value={currentPassword} onValueChange={setCurrentPassword} autoComplete="current-password" maxLength={2048} required disabled={busy} />
|
||||
</FormField>
|
||||
<FormField label="i18n:govoplan-access.password.new" help={policy ? i18nMessage("i18n:govoplan-access.password.length", { value0: policy.min_length, value1: policy.max_length }) : undefined} helpContextId="access.password.change" helpModuleId="access">
|
||||
<PasswordField aria-label={translateText("i18n:govoplan-access.password.new")} value={newPassword} onValueChange={setNewPassword} autoComplete="new-password" minLength={policy?.min_length} maxLength={2 * (policy?.max_length ?? 1024)} required disabled={busy} generator />
|
||||
</FormField>
|
||||
<FormField label="i18n:govoplan-access.password.confirm" helpContextId="access.password.change" helpModuleId="access">
|
||||
<PasswordField aria-label={translateText("i18n:govoplan-access.password.confirm")} value={confirmation} onValueChange={setConfirmation} autoComplete="new-password" maxLength={2 * (policy?.max_length ?? 1024)} required disabled={busy} />
|
||||
</FormField>
|
||||
{confirmation && newPassword !== confirmation && <p role="status">i18n:govoplan-access.password.mismatch</p>}
|
||||
<Button type="submit" variant="primary" disabled={busy || !complete} helpContextId="access.password.change" helpModuleId="access"
|
||||
disabledReason={busy ? "i18n:govoplan-access.password.saving" : !complete ? "i18n:govoplan-access.password.complete_fields" : undefined}>
|
||||
{busy ? "i18n:govoplan-access.password.saving" : "i18n:govoplan-access.password.change_title"}
|
||||
</Button>
|
||||
</FormLayout>
|
||||
</>}
|
||||
</section>;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Link } from "react-router";
|
||||
import type { ApiSettings } from "@govoplan/core-webui";
|
||||
import { usePasswordPolicy } from "./usePasswordPolicy";
|
||||
|
||||
export default function PasswordLoginHelp({ settings, onNavigate }: { settings: ApiSettings; onNavigate: () => void }) {
|
||||
const { policy } = usePasswordPolicy(settings);
|
||||
return policy?.recovery_enabled
|
||||
? <p><Link to="/password-recovery" onClick={onNavigate} data-help-context-id="access.password.recover" data-help-module-id="access">i18n:govoplan-access.password.forgot</Link></p>
|
||||
: null;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { useId, useState, type FormEvent } from "react";
|
||||
import {
|
||||
Button, Dialog, DismissibleAlert, FormField, FormLayout, PasswordField,
|
||||
i18nMessage, usePlatformLanguage, type ApiSettings
|
||||
} from "@govoplan/core-webui";
|
||||
import { issuePasswordRecovery, passwordErrorMessage, type PasswordRecoveryCode } from "../../api/passwords";
|
||||
|
||||
export default function PasswordRecoveryIssueDialog({ settings, account, onClose }: {
|
||||
settings: ApiSettings;
|
||||
account: { account_id: string; email: string };
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { translateText } = usePlatformLanguage();
|
||||
const formId = useId();
|
||||
const [password, setPassword] = useState("");
|
||||
const [verified, setVerified] = useState(false);
|
||||
const [result, setResult] = useState<PasswordRecoveryCode | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const ready = verified && Boolean(password) && Array.from(password).length <= 1024;
|
||||
|
||||
async function submit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
if (busy || !ready || result) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
setResult(await issuePasswordRecovery(settings, account.account_id, password, true));
|
||||
} catch (reason) { setError(passwordErrorMessage(reason)); }
|
||||
finally {
|
||||
setPassword("");
|
||||
setVerified(false);
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return <Dialog open variant="administration" size="large"
|
||||
title="i18n:govoplan-access.password.issue_title" onClose={() => { if (!busy) onClose(); }}
|
||||
footer={<>
|
||||
<Button onClick={onClose} disabled={busy} helpContextId="access.password.issue-recovery" helpModuleId="access">{result ? "i18n:govoplan-access.close.bbfa773e" : "i18n:govoplan-access.cancel.77dfd213"}</Button>
|
||||
{!result && <Button type="submit" form={formId} variant="primary" disabled={busy || !ready} helpContextId="access.password.issue-recovery" helpModuleId="access"
|
||||
disabledReason={busy ? "i18n:govoplan-access.password.saving" : !ready ? "i18n:govoplan-access.password.issue_requirements" : undefined}>
|
||||
i18n:govoplan-access.password.issue_title
|
||||
</Button>}
|
||||
</>}>
|
||||
<p>{i18nMessage("i18n:govoplan-access.password.issue_for", { value0: account.email })}</p>
|
||||
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
|
||||
{result ? <>
|
||||
<p>i18n:govoplan-access.password.code_once</p>
|
||||
<FormField label="i18n:govoplan-access.password.recovery_code" helpContextId="access.password.issue-recovery" helpModuleId="access"><input value={result.recovery_code} readOnly autoComplete="off" /></FormField>
|
||||
<p>{i18nMessage("i18n:govoplan-access.password.code_expires", { value0: new Date(result.expires_at).toLocaleString() })}</p>
|
||||
<p>i18n:govoplan-access.password.code_delivery</p>
|
||||
</> : <FormLayout columns={1} collapseAt="standard" id={formId} onSubmit={submit}>
|
||||
<p>i18n:govoplan-access.password.issue_consequences</p>
|
||||
<FormField label="i18n:govoplan-access.current_password.5e551021" helpContextId="access.password.issue-recovery" helpModuleId="access"><PasswordField aria-label={translateText("i18n:govoplan-access.current_password.5e551021")} value={password} onValueChange={setPassword} autoComplete="current-password" maxLength={2048} required disabled={busy} /></FormField>
|
||||
<label className="checkbox-field" data-help-context-id="access.password.issue-recovery" data-help-module-id="access"><input type="checkbox" checked={verified} onChange={(event) => setVerified(event.target.checked)} disabled={busy} required /> <span>i18n:govoplan-access.password.identity_verified</span></label>
|
||||
</FormLayout>}
|
||||
</Dialog>;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { Button, DismissibleAlert, FormField, FormLayout, PasswordField, i18nMessage, usePlatformLanguage, type ApiSettings } from "@govoplan/core-webui";
|
||||
import { passwordErrorMessage, recoverPassword } from "../../api/passwords";
|
||||
import { usePasswordPolicy } from "./usePasswordPolicy";
|
||||
|
||||
export default function PasswordRecoveryPage({ settings }: { settings: ApiSettings }) {
|
||||
const { translateText } = usePlatformLanguage();
|
||||
const { policy, error: policyError, reload } = usePasswordPolicy(settings);
|
||||
const [email, setEmail] = useState("");
|
||||
const [code, setCode] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirmation, setConfirmation] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [complete, setComplete] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const ready = Boolean(policy?.recovery_enabled && email.trim() && code.trim()
|
||||
&& password === confirmation && Array.from(password).length >= policy.min_length
|
||||
&& Array.from(password).length <= policy.max_length);
|
||||
|
||||
async function submit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
if (busy || !ready) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
await recoverPassword(settings, email.trim(), code.trim(), password);
|
||||
setComplete(true);
|
||||
setEmail("");
|
||||
} catch (reason) { setError(passwordErrorMessage(reason)); }
|
||||
finally {
|
||||
setCode("");
|
||||
setPassword("");
|
||||
setConfirmation("");
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return <div className="public-landing auth-action-page"><section className="public-card">
|
||||
<h1>i18n:govoplan-access.password.recover_title</h1>
|
||||
{complete ? <DismissibleAlert tone="success" dismissible={false}>i18n:govoplan-access.password.recovered</DismissibleAlert> : <>
|
||||
<p>i18n:govoplan-access.password.recovery_instructions</p>
|
||||
<p>i18n:govoplan-access.password.recovery_consequences</p>
|
||||
{policyError && <DismissibleAlert tone="warning" dismissible={false}>{policyError}<Button onClick={reload} helpContextId="access.password.recover" helpModuleId="access">i18n:govoplan-access.reload.cce71553</Button></DismissibleAlert>}
|
||||
{policy && !policy.recovery_enabled && <DismissibleAlert tone="info" dismissible={false}>i18n:govoplan-access.password.recovery_disabled</DismissibleAlert>}
|
||||
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
|
||||
{policy?.recovery_enabled && <FormLayout columns={1} collapseAt="standard" onSubmit={submit}>
|
||||
<FormField label="i18n:govoplan-access.email.84add5b2" helpContextId="access.password.recover" helpModuleId="access"><input type="email" autoComplete="username" value={email} onChange={(event) => setEmail(event.target.value)} required maxLength={320} disabled={busy} /></FormField>
|
||||
<FormField label="i18n:govoplan-access.password.recovery_code" helpContextId="access.password.recover" helpModuleId="access"><PasswordField aria-label={translateText("i18n:govoplan-access.password.recovery_code")} value={code} onValueChange={setCode} autoComplete="off" maxLength={256} required disabled={busy} /></FormField>
|
||||
<FormField label="i18n:govoplan-access.password.new" help={i18nMessage("i18n:govoplan-access.password.length", { value0: policy.min_length, value1: policy.max_length })} helpContextId="access.password.recover" helpModuleId="access"><PasswordField aria-label={translateText("i18n:govoplan-access.password.new")} value={password} onValueChange={setPassword} autoComplete="new-password" minLength={policy.min_length} maxLength={2 * policy.max_length} required disabled={busy} generator /></FormField>
|
||||
<FormField label="i18n:govoplan-access.password.confirm" helpContextId="access.password.recover" helpModuleId="access"><PasswordField aria-label={translateText("i18n:govoplan-access.password.confirm")} value={confirmation} onValueChange={setConfirmation} autoComplete="new-password" maxLength={2 * policy.max_length} required disabled={busy} /></FormField>
|
||||
{confirmation && confirmation !== password && <p role="status">i18n:govoplan-access.password.mismatch</p>}
|
||||
<Button type="submit" variant="primary" helpContextId="access.password.recover" helpModuleId="access" disabled={busy || !ready} disabledReason={busy ? "i18n:govoplan-access.password.saving" : !ready ? "i18n:govoplan-access.password.complete_fields" : undefined}>{busy ? "i18n:govoplan-access.password.saving" : "i18n:govoplan-access.password.recover_title"}</Button>
|
||||
</FormLayout>}
|
||||
</>}
|
||||
<p><Link to="/" data-help-context-id="access.password.recover" data-help-module-id="access">i18n:govoplan-access.password.return_sign_in</Link></p>
|
||||
</section></div>;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { ApiSettings } from "@govoplan/core-webui";
|
||||
import { fetchPasswordPolicy, passwordErrorMessage, type PasswordPolicy } from "../../api/passwords";
|
||||
|
||||
export function usePasswordPolicy(settings: ApiSettings) {
|
||||
const [policy, setPolicy] = useState<PasswordPolicy | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const [revision, reload] = useState(0);
|
||||
useEffect(() => {
|
||||
let current = true;
|
||||
setPolicy(null);
|
||||
setError("");
|
||||
fetchPasswordPolicy(settings).then((value) => {
|
||||
if (current) setPolicy(value);
|
||||
}).catch((reason) => {
|
||||
if (current) setError(passwordErrorMessage(reason));
|
||||
});
|
||||
return () => { current = false; };
|
||||
}, [settings.apiBaseUrl, revision]);
|
||||
return { policy, error, reload: () => reload((value) => value + 1) };
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { PlatformTranslations } from "@govoplan/core-webui";
|
||||
|
||||
export const passwordTranslations: PlatformTranslations = {
|
||||
en: {
|
||||
"i18n:govoplan-access.password.change_title": "Change password",
|
||||
"i18n:govoplan-access.password.required_title": "Change your initial password",
|
||||
"i18n:govoplan-access.password.required": "Set a new password before opening your workspace. Enter the current or initial password you used to sign in.",
|
||||
"i18n:govoplan-access.password.local_only": "Password changes require an interactive session for a local account. External accounts must use their identity provider.",
|
||||
"i18n:govoplan-access.password.change_consequences": "Changing your password ends all other browser sessions and revokes all account API keys. This browser receives a new session. Update integrations with newly issued API keys afterwards. Unused recovery codes for this account and codes it issued for other people also become invalid.",
|
||||
"i18n:govoplan-access.password.changed": "Your password was changed. Other sessions ended and account API keys were revoked.",
|
||||
"i18n:govoplan-access.password.new": "New password",
|
||||
"i18n:govoplan-access.password.confirm": "Confirm new password",
|
||||
"i18n:govoplan-access.password.length": "Use between {value0} and {value1} characters.",
|
||||
"i18n:govoplan-access.password.mismatch": "The new passwords do not match.",
|
||||
"i18n:govoplan-access.password.complete_fields": "Complete the required fields and enter matching new passwords of the required length.",
|
||||
"i18n:govoplan-access.password.saving": "Updating password…",
|
||||
"i18n:govoplan-access.password.current_invalid": "Your current password was not accepted. Enter it again to authorize this action.",
|
||||
"i18n:govoplan-access.password.invalid_new": "Use a new password between 10 and 1024 characters.",
|
||||
"i18n:govoplan-access.password.unchanged": "Choose a password different from your current password.",
|
||||
"i18n:govoplan-access.password.recovery_disabled": "Administrator-assisted password recovery is not enabled. Contact your administrator for help.",
|
||||
"i18n:govoplan-access.password.rate_limited": "Too many attempts. Wait before trying again.",
|
||||
"i18n:govoplan-access.password.recovery_invalid": "This recovery code is invalid, expired, already used, or no longer authorized. Ask your System owner for a new code.",
|
||||
"i18n:govoplan-access.password.issuer_required": "Only a current System owner can issue a recovery code.",
|
||||
"i18n:govoplan-access.password.membership_required": "This account needs an active tenant membership before password recovery is available.",
|
||||
"i18n:govoplan-access.password.changed_concurrently": "The account password changed during this operation. Sign in again before continuing.",
|
||||
"i18n:govoplan-access.password.session_expired": "Your session is no longer valid. Sign in again to continue.",
|
||||
"i18n:govoplan-access.password.not_allowed": "This password operation is not permitted for the current account or session.",
|
||||
"i18n:govoplan-access.password.invalid_fields": "Check the required fields and password length, then enter your credentials again.",
|
||||
"i18n:govoplan-access.password.request_failed": "The password service could not complete the request. Check your connection and try again.",
|
||||
"i18n:govoplan-access.password.forgot": "Forgot your password?",
|
||||
"i18n:govoplan-access.password.recover_title": "Recover local password",
|
||||
"i18n:govoplan-access.password.recovery_instructions": "Contact a System owner to verify your identity independently and receive a recovery code. Enter your account email and that code below. GovOPlaN does not send a recovery email.",
|
||||
"i18n:govoplan-access.password.recovery_consequences": "Successful recovery ends all browser sessions and revokes all account API keys. Unused recovery codes for this account and codes it issued for other people also become invalid. You must then sign in with the new password.",
|
||||
"i18n:govoplan-access.password.recovered": "Your password was replaced and existing sessions and API keys were revoked. Sign in with your new password.",
|
||||
"i18n:govoplan-access.password.recovery_code": "Recovery code",
|
||||
"i18n:govoplan-access.password.return_sign_in": "Return to sign in",
|
||||
"i18n:govoplan-access.password.issue_title": "Issue recovery code",
|
||||
"i18n:govoplan-access.password.issue_for": "Recover access for {value0}.",
|
||||
"i18n:govoplan-access.password.issue_requirements": "Enter your current password and confirm that you independently verified this person's identity.",
|
||||
"i18n:govoplan-access.password.identity_verified": "I independently verified this person's identity outside GovOPlaN.",
|
||||
"i18n:govoplan-access.password.issue_consequences": "Issuing a code replaces earlier unused recovery codes. When used, it replaces the account password, ends every browser session, and revokes all account API keys. Codes the target account issued for other people also become invalid. Verify the account holder before proceeding.",
|
||||
"i18n:govoplan-access.password.code_once": "This code is shown once. It can be used once before its expiry.",
|
||||
"i18n:govoplan-access.password.code_expires": "Expires: {value0}",
|
||||
"i18n:govoplan-access.password.code_delivery": "Give the code only to the verified account holder through your agreed confidential channel. Direct them to Password recovery from the sign-in screen. Closing this dialog clears the code from this screen."
|
||||
},
|
||||
de: {
|
||||
"i18n:govoplan-access.password.change_title": "Passwort ändern",
|
||||
"i18n:govoplan-access.password.required_title": "Initiales Passwort ändern",
|
||||
"i18n:govoplan-access.password.required": "Legen Sie ein neues Passwort fest, bevor Sie den Arbeitsbereich öffnen. Geben Sie das aktuelle oder initiale Passwort ein, mit dem Sie sich angemeldet haben.",
|
||||
"i18n:govoplan-access.password.local_only": "Passwortänderungen benötigen eine interaktive Sitzung für ein lokales Konto. Externe Konten verwenden ihren Identitätsanbieter.",
|
||||
"i18n:govoplan-access.password.change_consequences": "Die Passwortänderung beendet alle anderen Browsersitzungen und widerruft sämtliche API-Schlüssel des Kontos. Dieser Browser erhält eine neue Sitzung. Aktualisieren Sie anschließend Integrationen mit neu ausgestellten API-Schlüsseln. Ungenutzte Wiederherstellungscodes für dieses Konto sowie von ihm für andere Personen ausgestellte Codes werden ebenfalls ungültig.",
|
||||
"i18n:govoplan-access.password.changed": "Ihr Passwort wurde geändert. Andere Sitzungen wurden beendet und die API-Schlüssel des Kontos widerrufen.",
|
||||
"i18n:govoplan-access.password.new": "Neues Passwort",
|
||||
"i18n:govoplan-access.password.confirm": "Neues Passwort bestätigen",
|
||||
"i18n:govoplan-access.password.length": "Verwenden Sie zwischen {value0} und {value1} Zeichen.",
|
||||
"i18n:govoplan-access.password.mismatch": "Die neuen Passwörter stimmen nicht überein.",
|
||||
"i18n:govoplan-access.password.complete_fields": "Füllen Sie die Pflichtfelder aus und geben Sie übereinstimmende neue Passwörter der erforderlichen Länge ein.",
|
||||
"i18n:govoplan-access.password.saving": "Passwort wird aktualisiert…",
|
||||
"i18n:govoplan-access.password.current_invalid": "Ihr aktuelles Passwort wurde nicht akzeptiert. Geben Sie es erneut ein, um diese Aktion zu autorisieren.",
|
||||
"i18n:govoplan-access.password.invalid_new": "Verwenden Sie ein neues Passwort mit 10 bis 1024 Zeichen.",
|
||||
"i18n:govoplan-access.password.unchanged": "Wählen Sie ein anderes Passwort als Ihr aktuelles Passwort.",
|
||||
"i18n:govoplan-access.password.recovery_disabled": "Die administrativ unterstützte Passwortwiederherstellung ist nicht aktiviert. Wenden Sie sich an Ihre Administration.",
|
||||
"i18n:govoplan-access.password.rate_limited": "Zu viele Versuche. Warten Sie, bevor Sie es erneut versuchen.",
|
||||
"i18n:govoplan-access.password.recovery_invalid": "Dieser Wiederherstellungscode ist ungültig, abgelaufen, bereits verwendet oder nicht mehr autorisiert. Bitten Sie den Systemverantwortlichen um einen neuen Code.",
|
||||
"i18n:govoplan-access.password.issuer_required": "Nur ein aktueller Systemverantwortlicher darf einen Wiederherstellungscode ausstellen.",
|
||||
"i18n:govoplan-access.password.membership_required": "Das Konto benötigt vor einer Passwortwiederherstellung eine aktive Mandantenmitgliedschaft.",
|
||||
"i18n:govoplan-access.password.changed_concurrently": "Das Kontopasswort wurde während dieses Vorgangs geändert. Melden Sie sich erneut an, bevor Sie fortfahren.",
|
||||
"i18n:govoplan-access.password.session_expired": "Ihre Sitzung ist nicht mehr gültig. Melden Sie sich erneut an, um fortzufahren.",
|
||||
"i18n:govoplan-access.password.not_allowed": "Dieser Passwortvorgang ist für das aktuelle Konto oder die aktuelle Sitzung nicht erlaubt.",
|
||||
"i18n:govoplan-access.password.invalid_fields": "Prüfen Sie Pflichtfelder und Passwortlänge und geben Sie Ihre Zugangsdaten erneut ein.",
|
||||
"i18n:govoplan-access.password.request_failed": "Der Passwortdienst konnte die Anfrage nicht abschließen. Prüfen Sie Ihre Verbindung und versuchen Sie es erneut.",
|
||||
"i18n:govoplan-access.password.forgot": "Passwort vergessen?",
|
||||
"i18n:govoplan-access.password.recover_title": "Lokales Passwort wiederherstellen",
|
||||
"i18n:govoplan-access.password.recovery_instructions": "Wenden Sie sich an einen Systemverantwortlichen, um Ihre Identität unabhängig prüfen zu lassen und einen Wiederherstellungscode zu erhalten. Geben Sie unten Ihre Konto-E-Mail-Adresse und diesen Code ein. GovOPlaN versendet keine Wiederherstellungs-E-Mail.",
|
||||
"i18n:govoplan-access.password.recovery_consequences": "Eine erfolgreiche Wiederherstellung beendet alle Browsersitzungen und widerruft sämtliche API-Schlüssel des Kontos. Ungenutzte Wiederherstellungscodes für dieses Konto sowie von ihm für andere Personen ausgestellte Codes werden ebenfalls ungültig. Melden Sie sich anschließend mit dem neuen Passwort an.",
|
||||
"i18n:govoplan-access.password.recovered": "Ihr Passwort wurde ersetzt. Bestehende Sitzungen und API-Schlüssel wurden widerrufen. Melden Sie sich mit Ihrem neuen Passwort an.",
|
||||
"i18n:govoplan-access.password.recovery_code": "Wiederherstellungscode",
|
||||
"i18n:govoplan-access.password.return_sign_in": "Zurück zur Anmeldung",
|
||||
"i18n:govoplan-access.password.issue_title": "Wiederherstellungscode ausstellen",
|
||||
"i18n:govoplan-access.password.issue_for": "Zugriff für {value0} wiederherstellen.",
|
||||
"i18n:govoplan-access.password.issue_requirements": "Geben Sie Ihr aktuelles Passwort ein und bestätigen Sie die unabhängige Prüfung der Identität dieser Person.",
|
||||
"i18n:govoplan-access.password.identity_verified": "Ich habe die Identität dieser Person unabhängig außerhalb von GovOPlaN geprüft.",
|
||||
"i18n:govoplan-access.password.issue_consequences": "Ein neuer Code ersetzt frühere unbenutzte Wiederherstellungscodes. Seine Verwendung ersetzt das Kontopasswort, beendet jede Browsersitzung und widerruft sämtliche API-Schlüssel des Kontos. Vom Zielkonto für andere Personen ausgestellte Codes werden ebenfalls ungültig. Prüfen Sie vorab die Identität des Kontoinhabers.",
|
||||
"i18n:govoplan-access.password.code_once": "Dieser Code wird einmal angezeigt. Er kann vor seinem Ablauf einmal verwendet werden.",
|
||||
"i18n:govoplan-access.password.code_expires": "Gültig bis: {value0}",
|
||||
"i18n:govoplan-access.password.code_delivery": "Übermitteln Sie den Code ausschließlich dem verifizierten Kontoinhaber über den vereinbarten vertraulichen Kanal. Verweisen Sie auf die Passwortwiederherstellung im Anmeldebildschirm. Beim Schließen dieses Dialogs wird der Code aus dieser Ansicht entfernt."
|
||||
}
|
||||
};
|
||||
+22
-4
@@ -1,15 +1,19 @@
|
||||
import { createElement, lazy } from "react";
|
||||
import type { ActingContextRuntimeUiCapability, PlatformRouteContext, PlatformWebModule, SettingsSectionsUiCapability } from "@govoplan/core-webui";
|
||||
import type { ActingContextRuntimeUiCapability, AuthActionUiCapability, PlatformRouteContext, PlatformWebModule, SettingsSectionsUiCapability } from "@govoplan/core-webui";
|
||||
import { adminReadScopes } from "@govoplan/core-webui";
|
||||
import ActingContextSelector from "./features/acting-context/ActingContextSelector";
|
||||
import { generatedTranslations } from "./i18n/generatedTranslations";
|
||||
import { passwordTranslations } from "./i18n/passwordTranslations";
|
||||
|
||||
const AdminPage = lazy(() => import("./features/admin/AdminPage"));
|
||||
const SessionSettingsPanel = lazy(() => import("./features/sessions/SessionSettingsPanel"));
|
||||
const PasswordChangePanel = lazy(() => import("./features/passwords/PasswordChangePanel"));
|
||||
const PasswordRecoveryPage = lazy(() => import("./features/passwords/PasswordRecoveryPage"));
|
||||
const PasswordLoginHelp = lazy(() => import("./features/passwords/PasswordLoginHelp"));
|
||||
|
||||
const translations = {
|
||||
en: generatedTranslations.en,
|
||||
de: generatedTranslations.de
|
||||
en: { ...generatedTranslations.en, ...passwordTranslations.en },
|
||||
de: { ...generatedTranslations.de, ...passwordTranslations.de }
|
||||
};
|
||||
|
||||
const accessAdminSurfaces = [
|
||||
@@ -26,11 +30,21 @@ const accessAdminSurfaces = [
|
||||
{ id: "access.admin.group-credentials", moduleId: "access", kind: "section" as const, label: "i18n:govoplan-access.group_credentials.4af2c025", order: 30 },
|
||||
{ id: "access.admin.user-credentials", moduleId: "access", kind: "section" as const, label: "i18n:govoplan-access.user_credentials.4af2c026", order: 30 },
|
||||
{ id: "access.settings.credentials", moduleId: "access", kind: "section" as const, label: "i18n:govoplan-access.reusable_credentials.4af2c022", order: 30 },
|
||||
{ id: "access.settings.sessions", moduleId: "access", kind: "section" as const, label: "i18n:govoplan-access.sessions_and_devices.5e551001", order: 20 }
|
||||
{ id: "access.settings.sessions", moduleId: "access", kind: "section" as const, label: "i18n:govoplan-access.sessions_and_devices.5e551001", order: 20 },
|
||||
{ id: "access.settings.password", moduleId: "access", kind: "section" as const, label: "i18n:govoplan-access.password.change_title", order: 21 }
|
||||
];
|
||||
|
||||
const accessSettingsSections: SettingsSectionsUiCapability = {
|
||||
sections: [
|
||||
{
|
||||
id: "password",
|
||||
surfaceId: "access.settings.password",
|
||||
label: "i18n:govoplan-access.password.change_title",
|
||||
group: "account",
|
||||
order: 21,
|
||||
render: ({ settings, auth, onAuthChange }) => onAuthChange
|
||||
? createElement(PasswordChangePanel, { settings, auth, onAuthChange }) : null
|
||||
},
|
||||
{
|
||||
id: "sessions",
|
||||
surfaceId: "access.settings.sessions",
|
||||
@@ -61,7 +75,11 @@ export const accessModule: PlatformWebModule = {
|
||||
|
||||
routes: [
|
||||
{ path: "/admin", anyOf: adminReadScopes, order: 900, render: renderAdminRoute }],
|
||||
publicRoutes: [
|
||||
{ path: "/password-recovery", render: ({ settings }) => createElement(PasswordRecoveryPage, { settings }) }
|
||||
],
|
||||
uiCapabilities: {
|
||||
"auth.actions": { actions: ["change_password"], RequiredAction: PasswordChangePanel, LoginHelp: PasswordLoginHelp } satisfies AuthActionUiCapability,
|
||||
"access.actingContext": { Selector: ActingContextSelector } satisfies ActingContextRuntimeUiCapability,
|
||||
"settings.sections": accessSettingsSections
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user