@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.
Files changed (163) hide show
  1. package/package.json +7 -6
  2. package/src/auth-email-password/__tests__/account-unlock.integration.test.ts +283 -0
  3. package/src/auth-email-password/__tests__/auth-user-row.test.ts +36 -0
  4. package/src/auth-email-password/__tests__/auth.integration.test.ts +21 -0
  5. package/src/auth-email-password/__tests__/email-verification.integration.test.ts +1 -1
  6. package/src/auth-email-password/__tests__/password-policy.test.ts +15 -0
  7. package/src/auth-email-password/__tests__/password-reset.integration.test.ts +15 -1
  8. package/src/auth-email-password/__tests__/session-strict-mode.integration.test.ts +7 -14
  9. package/src/auth-email-password/__tests__/signup-flow.integration.test.ts +12 -0
  10. package/src/auth-email-password/constants.ts +15 -0
  11. package/src/auth-email-password/email-templates.ts +32 -0
  12. package/src/auth-email-password/errors.ts +9 -0
  13. package/src/auth-email-password/feature.ts +37 -5
  14. package/src/auth-email-password/handlers/change-password.write.ts +2 -1
  15. package/src/auth-email-password/handlers/confirm-account-unlock.write.ts +59 -0
  16. package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +1 -0
  17. package/src/auth-email-password/handlers/invite-accept.write.ts +6 -2
  18. package/src/auth-email-password/handlers/invite-signup-complete.write.ts +1 -0
  19. package/src/auth-email-password/handlers/request-account-unlock.write.ts +38 -0
  20. package/src/auth-email-password/handlers/reset-password.write.ts +2 -1
  21. package/src/auth-email-password/handlers/signup-confirm.write.ts +2 -1
  22. package/src/auth-email-password/i18n.ts +42 -0
  23. package/src/auth-email-password/index.ts +2 -0
  24. package/src/auth-email-password/lockout-store.ts +10 -5
  25. package/src/auth-email-password/password-policy.ts +2108 -0
  26. package/src/auth-email-password/signed-token.ts +1 -0
  27. package/src/auth-email-password/unlock-token.ts +28 -0
  28. package/src/auth-email-password/web/__tests__/auth-client.test.ts +525 -0
  29. package/src/auth-email-password/web/__tests__/auth-gate.test.tsx +65 -3
  30. package/src/auth-email-password/web/__tests__/confirm-account-unlock-screen.test.tsx +57 -0
  31. package/src/auth-email-password/web/__tests__/invite-accept-screen.test.tsx +126 -2
  32. package/src/auth-email-password/web/__tests__/login-screen.test.tsx +125 -0
  33. package/src/auth-email-password/web/__tests__/request-account-unlock-screen.test.tsx +131 -0
  34. package/src/auth-email-password/web/auth-client.ts +33 -0
  35. package/src/auth-email-password/web/confirm-account-unlock-screen.tsx +95 -0
  36. package/src/auth-email-password/web/index.ts +6 -0
  37. package/src/auth-email-password/web/login-screen.tsx +15 -0
  38. package/src/auth-email-password/web/request-account-unlock-screen.tsx +112 -0
  39. package/src/auth-foundation/__tests__/feature.test.ts +33 -0
  40. package/src/auth-foundation/__tests__/session-store.test.ts +78 -0
  41. package/src/auth-foundation/__tests__/token-verifier.test.ts +117 -0
  42. package/src/auth-foundation/boot-checks.ts +91 -0
  43. package/src/auth-foundation/feature.ts +103 -0
  44. package/src/auth-foundation/index.ts +23 -0
  45. package/src/auth-foundation/types.ts +107 -0
  46. package/src/auth-mfa/__tests__/errors-i18n-parity.test.ts +8 -0
  47. package/src/auth-mfa/__tests__/verify.integration.test.ts +44 -0
  48. package/src/auth-mfa/handlers/verify.write.ts +1 -1
  49. package/src/auth-mfa/web/__tests__/mfa-client.test.ts +118 -0
  50. package/src/auth-mfa/web/__tests__/mfa-enable-screen.test.tsx +173 -0
  51. package/src/auth-mfa/web/__tests__/mfa-verify-screen.test.tsx +100 -0
  52. package/src/billing-foundation/__tests__/subscription-provider-contract.ts +69 -0
  53. package/src/channel-email/pii-guard.ts +6 -3
  54. package/src/config/__tests__/app-overrides.test.ts +11 -0
  55. package/src/config/__tests__/backing-secrets.integration.test.ts +18 -0
  56. package/src/config/__tests__/cascade.integration.test.ts +39 -0
  57. package/src/config/__tests__/config.integration.test.ts +14 -0
  58. package/src/config/__tests__/pii-encrypted.integration.test.ts +23 -2
  59. package/src/config/__tests__/reencrypt-job-concurrent-write-race.integration.test.ts +20 -3
  60. package/src/config/__tests__/reencrypt-job-kek-rotation.integration.test.ts +2 -3
  61. package/src/config/feature.ts +2 -4
  62. package/src/config/handlers/set.write.ts +7 -1
  63. package/src/config/resolver.ts +1 -1
  64. package/src/custom-fields/__tests__/user-data-rights.integration.test.ts +2 -0
  65. package/src/delivery/handlers/log.query.ts +1 -1
  66. package/src/files-provider-s3/__tests__/s3-provider.integration.test.ts +7 -0
  67. package/src/folders/web/__tests__/folder-manager.test.tsx +27 -2
  68. package/src/inbound-mail-foundation/__tests__/account-state.test.ts +30 -0
  69. package/src/inbound-mail-foundation/__tests__/inbound-mail-foundation.integration.test.ts +57 -6
  70. package/src/inbound-mail-foundation/__tests__/inbound-mail-provider-contract.ts +93 -0
  71. package/src/inbound-mail-foundation/__tests__/watch-supervisor.integration.test.ts +154 -0
  72. package/src/inbound-mail-foundation/handlers/ingest-message.write.ts +60 -50
  73. package/src/inbound-provider-imap/__tests__/imap-live.integration.test.ts +12 -0
  74. package/src/inbound-provider-imap/__tests__/plugin-mocked.test.ts +331 -0
  75. package/src/inbound-provider-inmemory/__tests__/feature.test.ts +48 -0
  76. package/src/inbound-provider-inmemory/feature.ts +2 -2
  77. package/src/jobs/__tests__/reindex-entity-job.integration.test.ts +53 -1
  78. package/src/jobs/handlers/detail.query.ts +1 -1
  79. package/src/jobs/handlers/list.query.ts +1 -1
  80. package/src/mail-foundation/__tests__/mail-transport-contract.ts +42 -0
  81. package/src/mail-transport-inmemory/__tests__/feature.test.ts +19 -0
  82. package/src/mail-transport-smtp/__tests__/feature.test.ts +60 -0
  83. package/src/managed-pages/handlers/branding.query.ts +5 -1
  84. package/src/personal-access-tokens/__tests__/feature.test.ts +18 -0
  85. package/src/personal-access-tokens/__tests__/pat.integration.test.ts +8 -11
  86. package/src/personal-access-tokens/feature.ts +23 -8
  87. package/src/personal-access-tokens/handlers/list.query.ts +1 -1
  88. package/src/personal-access-tokens/index.ts +0 -14
  89. package/src/personal-access-tokens/resolver.ts +2 -2
  90. package/src/secrets/__tests__/rotate.integration.test.ts +31 -0
  91. package/src/sessions/__tests__/cleanup.integration.test.ts +2 -1
  92. package/src/sessions/__tests__/password-auto-revoke.integration.test.ts +5 -3
  93. package/src/sessions/__tests__/rebuild-survival.integration.test.ts +11 -2
  94. package/src/sessions/__tests__/session-store-registration.test.ts +21 -0
  95. package/src/sessions/__tests__/sessions-screens.boot.test.ts +10 -1
  96. package/src/sessions/__tests__/sessions.integration.test.ts +28 -17
  97. package/src/sessions/__tests__/test-helpers.ts +15 -2
  98. package/src/sessions/feature.ts +31 -3
  99. package/src/sessions/handlers/detail.query.ts +2 -2
  100. package/src/sessions/handlers/list.query.ts +4 -2
  101. package/src/sessions/handlers/mine.query.ts +4 -2
  102. package/src/sessions/testing.ts +14 -1
  103. package/src/shared/__tests__/chunked-entity-migration.test.ts +145 -0
  104. package/src/shared/decrypt-stored-pii.ts +12 -5
  105. package/src/subscription-mollie/__tests__/feature.test.ts +87 -0
  106. package/src/subscription-stripe/__tests__/feature.test.ts +120 -1
  107. package/src/tags/web/__tests__/tag-manager.test.tsx +277 -8
  108. package/src/tenant/__tests__/members-query-kms.integration.test.ts +98 -0
  109. package/src/tenant/handlers/invitations.query.ts +4 -2
  110. package/src/tenant/handlers/members.query.ts +2 -2
  111. package/src/tenant/seeding.ts +2 -2
  112. package/src/tenant-lifecycle/__tests__/tenant-lifecycle.integration.test.ts +2 -0
  113. package/src/tenant-lifecycle/boot-checks.ts +53 -0
  114. package/src/tenant-lifecycle/feature.ts +7 -0
  115. package/src/tenant-lifecycle/handlers/request-destruction.write.ts +1 -1
  116. package/src/tier-engine/feature.ts +2 -2
  117. package/src/user-data-rights/COMPLIANCE.md +20 -0
  118. package/src/user-data-rights/__tests__/anonymous-deletion-kms.integration.test.ts +2 -0
  119. package/src/user-data-rights/__tests__/anonymous-deletion.integration.test.ts +3 -0
  120. package/src/user-data-rights/__tests__/audit-log.integration.test.ts +2 -0
  121. package/src/user-data-rights/__tests__/boot-checks.test.ts +172 -0
  122. package/src/user-data-rights/__tests__/cross-data-matrix.integration.test.ts +2 -0
  123. package/src/user-data-rights/__tests__/download-by-token-resolver-trust.integration.test.ts +2 -0
  124. package/src/user-data-rights/__tests__/download.integration.test.ts +59 -4
  125. package/src/user-data-rights/__tests__/export-encrypted-fields.integration.test.ts +2 -0
  126. package/src/user-data-rights/__tests__/export-job-idempotency.integration.test.ts +2 -0
  127. package/src/user-data-rights/__tests__/export-job-list-cross-tenant.integration.test.ts +2 -0
  128. package/src/user-data-rights/__tests__/file-binary-forget-cleanup.integration.test.ts +2 -0
  129. package/src/user-data-rights/__tests__/file-binary-forget-failure.integration.test.ts +2 -0
  130. package/src/user-data-rights/__tests__/file-retention.integration.test.ts +2 -0
  131. package/src/user-data-rights/__tests__/file-storage-unification.integration.test.ts +2 -0
  132. package/src/user-data-rights/__tests__/forget-cleanup-hook-ordering.integration.test.ts +2 -0
  133. package/src/user-data-rights/__tests__/forget-hook-registry.integration.test.ts +2 -0
  134. package/src/user-data-rights/__tests__/inspector-screens.boot.test.ts +10 -1
  135. package/src/user-data-rights/__tests__/mail-default-bridge.integration.test.ts +2 -0
  136. package/src/user-data-rights/__tests__/read-users-rebuild-survives-lifecycle.integration.test.ts +15 -7
  137. package/src/user-data-rights/__tests__/request-cancel-deletion.integration.test.ts +2 -0
  138. package/src/user-data-rights/__tests__/request-deletion-callback.integration.test.ts +2 -0
  139. package/src/user-data-rights/__tests__/request-export.integration.test.ts +2 -0
  140. package/src/user-data-rights/__tests__/restriction-flow.integration.test.ts +48 -15
  141. package/src/user-data-rights/__tests__/run-export-jobs-cron-context.integration.test.ts +2 -0
  142. package/src/user-data-rights/__tests__/run-export-jobs.integration.test.ts +184 -1
  143. package/src/user-data-rights/__tests__/run-forget-cleanup.integration.test.ts +2 -0
  144. package/src/user-data-rights/__tests__/run-user-export.integration.test.ts +2 -0
  145. package/src/user-data-rights/__tests__/tenant-model-erasure.integration.test.ts +2 -0
  146. package/src/user-data-rights/__tests__/user-data-rights.integration.test.ts +9 -1
  147. package/src/user-data-rights/boot-checks.ts +86 -0
  148. package/src/user-data-rights/feature.ts +116 -6
  149. package/src/user-data-rights/handlers/deletion-grace-period.ts +1 -1
  150. package/src/user-data-rights/handlers/download-by-token.query.ts +1 -1
  151. package/src/user-data-rights/handlers/lift-restriction.write.ts +13 -19
  152. package/src/user-data-rights/handlers/restrict-account.write.ts +30 -8
  153. package/src/user-data-rights/run-export-jobs.ts +3 -3
  154. package/src/user-data-rights/run-forget-cleanup.ts +1 -1
  155. package/src/user-data-rights/web/__tests__/privacy-center-screen.test.tsx +1 -0
  156. package/src/user-data-rights-defaults/__tests__/bundled-entities.userdata-hooks.integration.test.ts +3 -0
  157. package/src/user-data-rights-defaults/__tests__/feature-mounted.test.ts +24 -0
  158. package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.test.ts +2 -0
  159. package/src/user-data-rights-defaults/hooks/tenant-invitation.userdata-hook.ts +1 -1
  160. package/src/user-profile/__tests__/change-email.integration.test.ts +2 -0
  161. package/src/user-profile/__tests__/profile-screen.test.tsx +1 -0
  162. package/src/user-profile/handlers/change-email.write.ts +1 -1
  163. 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
  )}