@cosmicdrift/kumiko-bundled-features 0.158.2 → 0.160.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +7 -6
- package/src/auth-email-password/__tests__/account-unlock.integration.test.ts +283 -0
- package/src/auth-email-password/__tests__/auth-user-row.test.ts +36 -0
- package/src/auth-email-password/__tests__/auth.integration.test.ts +21 -0
- package/src/auth-email-password/__tests__/email-verification.integration.test.ts +1 -1
- package/src/auth-email-password/__tests__/password-policy.test.ts +15 -0
- package/src/auth-email-password/__tests__/password-reset.integration.test.ts +15 -1
- package/src/auth-email-password/__tests__/session-strict-mode.integration.test.ts +7 -14
- package/src/auth-email-password/__tests__/signup-flow.integration.test.ts +12 -0
- package/src/auth-email-password/constants.ts +15 -0
- package/src/auth-email-password/email-templates.ts +32 -0
- package/src/auth-email-password/errors.ts +9 -0
- package/src/auth-email-password/feature.ts +37 -5
- package/src/auth-email-password/handlers/change-password.write.ts +2 -1
- package/src/auth-email-password/handlers/confirm-account-unlock.write.ts +59 -0
- package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +1 -0
- package/src/auth-email-password/handlers/invite-accept.write.ts +6 -2
- package/src/auth-email-password/handlers/invite-signup-complete.write.ts +1 -0
- package/src/auth-email-password/handlers/request-account-unlock.write.ts +38 -0
- package/src/auth-email-password/handlers/reset-password.write.ts +2 -1
- package/src/auth-email-password/handlers/signup-confirm.write.ts +2 -1
- package/src/auth-email-password/i18n.ts +42 -0
- package/src/auth-email-password/index.ts +2 -0
- package/src/auth-email-password/lockout-store.ts +10 -5
- package/src/auth-email-password/password-policy.ts +2108 -0
- package/src/auth-email-password/signed-token.ts +1 -0
- package/src/auth-email-password/unlock-token.ts +28 -0
- package/src/auth-email-password/web/__tests__/auth-client.test.ts +525 -0
- package/src/auth-email-password/web/__tests__/auth-gate.test.tsx +65 -3
- package/src/auth-email-password/web/__tests__/confirm-account-unlock-screen.test.tsx +57 -0
- package/src/auth-email-password/web/__tests__/invite-accept-screen.test.tsx +126 -2
- package/src/auth-email-password/web/__tests__/login-screen.test.tsx +125 -0
- package/src/auth-email-password/web/__tests__/request-account-unlock-screen.test.tsx +131 -0
- package/src/auth-email-password/web/auth-client.ts +33 -0
- package/src/auth-email-password/web/confirm-account-unlock-screen.tsx +95 -0
- package/src/auth-email-password/web/index.ts +6 -0
- package/src/auth-email-password/web/login-screen.tsx +15 -0
- package/src/auth-email-password/web/request-account-unlock-screen.tsx +112 -0
- package/src/auth-foundation/__tests__/feature.test.ts +33 -0
- package/src/auth-foundation/__tests__/session-store.test.ts +78 -0
- package/src/auth-foundation/__tests__/token-verifier.test.ts +117 -0
- package/src/auth-foundation/boot-checks.ts +91 -0
- package/src/auth-foundation/feature.ts +103 -0
- package/src/auth-foundation/index.ts +23 -0
- package/src/auth-foundation/types.ts +107 -0
- package/src/auth-mfa/__tests__/errors-i18n-parity.test.ts +8 -0
- package/src/auth-mfa/__tests__/verify.integration.test.ts +44 -0
- package/src/auth-mfa/handlers/verify.write.ts +1 -1
- package/src/auth-mfa/web/__tests__/mfa-client.test.ts +118 -0
- package/src/auth-mfa/web/__tests__/mfa-enable-screen.test.tsx +173 -0
- package/src/auth-mfa/web/__tests__/mfa-verify-screen.test.tsx +100 -0
- package/src/billing-foundation/__tests__/subscription-provider-contract.ts +69 -0
- package/src/channel-email/pii-guard.ts +6 -3
- package/src/config/__tests__/app-overrides.test.ts +11 -0
- package/src/config/__tests__/backing-secrets.integration.test.ts +18 -0
- package/src/config/__tests__/cascade.integration.test.ts +39 -0
- package/src/config/__tests__/config.integration.test.ts +14 -0
- package/src/config/__tests__/pii-encrypted.integration.test.ts +23 -2
- package/src/config/__tests__/reencrypt-job-concurrent-write-race.integration.test.ts +20 -3
- package/src/config/__tests__/reencrypt-job-kek-rotation.integration.test.ts +2 -3
- package/src/config/feature.ts +2 -4
- package/src/config/handlers/set.write.ts +7 -1
- package/src/config/resolver.ts +1 -1
- package/src/custom-fields/__tests__/user-data-rights.integration.test.ts +2 -0
- package/src/delivery/handlers/log.query.ts +1 -1
- package/src/files-provider-s3/__tests__/s3-provider.integration.test.ts +7 -0
- package/src/folders/web/__tests__/folder-manager.test.tsx +27 -2
- package/src/inbound-mail-foundation/__tests__/account-state.test.ts +30 -0
- package/src/inbound-mail-foundation/__tests__/inbound-mail-foundation.integration.test.ts +57 -6
- package/src/inbound-mail-foundation/__tests__/inbound-mail-provider-contract.ts +93 -0
- package/src/inbound-mail-foundation/__tests__/watch-supervisor.integration.test.ts +154 -0
- package/src/inbound-mail-foundation/handlers/ingest-message.write.ts +60 -50
- package/src/inbound-provider-imap/__tests__/imap-live.integration.test.ts +12 -0
- package/src/inbound-provider-imap/__tests__/plugin-mocked.test.ts +331 -0
- package/src/inbound-provider-inmemory/__tests__/feature.test.ts +48 -0
- package/src/inbound-provider-inmemory/feature.ts +2 -2
- package/src/jobs/__tests__/reindex-entity-job.integration.test.ts +53 -1
- package/src/jobs/handlers/detail.query.ts +1 -1
- package/src/jobs/handlers/list.query.ts +1 -1
- package/src/mail-foundation/__tests__/mail-transport-contract.ts +42 -0
- package/src/mail-transport-inmemory/__tests__/feature.test.ts +19 -0
- package/src/mail-transport-smtp/__tests__/feature.test.ts +60 -0
- package/src/managed-pages/handlers/branding.query.ts +5 -1
- package/src/personal-access-tokens/__tests__/feature.test.ts +18 -0
- package/src/personal-access-tokens/__tests__/pat.integration.test.ts +8 -11
- package/src/personal-access-tokens/feature.ts +23 -8
- package/src/personal-access-tokens/handlers/list.query.ts +1 -1
- package/src/personal-access-tokens/index.ts +0 -14
- package/src/personal-access-tokens/resolver.ts +2 -2
- package/src/secrets/__tests__/rotate.integration.test.ts +31 -0
- package/src/sessions/__tests__/cleanup.integration.test.ts +2 -1
- package/src/sessions/__tests__/password-auto-revoke.integration.test.ts +5 -3
- package/src/sessions/__tests__/rebuild-survival.integration.test.ts +11 -2
- package/src/sessions/__tests__/session-store-registration.test.ts +21 -0
- package/src/sessions/__tests__/sessions-screens.boot.test.ts +10 -1
- package/src/sessions/__tests__/sessions.integration.test.ts +28 -17
- package/src/sessions/__tests__/test-helpers.ts +15 -2
- package/src/sessions/feature.ts +31 -3
- package/src/sessions/handlers/detail.query.ts +2 -2
- package/src/sessions/handlers/list.query.ts +4 -2
- package/src/sessions/handlers/mine.query.ts +4 -2
- package/src/sessions/testing.ts +14 -1
- package/src/shared/__tests__/chunked-entity-migration.test.ts +145 -0
- package/src/shared/decrypt-stored-pii.ts +12 -5
- package/src/subscription-mollie/__tests__/feature.test.ts +87 -0
- package/src/subscription-stripe/__tests__/feature.test.ts +120 -1
- package/src/tags/web/__tests__/tag-manager.test.tsx +277 -8
- package/src/tenant/__tests__/members-query-kms.integration.test.ts +98 -0
- package/src/tenant/handlers/invitations.query.ts +4 -2
- package/src/tenant/handlers/members.query.ts +2 -2
- package/src/tenant/seeding.ts +2 -2
- package/src/tenant-lifecycle/__tests__/tenant-lifecycle.integration.test.ts +2 -0
- package/src/tenant-lifecycle/boot-checks.ts +53 -0
- package/src/tenant-lifecycle/feature.ts +7 -0
- package/src/tenant-lifecycle/handlers/request-destruction.write.ts +1 -1
- package/src/tier-engine/feature.ts +2 -2
- package/src/user-data-rights/COMPLIANCE.md +20 -0
- package/src/user-data-rights/__tests__/anonymous-deletion-kms.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/anonymous-deletion.integration.test.ts +3 -0
- package/src/user-data-rights/__tests__/audit-log.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/boot-checks.test.ts +172 -0
- package/src/user-data-rights/__tests__/cross-data-matrix.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/download-by-token-resolver-trust.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/download.integration.test.ts +59 -4
- package/src/user-data-rights/__tests__/export-encrypted-fields.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/export-job-idempotency.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/export-job-list-cross-tenant.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/file-binary-forget-cleanup.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/file-binary-forget-failure.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/file-retention.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/file-storage-unification.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/forget-cleanup-hook-ordering.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/forget-hook-registry.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/inspector-screens.boot.test.ts +10 -1
- package/src/user-data-rights/__tests__/mail-default-bridge.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/read-users-rebuild-survives-lifecycle.integration.test.ts +15 -7
- package/src/user-data-rights/__tests__/request-cancel-deletion.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/request-deletion-callback.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/request-export.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/restriction-flow.integration.test.ts +48 -15
- package/src/user-data-rights/__tests__/run-export-jobs-cron-context.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/run-export-jobs.integration.test.ts +184 -1
- package/src/user-data-rights/__tests__/run-forget-cleanup.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/run-user-export.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/tenant-model-erasure.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/user-data-rights.integration.test.ts +9 -1
- package/src/user-data-rights/boot-checks.ts +86 -0
- package/src/user-data-rights/feature.ts +116 -6
- package/src/user-data-rights/handlers/deletion-grace-period.ts +1 -1
- package/src/user-data-rights/handlers/download-by-token.query.ts +1 -1
- package/src/user-data-rights/handlers/lift-restriction.write.ts +13 -19
- package/src/user-data-rights/handlers/restrict-account.write.ts +30 -8
- package/src/user-data-rights/run-export-jobs.ts +3 -3
- package/src/user-data-rights/run-forget-cleanup.ts +1 -1
- package/src/user-data-rights/web/__tests__/privacy-center-screen.test.tsx +1 -0
- package/src/user-data-rights-defaults/__tests__/bundled-entities.userdata-hooks.integration.test.ts +3 -0
- package/src/user-data-rights-defaults/__tests__/feature-mounted.test.ts +24 -0
- package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.test.ts +2 -0
- package/src/user-data-rights-defaults/hooks/tenant-invitation.userdata-hook.ts +1 -1
- package/src/user-profile/__tests__/change-email.integration.test.ts +2 -0
- package/src/user-profile/__tests__/profile-screen.test.tsx +1 -0
- package/src/user-profile/handlers/change-email.write.ts +1 -1
- package/src/config/__tests__/encrypted-legacy-migration.integration.test.ts +0 -179
|
@@ -3,16 +3,17 @@
|
|
|
3
3
|
// useSession() throws without a provider, so any consumer mounting this
|
|
4
4
|
// screen on a public route outside SessionAuthGate crashed at runtime.
|
|
5
5
|
|
|
6
|
-
import { describe, expect, test } from "bun:test";
|
|
6
|
+
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
|
|
7
7
|
import {
|
|
8
8
|
createStaticLocaleResolver,
|
|
9
9
|
LocaleProvider,
|
|
10
10
|
PrimitivesProvider,
|
|
11
11
|
} from "@cosmicdrift/kumiko-renderer";
|
|
12
12
|
import { defaultPrimitives } from "@cosmicdrift/kumiko-renderer-web";
|
|
13
|
-
import { render, screen } from "@testing-library/react";
|
|
13
|
+
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
|
14
14
|
import { defaultTranslations } from "../../i18n";
|
|
15
15
|
import { InviteAcceptScreen } from "../invite-accept-screen";
|
|
16
|
+
import { makeSessionApi, renderWithProviders } from "./test-utils";
|
|
16
17
|
|
|
17
18
|
const resolver = createStaticLocaleResolver({ locale: "de" });
|
|
18
19
|
|
|
@@ -26,6 +27,24 @@ function renderWithoutSessionProvider(token: string) {
|
|
|
26
27
|
);
|
|
27
28
|
}
|
|
28
29
|
|
|
30
|
+
beforeEach(() => {
|
|
31
|
+
globalThis.fetch = mock(
|
|
32
|
+
async () => new Response(JSON.stringify({ tenantId: "tenant-new" }), { status: 200 }),
|
|
33
|
+
) as unknown as typeof fetch;
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
// Capture once — afterEach must restore the real Location, not `{...location}`
|
|
37
|
+
// (plain objects break `new URL(window.location.href)` in useUrlToken → ResetPasswordScreen).
|
|
38
|
+
const originalLocation = window.location;
|
|
39
|
+
|
|
40
|
+
afterEach(() => {
|
|
41
|
+
Object.defineProperty(window, "location", {
|
|
42
|
+
configurable: true,
|
|
43
|
+
writable: true,
|
|
44
|
+
value: originalLocation,
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
29
48
|
describe("InviteAcceptScreen — no <SessionProvider> ancestor (632/1)", () => {
|
|
30
49
|
test("renders the anonymous accept-form instead of throwing", () => {
|
|
31
50
|
renderWithoutSessionProvider("tok-123");
|
|
@@ -40,5 +59,110 @@ describe("InviteAcceptScreen — no <SessionProvider> ancestor (632/1)", () => {
|
|
|
40
59
|
expect(
|
|
41
60
|
screen.getByText("Der Einladungs-Link enthält keinen Token oder ist ungültig."),
|
|
42
61
|
).toBeTruthy();
|
|
62
|
+
expect(screen.getByRole("link", { name: "Zum Login" }).getAttribute("href")).toBe("/login");
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe("InviteAcceptScreen — logged-in branch", () => {
|
|
67
|
+
test("authenticated session shows 1-click accept form", () => {
|
|
68
|
+
renderWithProviders(<InviteAcceptScreen token="tok-123" />, {
|
|
69
|
+
session: makeSessionApi({ status: "authenticated" }),
|
|
70
|
+
});
|
|
71
|
+
expect(screen.getByText(/Du bist eingeloggt/)).toBeTruthy();
|
|
72
|
+
expect(screen.getByRole("button", { name: "Annehmen" })).toBeTruthy();
|
|
73
|
+
expect(screen.queryByLabelText(/^E-Mail/)).toBeNull();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("accept logged-in success redirects via loggedInHref function", async () => {
|
|
77
|
+
const assign = mock<(url: string) => void>();
|
|
78
|
+
Object.defineProperty(window, "location", {
|
|
79
|
+
configurable: true,
|
|
80
|
+
writable: true,
|
|
81
|
+
value: { href: originalLocation.href, search: originalLocation.search, assign },
|
|
82
|
+
});
|
|
83
|
+
renderWithProviders(
|
|
84
|
+
<InviteAcceptScreen token="tok-123" loggedInHref={({ tenantId }) => `/${tenantId}/home`} />,
|
|
85
|
+
{ session: makeSessionApi({ status: "authenticated" }) },
|
|
86
|
+
);
|
|
87
|
+
fireEvent.click(screen.getByRole("button", { name: "Annehmen" }));
|
|
88
|
+
await waitFor(() => {
|
|
89
|
+
expect(assign).toHaveBeenCalledWith("/tenant-new/home");
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("accept logged-in failure shows invalidInviteToken banner", async () => {
|
|
94
|
+
globalThis.fetch = mock(
|
|
95
|
+
async () => new Response(null, { status: 422 }),
|
|
96
|
+
) as unknown as typeof fetch;
|
|
97
|
+
renderWithProviders(<InviteAcceptScreen token="bad" />, {
|
|
98
|
+
session: makeSessionApi({ status: "authenticated" }),
|
|
99
|
+
});
|
|
100
|
+
fireEvent.click(screen.getByRole("button", { name: "Annehmen" }));
|
|
101
|
+
await waitFor(() => {
|
|
102
|
+
expect(screen.getByRole("alert").textContent).toContain("auth.errors.invalidInviteToken");
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("use other account switches to anon-existing form", () => {
|
|
107
|
+
renderWithProviders(<InviteAcceptScreen token="tok-123" />, {
|
|
108
|
+
session: makeSessionApi({ status: "authenticated" }),
|
|
109
|
+
});
|
|
110
|
+
fireEvent.click(screen.getByRole("button", { name: "Mit anderem Account anmelden" }));
|
|
111
|
+
expect(screen.getByLabelText(/^E-Mail/)).toBeTruthy();
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe("InviteAcceptScreen — anonymous branches", () => {
|
|
116
|
+
test("anon-existing submit posts invite-accept-with-login", async () => {
|
|
117
|
+
const fetchMock = mock(
|
|
118
|
+
async () => new Response(JSON.stringify({ tenantId: "t1" }), { status: 200 }),
|
|
119
|
+
);
|
|
120
|
+
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
|
121
|
+
renderWithoutSessionProvider("tok-123");
|
|
122
|
+
fireEvent.change(screen.getByLabelText(/^E-Mail/), { target: { value: "a@example.com" } });
|
|
123
|
+
fireEvent.change(screen.getByLabelText(/^Passwort/), { target: { value: "secret123" } });
|
|
124
|
+
fireEvent.click(screen.getByRole("button", { name: "Annehmen + Anmelden" }));
|
|
125
|
+
await waitFor(() => {
|
|
126
|
+
expect(fetchMock).toHaveBeenCalledWith(
|
|
127
|
+
"/api/auth/invite-accept-with-login",
|
|
128
|
+
expect.objectContaining({
|
|
129
|
+
method: "POST",
|
|
130
|
+
body: JSON.stringify({ token: "tok-123", email: "a@example.com", password: "secret123" }),
|
|
131
|
+
}),
|
|
132
|
+
);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("toggle to anon-new hides email and posts invite-signup-complete", async () => {
|
|
137
|
+
const fetchMock = mock(
|
|
138
|
+
async () => new Response(JSON.stringify({ tenantId: "t1" }), { status: 200 }),
|
|
139
|
+
);
|
|
140
|
+
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
|
141
|
+
renderWithoutSessionProvider("tok-123");
|
|
142
|
+
fireEvent.click(screen.getByRole("button", { name: "Ich habe noch keinen Account" }));
|
|
143
|
+
expect(screen.queryByLabelText(/^E-Mail/)).toBeNull();
|
|
144
|
+
fireEvent.change(screen.getByLabelText(/^Passwort/), { target: { value: "newpass123" } });
|
|
145
|
+
fireEvent.click(screen.getByRole("button", { name: "Annehmen + Anmelden" }));
|
|
146
|
+
await waitFor(() => {
|
|
147
|
+
expect(fetchMock).toHaveBeenCalledWith(
|
|
148
|
+
"/api/auth/invite-signup-complete",
|
|
149
|
+
expect.objectContaining({
|
|
150
|
+
body: JSON.stringify({ token: "tok-123", password: "newpass123" }),
|
|
151
|
+
}),
|
|
152
|
+
);
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("anon failure shows invalidInviteToken banner", async () => {
|
|
157
|
+
globalThis.fetch = mock(
|
|
158
|
+
async () => new Response(null, { status: 422 }),
|
|
159
|
+
) as unknown as typeof fetch;
|
|
160
|
+
renderWithoutSessionProvider("tok-123");
|
|
161
|
+
fireEvent.change(screen.getByLabelText(/^E-Mail/), { target: { value: "a@example.com" } });
|
|
162
|
+
fireEvent.change(screen.getByLabelText(/^Passwort/), { target: { value: "wrong" } });
|
|
163
|
+
fireEvent.click(screen.getByRole("button", { name: "Annehmen + Anmelden" }));
|
|
164
|
+
await waitFor(() => {
|
|
165
|
+
expect(screen.getByRole("alert").textContent).toContain("auth.errors.invalidInviteToken");
|
|
166
|
+
});
|
|
43
167
|
});
|
|
44
168
|
});
|
|
@@ -247,4 +247,129 @@ describe("LoginScreen", () => {
|
|
|
247
247
|
expect(screen.queryByRole("button", { name: /erneut senden/i })).toBeNull();
|
|
248
248
|
});
|
|
249
249
|
});
|
|
250
|
+
|
|
251
|
+
test("account_locked without retry → unlock link when href set", async () => {
|
|
252
|
+
const session = makeSessionApi({
|
|
253
|
+
status: "unauthenticated",
|
|
254
|
+
user: null,
|
|
255
|
+
login: mock<SessionApi["login"]>(async () => ({
|
|
256
|
+
kind: "failure",
|
|
257
|
+
error: { reason: "account_locked" },
|
|
258
|
+
})),
|
|
259
|
+
});
|
|
260
|
+
renderWithProviders(<LoginScreen unlockAccountHref="/unlock" />, { session });
|
|
261
|
+
fireEvent.change(screen.getByLabelText(/^E-Mail/), { target: { value: "a@b.c" } });
|
|
262
|
+
fireEvent.change(screen.getByLabelText(/^Passwort/), { target: { value: "x" } });
|
|
263
|
+
fireEvent.click(screen.getByRole("button", { name: "Einloggen" }));
|
|
264
|
+
await waitFor(() => {
|
|
265
|
+
expect(screen.getByRole("alert").textContent).toContain("vorübergehend gesperrt");
|
|
266
|
+
});
|
|
267
|
+
expect(screen.getByRole("link", { name: /Konto entsperren/i }).getAttribute("href")).toBe(
|
|
268
|
+
"/unlock",
|
|
269
|
+
);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
test("reasonToKey covers no_membership, rate_limited, invalid_body, default", async () => {
|
|
273
|
+
const cases: Array<{ reason: string; needle: string }> = [
|
|
274
|
+
{ reason: "no_membership", needle: "keinen Tenant-Zugang" },
|
|
275
|
+
{ reason: "rate_limited", needle: "Zu viele Login-Versuche" },
|
|
276
|
+
{ reason: "invalid_body", needle: "Ungültige Eingabe" },
|
|
277
|
+
{ reason: "weird_unknown", needle: "Login fehlgeschlagen" },
|
|
278
|
+
];
|
|
279
|
+
for (const { reason, needle } of cases) {
|
|
280
|
+
const session = makeSessionApi({
|
|
281
|
+
status: "unauthenticated",
|
|
282
|
+
user: null,
|
|
283
|
+
login: mock<SessionApi["login"]>(async () => ({
|
|
284
|
+
kind: "failure",
|
|
285
|
+
error: { reason },
|
|
286
|
+
})),
|
|
287
|
+
});
|
|
288
|
+
const { unmount } = renderWithProviders(<LoginScreen />, { session });
|
|
289
|
+
fireEvent.change(screen.getByLabelText(/^E-Mail/), { target: { value: "a@b.c" } });
|
|
290
|
+
fireEvent.change(screen.getByLabelText(/^Passwort/), { target: { value: "x" } });
|
|
291
|
+
fireEvent.click(screen.getByRole("button", { name: "Einloggen" }));
|
|
292
|
+
await waitFor(() => {
|
|
293
|
+
expect(screen.getByRole("alert").textContent).toContain(needle);
|
|
294
|
+
});
|
|
295
|
+
unmount();
|
|
296
|
+
}
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
test("mfa-challenge with onMfaChallenge fires token", async () => {
|
|
300
|
+
const onMfaChallenge = mock<(t: string) => void>();
|
|
301
|
+
const session = makeSessionApi({
|
|
302
|
+
status: "unauthenticated",
|
|
303
|
+
user: null,
|
|
304
|
+
login: mock<SessionApi["login"]>(async () => ({
|
|
305
|
+
kind: "mfa-challenge",
|
|
306
|
+
challengeToken: "chal-1",
|
|
307
|
+
})),
|
|
308
|
+
});
|
|
309
|
+
renderWithProviders(<LoginScreen onMfaChallenge={onMfaChallenge} />, { session });
|
|
310
|
+
fireEvent.change(screen.getByLabelText(/^E-Mail/), { target: { value: "a@b.c" } });
|
|
311
|
+
fireEvent.change(screen.getByLabelText(/^Passwort/), { target: { value: "x" } });
|
|
312
|
+
fireEvent.click(screen.getByRole("button", { name: "Einloggen" }));
|
|
313
|
+
await waitFor(() => {
|
|
314
|
+
expect(onMfaChallenge).toHaveBeenCalledWith("chal-1");
|
|
315
|
+
});
|
|
316
|
+
expect(screen.queryByRole("alert")).toBeNull();
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
test("mfa-challenge without callback → mfa_not_supported", async () => {
|
|
320
|
+
const session = makeSessionApi({
|
|
321
|
+
status: "unauthenticated",
|
|
322
|
+
user: null,
|
|
323
|
+
login: mock<SessionApi["login"]>(async () => ({
|
|
324
|
+
kind: "mfa-challenge",
|
|
325
|
+
challengeToken: "chal-1",
|
|
326
|
+
})),
|
|
327
|
+
});
|
|
328
|
+
renderWithProviders(<LoginScreen />, { session });
|
|
329
|
+
fireEvent.change(screen.getByLabelText(/^E-Mail/), { target: { value: "a@b.c" } });
|
|
330
|
+
fireEvent.change(screen.getByLabelText(/^Passwort/), { target: { value: "x" } });
|
|
331
|
+
fireEvent.click(screen.getByRole("button", { name: "Einloggen" }));
|
|
332
|
+
await waitFor(() => {
|
|
333
|
+
expect(screen.getByRole("alert").textContent).toMatch(/MFA|Zwei-Faktor|nicht unterstützt/i);
|
|
334
|
+
});
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
test("mfa-setup-required with and without onMfaSetupRequired", async () => {
|
|
338
|
+
const onMfaSetupRequired = mock<() => void>();
|
|
339
|
+
const sessionOk = makeSessionApi({
|
|
340
|
+
status: "unauthenticated",
|
|
341
|
+
user: null,
|
|
342
|
+
login: mock<SessionApi["login"]>(async () => ({ kind: "mfa-setup-required" })),
|
|
343
|
+
});
|
|
344
|
+
const { unmount } = renderWithProviders(
|
|
345
|
+
<LoginScreen onMfaSetupRequired={onMfaSetupRequired} />,
|
|
346
|
+
{ session: sessionOk },
|
|
347
|
+
);
|
|
348
|
+
fireEvent.change(screen.getByLabelText(/^E-Mail/), { target: { value: "a@b.c" } });
|
|
349
|
+
fireEvent.change(screen.getByLabelText(/^Passwort/), { target: { value: "x" } });
|
|
350
|
+
fireEvent.click(screen.getByRole("button", { name: "Einloggen" }));
|
|
351
|
+
await waitFor(() => {
|
|
352
|
+
expect(onMfaSetupRequired).toHaveBeenCalled();
|
|
353
|
+
});
|
|
354
|
+
unmount();
|
|
355
|
+
|
|
356
|
+
const sessionBare = makeSessionApi({
|
|
357
|
+
status: "unauthenticated",
|
|
358
|
+
user: null,
|
|
359
|
+
login: mock<SessionApi["login"]>(async () => ({ kind: "mfa-setup-required" })),
|
|
360
|
+
});
|
|
361
|
+
renderWithProviders(<LoginScreen />, { session: sessionBare });
|
|
362
|
+
fireEvent.change(screen.getByLabelText(/^E-Mail/), { target: { value: "a@b.c" } });
|
|
363
|
+
fireEvent.change(screen.getByLabelText(/^Passwort/), { target: { value: "x" } });
|
|
364
|
+
fireEvent.click(screen.getByRole("button", { name: "Einloggen" }));
|
|
365
|
+
await waitFor(() => {
|
|
366
|
+
expect(screen.getByRole("alert").textContent).toContain("Zwei-Faktor-Bestätigung");
|
|
367
|
+
});
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
test("signupHref renders signup link", () => {
|
|
371
|
+
renderWithProviders(<LoginScreen signupHref="/signup" />);
|
|
372
|
+
const link = screen.getByRole("link", { name: "Account erstellen" });
|
|
373
|
+
expect(link.getAttribute("href")).toBe("/signup");
|
|
374
|
+
});
|
|
250
375
|
});
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
|
|
2
|
+
import { fireEvent, screen, waitFor } from "@testing-library/react";
|
|
3
|
+
import { RequestAccountUnlockScreen } from "../request-account-unlock-screen";
|
|
4
|
+
import { renderWithProviders } from "./test-utils";
|
|
5
|
+
|
|
6
|
+
beforeEach(() => {
|
|
7
|
+
globalThis.fetch = mock(
|
|
8
|
+
async () => new Response(null, { status: 200 }),
|
|
9
|
+
) as unknown as typeof fetch;
|
|
10
|
+
});
|
|
11
|
+
afterEach(() => {});
|
|
12
|
+
|
|
13
|
+
describe("RequestAccountUnlockScreen", () => {
|
|
14
|
+
test("renders title + email input + submit (de)", () => {
|
|
15
|
+
renderWithProviders(<RequestAccountUnlockScreen />);
|
|
16
|
+
expect(screen.getByText("Konto entsperren")).toBeTruthy();
|
|
17
|
+
expect(screen.getByLabelText(/^E-Mail/)).toBeTruthy();
|
|
18
|
+
expect(screen.getByRole("button", { name: "Link anfordern" })).toBeTruthy();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("submit posts /api/auth/request-account-unlock with email", async () => {
|
|
22
|
+
const fetchMock = mock(async () => new Response(null, { status: 200 }));
|
|
23
|
+
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
|
24
|
+
|
|
25
|
+
renderWithProviders(<RequestAccountUnlockScreen />);
|
|
26
|
+
fireEvent.change(screen.getByLabelText(/^E-Mail/), {
|
|
27
|
+
target: { value: "user@example.com" },
|
|
28
|
+
});
|
|
29
|
+
fireEvent.click(screen.getByRole("button", { name: "Link anfordern" }));
|
|
30
|
+
|
|
31
|
+
await waitFor(() => {
|
|
32
|
+
expect(fetchMock).toHaveBeenCalledWith(
|
|
33
|
+
"/api/auth/request-account-unlock",
|
|
34
|
+
expect.objectContaining({
|
|
35
|
+
method: "POST",
|
|
36
|
+
body: JSON.stringify({ email: "user@example.com" }),
|
|
37
|
+
}),
|
|
38
|
+
);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("success → silent-success banner + back-to-login link", async () => {
|
|
43
|
+
renderWithProviders(<RequestAccountUnlockScreen loginHref="/signin" />);
|
|
44
|
+
fireEvent.change(screen.getByLabelText(/^E-Mail/), {
|
|
45
|
+
target: { value: "user@example.com" },
|
|
46
|
+
});
|
|
47
|
+
fireEvent.click(screen.getByRole("button", { name: "Link anfordern" }));
|
|
48
|
+
|
|
49
|
+
await waitFor(() => {
|
|
50
|
+
expect(screen.getByText("Mail gesendet")).toBeTruthy();
|
|
51
|
+
});
|
|
52
|
+
expect(screen.getByRole("link", { name: /Zurück zum Login/i }).getAttribute("href")).toBe(
|
|
53
|
+
"/signin",
|
|
54
|
+
);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("429 with retryAfterSeconds → accountLockedRetry banner", async () => {
|
|
58
|
+
globalThis.fetch = mock(
|
|
59
|
+
async () =>
|
|
60
|
+
new Response(JSON.stringify({ error: { details: { retryAfterSeconds: 180 } } }), {
|
|
61
|
+
status: 429,
|
|
62
|
+
}),
|
|
63
|
+
) as unknown as typeof fetch;
|
|
64
|
+
|
|
65
|
+
renderWithProviders(<RequestAccountUnlockScreen />);
|
|
66
|
+
fireEvent.change(screen.getByLabelText(/^E-Mail/), {
|
|
67
|
+
target: { value: "user@example.com" },
|
|
68
|
+
});
|
|
69
|
+
fireEvent.click(screen.getByRole("button", { name: "Link anfordern" }));
|
|
70
|
+
|
|
71
|
+
await waitFor(() => {
|
|
72
|
+
expect(screen.getByRole("alert").textContent).toContain("3 Minuten");
|
|
73
|
+
});
|
|
74
|
+
expect(screen.queryByText("Mail gesendet")).toBeNull();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("429 without retryAfterSeconds → generic rateLimited banner", async () => {
|
|
78
|
+
globalThis.fetch = mock(
|
|
79
|
+
async () => new Response(null, { status: 429 }),
|
|
80
|
+
) as unknown as typeof fetch;
|
|
81
|
+
|
|
82
|
+
renderWithProviders(<RequestAccountUnlockScreen />);
|
|
83
|
+
fireEvent.change(screen.getByLabelText(/^E-Mail/), {
|
|
84
|
+
target: { value: "user@example.com" },
|
|
85
|
+
});
|
|
86
|
+
fireEvent.click(screen.getByRole("button", { name: "Link anfordern" }));
|
|
87
|
+
|
|
88
|
+
await waitFor(() => {
|
|
89
|
+
expect(screen.getByRole("alert").textContent).toContain("Zu viele Login-Versuche");
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("server 5xx → unknownError banner, stays on form", async () => {
|
|
94
|
+
globalThis.fetch = mock(
|
|
95
|
+
async () => new Response(null, { status: 500 }),
|
|
96
|
+
) as unknown as typeof fetch;
|
|
97
|
+
|
|
98
|
+
renderWithProviders(<RequestAccountUnlockScreen />);
|
|
99
|
+
fireEvent.change(screen.getByLabelText(/^E-Mail/), {
|
|
100
|
+
target: { value: "user@example.com" },
|
|
101
|
+
});
|
|
102
|
+
fireEvent.click(screen.getByRole("button", { name: "Link anfordern" }));
|
|
103
|
+
|
|
104
|
+
await waitFor(() => {
|
|
105
|
+
expect(screen.getByRole("alert").textContent).toContain("schief");
|
|
106
|
+
});
|
|
107
|
+
expect(screen.queryByText("Mail gesendet")).toBeNull();
|
|
108
|
+
expect(screen.getByLabelText(/^E-Mail/)).toBeTruthy();
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("fetch throw → unknownError banner", async () => {
|
|
112
|
+
globalThis.fetch = mock(async () => {
|
|
113
|
+
throw new Error("network down");
|
|
114
|
+
}) as unknown as typeof fetch;
|
|
115
|
+
|
|
116
|
+
renderWithProviders(<RequestAccountUnlockScreen />);
|
|
117
|
+
fireEvent.change(screen.getByLabelText(/^E-Mail/), {
|
|
118
|
+
target: { value: "user@example.com" },
|
|
119
|
+
});
|
|
120
|
+
fireEvent.click(screen.getByRole("button", { name: "Link anfordern" }));
|
|
121
|
+
|
|
122
|
+
await waitFor(() => {
|
|
123
|
+
expect(screen.getByRole("alert").textContent).toContain("schief");
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("custom title prop overrides default", () => {
|
|
128
|
+
renderWithProviders(<RequestAccountUnlockScreen title="Custom Unlock" />);
|
|
129
|
+
expect(screen.getByText("Custom Unlock")).toBeTruthy();
|
|
130
|
+
});
|
|
131
|
+
});
|
|
@@ -233,6 +233,39 @@ export async function verifyEmail(
|
|
|
233
233
|
return { ok: false, error: await parseTokenFailure(res) };
|
|
234
234
|
}
|
|
235
235
|
|
|
236
|
+
// POST /api/auth/request-account-unlock. Same silent-success semantik wie
|
|
237
|
+
// request-password-reset. 429 → rate-limit-Failure.
|
|
238
|
+
export async function requestAccountUnlock(
|
|
239
|
+
email: string,
|
|
240
|
+
): Promise<{ ok: true } | { ok: false; error: AuthTokenFailure }> {
|
|
241
|
+
const res = await fetch("/api/auth/request-account-unlock", {
|
|
242
|
+
method: "POST",
|
|
243
|
+
credentials: "same-origin",
|
|
244
|
+
headers: { "Content-Type": "application/json", ...csrfHeader() },
|
|
245
|
+
body: JSON.stringify({ email }),
|
|
246
|
+
});
|
|
247
|
+
if (res.ok) return { ok: true };
|
|
248
|
+
return { ok: false, error: await parseTokenFailure(res) };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// POST /api/auth/confirm-account-unlock. Auto-submitted by
|
|
252
|
+
// ConfirmAccountUnlockScreen after parsing `?token=...`. Server collapses
|
|
253
|
+
// every verify failure into `invalid_unlock_token` (anti-enumeration,
|
|
254
|
+
// parallel to reset/verify). Only clears the Redis lockout state, no
|
|
255
|
+
// entity write.
|
|
256
|
+
export async function confirmAccountUnlock(
|
|
257
|
+
token: string,
|
|
258
|
+
): Promise<{ ok: true } | { ok: false; error: AuthTokenFailure }> {
|
|
259
|
+
const res = await fetch("/api/auth/confirm-account-unlock", {
|
|
260
|
+
method: "POST",
|
|
261
|
+
credentials: "same-origin",
|
|
262
|
+
headers: { "Content-Type": "application/json", ...csrfHeader() },
|
|
263
|
+
body: JSON.stringify({ token }),
|
|
264
|
+
});
|
|
265
|
+
if (res.ok) return { ok: true };
|
|
266
|
+
return { ok: false, error: await parseTokenFailure(res) };
|
|
267
|
+
}
|
|
268
|
+
|
|
236
269
|
// POST /api/auth/signup-request. Always-200 (anti-enumeration; wir
|
|
237
270
|
// sagen nicht ob die Email schon registriert ist). Server schickt
|
|
238
271
|
// Activation-Mail an die Adresse — beim Klick auf den Link landet der
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// @runtime client
|
|
2
|
+
// ConfirmAccountUnlockScreen — reads `?token=...` from the URL, auto-posts
|
|
3
|
+
// it to /api/auth/confirm-account-unlock on mount, shows success/error.
|
|
4
|
+
// Idempotent server-side (clearLockoutState is a no-op when already
|
|
5
|
+
// unlocked), so no re-submit risk. Mirrors VerifyEmailScreen 1:1.
|
|
6
|
+
//
|
|
7
|
+
// useEffect with empty deps + a once-guard so React Strict Mode's double
|
|
8
|
+
// useEffect dispatch in dev fires the confirm call only once.
|
|
9
|
+
|
|
10
|
+
import { usePrimitives, useTranslation } from "@cosmicdrift/kumiko-renderer";
|
|
11
|
+
import { type ReactNode, useEffect, useRef, useState } from "react";
|
|
12
|
+
import { confirmAccountUnlock } from "./auth-client";
|
|
13
|
+
import { AuthCard, useUrlToken } from "./auth-form-primitives";
|
|
14
|
+
|
|
15
|
+
export type ConfirmAccountUnlockScreenProps = {
|
|
16
|
+
readonly title?: string;
|
|
17
|
+
/** Override for the token from the URL. Default: parsed from `?token=...`. */
|
|
18
|
+
readonly token?: string;
|
|
19
|
+
/** href for the "go to sign in" link. Default "/login". */
|
|
20
|
+
readonly loginHref?: string;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
type Status = "verifying" | "success" | "error" | "missing-token";
|
|
24
|
+
|
|
25
|
+
export function ConfirmAccountUnlockScreen({
|
|
26
|
+
title,
|
|
27
|
+
token: tokenProp,
|
|
28
|
+
loginHref = "/login",
|
|
29
|
+
}: ConfirmAccountUnlockScreenProps): ReactNode {
|
|
30
|
+
const t = useTranslation();
|
|
31
|
+
const { Link } = usePrimitives();
|
|
32
|
+
const token = useUrlToken(tokenProp);
|
|
33
|
+
const [status, setStatus] = useState<Status>(token === "" ? "missing-token" : "verifying");
|
|
34
|
+
const startedRef = useRef(false);
|
|
35
|
+
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
if (status !== "verifying") return;
|
|
38
|
+
if (startedRef.current) return;
|
|
39
|
+
startedRef.current = true;
|
|
40
|
+
void (async () => {
|
|
41
|
+
const res = await confirmAccountUnlock(token);
|
|
42
|
+
setStatus(res.ok ? "success" : "error");
|
|
43
|
+
})();
|
|
44
|
+
}, [status, token]);
|
|
45
|
+
|
|
46
|
+
if (status === "missing-token") {
|
|
47
|
+
return (
|
|
48
|
+
<AuthCard title={title ?? t("auth.unlockAccount.errorTitle")}>
|
|
49
|
+
<div className="p-6 pt-0 flex flex-col gap-4">
|
|
50
|
+
<p className="text-sm text-muted-foreground">{t("auth.unlockAccount.missingToken")}</p>
|
|
51
|
+
<Link href={loginHref} variant="muted">
|
|
52
|
+
{t("auth.unlockAccount.goToLogin")}
|
|
53
|
+
</Link>
|
|
54
|
+
</div>
|
|
55
|
+
</AuthCard>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (status === "verifying") {
|
|
60
|
+
return (
|
|
61
|
+
<AuthCard>
|
|
62
|
+
<div className="p-6">
|
|
63
|
+
<p className="text-sm text-muted-foreground" role="status">
|
|
64
|
+
{t("auth.unlockAccount.verifying")}
|
|
65
|
+
</p>
|
|
66
|
+
</div>
|
|
67
|
+
</AuthCard>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (status === "success") {
|
|
72
|
+
return (
|
|
73
|
+
<AuthCard title={title ?? t("auth.unlockAccount.successTitle")}>
|
|
74
|
+
<div className="p-6 pt-0 flex flex-col gap-4">
|
|
75
|
+
<p className="text-sm text-muted-foreground">{t("auth.unlockAccount.successBody")}</p>
|
|
76
|
+
<Link href={loginHref} variant="button">
|
|
77
|
+
{t("auth.unlockAccount.goToLogin")}
|
|
78
|
+
</Link>
|
|
79
|
+
</div>
|
|
80
|
+
</AuthCard>
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// status === "error"
|
|
85
|
+
return (
|
|
86
|
+
<AuthCard title={title ?? t("auth.unlockAccount.errorTitle")}>
|
|
87
|
+
<div className="p-6 pt-0 flex flex-col gap-4">
|
|
88
|
+
<p className="text-sm text-muted-foreground">{t("auth.unlockAccount.errorBody")}</p>
|
|
89
|
+
<Link href={loginHref} variant="muted">
|
|
90
|
+
{t("auth.unlockAccount.goToLogin")}
|
|
91
|
+
</Link>
|
|
92
|
+
</div>
|
|
93
|
+
</AuthCard>
|
|
94
|
+
);
|
|
95
|
+
}
|
|
@@ -18,7 +18,9 @@ export type {
|
|
|
18
18
|
VerifyEmailFailure,
|
|
19
19
|
} from "./auth-client";
|
|
20
20
|
export {
|
|
21
|
+
confirmAccountUnlock,
|
|
21
22
|
confirmSignup,
|
|
23
|
+
requestAccountUnlock,
|
|
22
24
|
requestEmailVerification,
|
|
23
25
|
requestPasswordReset,
|
|
24
26
|
requestSignup,
|
|
@@ -34,6 +36,8 @@ export type {
|
|
|
34
36
|
EmailPasswordClientOptions,
|
|
35
37
|
} from "./client-plugin";
|
|
36
38
|
export { emailPasswordClient } from "./client-plugin";
|
|
39
|
+
export type { ConfirmAccountUnlockScreenProps } from "./confirm-account-unlock-screen";
|
|
40
|
+
export { ConfirmAccountUnlockScreen } from "./confirm-account-unlock-screen";
|
|
37
41
|
export type { DefaultTopbarActionsProps } from "./default-topbar-actions";
|
|
38
42
|
export { DefaultTopbarActions } from "./default-topbar-actions";
|
|
39
43
|
export type { ForgotPasswordScreenProps } from "./forgot-password-screen";
|
|
@@ -42,6 +46,8 @@ export type { InviteAcceptScreenProps } from "./invite-accept-screen";
|
|
|
42
46
|
export { InviteAcceptScreen } from "./invite-accept-screen";
|
|
43
47
|
export type { AuthLegalLink, LoginScreenProps } from "./login-screen";
|
|
44
48
|
export { LoginScreen } from "./login-screen";
|
|
49
|
+
export type { RequestAccountUnlockScreenProps } from "./request-account-unlock-screen";
|
|
50
|
+
export { RequestAccountUnlockScreen } from "./request-account-unlock-screen";
|
|
45
51
|
export type { ResetPasswordScreenProps } from "./reset-password-screen";
|
|
46
52
|
export { ResetPasswordScreen } from "./reset-password-screen";
|
|
47
53
|
export type { SessionApi, SessionState, SessionStatus } from "./session";
|
|
@@ -40,6 +40,13 @@ export type LoginScreenProps = {
|
|
|
40
40
|
* Link. Apps die den Reset-Flow NICHT anbieten (z.B. nur Magic-Link),
|
|
41
41
|
* setzen das einfach nicht — Login bleibt minimalistisch. */
|
|
42
42
|
readonly forgotPasswordHref?: string;
|
|
43
|
+
/** Optional href to RequestAccountUnlockScreen (#1266). When set, an
|
|
44
|
+
* account_locked login error renders an "Unlock account?" link inline —
|
|
45
|
+
* the account-lockout escape hatch, since forgotPasswordHref does NOT
|
|
46
|
+
* help here: the lockout gate blocks login before password verify even
|
|
47
|
+
* runs, so it also blocks the post-reset login. Apps without
|
|
48
|
+
* accountUnlock configured just leave this unset. */
|
|
49
|
+
readonly unlockAccountHref?: string;
|
|
43
50
|
/** Optional href zum SignupScreen. Wenn gesetzt rendert die LoginScreen
|
|
44
51
|
* einen "Account erstellen"-Link. Apps die kein Self-Signup wollen
|
|
45
52
|
* (closed-invite-only) setzen das einfach nicht. */
|
|
@@ -100,6 +107,7 @@ export function LoginScreen({
|
|
|
100
107
|
subtitle,
|
|
101
108
|
submitLabel,
|
|
102
109
|
forgotPasswordHref,
|
|
110
|
+
unlockAccountHref,
|
|
103
111
|
signupHref,
|
|
104
112
|
legalLinks,
|
|
105
113
|
onMfaChallenge,
|
|
@@ -229,6 +237,13 @@ export function LoginScreen({
|
|
|
229
237
|
</Button>
|
|
230
238
|
</span>
|
|
231
239
|
)}
|
|
240
|
+
{error.reason === "account_locked" && unlockAccountHref !== undefined && (
|
|
241
|
+
<span className="self-start">
|
|
242
|
+
<Link href={unlockAccountHref} variant="default">
|
|
243
|
+
{t("auth.login.unlockAccount")}
|
|
244
|
+
</Link>
|
|
245
|
+
</span>
|
|
246
|
+
)}
|
|
232
247
|
{resendStatus.kind === "rateLimited" && (
|
|
233
248
|
<span className="text-xs">{t("auth.login.resendRateLimited")}</span>
|
|
234
249
|
)}
|