feat(security): isolate bounded work and support required auth actions

This commit is contained in:
2026-09-08 07:47:17 +02:00
parent a6d056a3df
commit dc1f244f17
23 changed files with 1628 additions and 21 deletions
+3
View File
@@ -28,6 +28,7 @@ import CampaignBulkReviewScenario from "./CampaignBulkReviewScenario";
import CampaignReviewDetailsScenario from "./CampaignReviewDetailsScenario";
import CampaignDeliveryPolicyScenario from "./CampaignDeliveryPolicyScenario";
import MailCredentialPolicyScenario from "./MailCredentialPolicyScenario";
import PasswordLifecycleScenario from "./PasswordLifecycleScenario";
import type { NavigationPreferenceScope } from "../src/components/navigationPreferenceLayout";
import FormInstancePage from "../../../govoplan-forms-runtime/webui/src/features/forms/FormInstancePage";
import FormsRuntimePage from "../../../govoplan-forms-runtime/webui/src/features/forms/FormsRuntimePage";
@@ -83,6 +84,8 @@ export default function ConformanceApp() {
const [editorDirty, setEditorDirty] = useState(true);
const [metricDrilldown, setMetricDrilldown] = useState("");
if (new URLSearchParams(location.search).has("password-lifecycle")) return <PasswordLifecycleScenario />;
if (new URLSearchParams(location.search).has("credential-references")) return <CredentialReferencesScenario />;
if (new URLSearchParams(location.search).has("files-toolbar")) return <FilesToolbarScenario />;
if (new URLSearchParams(location.search).has("form-control-layout")) return <FormControlLayoutScenario />;
@@ -0,0 +1,67 @@
import { useState } from "react";
import "../src/styles/auth-gate.css";
import { useLocation } from "react-router";
import PasswordChangePanel from "../../../govoplan-access/webui/src/features/passwords/PasswordChangePanel";
import PasswordRecoveryPage from "../../../govoplan-access/webui/src/features/passwords/PasswordRecoveryPage";
import PasswordRecoveryIssueDialog from "../../../govoplan-access/webui/src/features/passwords/PasswordRecoveryIssueDialog";
import PasswordLoginHelp from "../../../govoplan-access/webui/src/features/passwords/PasswordLoginHelp";
import SystemUsersPanel from "../../../govoplan-access/webui/src/features/admin/SystemUsersPanel";
import { passwordTranslations } from "../../../govoplan-access/webui/src/i18n/passwordTranslations";
import { generatedTranslations } from "../../../govoplan-access/webui/src/i18n/generatedTranslations";
import AuthActionGate from "../src/features/auth/AuthActionGate";
import LoginModal from "../src/features/auth/LoginModal";
import Button from "../src/components/Button";
import { PlatformModulesProvider } from "../src/platform/ModuleContext";
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
import type { ApiSettings, AuthActionUiCapability, AuthInfo, AuthUpdate, PlatformWebModule } from "../src/types";
const settings: ApiSettings = { apiBaseUrl: "", apiKey: "", accessToken: "" };
const capability: AuthActionUiCapability = {
actions: ["change_password"], RequiredAction: PasswordChangePanel, LoginHelp: PasswordLoginHelp
};
const modules: PlatformWebModule[] = [{
id: "access", label: "Access", version: "fixture", uiCapabilities: { "auth.actions": capability },
helpContexts: [
{ id: "access.password.change", topic_id: "access.help.password-change", title: "Change your local password", documentation_types: ["user", "admin"] },
{ id: "access.password.recover", topic_id: "access.help.password-recovery", title: "Recover a local password", documentation_types: ["user", "admin"] },
{ id: "access.password.issue-recovery", topic_id: "access.help.password-issue-recovery", title: "Issue and hand over a recovery code", documentation_types: ["user", "admin"] }
]
}];
export default function PasswordLifecycleScenario() {
const parameters = new URLSearchParams(useLocation().search);
const mode = parameters.get("mode") ?? "required";
const language = parameters.get("language") ?? "en";
const tenant = { id: "tenant-1", slug: "fixture", name: "Fixture" };
const owner = parameters.get("owner") !== "false";
const [auth, setAuth] = useState<AuthInfo>({
user: { id: "membership-1", account_id: "account-1", email: "person@example.test", local_password: parameters.get("external") !== "true", required_auth_action: mode === "required" || mode === "missing" ? "change_password" : null },
tenant, active_tenant: tenant, scopes: mode === "required" || mode === "missing" ? [] : owner ? ["system:*"] : ["system:accounts:update"], roles: [], groups: [],
principal: { account_id: "account-1", membership_id: "membership-1", auth_method: parameters.get("api-key") ? "api_key" : "session", scopes: [], group_ids: [], session_id: "old-session" },
profile_loaded: true, roles_loaded: true, groups_loaded: true
});
const [updated, setUpdated] = useState("");
const [open, setOpen] = useState(true);
function update(next: AuthUpdate | null, token?: string) {
setUpdated(JSON.stringify({ action: next?.user?.required_auth_action ?? null, token, session: next?.principal?.session_id }));
if (next?.user) setAuth((current) => ({ ...current, ...next, user: { ...current.user, ...next.user }, tenant: current.tenant, active_tenant: current.active_tenant, tenants: current.tenants }));
}
return <PlatformLanguageProvider preferredLanguageCode={language} moduleTranslations={[generatedTranslations, passwordTranslations]}>
<PlatformModulesProvider modules={mode === "missing" ? [] : modules}>
<div data-testid="password-scenario">
{mode === "required" || mode === "missing"
? auth.user.required_auth_action
? <AuthActionGate settings={settings} auth={auth} capability={mode === "missing" ? null : capability} onAuthChange={update} onSignOut={() => setOpen(false)} />
: <h1>Workspace available</h1>
: mode === "recover" ? <PasswordRecoveryPage settings={settings} />
: mode === "issue" ? open
? <PasswordRecoveryIssueDialog settings={settings} account={{ account_id: "target-1", email: "target@example.test" }} onClose={() => setOpen(false)} />
: <Button onClick={() => setOpen(true)}>Reopen recovery</Button>
: mode === "admin" ? <SystemUsersPanel settings={settings} auth={auth} canCreate={false} canUpdate canSuspend={false} canAssignRoles={false} canManageMemberships={false} onAuthRefresh={async () => {}} />
: mode === "login" ? open && <LoginModal settings={settings} onClose={() => setOpen(false)} onLogin={() => {}} />
: <PasswordChangePanel settings={settings} auth={auth} onAuthChange={update} />}
<output data-testid="auth-update">{updated}</output>
</div>
</PlatformModulesProvider>
</PlatformLanguageProvider>;
}
+5 -1
View File
@@ -3,6 +3,10 @@
// generated module catalogue into this isolated test bundle.
export { ApiError, apiDownload, apiFetch, apiGetList, apiPath, apiPost, apiPostJson, apiQuery, apiUrl, authHeaders, csrfToken } from "../src/api/client";
export { fetchAuthGroups } from "../src/api/auth";
export { fetchAdminOverview, fetchPermissionCatalog, fetchTenants } from "../src/api/adminCommon";
export type { AdminOverview, PermissionItem, TenantAdminItem } from "../src/api/adminCommon";
export type * from "../src/api/privacyRetention";
export type { ResourceAccessExplanationOptions } from "../src/api/resourceAccess";
export { default as FormSection } from "../src/components/FormSection";
export { mailProfilePatternKeys, mailProfilePolicyLimitKeys } from "../src/api/mailContracts";
export type * from "../src/api/mailContracts";
@@ -62,7 +66,7 @@ export { MailServerFolderLookupResultView } from "../src/components/mail/MailSer
export type { MailServerFolderLookupResult } from "../src/components/mail/MailServerSettingsPanel";
export { default as AdminSelectionList } from "../src/components/admin/AdminSelectionList";
export { default as AdminPageLayout } from "../src/components/admin/AdminPageLayout";
export { adminErrorMessage } from "../src/components/admin/adminUtils";
export { adminErrorMessage, formatAdminDateTime, joinLabels } from "../src/components/admin/adminUtils";
export { default as ConnectionTree } from "../src/components/ConnectionTree";
export type { ConnectionTreeColumn } from "../src/components/ConnectionTree";
export { default as StageRail } from "../src/components/StageRail";
@@ -0,0 +1,273 @@
import { expect, test, type Locator, type Page } from "@playwright/test";
import { createRequire } from "node:module";
const axePath = createRequire(import.meta.url).resolve("axe-core/axe.min.js");
const currentPassword = "current-password-fixture";
const nextPassword = "new-password-fixture";
const recoveryCode = "pr_fixture-code-never-a-real-secret";
const policy = { recovery_enabled: true, min_length: 10, max_length: 1024, recovery_minutes: 15 };
async function mockPasswordApi(page: Page, options: { enabled?: boolean; failChange?: boolean; failRecovery?: boolean } = {}) {
const posts: Array<{ path: string; body: Record<string, unknown> }> = [];
await page.route("**/api/v1/**", async (route) => {
const request = route.request();
const path = new URL(request.url()).pathname;
if (request.method() === "POST") posts.push({ path, body: request.postDataJSON() });
const json = (data: unknown, status = 200) => route.fulfill({ status, contentType: "application/json", body: JSON.stringify(data) });
if (path === "/api/v1/auth/password/policy") return json({ ...policy, recovery_enabled: options.enabled ?? true });
if (path === "/api/v1/auth/password/change") {
if (options.failChange) return json({ detail: { code: "current_password_invalid", input: currentPassword } }, 403);
return json({ user: { required_auth_action: null, local_password: true }, principal: { auth_method: "session", session_id: "rotated-session" } });
}
if (path.startsWith("/api/v1/auth/password/recovery/")) return json({ recovery_code: recoveryCode, expires_at: "2026-10-01T10:15:00Z" });
if (path === "/api/v1/auth/password/recover") return options.failRecovery
? json({ detail: { code: "recovery_invalid", input: recoveryCode } }, 400) : json({ ok: true });
if (path === "/api/v1/admin/system/accounts/delta") return json({ accounts: [
{ account_id: "target-local", email: "local@example.test", local_password: true, is_active: true, roles: [], memberships: [] },
{ account_id: "target-external", email: "external@example.test", local_password: false, is_active: true, roles: [], memberships: [] }
], roles: [], deleted: [], watermark: "fixture", full: true, has_more: false });
if (path.endsWith("/tenants")) return json({ tenants: [] });
// All browser verification uses synthetic responses; nothing reaches a live provider.
return json({ detail: "Unmocked fixture request" }, 404);
});
return posts;
}
async function expectNoSecretsInStorage(page: Page) {
const values = await page.evaluate(() => JSON.stringify({ local: { ...localStorage }, session: { ...sessionStorage } }));
for (const secret of [currentPassword, nextPassword, recoveryCode]) {
expect(values).not.toContain(secret);
expect(page.url()).not.toContain(secret);
}
}
async function expectHelpContext(control: Locator, context: string) {
await expect(control).toBeVisible();
expect(await control.evaluate((element) => {
const scoped = element.closest<HTMLElement>("[data-help-context-id]");
return { context: scoped?.dataset.helpContextId, module: scoped?.dataset.helpModuleId };
})).toEqual({ context, module: "access" });
}
test("required-action F1 resolves public static help without privileged API calls or secret queries", async ({ page }) => {
await mockPasswordApi(page);
const apiRequests: string[] = [];
page.on("request", (request) => {
if (new URL(request.url()).pathname.startsWith("/api/v1/")) apiRequests.push(new URL(request.url()).pathname);
});
await page.goto("/?password-lifecycle&mode=required&language=en");
const current = page.getByLabel("Current password", { exact: true });
await current.fill(currentPassword);
await expectHelpContext(current, "access.password.change");
await expectHelpContext(page.getByLabel("New password", { exact: true }), "access.password.change");
await expectHelpContext(page.getByLabel("Confirm new password", { exact: true }), "access.password.change");
await expectHelpContext(page.getByRole("button", { name: "Change password", exact: true }), "access.password.change");
await current.press("F1");
const dialog = page.getByRole("dialog");
await expect(dialog.locator('[data-help-context="access.password.change"]')).toBeVisible();
await expect(dialog).toContainText("access.help.password-change");
await expect(dialog).not.toContainText(currentPassword);
await page.evaluate(() => {
window.open = (url) => {
document.body.dataset.openedHelpUrl = String(url);
return null;
};
});
await dialog.getByRole("button", { name: "Open user documentation", exact: true }).click();
const opened = new URL(await page.locator("body").getAttribute("data-opened-help-url") ?? "");
expect(opened.origin).toBe("https://govoplan.add-ideas.de");
expect(opened.searchParams.get("topic")).toBe("access.help.password-change");
expect(opened.searchParams.get("module")).toBe("access");
expect(opened.href).not.toContain(currentPassword);
expect(apiRequests.every((path) => path === "/api/v1/auth/password/policy")).toBe(true);
await expect(page.getByText("Workspace available")).toHaveCount(0);
await expectNoSecretsInStorage(page);
});
test("recovery credentials, verification, one-time display and navigation have exact owning help", async ({ page }) => {
await mockPasswordApi(page);
await page.goto("/?password-lifecycle&mode=recover&language=en");
for (const label of ["Email", "Recovery code", "New password", "Confirm new password"]) {
await expectHelpContext(page.getByLabel(label, { exact: true }), "access.password.recover");
}
await expectHelpContext(page.getByRole("button", { name: "Recover local password", exact: true }), "access.password.recover");
await expectHelpContext(page.getByRole("link", { name: "Return to sign in", exact: true }), "access.password.recover");
await page.goto("/?password-lifecycle&mode=login&language=en");
await expectHelpContext(page.getByRole("link", { name: "Forgot your password?", exact: true }), "access.password.recover");
await page.goto("/?password-lifecycle&mode=admin&language=en");
await expectHelpContext(page.getByRole("button", { name: "Issue recovery code", exact: true }), "access.password.issue-recovery");
await page.goto("/?password-lifecycle&mode=issue&language=en");
const current = page.getByLabel("Current password", { exact: true });
await expectHelpContext(current, "access.password.issue-recovery");
const verified = page.getByRole("checkbox");
await expectHelpContext(verified, "access.password.issue-recovery");
const issue = page.getByRole("button", { name: "Issue recovery code", exact: true });
await expectHelpContext(issue, "access.password.issue-recovery");
await current.fill(currentPassword);
await verified.check();
await issue.click();
await expectHelpContext(page.getByLabel("Recovery code", { exact: true }), "access.password.issue-recovery");
await expectHelpContext(page.getByRole("dialog").getByRole("button", { name: "Close", exact: true }).last(), "access.password.issue-recovery");
});
test("required password change gates workspace and accepts the rotated cookie session", async ({ page }) => {
const posts = await mockPasswordApi(page);
await page.goto("/?password-lifecycle&mode=required&language=en");
await expect(page.getByRole("heading", { name: "Change your initial password" })).toBeVisible();
await expect(page.getByText("Workspace available")).toHaveCount(0);
await page.getByLabel("Current password", { exact: true }).fill(currentPassword);
await page.getByLabel("New password", { exact: true }).fill(nextPassword);
await page.getByLabel("Confirm new password", { exact: true }).fill(nextPassword);
await page.getByRole("button", { name: "Change password", exact: true }).click();
await expect(page.getByRole("heading", { name: "Workspace available" })).toBeVisible();
expect(posts).toEqual([{ path: "/api/v1/auth/password/change", body: { current_password: currentPassword, new_password: nextPassword } }]);
await expect(page.getByTestId("auth-update")).toHaveText(JSON.stringify({ action: null, token: "", session: "rotated-session" }));
await expectNoSecretsInStorage(page);
});
test("missing optional auth UI keeps the required account out of the workspace", async ({ page }) => {
await mockPasswordApi(page);
await page.goto("/?password-lifecycle&mode=missing&language=en");
await expect(page.getByText(/A required account action must be completed/)).toBeVisible();
await expect(page.getByText("Workspace available")).toHaveCount(0);
});
test("password limits count Unicode code points, including astral characters", async ({ page }) => {
const posts = await mockPasswordApi(page);
const unicodeCurrent = "🔑".repeat(600);
const unicodeNext = "🔐".repeat(1024);
await page.goto("/?password-lifecycle&mode=settings&language=en");
await page.getByLabel("Current password", { exact: true }).fill(unicodeCurrent);
const password = page.getByLabel("New password", { exact: true });
const confirmation = page.getByLabel("Confirm new password", { exact: true });
await password.fill("a".repeat(1025));
await confirmation.fill("a".repeat(1025));
await expect(page.getByRole("button", { name: "Change password", exact: true })).toBeDisabled();
await password.fill(unicodeNext);
await confirmation.fill(unicodeNext);
await expect(password).toHaveValue(unicodeNext);
await page.getByRole("button", { name: "Change password", exact: true }).click();
await expect(page.getByTestId("auth-update")).toContainText("rotated-session");
expect(posts[0].body).toEqual({ current_password: unicodeCurrent, new_password: unicodeNext });
});
test("self-service is available with recovery disabled and clears rejected credentials", async ({ page }) => {
const posts = await mockPasswordApi(page, { enabled: false, failChange: true });
await page.goto("/?password-lifecycle&mode=settings&language=en");
await page.getByLabel("Current password", { exact: true }).fill(currentPassword);
await page.getByLabel("New password", { exact: true }).fill(nextPassword);
await page.getByLabel("Confirm new password", { exact: true }).fill(nextPassword);
await page.getByRole("button", { name: "Change password", exact: true }).click();
await expect(page.getByText(/Your current password was not accepted/)).toBeVisible();
expect(posts).toHaveLength(1);
for (const label of ["Current password", "New password", "Confirm new password"]) await expect(page.getByLabel(label, { exact: true })).toHaveValue("");
await expect(page.locator("body")).not.toContainText(currentPassword);
await expectNoSecretsInStorage(page);
});
test("external accounts and API-key sessions cannot use the password change form", async ({ page }) => {
const posts = await mockPasswordApi(page);
for (const query of ["external=true", "api-key=true"]) {
await page.goto(`/?password-lifecycle&mode=settings&language=en&${query}`);
await expect(page.getByText(/Password changes require an interactive session/)).toBeVisible();
await expect(page.getByLabel("Current password", { exact: true })).toHaveCount(0);
}
expect(posts).toHaveLength(0);
});
test("recovery replaces the password without signing in and clears all secrets", async ({ page }) => {
const posts = await mockPasswordApi(page);
await page.goto("/?password-lifecycle&mode=recover&language=en");
await page.getByLabel("Email", { exact: true }).fill("person@example.test");
await page.getByLabel("Recovery code", { exact: true }).fill(recoveryCode);
await page.getByLabel("New password", { exact: true }).fill(nextPassword);
await page.getByLabel("Confirm new password", { exact: true }).fill(nextPassword);
await page.getByRole("button", { name: "Recover local password", exact: true }).click();
await expect(page.getByText(/Your password was replaced and existing sessions/)).toBeVisible();
expect(posts).toEqual([{ path: "/api/v1/auth/password/recover", body: { email: "person@example.test", recovery_code: recoveryCode, new_password: nextPassword } }]);
await expect(page.getByTestId("auth-update")).toHaveText("");
await expect(page.getByRole("link", { name: "Return to sign in" })).toHaveAttribute("href", "/");
await expect(page.getByLabel("Recovery code", { exact: true })).toHaveCount(0);
await expectNoSecretsInStorage(page);
});
test("expired recovery codes show translated errors without reflecting response secrets", async ({ page }) => {
await mockPasswordApi(page, { failRecovery: true });
await page.goto("/?password-lifecycle&mode=recover&language=de");
await page.getByLabel("E-Mail", { exact: true }).fill("person@example.test");
await page.getByLabel("Wiederherstellungscode", { exact: true }).fill(recoveryCode);
await page.getByLabel("Neues Passwort", { exact: true }).fill(nextPassword);
await page.getByLabel("Neues Passwort bestätigen", { exact: true }).fill(nextPassword);
await page.getByRole("button", { name: "Lokales Passwort wiederherstellen", exact: true }).click();
await expect(page.getByText(/Dieser Wiederherstellungscode ist ungültig/)).toBeVisible();
await expect(page.getByLabel("Wiederherstellungscode", { exact: true })).toHaveValue("");
await expect(page.locator("body")).not.toContainText(recoveryCode);
});
test("issuing a code requires independent identity verification and discards the one-time display on close", async ({ page }) => {
const posts = await mockPasswordApi(page);
await page.goto("/?password-lifecycle&mode=issue&language=en");
const issue = page.getByRole("button", { name: "Issue recovery code", exact: true });
await page.getByLabel("Current password", { exact: true }).fill(currentPassword);
await expect(issue).toBeDisabled();
await page.getByRole("checkbox", { name: /I independently verified/ }).check();
await issue.click();
await expect(page.getByLabel("Recovery code", { exact: true })).toHaveValue(recoveryCode);
expect(posts).toEqual([{ path: "/api/v1/auth/password/recovery/target-1", body: { current_password: currentPassword, identity_verified: true } }]);
await expect(page.getByText(/Expires:/)).toBeVisible();
await expectNoSecretsInStorage(page);
await page.getByRole("dialog").getByRole("button", { name: "Close", exact: true }).last().click();
await page.getByRole("button", { name: "Reopen recovery" }).click();
await expect(page.getByLabel("Recovery code", { exact: true })).toHaveCount(0);
await expect(page.getByLabel("Current password", { exact: true })).toHaveValue("");
await expect(page.getByRole("checkbox", { name: /I independently verified/ })).not.toBeChecked();
});
test("System account recovery actions require a local interactive System owner and a local target", async ({ page }) => {
await mockPasswordApi(page);
await page.goto("/?password-lifecycle&mode=admin&language=en");
await expect(page.getByRole("button", { name: "Issue recovery code", exact: true })).toHaveCount(1);
for (const query of ["owner=false", "external=true", "api-key=true"]) {
await page.goto(`/?password-lifecycle&mode=admin&language=en&${query}`);
await expect(page.getByText("local@example.test", { exact: true }).first()).toBeVisible();
await expect(page.getByRole("button", { name: "Issue recovery code", exact: true })).toHaveCount(0);
}
});
test("forgot-password link and recovery controls follow the disabled policy", async ({ page }) => {
await mockPasswordApi(page, { enabled: false });
await page.goto("/?password-lifecycle&mode=login&language=en");
await expect(page.getByRole("dialog")).toBeVisible();
await expect(page.getByRole("link", { name: "Forgot your password?" })).toHaveCount(0);
await page.goto("/?password-lifecycle&mode=recover&language=en");
await expect(page.getByText(/Administrator-assisted password recovery is not enabled/)).toBeVisible();
await expect(page.getByLabel("Recovery code", { exact: true })).toHaveCount(0);
});
test("enabled forgot-password link navigates without credentials in its URL", async ({ page }) => {
await mockPasswordApi(page);
await page.goto("/?password-lifecycle&mode=login&language=en");
const link = page.getByRole("link", { name: "Forgot your password?" });
await expect(link).toHaveAttribute("href", "/password-recovery");
await link.click();
await expect(page).toHaveURL(/\/password-recovery$/);
});
for (const [language, theme] of [["en", "light"], ["de", "light"], ["en", "dark"], ["de", "dark"]]) {
test(`required password form is accessible on mobile in ${language} ${theme}`, async ({ page }, testInfo) => {
await mockPasswordApi(page);
await page.setViewportSize({ width: 390, height: 844 });
await page.goto(`/?password-lifecycle&mode=required&language=${language}&theme=${theme}`);
await expect(page.getByRole("heading", { level: 1 })).toBeVisible();
await page.addScriptTag({ path: axePath });
const violations = await page.evaluate(async () => {
const axe = (window as typeof window & { axe: { run: (options: unknown) => Promise<{ violations: Array<{ id: string; nodes: Array<{ target: unknown; failureSummary?: string }> }> }> } }).axe;
const result = await axe.run({ runOnly: { type: "tag", values: ["wcag2a", "wcag2aa", "wcag21aa"] } });
return result.violations.map(({ id, nodes }) => ({ id, nodes: nodes.map(({ target, failureSummary }) => ({ target, failureSummary })) }));
});
expect(violations).toEqual([]);
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
await page.screenshot({ path: testInfo.outputPath(`password-required-${language}-${theme}.png`), fullPage: true });
});
}
+1
View File
@@ -43,6 +43,7 @@
"test:core-interface-patterns": "node scripts/test-core-interface-patterns.mjs",
"test:vite-cache-isolation": "node scripts/test-vite-cache-isolation.mjs",
"test:api-client-cache": "node --test tests/api-client-cache.test.mjs",
"test:auth-action-state": "node --test tests/auth-action-state.test.mjs",
"test:dependency-security": "node --test tests/dependency-security.test.mjs",
"test:file-drop-zone": "rm -rf .file-drop-test-build && mkdir -p .file-drop-test-build && printf '{\"type\":\"commonjs\"}\\n' > .file-drop-test-build/package.json && tsc -p tsconfig.file-drop-tests.json && node .file-drop-test-build/tests/file-drop-resolver.test.js && node scripts/test-file-drop-zone-structure.mjs",
"test:data-grid-actions": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/data-grid-actions.test.js && node .component-test-build/tests/data-grid-sizing.test.js",
+50 -11
View File
@@ -1,6 +1,7 @@
import { Navigate, Route, Routes, useLocation } from "react-router";
import { lazy, useEffect, useMemo, useState } from "react";
import { fetchSession, fetchShellAuth, updateProfile } from "./api/auth";
import { fetchSession, fetchShellAuth, logout, updateProfile } from "./api/auth";
import type { AuthActionUiCapability } from "./types";
import { fetchPlatformModules, fetchPlatformPublicModules, fetchPlatformStatus } from "./api/platform";
import { AUTH_REQUIRED_EVENT, apiSettingsForAuthUpdate, clearApiReadCache, isApiError, loadApiSettings, saveApiSettings, type AuthRequiredEventDetail } from "./api/client";
import type { ApiSettings, AuthInfo, AuthSessionInfo, AuthUpdate, AuthUser, EffectiveViewProjection, LoginResponse, PlatformModuleInfo, PlatformPublicModuleInfo, PlatformWebModule, UserUiPalette, UserUiPreferences, ViewsRuntimeUiCapability } from "./types";
@@ -34,6 +35,7 @@ import { applyAppearanceOverrides } from "./components/appearanceOverrides";
const DashboardPage = lazy(() => import("./features/dashboard/DashboardPage"));
const SettingsPage = lazy(() => import("./features/settings/SettingsPage"));
const ProductSurfaceRoute = lazy(() => import("./components/ProductSurfaceRoute"));
const AuthActionGate = lazy(() => import("./features/auth/AuthActionGate"));
const DEFAULT_UI_PREFERENCES: UserUiPreferences = {
compact_tables: false,
@@ -69,6 +71,11 @@ export default function App() {
const webModules = useMemo(() => mergeWebModules(localWebModules, remoteWebModules), [localWebModules, remoteWebModules]);
const publicWebModules = useMemo(() => mergeWebModules(localPublicWebModules, remotePublicWebModules), [localPublicWebModules, remotePublicWebModules]);
const requiredAuthAction = auth?.user.required_auth_action ?? null;
const authActions = useMemo(
() => uiCapability<AuthActionUiCapability>("auth.actions", publicWebModules),
[publicWebModules]
);
const viewsRuntime = useMemo(
() => uiCapability<ViewsRuntimeUiCapability>("views.runtime", webModules),
[webModules]
@@ -83,7 +90,7 @@ export default function App() {
);
const moduleRoutes = useMemo(() => routeContributionsForModules(webModules), [webModules]);
const publicRoutes = useMemo(() => publicRouteContributionsForModules(publicWebModules), [publicWebModules]);
const contextModules = auth ? webModules : publicWebModules;
const contextModules = auth && !requiredAuthAction ? webModules : publicWebModules;
const moduleTranslations = useMemo(() => contextModules.map((module) => module.translations).filter(Boolean), [contextModules]);
const dashboardModuleInstalled = useMemo(() => moduleInstalled("dashboard", webModules), [webModules]);
@@ -102,7 +109,7 @@ export default function App() {
}, []);
useEffect(() => {
if (!auth || !viewsRuntime) {
if (!auth || requiredAuthAction || !viewsRuntime) {
setBaseViewProjection(null);
setWorkflowViewProjection(null);
return;
@@ -137,11 +144,12 @@ export default function App() {
settings.accessToken,
settings.apiBaseUrl,
settings.apiKey,
requiredAuthAction,
viewsRuntime
]);
useEffect(() => {
if (!auth || !viewsRuntime) {
if (!auth || requiredAuthAction || !viewsRuntime) {
setWorkflowViewProjection(null);
return;
}
@@ -167,6 +175,7 @@ export default function App() {
auth?.user?.id,
auth?.active_tenant?.id,
auth?.tenant.id,
requiredAuthAction,
viewsRuntime
]);
@@ -289,7 +298,10 @@ export default function App() {
}, [settings.apiBaseUrl, settings.apiKey]);
useEffect(() => {
if (!auth) return;
if (!auth || requiredAuthAction) {
setPlatformModules(null);
return;
}
let cancelled = false;
let inFlight = false;
@@ -329,12 +341,12 @@ export default function App() {
window.removeEventListener("focus", refreshVisibleModules);
document.removeEventListener("visibilitychange", refreshVisibleModules);
};
}, [auth?.user?.id, auth?.active_tenant?.id, auth?.tenant.id, settings.apiBaseUrl, settings.apiKey]);
}, [auth?.user?.id, auth?.active_tenant?.id, auth?.tenant.id, requiredAuthAction, settings.apiBaseUrl, settings.apiKey]);
useEffect(() => {
let cancelled = false;
setWebModuleLoadFailures([]);
if (!auth) {
if (!auth || requiredAuthAction) {
setLocalWebModules([]);
setRemoteWebModules([]);
setWebModulesLoading(false);
@@ -374,7 +386,7 @@ export default function App() {
}
});
return () => {cancelled = true;};
}, [auth?.user?.id, auth?.active_tenant?.id, auth?.tenant.id, platformModules]);
}, [auth?.user?.id, auth?.active_tenant?.id, auth?.tenant.id, requiredAuthAction, platformModules]);
useEffect(() => {
let cancelled = false;
@@ -489,7 +501,7 @@ export default function App() {
window.removeEventListener("focus", refreshVisibleSession);
document.removeEventListener("visibilitychange", refreshVisibleSession);
};
}, [auth?.user?.id, auth?.active_tenant?.id, auth?.tenant.id, settings.apiBaseUrl, settings.apiKey]);
}, [auth?.user?.id, auth?.active_tenant?.id, auth?.tenant.id, auth?.principal?.session_id, requiredAuthAction, settings.apiBaseUrl, settings.apiKey]);
if (checkingSession) {
return (
@@ -540,6 +552,27 @@ export default function App() {
}
if (requiredAuthAction) {
return <PlatformLanguageProvider
systemAvailableLanguages={systemLanguages?.available}
systemEnabledLanguageCodes={systemLanguages?.enabled}
defaultLanguage={systemLanguages?.defaultLanguage}
preferredLanguageCode={auth.user.preferred_language ?? undefined}
moduleTranslations={moduleTranslations}>
<PlatformModulesProvider modules={publicWebModules}>
<PlatformViewProvider modules={publicWebModules} projection={null}>
<ModuleLoadBoundary resetKey={requiredAuthAction}>
<AuthActionGate settings={settings} auth={auth} capability={authActions}
onAuthChange={updateAuth}
onSignOut={() => { void logout(settings).catch(() => undefined).finally(() => updateAuth(null, "")); }} />
</ModuleLoadBoundary>
{reloginMessage && <LoginModal settings={settings} message={reloginMessage}
onClose={() => setReloginMessage("")} onLogin={handleRelogin} />}
</PlatformViewProvider>
</PlatformModulesProvider>
</PlatformLanguageProvider>;
}
const defaultRoute = firstAccessibleRoute(auth, webModules, viewProjection);
const localDocsAvailable = hasAnyScope(auth, ["docs:documentation:read", "docs:documentation:admin", "system:settings:read", "admin:settings:read"]) &&
webModules.some((module) => module.id === "docs" && module.routes?.some((route) => route.path === "/docs"));
@@ -657,7 +690,7 @@ function mergeAuthPayload(current: AuthInfo | null, next: AuthPayload): AuthPayl
};
}
function normalizeAuthInfo(response: AuthPayload): AuthInfo {
export function normalizeAuthInfo(response: AuthPayload): AuthInfo {
const principal = response.principal ?? null;
const activeTenant = response.active_tenant ?? response.tenant ?? response.tenants?.[0] ?? null;
const user = normalizeAuthUser(response.user, principal);
@@ -710,6 +743,8 @@ function normalizeAuthUser(user: Partial<AuthUser> | null | undefined, principal
tenant_display_name: user.tenant_display_name ?? null,
is_tenant_admin: user.is_tenant_admin ?? false,
password_reset_required: user.password_reset_required ?? false,
required_auth_action: user.required_auth_action ?? null,
local_password: user.local_password ?? false,
preferred_language: user.preferred_language ?? null,
enabled_language_codes: user.enabled_language_codes ?? [],
ui_preferences: normalizeUiPreferences(user.ui_preferences)
@@ -728,16 +763,20 @@ function normalizeAuthUser(user: Partial<AuthUser> | null | undefined, principal
tenant_display_name: principal.display_name ?? null,
is_tenant_admin: false,
password_reset_required: false,
required_auth_action: null,
local_password: false,
preferred_language: null,
enabled_language_codes: [],
ui_preferences: DEFAULT_UI_PREFERENCES
};
}
function sessionMatchesAuth(sessionInfo: AuthSessionInfo, auth: AuthInfo): boolean {
export function sessionMatchesAuth(sessionInfo: AuthSessionInfo, auth: AuthInfo): boolean {
const activeTenant = auth.active_tenant ?? auth.tenant;
if (sessionInfo.user.id !== auth.user.id) return false;
if (sessionInfo.user.account_id !== auth.user.account_id) return false;
if ((sessionInfo.user.required_auth_action ?? null) !== (auth.user.required_auth_action ?? null)) return false;
if (Boolean(sessionInfo.user.local_password) !== Boolean(auth.user.local_password)) return false;
if ((sessionInfo.active_tenant ?? sessionInfo.tenant).id !== activeTenant.id) return false;
if (auth.principal?.auth_method && sessionInfo.auth_method !== auth.principal.auth_method) return false;
if (auth.principal?.session_id && sessionInfo.session_id && auth.principal.session_id !== sessionInfo.session_id) return false;
@@ -0,0 +1,31 @@
import type { ApiSettings, AuthActionUiCapability, AuthInfo, AuthUpdate } from "../../types";
import Button from "../../components/Button";
import DismissibleAlert from "../../components/DismissibleAlert";
import ModuleLoadBoundary from "../../components/ModuleLoadBoundary";
import HelpMenu from "../../layout/HelpMenu";
export default function AuthActionGate({
settings, auth, capability, onAuthChange, onSignOut
}: {
settings: ApiSettings;
auth: AuthInfo;
capability: AuthActionUiCapability | null;
onAuthChange: (auth: AuthUpdate | null, accessToken?: string) => void;
onSignOut: () => void;
}) {
const action = auth.user.required_auth_action;
const RequiredAction = action && capability?.actions.includes(action)
? capability.RequiredAction : null;
return <main className="public-landing auth-action-page">
<section className="public-card">
<ModuleLoadBoundary resetKey={action ?? "auth-action"}>
{RequiredAction
? <RequiredAction settings={settings} auth={auth} onAuthChange={onAuthChange} />
: <DismissibleAlert tone="warning" dismissible={false}>
i18n:govoplan-core.required_account_action_unavailable
</DismissibleAlert>}
</ModuleLoadBoundary>
<div className="public-actions"><Button onClick={onSignOut}>i18n:govoplan-core.sign_out.dc1649a1</Button><HelpMenu auth={auth} /></div>
</section>
</main>;
}
+7 -1
View File
@@ -1,12 +1,14 @@
import { FormLayout } from "../../components/ContentGrid";
import { useId, useState } from "react";
import type { ApiSettings, LoginResponse } from "../../types";
import type { ApiSettings, AuthActionUiCapability, LoginResponse } from "../../types";
import { login } from "../../api/auth";
import Button from "../../components/Button";
import Dialog from "../../components/Dialog";
import FormField from "../../components/FormField";
import PasswordField from "../../components/PasswordField";
import DismissibleAlert from "../../components/DismissibleAlert";
import { usePlatformUiCapability } from "../../platform/ModuleContext";
import { Suspense } from "react";
export default function LoginModal({
settings,
@@ -26,6 +28,8 @@ export default function LoginModal({
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
const formId = useId();
const authActions = usePlatformUiCapability<AuthActionUiCapability>("auth.actions");
const LoginHelp = authActions?.LoginHelp;
async function submit(event: React.FormEvent) {
event.preventDefault();
@@ -38,6 +42,7 @@ export default function LoginModal({
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setPassword("");
setBusy(false);
}
}
@@ -64,6 +69,7 @@ export default function LoginModal({
<PasswordField helpContextId="access.authentication.password" helpModuleId="access" value={password} autoComplete="current-password" onValueChange={setPassword} />
</FormField>
</FormLayout>
{LoginHelp && <Suspense fallback={null}><LoginHelp settings={settings} onNavigate={onClose} /></Suspense>}
</Dialog>);
}
+3 -1
View File
@@ -2,6 +2,7 @@ import type { PlatformTranslations } from "../types";
export const generatedTranslations: PlatformTranslations = {
"en": {
"i18n:govoplan-core.required_account_action_unavailable": "A required account action must be completed before you can continue. The account module is loading or unavailable. If this persists, contact your administrator or sign out.",
"i18n:govoplan-core.optional_module_load_failed": "An enabled module could not load after retrying: {value0}. Its screens and integrations may be unavailable; the module has not been uninstalled. Save any other drafts before reloading this page.",
"i18n:govoplan-core.data_grid_resize_help": "Drag to resize. Left/Right: 10 px; Shift: 40 px. Enter or double-click: reset this column. Escape: cancel dragging.",
"i18n:govoplan-core.inherit_governed_palette": "Inherit governed default",
@@ -741,6 +742,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-core.temporal_selection_invalid": "The selected data state is invalid."
},
"de": {
"i18n:govoplan-core.required_account_action_unavailable": "Bevor Sie fortfahren können, müssen Sie eine erforderliche Kontoaktion abschließen. Das Kontomodul wird geladen oder ist nicht verfügbar. Wenden Sie sich bei anhaltenden Problemen an die Administration oder melden Sie sich ab.",
"i18n:govoplan-core.optional_module_load_failed": "Ein aktiviertes Modul konnte auch nach einem Wiederholungsversuch nicht geladen werden: {value0}. Seine Ansichten und Integrationen sind möglicherweise nicht verfügbar; das Modul wurde nicht deinstalliert. Andere Entwürfe vor dem Neuladen dieser Seite speichern.",
"i18n:govoplan-core.data_grid_resize_help": "Zum Ändern der Breite ziehen. Links/Rechts: 10 px; Umschalt: 40 px. Eingabe oder Doppelklick: Spalte zurücksetzen. Escape: Ziehen abbrechen.",
"i18n:govoplan-core.inherit_governed_palette": "Verwalteten Standard übernehmen",
@@ -1291,7 +1293,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-core.show_password.044b852f": "Show password",
"i18n:govoplan-core.sign_in_to_open_the_modules_available_to_your_te.8bb7dab4": "Sign in to open the modules available to your tenant and role.",
"i18n:govoplan-core.sign_in.ada2e9e9": "Anmelden",
"i18n:govoplan-core.sign_out.dc1649a1": "Sign out",
"i18n:govoplan-core.sign_out.dc1649a1": "Abmelden",
"i18n:govoplan-core.signing_in.c66b2adc": "Signing in…",
"i18n:govoplan-core.smtp_host.2d4a434b": "SMTP host",
"i18n:govoplan-core.smtp_port.65b5a108": "SMTP port",
+6
View File
@@ -38,6 +38,12 @@
padding: 42px 48px;
}
.auth-action-page form { margin-top: 18px; }
@media (max-width: 600px) {
.auth-action-page { padding: 16px; }
.auth-action-page .public-card { padding: 24px; }
}
.public-kicker {
color: var(--accent);
text-transform: uppercase;
+10 -1
View File
@@ -35,6 +35,8 @@ export type AuthUser = {
tenant_display_name?: string | null;
is_tenant_admin?: boolean;
password_reset_required?: boolean;
required_auth_action?: "change_password" | null;
local_password?: boolean;
preferred_language?: string | null;
enabled_language_codes?: string[];
ui_preferences?: UserUiPreferences;
@@ -134,6 +136,13 @@ export type ActingContextRuntimeUiCapability = {
Selector: ComponentType<ActingContextSelectorProps>;
};
/** Optional authentication UI, including actions before normal module access. */
export type AuthActionUiCapability = {
actions: readonly string[];
RequiredAction: ComponentType<ActingContextSelectorProps>;
LoginHelp?: ComponentType<{ settings: ApiSettings; onNavigate: () => void }>;
};
export type AuthInfo = {
user: AuthUser;
// Backwards-compatible active tenant alias returned by older/newer APIs.
@@ -162,7 +171,7 @@ export type AuthUpdate = Partial<Omit<AuthInfo, "user" | "tenant" | "active_tena
export type AuthSessionInfo = {
authenticated: boolean;
auth_method: "session" | "api_key";
user: Pick<AuthUser, "id" | "account_id" | "email" | "display_name" | "tenant_display_name" | "is_tenant_admin" | "password_reset_required">;
user: Pick<AuthUser, "id" | "account_id" | "email" | "display_name" | "tenant_display_name" | "is_tenant_admin" | "password_reset_required" | "required_auth_action" | "local_password">;
tenant: AuthTenant;
active_tenant: AuthTenant;
session_id?: string | null;
+46
View File
@@ -0,0 +1,46 @@
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";
const require = createRequire(import.meta.url);
const { transformSync } = require("esbuild");
const source = readFileSync(new URL("../src/App.tsx", import.meta.url), "utf8");
const code = transformSync(source, { loader: "tsx", format: "cjs", target: "es2022" }).code;
const context = vm.createContext({
module: { exports: {} },
require: (name) => name === "react" ? { lazy: () => null } : {}
});
context.exports = context.module.exports;
vm.runInContext(code, context);
const { normalizeAuthInfo, sessionMatchesAuth } = context.module.exports;
const tenant = { id: "tenant-1", name: "Tenant", slug: "tenant" };
const base = {
user: { id: "membership-1", account_id: "account-1", email: "person@example.test", password_reset_required: true },
tenant,
principal: { auth_method: "session", account_id: "account-1", membership_id: "membership-1", session_id: "session-1", scopes: [] }
};
test("normalization preserves an explicit required action and local password capability", () => {
const normalized = normalizeAuthInfo({ ...base, user: { ...base.user, required_auth_action: "change_password", local_password: true } });
assert.equal(normalized.user.required_auth_action, "change_password");
assert.equal(normalized.user.local_password, true);
assert.equal(normalized.scopes.length, 0);
});
test("legacy password-reset metadata remains advisory without the server action", () => {
const normalized = normalizeAuthInfo(base);
assert.equal(normalized.user.password_reset_required, true);
assert.equal(normalized.user.required_auth_action, null);
assert.equal(normalized.user.local_password, false);
});
test("lightweight session changes trigger full auth refresh for required actions and provider changes", () => {
const auth = normalizeAuthInfo({ ...base, user: { ...base.user, required_auth_action: null, local_password: true } });
const session = { user: { ...auth.user }, tenant, active_tenant: tenant, auth_method: "session", session_id: "session-1" };
assert.equal(sessionMatchesAuth(session, auth), true);
assert.equal(sessionMatchesAuth({ ...session, user: { ...session.user, required_auth_action: "change_password" } }, auth), false);
assert.equal(sessionMatchesAuth({ ...session, user: { ...session.user, local_password: false } }, auth), false);
assert.equal(sessionMatchesAuth({ ...session, session_id: "rotated-session" }, auth), false);
});
+14
View File
@@ -1,5 +1,6 @@
import type {
AuthInfo,
AuthActionUiCapability,
DashboardWidgetsUiCapability,
OrganizationFunctionActionContext,
OrganizationFunctionActionContribution,
@@ -80,6 +81,19 @@ for (const testCase of cases) {
assert(uiCapability("files.fileExplorer", [access, files]) === filesCapability, "files capability should return the module-provided object");
assert(uiCapability("mail.profiles", [access, mail]) === mailCapability, "mail capability should return the module-provided object");
const authActionCapability: AuthActionUiCapability = {
actions: ["change_password"], RequiredAction: () => null, LoginHelp: () => null
};
const publicAuthModule: PlatformWebModule = {
id: "access", label: "Access", version: "test",
publicRoutes: [{ path: "/password-recovery", render: () => null }],
uiCapabilities: { "auth.actions": authActionCapability }
};
assert(uiCapability<AuthActionUiCapability>("auth.actions", [publicAuthModule]) === authActionCapability,
"required authentication actions can resolve from the public catalogue without normal scopes");
assert(uiCapability<AuthActionUiCapability>("auth.actions", []) === null,
"core-only compositions have no authentication action implementation");
const configurableDashboardWidgets: DashboardWidgetsUiCapability = {
widgets: [
{