@cosmicdrift/kumiko-bundled-features 0.46.0 → 0.48.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.46.0",
3
+ "version": "0.48.0",
4
4
  "description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -40,6 +40,7 @@
40
40
  "./file-provider-inmemory": "./src/file-provider-inmemory/index.ts",
41
41
  "./files": "./src/files/index.ts",
42
42
  "./user-data-rights": "./src/user-data-rights/index.ts",
43
+ "./user-data-rights/web": "./src/user-data-rights/web/index.ts",
43
44
  "./user-data-rights-defaults": "./src/user-data-rights-defaults/index.ts",
44
45
  "./tenant": "./src/tenant/index.ts",
45
46
  "./tenant/constants": "./src/tenant/constants.ts",
@@ -76,11 +77,11 @@
76
77
  "./step-dispatcher": "./src/step-dispatcher/index.ts"
77
78
  },
78
79
  "dependencies": {
79
- "@cosmicdrift/kumiko-dispatcher-live": "0.40.1",
80
- "@cosmicdrift/kumiko-framework": "0.40.1",
81
- "@cosmicdrift/kumiko-headless": "0.40.1",
82
- "@cosmicdrift/kumiko-renderer": "0.40.1",
83
- "@cosmicdrift/kumiko-renderer-web": "0.40.1",
80
+ "@cosmicdrift/kumiko-dispatcher-live": "0.45.0",
81
+ "@cosmicdrift/kumiko-framework": "0.45.0",
82
+ "@cosmicdrift/kumiko-headless": "0.45.0",
83
+ "@cosmicdrift/kumiko-renderer": "0.45.0",
84
+ "@cosmicdrift/kumiko-renderer-web": "0.45.0",
84
85
  "@mollie/api-client": "^4.5.0",
85
86
  "@node-rs/argon2": "^2.0.2",
86
87
  "@types/nodemailer": "^8.0.0",
@@ -0,0 +1,35 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { ReactNode } from "react";
3
+ import { AuthCard, AuthShellProvider } from "../auth-form-primitives";
4
+ import { renderWithProviders } from "./test-utils";
5
+
6
+ describe("AuthCard / AuthShell", () => {
7
+ test("ohne Provider → Default-Fullscreen-Wrapper (rückwärtskompatibel)", () => {
8
+ const { container } = renderWithProviders(
9
+ <AuthCard title="Login">
10
+ <div data-testid="body">body</div>
11
+ </AuthCard>,
12
+ );
13
+ expect(container.querySelector(".min-h-screen")).not.toBeNull();
14
+ expect(container.querySelector(".max-w-sm")).not.toBeNull();
15
+ expect(container.querySelector("[data-testid=body]")).not.toBeNull();
16
+ });
17
+
18
+ test("mit Provider → App-Shell ersetzt Fullscreen-Wrapper, Card bleibt", () => {
19
+ function Shell({ card }: { readonly card: ReactNode }): ReactNode {
20
+ return <div data-testid="apex-chrome">{card}</div>;
21
+ }
22
+ const { container } = renderWithProviders(
23
+ <AuthShellProvider shell={(card) => <Shell card={card} />}>
24
+ <AuthCard title="Login">
25
+ <div data-testid="body">body</div>
26
+ </AuthCard>
27
+ </AuthShellProvider>,
28
+ );
29
+ expect(container.querySelector("[data-testid=apex-chrome]")).not.toBeNull();
30
+ expect(container.querySelector(".min-h-screen")).toBeNull();
31
+ // Card-Box (max-w-sm) + Inhalt bleiben — Shell wrappt nur, ersetzt nicht.
32
+ expect(container.querySelector(".max-w-sm")).not.toBeNull();
33
+ expect(container.querySelector("[data-testid=body]")).not.toBeNull();
34
+ });
35
+ });
@@ -16,7 +16,33 @@
16
16
  // parseUrlToken — URL-Param-Helper (window.location.search).
17
17
 
18
18
  import { cn } from "@cosmicdrift/kumiko-renderer-web";
19
- import type { ReactNode } from "react";
19
+ import { createContext, type ReactNode, useContext } from "react";
20
+
21
+ // Wrappt die zentrierte Auth-Card in ihre Umgebung. Default = Fullscreen-
22
+ // zentriert (Standalone-Auth-Page). Eine Apex-/Marketing-Chrome reicht über
23
+ // AuthShellProvider eine eigene Variante rein, ohne dass `min-h-screen` die
24
+ // Chrome übermalt.
25
+ export type AuthShellRenderer = (card: ReactNode) => ReactNode;
26
+
27
+ const defaultAuthShell: AuthShellRenderer = (card) => (
28
+ <div className="min-h-screen flex items-center justify-center bg-background px-4">{card}</div>
29
+ );
30
+
31
+ const AuthShellContext = createContext<AuthShellRenderer | null>(null);
32
+
33
+ export function AuthShellProvider({
34
+ shell,
35
+ children,
36
+ }: {
37
+ readonly shell: AuthShellRenderer;
38
+ readonly children: ReactNode;
39
+ }): ReactNode {
40
+ return <AuthShellContext.Provider value={shell}>{children}</AuthShellContext.Provider>;
41
+ }
42
+
43
+ export function useAuthShell(): AuthShellRenderer | null {
44
+ return useContext(AuthShellContext);
45
+ }
20
46
 
21
47
  export type AuthCardProps = {
22
48
  readonly title?: string;
@@ -25,21 +51,19 @@ export type AuthCardProps = {
25
51
  };
26
52
 
27
53
  export function AuthCard({ title, subtitle, children }: AuthCardProps): ReactNode {
28
- return (
29
- <div className="min-h-screen flex items-center justify-center bg-background px-4">
30
- <div className="w-full max-w-sm rounded-lg border bg-card text-card-foreground shadow-sm">
31
- {(title !== undefined || subtitle !== undefined) && (
32
- <div className="flex flex-col space-y-1.5 p-6 pb-4">
33
- {title !== undefined && (
34
- <h1 className="text-xl font-semibold tracking-tight">{title}</h1>
35
- )}
36
- {subtitle !== undefined && <p className="text-sm text-muted-foreground">{subtitle}</p>}
37
- </div>
38
- )}
39
- {children}
40
- </div>
54
+ const shell = useAuthShell() ?? defaultAuthShell;
55
+ const card = (
56
+ <div className="w-full max-w-sm rounded-lg border bg-card text-card-foreground shadow-sm">
57
+ {(title !== undefined || subtitle !== undefined) && (
58
+ <div className="flex flex-col space-y-1.5 p-6 pb-4">
59
+ {title !== undefined && <h1 className="text-xl font-semibold tracking-tight">{title}</h1>}
60
+ {subtitle !== undefined && <p className="text-sm text-muted-foreground">{subtitle}</p>}
61
+ </div>
62
+ )}
63
+ {children}
41
64
  </div>
42
65
  );
66
+ return shell(card);
43
67
  }
44
68
 
45
69
  // Primary-button-Style für anchor-Tags die wie ein Button aussehen
@@ -24,6 +24,8 @@ export {
24
24
  resetPassword,
25
25
  verifyEmail,
26
26
  } from "./auth-client";
27
+ export type { AuthShellRenderer } from "./auth-form-primitives";
28
+ export { AuthShellProvider, useAuthShell } from "./auth-form-primitives";
27
29
  export { makeAuthGate } from "./auth-gate";
28
30
  export type {
29
31
  EmailPasswordClientFeature,
@@ -0,0 +1,281 @@
1
+ // Anonymer, email-verifizierter Deletion-Flow (Apex, Lockout-sicher).
2
+ //
3
+ // Schritt 1 (request-deletion-by-email): anonym, enumeration-safe, signt ein
4
+ // HMAC-Token + ruft den Verify-Mail-Callback. Schritt 2 (confirm-deletion-by-
5
+ // token): anonym, verifiziert das Token + startet die Grace-Period.
6
+ // Beweist end-to-end via echte /api/write-Calls OHNE Auth (anonymousAccess).
7
+
8
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
9
+ import { insertOne, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
10
+ import { createEventsTable, eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
11
+ import {
12
+ createTestUser,
13
+ setupTestStack,
14
+ type TestStack,
15
+ testTenantId,
16
+ unsafeCreateEntityTable,
17
+ } from "@cosmicdrift/kumiko-framework/stack";
18
+ import { resetTestTables } from "@cosmicdrift/kumiko-framework/testing";
19
+ import {
20
+ createComplianceProfilesFeature,
21
+ tenantComplianceProfileEntity,
22
+ tenantComplianceProfileTable,
23
+ } from "../../compliance-profiles";
24
+ import { createDataRetentionFeature } from "../../data-retention";
25
+ import { createSessionsFeature } from "../../sessions";
26
+ import { USER_STATUS, userEntity, userTable } from "../../user";
27
+ import { createUserFeature } from "../../user/feature";
28
+ import { signDeletionToken } from "../deletion-token";
29
+ import { createUserDataRightsFeature } from "../feature";
30
+ import type { SendDeletionVerificationEmailFn } from "../handlers/request-deletion-by-email.write";
31
+
32
+ const REQUEST_BY_EMAIL = "user-data-rights:write:request-deletion-by-email";
33
+ const CONFIRM_BY_TOKEN = "user-data-rights:write:confirm-deletion-by-token";
34
+ const DELETION_SECRET = "test-deletion-secret-0123456789abcdef";
35
+ const VERIFY_URL = "https://app.example.test/delete-account/confirm";
36
+
37
+ const tenantA = testTenantId(1);
38
+ const aliceUser = createTestUser({ id: 42, tenantId: tenantA, roles: ["Member"] });
39
+ const ALICE_EMAIL = "alice.anon@example.com";
40
+
41
+ type VerifyArgs = Parameters<SendDeletionVerificationEmailFn>[0];
42
+ const verifyCalls: VerifyArgs[] = [];
43
+ const sendDeletionVerificationEmail: SendDeletionVerificationEmailFn = async (args) => {
44
+ verifyCalls.push(args);
45
+ };
46
+
47
+ let stack: TestStack;
48
+
49
+ beforeAll(async () => {
50
+ stack = await setupTestStack({
51
+ features: [
52
+ createUserFeature(),
53
+ createDataRetentionFeature(),
54
+ createComplianceProfilesFeature(),
55
+ createSessionsFeature(),
56
+ createUserDataRightsFeature({
57
+ deletionTokenSecret: DELETION_SECRET,
58
+ deletionVerifyUrl: VERIFY_URL,
59
+ sendDeletionVerificationEmail,
60
+ }),
61
+ ],
62
+ anonymousAccess: { defaultTenantId: tenantA },
63
+ });
64
+ await unsafeCreateEntityTable(stack.db, userEntity);
65
+ await unsafeCreateEntityTable(stack.db, tenantComplianceProfileEntity);
66
+ await createEventsTable(stack.db);
67
+ });
68
+
69
+ afterAll(async () => {
70
+ await stack.cleanup();
71
+ });
72
+
73
+ beforeEach(async () => {
74
+ verifyCalls.length = 0;
75
+ await resetTestTables(stack.db, [userTable, tenantComplianceProfileTable, eventsTable]);
76
+ });
77
+
78
+ async function seedAlice(status: string = USER_STATUS.Active, email: string = ALICE_EMAIL) {
79
+ await insertOne(stack.db, userTable, {
80
+ id: aliceUser.id,
81
+ tenantId: tenantA,
82
+ email,
83
+ passwordHash: "hashed",
84
+ displayName: "Alice",
85
+ locale: "de",
86
+ emailVerified: true,
87
+ roles: '["Member"]',
88
+ status,
89
+ gracePeriodEnd: null,
90
+ });
91
+ }
92
+
93
+ function tokenFromLastVerifyCall(): string {
94
+ const url = new URL(verifyCalls[0]?.verifyUrl ?? "");
95
+ return url.searchParams.get("token") ?? "";
96
+ }
97
+
98
+ async function statusOf(): Promise<string | undefined> {
99
+ const rows = (await selectMany(stack.db, userTable, { id: aliceUser.id })) as Array<{
100
+ status: string;
101
+ }>;
102
+ return rows[0]?.status;
103
+ }
104
+
105
+ describe("anonymous deletion flow", () => {
106
+ test("request-by-email (anonym) für aktiven User → success + Verify-Mail mit Token", async () => {
107
+ await seedAlice();
108
+
109
+ const res = await stack.http.raw("POST", "/api/write", {
110
+ type: REQUEST_BY_EMAIL,
111
+ payload: { email: ALICE_EMAIL },
112
+ });
113
+ expect(res.status).toBe(200);
114
+ expect(((await res.json()) as { isSuccess: boolean }).isSuccess).toBe(true);
115
+
116
+ expect(verifyCalls).toHaveLength(1);
117
+ expect(verifyCalls[0]?.email).toBe(ALICE_EMAIL);
118
+ expect(verifyCalls[0]?.verifyUrl.startsWith(`${VERIFY_URL}?token=`)).toBe(true);
119
+ expect(tokenFromLastVerifyCall().length).toBeGreaterThan(0);
120
+ // Status noch NICHT geflipt — erst confirm startet die Grace-Period.
121
+ expect(await statusOf()).toBe(USER_STATUS.Active);
122
+ });
123
+
124
+ test("confirm-by-token (anonym) mit echtem Token → Grace-Period gestartet", async () => {
125
+ await seedAlice();
126
+ await stack.http.raw("POST", "/api/write", {
127
+ type: REQUEST_BY_EMAIL,
128
+ payload: { email: ALICE_EMAIL },
129
+ });
130
+ const token = tokenFromLastVerifyCall();
131
+
132
+ const res = await stack.http.raw("POST", "/api/write", {
133
+ type: CONFIRM_BY_TOKEN,
134
+ payload: { token },
135
+ });
136
+ expect(res.status).toBe(200);
137
+ const body = (await res.json()) as {
138
+ isSuccess: boolean;
139
+ data: { status: string; gracePeriodEnd: string };
140
+ };
141
+ expect(body.isSuccess).toBe(true);
142
+ expect(body.data.status).toBe(USER_STATUS.DeletionRequested);
143
+ expect(body.data.gracePeriodEnd.length).toBeGreaterThan(0);
144
+
145
+ // DB-State tatsächlich geflipt + gracePeriodEnd gesetzt.
146
+ const rows = (await selectMany(stack.db, userTable, { id: aliceUser.id })) as Array<{
147
+ status: string;
148
+ gracePeriodEnd: unknown;
149
+ }>;
150
+ expect(rows[0]?.status).toBe(USER_STATUS.DeletionRequested);
151
+ expect(rows[0]?.gracePeriodEnd).not.toBeNull();
152
+ });
153
+
154
+ test("confirm-replay (anonym): zweites Confirm mit gleichem Token → 422, Status unverändert", async () => {
155
+ await seedAlice();
156
+ await stack.http.raw("POST", "/api/write", {
157
+ type: REQUEST_BY_EMAIL,
158
+ payload: { email: ALICE_EMAIL },
159
+ });
160
+ const token = tokenFromLastVerifyCall();
161
+
162
+ const first = await stack.http.raw("POST", "/api/write", {
163
+ type: CONFIRM_BY_TOKEN,
164
+ payload: { token },
165
+ });
166
+ expect(first.status).toBe(200);
167
+ expect(await statusOf()).toBe(USER_STATUS.DeletionRequested);
168
+
169
+ // Token ist bewusst replaybar (kein single-use); die Replay-Sicherheit kommt
170
+ // allein aus dem Active-State-Guard — zweites Confirm trifft non-active → 422.
171
+ const second = await stack.http.raw("POST", "/api/write", {
172
+ type: CONFIRM_BY_TOKEN,
173
+ payload: { token },
174
+ });
175
+ expect(second.status).toBe(422);
176
+ expect(await statusOf()).toBe(USER_STATUS.DeletionRequested);
177
+ });
178
+
179
+ test("request-by-email für nicht-existente Email → success, KEINE Mail (enumeration-safe)", async () => {
180
+ await seedAlice();
181
+ const res = await stack.http.raw("POST", "/api/write", {
182
+ type: REQUEST_BY_EMAIL,
183
+ payload: { email: "ghost@example.com" },
184
+ });
185
+ expect(res.status).toBe(200);
186
+ expect(((await res.json()) as { isSuccess: boolean }).isSuccess).toBe(true);
187
+ expect(verifyCalls).toHaveLength(0);
188
+ });
189
+
190
+ test("request-by-email für non-active User → success, KEINE Mail", async () => {
191
+ await seedAlice(USER_STATUS.DeletionRequested);
192
+ const res = await stack.http.raw("POST", "/api/write", {
193
+ type: REQUEST_BY_EMAIL,
194
+ payload: { email: ALICE_EMAIL },
195
+ });
196
+ expect(res.status).toBe(200);
197
+ expect(((await res.json()) as { isSuccess: boolean }).isSuccess).toBe(true);
198
+ expect(verifyCalls).toHaveLength(0);
199
+ });
200
+
201
+ test("confirm mit Garbage-Token → 422, Status unverändert", async () => {
202
+ await seedAlice();
203
+ const res = await stack.http.raw("POST", "/api/write", {
204
+ type: CONFIRM_BY_TOKEN,
205
+ payload: { token: "not.a.realtoken" },
206
+ });
207
+ expect(res.status).toBe(422);
208
+ expect(await statusOf()).toBe(USER_STATUS.Active);
209
+ });
210
+
211
+ test("confirm mit falsch-signiertem Token → 422", async () => {
212
+ await seedAlice();
213
+ const { token } = signDeletionToken(aliceUser.id, 60, "the-wrong-secret-totally-different");
214
+ const res = await stack.http.raw("POST", "/api/write", {
215
+ type: CONFIRM_BY_TOKEN,
216
+ payload: { token },
217
+ });
218
+ expect(res.status).toBe(422);
219
+ expect(await statusOf()).toBe(USER_STATUS.Active);
220
+ });
221
+ });
222
+
223
+ describe("anonymous deletion flow — not configured (kein Secret)", () => {
224
+ let bareStack: TestStack;
225
+ const bareVerifyCalls: VerifyArgs[] = [];
226
+
227
+ beforeAll(async () => {
228
+ bareStack = await setupTestStack({
229
+ features: [
230
+ createUserFeature(),
231
+ createDataRetentionFeature(),
232
+ createComplianceProfilesFeature(),
233
+ createSessionsFeature(),
234
+ createUserDataRightsFeature({
235
+ sendDeletionVerificationEmail: async (args) => {
236
+ bareVerifyCalls.push(args);
237
+ },
238
+ }),
239
+ ],
240
+ anonymousAccess: { defaultTenantId: tenantA },
241
+ });
242
+ await unsafeCreateEntityTable(bareStack.db, userEntity);
243
+ await unsafeCreateEntityTable(bareStack.db, tenantComplianceProfileEntity);
244
+ await createEventsTable(bareStack.db);
245
+ await insertOne(bareStack.db, userTable, {
246
+ id: aliceUser.id,
247
+ tenantId: tenantA,
248
+ email: ALICE_EMAIL,
249
+ passwordHash: "hashed",
250
+ displayName: "Alice",
251
+ locale: "de",
252
+ emailVerified: true,
253
+ roles: '["Member"]',
254
+ status: USER_STATUS.Active,
255
+ gracePeriodEnd: null,
256
+ });
257
+ });
258
+
259
+ afterAll(async () => {
260
+ await bareStack.cleanup();
261
+ });
262
+
263
+ test("request-by-email ohne Secret → success no-op, KEINE Mail", async () => {
264
+ const res = await bareStack.http.raw("POST", "/api/write", {
265
+ type: REQUEST_BY_EMAIL,
266
+ payload: { email: ALICE_EMAIL },
267
+ });
268
+ expect(res.status).toBe(200);
269
+ expect(((await res.json()) as { isSuccess: boolean }).isSuccess).toBe(true);
270
+ expect(bareVerifyCalls).toHaveLength(0);
271
+ });
272
+
273
+ test("confirm ohne Secret → 422", async () => {
274
+ const { token } = signDeletionToken(aliceUser.id, 60, DELETION_SECRET);
275
+ const res = await bareStack.http.raw("POST", "/api/write", {
276
+ type: CONFIRM_BY_TOKEN,
277
+ payload: { token },
278
+ });
279
+ expect(res.status).toBe(422);
280
+ });
281
+ });
@@ -0,0 +1,33 @@
1
+ // Thin wrapper around the shared HMAC-signed-token primitive, pinning the
2
+ // purpose to "deletion-request". Mirrors auth-email-password/reset-token.ts —
3
+ // email-verified account deletion is an auth-adjacent proof-of-email-ownership
4
+ // flow, so it reuses the same self-contained token mechanism (no DB row, no
5
+ // Redis: the userId + expiry are baked into the signed token, single-use is
6
+ // not required because the grace-period flip is idempotent on a non-active
7
+ // user).
8
+
9
+ import type { Temporal } from "temporal-polyfill";
10
+ import { signToken, verifyToken } from "../auth-email-password";
11
+
12
+ const DELETION_REQUEST_PURPOSE = "deletion-request";
13
+
14
+ export type VerifyResult =
15
+ | { readonly ok: true; readonly userId: string; readonly expiresAtMs: number }
16
+ | { readonly ok: false; readonly reason: "malformed" | "bad_signature" | "expired" };
17
+
18
+ export function signDeletionToken(
19
+ userId: string,
20
+ ttlMinutes: number,
21
+ secret: string,
22
+ now?: Temporal.Instant,
23
+ ): { token: string; expiresAt: Temporal.Instant } {
24
+ return signToken(userId, DELETION_REQUEST_PURPOSE, ttlMinutes, secret, now);
25
+ }
26
+
27
+ export function verifyDeletionToken(
28
+ token: string,
29
+ secret: string,
30
+ now?: Temporal.Instant,
31
+ ): VerifyResult {
32
+ return verifyToken(token, DELETION_REQUEST_PURPOSE, secret, now);
33
+ }
@@ -6,6 +6,7 @@ import {
6
6
  } from "@cosmicdrift/kumiko-framework/engine";
7
7
  import { createFileProviderForTenant } from "../file-foundation";
8
8
  import { cancelDeletionWrite } from "./handlers/cancel-deletion.write";
9
+ import { createConfirmDeletionByTokenHandler } from "./handlers/confirm-deletion-by-token.write";
9
10
  import { downloadByJobQuery } from "./handlers/download-by-job.query";
10
11
  import { downloadByTokenQuery } from "./handlers/download-by-token.query";
11
12
  import { exportStatusQuery } from "./handlers/export-status.query";
@@ -16,6 +17,10 @@ import {
16
17
  createRequestDeletionHandler,
17
18
  type SendDeletionRequestedEmailFn,
18
19
  } from "./handlers/request-deletion.write";
20
+ import {
21
+ createRequestDeletionByEmailHandler,
22
+ type SendDeletionVerificationEmailFn,
23
+ } from "./handlers/request-deletion-by-email.write";
19
24
  import { requestExportWrite } from "./handlers/request-export.write";
20
25
  import { restrictAccountWrite } from "./handlers/restrict-account.write";
21
26
  import { createRunForgetCleanupHandler } from "./handlers/run-forget-cleanup.write";
@@ -80,12 +85,26 @@ export type UserDataRightsOptions = {
80
85
  * userEmail+tenantIds PRE-tx und reicht sie ephemeral an die
81
86
  * Callback-Implementation (siehe run-forget-cleanup.ts). */
82
87
  readonly sendDeletionExecutedEmail?: SendDeletionExecutedEmailFn;
88
+ /** Anonymer, email-verifizierter Apex-Deletion-Flow (Lockout-sicher).
89
+ * HMAC-Secret zum Signieren des Verify-Tokens. Ohne Secret bleibt der
90
+ * Flow deaktiviert (request-by-email antwortet still success, confirm-
91
+ * by-token weist generisch ab). */
92
+ readonly deletionTokenSecret?: string;
93
+ /** Basis-URL des Apex-Confirm-Screens, z.B.
94
+ * "https://app.example.com/delete-account/confirm". Der Handler hängt
95
+ * `?token=<token>` an. Required wenn deletionTokenSecret gesetzt. */
96
+ readonly deletionVerifyUrl?: string;
97
+ /** Versand des Verify-Magic-Links (Schritt 1 des anonymen Flows).
98
+ * Best-effort, app-author-wired. MUSS non-blocking sein (enqueue, z.B.
99
+ * delivery.notify) — ein synchroner Send reintroduziert ein Timing-Oracle
100
+ * für Account-Enumeration (siehe SendDeletionVerificationEmailFn-Doc). */
101
+ readonly sendDeletionVerificationEmail?: SendDeletionVerificationEmailFn;
83
102
  };
84
103
 
85
104
  export function createUserDataRightsFeature(opts: UserDataRightsOptions = {}): FeatureDefinition {
86
105
  return defineFeature("user-data-rights", (r) => {
87
106
  r.describe(
88
- 'Implements GDPR Art. 15 (access / `my-audit-log` query), Art. 17 (erasure / `request-deletion` + `cancel-deletion` + cron cleanup with grace period), Art. 18 (restriction / `restrict-account` + `lift-restriction`), and Art. 20 (portability / async `request-export` \u2192 ZIP via `file-foundation`, Magic-Link download) as first-class HTTP handlers and cron jobs. Each domain feature opts in by calling `r.useExtension(EXT_USER_DATA, "<entity>", { export, delete })` \u2014 the feature then orchestrates the export and forget pipelines across all registered hooks automatically. Requires `user`, `data-retention`, `compliance-profiles`, and `sessions`.',
107
+ 'Implements GDPR Art. 15 (access / `my-audit-log` query), Art. 17 (erasure / `request-deletion` + `cancel-deletion`, plus the anonymous email-verified `request-deletion-by-email` + `confirm-deletion-by-token` flow for lockout-safe self-service, + cron cleanup with grace period), Art. 18 (restriction / `restrict-account` + `lift-restriction`), and Art. 20 (portability / async `request-export` \u2192 ZIP via `file-foundation`, Magic-Link download) as first-class HTTP handlers and cron jobs. Each domain feature opts in by calling `r.useExtension(EXT_USER_DATA, "<entity>", { export, delete })` \u2014 the feature then orchestrates the export and forget pipelines across all registered hooks automatically. Requires `user`, `data-retention`, `compliance-profiles`, and `sessions`.',
89
108
  );
90
109
  r.requires("user", "data-retention", "compliance-profiles", "sessions");
91
110
  r.usesApi("compliance.forTenant");
@@ -128,6 +147,28 @@ export function createUserDataRightsFeature(opts: UserDataRightsOptions = {}): F
128
147
  );
129
148
  r.writeHandler(cancelDeletionWrite);
130
149
 
150
+ // Anonymer, email-verifizierter Apex-Deletion-Flow (Lockout-sicher):
151
+ // request-by-email (Schritt 1, Magic-Link) + confirm-by-token (Schritt 2,
152
+ // startet dieselbe Grace-Period via startDeletionGracePeriod).
153
+ r.writeHandler(
154
+ createRequestDeletionByEmailHandler({
155
+ ...(opts.deletionTokenSecret !== undefined && {
156
+ deletionTokenSecret: opts.deletionTokenSecret,
157
+ }),
158
+ ...(opts.deletionVerifyUrl !== undefined && { deletionVerifyUrl: opts.deletionVerifyUrl }),
159
+ ...(opts.sendDeletionVerificationEmail !== undefined && {
160
+ sendDeletionVerificationEmail: opts.sendDeletionVerificationEmail,
161
+ }),
162
+ }),
163
+ );
164
+ r.writeHandler(
165
+ createConfirmDeletionByTokenHandler(
166
+ opts.deletionTokenSecret !== undefined
167
+ ? { deletionTokenSecret: opts.deletionTokenSecret }
168
+ : {},
169
+ ),
170
+ );
171
+
131
172
  // S2.U5b — Cleanup-Runner als privileged-Handler. Atom 5b: Wenn
132
173
  // sendDeletionExecutedEmail gesetzt, reicht der Handler den Callback
133
174
  // an runForgetCleanup weiter (Worker cached userEmail+tenantIds
@@ -0,0 +1,48 @@
1
+ import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { UnprocessableError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
3
+ import { z } from "zod";
4
+ import { USER_STATUS } from "../../user";
5
+ import { verifyDeletionToken } from "../deletion-token";
6
+ import { startDeletionGracePeriod } from "./deletion-grace-period";
7
+
8
+ export type ConfirmDeletionByTokenOptions = {
9
+ readonly deletionTokenSecret?: string;
10
+ };
11
+
12
+ // Generischer 422 für jeden Token-Fehlerpfad (malformed / bad_signature /
13
+ // expired / kein Secret) — kein Signal ob ein Token zu einem User gehört.
14
+ function invalidToken(): UnprocessableError {
15
+ return new UnprocessableError("invalid_or_expired_token", {
16
+ details: { reason: "invalid_or_expired_token" },
17
+ });
18
+ }
19
+
20
+ // Anonymer Apex-Flow Schritt 2: Verify-Link-Target. Verifiziert das
21
+ // HMAC-Token, extrahiert die userId und startet die Grace-Period über die
22
+ // geteilte Logik. Idempotent: ein zweites Confirm trifft auf den bereits
23
+ // geflippten (non-active) User → user_not_in_active_state, kein Schaden.
24
+ export function createConfirmDeletionByTokenHandler(opts: ConfirmDeletionByTokenOptions = {}) {
25
+ return defineWriteHandler({
26
+ name: "confirm-deletion-by-token",
27
+ schema: z.object({ token: z.string().min(1) }),
28
+ access: { roles: ["anonymous", "Member", "User", "TenantAdmin", "SystemAdmin"] },
29
+ rateLimit: { per: "ip", limit: 10, windowSeconds: 60 },
30
+ handler: async (event, ctx) => {
31
+ if (!opts.deletionTokenSecret) return writeFailure(invalidToken());
32
+
33
+ const verified = verifyDeletionToken(event.payload.token, opts.deletionTokenSecret);
34
+ if (!verified.ok) return writeFailure(invalidToken());
35
+
36
+ const res = await startDeletionGracePeriod(ctx, verified.userId, event.user.tenantId);
37
+ if (!res.ok) return writeFailure(res.error);
38
+
39
+ return {
40
+ isSuccess: true as const,
41
+ data: {
42
+ status: USER_STATUS.DeletionRequested,
43
+ gracePeriodEnd: res.gracePeriodEnd.toString(),
44
+ },
45
+ };
46
+ },
47
+ });
48
+ }
@@ -0,0 +1,68 @@
1
+ import { fetchOne, updateMany } from "@cosmicdrift/kumiko-framework/bun-db";
2
+ import { addDurationSpec, type DurationSpec } from "@cosmicdrift/kumiko-framework/compliance";
3
+ import { createSystemUser, type HandlerContext } from "@cosmicdrift/kumiko-framework/engine";
4
+ import { UnprocessableError } from "@cosmicdrift/kumiko-framework/errors";
5
+ import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
6
+ import { USER_STATUS, userTable } from "../../user";
7
+
8
+ type Instant = InstanceType<ReturnType<typeof getTemporal>["Instant"]>;
9
+
10
+ export type StartGracePeriodResult =
11
+ | { readonly ok: true; readonly gracePeriodEnd: Instant; readonly userEmail: string }
12
+ | { readonly ok: false; readonly error: UnprocessableError };
13
+
14
+ // Flippt einen aktiven User auf DeletionRequested + setzt gracePeriodEnd aus
15
+ // dem Compliance-Profile. Geteilt zwischen dem authentifizierten
16
+ // request-deletion-Pfad (event.user) und dem anonymen confirm-by-token-Pfad
17
+ // (userId aus verifiziertem Token) — eine Quelle für die Grace-Period-Logik.
18
+ //
19
+ // `complianceTenantId`: Tenant dessen Compliance-Profile die Grace-Dauer
20
+ // liefert. Authenticated = aktiver Tenant des Users; anonym = Dispatch-Tenant
21
+ // der Apex-Surface. Die User-Row ist tenant-agnostisch (Account-weite
22
+ // Löschung), nur die Grace-Dauer ist tenant-konfiguriert.
23
+ export async function startDeletionGracePeriod(
24
+ ctx: HandlerContext,
25
+ userId: string,
26
+ complianceTenantId: string,
27
+ ): Promise<StartGracePeriodResult> {
28
+ const userRow = await fetchOne<{ status: string; email: string }>(ctx.db.raw, userTable, {
29
+ id: userId,
30
+ });
31
+ if (!userRow) {
32
+ return {
33
+ ok: false,
34
+ error: new UnprocessableError("user_not_found", {
35
+ details: { reason: "user_not_found", userId },
36
+ }),
37
+ };
38
+ }
39
+ if (userRow["status"] !== USER_STATUS.Active) {
40
+ return {
41
+ ok: false,
42
+ error: new UnprocessableError("user_not_in_active_state", {
43
+ details: { reason: "user_not_in_active_state", currentStatus: userRow["status"] },
44
+ }),
45
+ };
46
+ }
47
+
48
+ // @cast-boundary engine-payload — queryAs liefert unknown, narrow auf den
49
+ // effektiven Profile-Shape (siehe request-deletion-Original).
50
+ const profile = (await ctx.queryAs(
51
+ createSystemUser(complianceTenantId),
52
+ "compliance-profiles:query:for-tenant",
53
+ {},
54
+ )) as { profile: { userRights: { gracePeriod: DurationSpec } } };
55
+
56
+ const gracePeriod = profile.profile.userRights.gracePeriod;
57
+ const T = getTemporal();
58
+ const gracePeriodEnd = addDurationSpec(T.Now.instant(), gracePeriod);
59
+
60
+ await updateMany(
61
+ ctx.db.raw,
62
+ userTable,
63
+ { status: USER_STATUS.DeletionRequested, gracePeriodEnd },
64
+ { id: userId },
65
+ );
66
+
67
+ return { ok: true, gracePeriodEnd, userEmail: userRow["email"] ?? "" };
68
+ }
@@ -0,0 +1,94 @@
1
+ import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
2
+ import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { z } from "zod";
4
+ import { USER_STATUS, userTable } from "../../user";
5
+ import { signDeletionToken } from "../deletion-token";
6
+
7
+ // TTL des Verify-Links. 60 min — lang genug für einen Mail-Roundtrip,
8
+ // kurz genug dass ein abgefangener Link nicht ewig gültig ist.
9
+ const DELETION_VERIFY_TTL_MINUTES = 60;
10
+
11
+ // Apex-Magic-Link-Versand für den anonymen Deletion-Request. Der Handler
12
+ // gibt den Link NICHT zurück (content-Enumeration-Safety) — er ruft den
13
+ // Callback direkt, analog sendDeletionRequestedEmail.
14
+ //
15
+ // WICHTIG (Timing-Oracle): Der Callback MUSS non-blocking sein (enqueue, z.B.
16
+ // delivery.notify / Job), NICHT synchron senden. Er läuft nur im
17
+ // existierenden-aktiven-User-Pfad; ein synchroner Send macht die
18
+ // Response-Latenz für reale Accounts messbar länger als für nicht-existente
19
+ // → Enumeration über Timing. Ein schnelles enqueue gleicht das praktisch aus.
20
+ // Vollständige Timing-Angleichung (immer äquivalente Arbeit) ist v1-Follow-up.
21
+ export type SendDeletionVerificationEmailFn = (args: {
22
+ readonly email: string;
23
+ readonly verifyUrl: string;
24
+ readonly expiresAt: string;
25
+ }) => Promise<void>;
26
+
27
+ export type RequestDeletionByEmailOptions = {
28
+ /** HMAC-Secret zum Signieren des Verify-Tokens. Ohne Secret ist der Flow
29
+ * deaktiviert (Handler antwortet still mit success, kein Link). */
30
+ readonly deletionTokenSecret?: string;
31
+ /** Basis-URL des Apex-Confirm-Screens, z.B.
32
+ * "https://app.example.com/delete-account/confirm". Der Handler hängt
33
+ * `?token=<token>` an. Ohne URL kein Link. */
34
+ readonly deletionVerifyUrl?: string;
35
+ readonly sendDeletionVerificationEmail?: SendDeletionVerificationEmailFn;
36
+ };
37
+
38
+ // Anonymer Apex-Flow Schritt 1: "Account-Löschung beantragen" per Email.
39
+ // DSGVO-relevant gerade wenn der User sich NICHT mehr einloggen kann
40
+ // (Lockout). Email → Magic-Link → confirm-deletion-by-token.
41
+ //
42
+ // Enumeration-safe (content): antwortet IMMER mit derselben success-Shape,
43
+ // egal ob die Email existiert, der User aktiv ist oder der Flow konfiguriert
44
+ // ist. Ein Link wird nur für einen existierenden, aktiven User generiert.
45
+ // Timing-Safety hängt am non-blocking Callback (siehe Type-Doc oben).
46
+ export function createRequestDeletionByEmailHandler(opts: RequestDeletionByEmailOptions = {}) {
47
+ return defineWriteHandler({
48
+ name: "request-deletion-by-email",
49
+ schema: z.object({ email: z.email() }),
50
+ access: { roles: ["anonymous", "Member", "User", "TenantAdmin", "SystemAdmin"] },
51
+ // Defense-in-depth gegen Email-Probing auf dem anonymen Endpoint.
52
+ rateLimit: { per: "ip", limit: 10, windowSeconds: 60 },
53
+ handler: async (event, ctx) => {
54
+ const success = { isSuccess: true as const, data: { kind: "requested" as const } };
55
+
56
+ // not-configured-safe: ohne Secret/URL kein Link, aber gleiche Antwort.
57
+ if (!opts.deletionTokenSecret || !opts.deletionVerifyUrl) return success;
58
+
59
+ // userTable ist tenant-agnostisch (Account-weite Löschung) → ctx.db.raw.
60
+ const userRow = await fetchOne<{ id: string; status: string; email: string }>(
61
+ ctx.db.raw,
62
+ userTable,
63
+ { email: event.payload.email },
64
+ );
65
+ if (!userRow || userRow["status"] !== USER_STATUS.Active || !userRow["email"]) {
66
+ return success;
67
+ }
68
+
69
+ const { token, expiresAt } = signDeletionToken(
70
+ userRow["id"],
71
+ DELETION_VERIFY_TTL_MINUTES,
72
+ opts.deletionTokenSecret,
73
+ );
74
+ const verifyUrl = `${opts.deletionVerifyUrl}?token=${encodeURIComponent(token)}`;
75
+
76
+ if (opts.sendDeletionVerificationEmail) {
77
+ try {
78
+ await opts.sendDeletionVerificationEmail({
79
+ email: userRow["email"],
80
+ verifyUrl,
81
+ expiresAt: expiresAt.toString(),
82
+ });
83
+ } catch (err) {
84
+ // biome-ignore lint/suspicious/noConsole: operator-visibility for email-send-failure
85
+ console.warn(
86
+ `[user-data-rights:request-deletion-by-email] send failed err=${err instanceof Error ? err.message : String(err)}`,
87
+ );
88
+ }
89
+ }
90
+
91
+ return success;
92
+ },
93
+ });
94
+ }
@@ -1,10 +1,8 @@
1
- import { fetchOne, updateMany } from "@cosmicdrift/kumiko-framework/bun-db";
2
- import { addDurationSpec, type DurationSpec } from "@cosmicdrift/kumiko-framework/compliance";
3
- import { createSystemUser, defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
4
- import { UnprocessableError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
5
- import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
1
+ import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { writeFailure } from "@cosmicdrift/kumiko-framework/errors";
6
3
  import { z } from "zod";
7
- import { USER_STATUS, userTable } from "../../user";
4
+ import { USER_STATUS } from "../../user";
5
+ import { startDeletionGracePeriod } from "./deletion-grace-period";
8
6
 
9
7
  // Atom 5b — Email-Notification beim deletion-requested-flip. Pattern:
10
8
  // password-reset-Callback aus auth-routes.ts. Best-effort — Throw beim
@@ -25,73 +23,23 @@ export type RequestDeletionOptions = {
25
23
  };
26
24
 
27
25
  // POST /api/user/request-deletion (S2.U5a) — DSGVO Art. 17 Forget-Antrag.
28
- // Flippt status=Active → deletionRequested, setzt gracePeriodEnd aus
29
- // Compliance-Profile. Account-weite Semantik (1 User-Row global), siehe
30
- // docs/plans/architecture/user-data-rights.md "Cross-Tenant-Semantik".
26
+ // Flippt status=Active → deletionRequested, setzt gracePeriodEnd aus dem
27
+ // Compliance-Profile (geteilte Logik: startDeletionGracePeriod). Account-
28
+ // weite Semantik (1 User-Row global), siehe docs/plans/architecture/
29
+ // user-data-rights.md "Cross-Tenant-Semantik".
31
30
  export function createRequestDeletionHandler(opts: RequestDeletionOptions = {}) {
32
31
  return defineWriteHandler({
33
32
  name: "request-deletion",
34
33
  schema: z.object({}),
35
34
  access: { openToAll: true },
36
35
  handler: async (event, ctx) => {
37
- // ctx.db.raw (kein TenantDb-Wrapper) weil User-Entity tenant-agnostisch
38
- // ist siehe Plan-Doc Cross-Tenant-Section.
39
- const userRow = await fetchOne<{ status: string; email: string }>(ctx.db.raw, userTable, {
40
- id: event.user.id,
41
- });
42
-
43
- if (!userRow) {
44
- return writeFailure(
45
- new UnprocessableError("user_not_found", {
46
- details: { reason: "user_not_found", userId: event.user.id },
47
- }),
48
- );
49
- }
50
-
51
- if (userRow["status"] !== USER_STATUS.Active) {
52
- return writeFailure(
53
- new UnprocessableError("user_not_in_active_state", {
54
- details: {
55
- reason: "user_not_in_active_state",
56
- currentStatus: userRow["status"],
57
- },
58
- }),
59
- );
60
- }
61
-
62
- // Compliance-Profile fuer gracePeriod via Cross-Feature-Query. Pattern:
63
- // ctx.queryAs(user, qn, payload) — siehe auth-email-password/change-
64
- // password.write.ts. @cast-boundary engine-bridge — queryAs liefert
65
- // unknown, narrow auf den effektiven Profile-Shape.
66
- const profile = (await ctx.queryAs(
67
- createSystemUser(event.user.tenantId),
68
- "compliance-profiles:query:for-tenant",
69
- {},
70
- )) as { profile: { userRights: { gracePeriod: DurationSpec } } }; // @cast-boundary engine-payload
71
-
72
- // addDurationSpec deckt `{days}` und `{hours}` ab. App-Server-Clock
73
- // ist authoritative — instant() customType nimmt Temporal.Instant
74
- // direkt, kein SQL-interval-Bypass des Codecs.
75
- const gracePeriod = profile.profile.userRights.gracePeriod;
76
- const T = getTemporal();
77
- const gracePeriodEnd = addDurationSpec(T.Now.instant(), gracePeriod);
78
-
79
- await updateMany(
80
- ctx.db.raw,
81
- userTable,
82
- {
83
- status: USER_STATUS.DeletionRequested,
84
- gracePeriodEnd,
85
- },
86
- { id: event.user.id },
87
- );
36
+ const res = await startDeletionGracePeriod(ctx, event.user.id, event.user.tenantId);
37
+ if (!res.ok) return writeFailure(res.error);
38
+ const { gracePeriodEnd, userEmail } = res;
88
39
 
89
40
  // Best-effort Email-Notification. Send-Failure darf das Write nicht
90
- // killen — siehe Type-Doc oben. console.warn ist die Operator-
91
- // Sichtbarkeit; defineWriteHandler-Context fuehrt aktuell keinen
92
- // structured-logger durch, Refactor-Kandidat wenn ctx.log threadet.
93
- const userEmail = userRow["email"];
94
- if (opts.sendDeletionRequestedEmail && userEmail && userEmail.length > 0) {
41
+ // killen — siehe Type-Doc oben.
42
+ if (opts.sendDeletionRequestedEmail && userEmail.length > 0) {
95
43
  try {
96
44
  await opts.sendDeletionRequestedEmail({
97
45
  userId: event.user.id,
@@ -108,8 +56,7 @@ export function createRequestDeletionHandler(opts: RequestDeletionOptions = {})
108
56
  }
109
57
 
110
58
  // Response liefert den absoluten gracePeriodEnd-Timestamp damit
111
- // Frontend/Audit/Cleanup-Runner alle denselben Wert lesen — nicht
112
- // den Input-`{days|hours}`, der ist Konfiguration nicht Result.
59
+ // Frontend/Audit/Cleanup-Runner alle denselben Wert lesen.
113
60
  return {
114
61
  isSuccess: true as const,
115
62
  data: {
@@ -1,4 +1,5 @@
1
1
  export { createUserDataRightsFeature, type UserDataRightsOptions } from "./feature";
2
+ export type { SendDeletionVerificationEmailFn } from "./handlers/request-deletion-by-email.write";
2
3
  export type {
3
4
  SendExportFailedEmailFn,
4
5
  SendExportReadyEmailFn,
@@ -0,0 +1,103 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { Dispatcher } from "@cosmicdrift/kumiko-headless";
3
+ import {
4
+ createStaticLocaleResolver,
5
+ DispatcherProvider,
6
+ kumikoDefaultTranslations,
7
+ LocaleProvider,
8
+ PrimitivesProvider,
9
+ } from "@cosmicdrift/kumiko-renderer";
10
+ import { defaultPrimitives } from "@cosmicdrift/kumiko-renderer-web";
11
+ import { fireEvent, render, screen, waitFor } from "@testing-library/react";
12
+ import type { ReactElement } from "react";
13
+ import { ConfirmAccountDeletionScreen } from "../confirm-deletion-screen";
14
+ import { defaultTranslations } from "../i18n";
15
+ import { RequestAccountDeletionScreen } from "../request-deletion-screen";
16
+
17
+ const resolver = createStaticLocaleResolver({ locale: "de" });
18
+
19
+ type WriteCall = { readonly type: string; readonly payload: unknown };
20
+
21
+ function makeDispatcher(ok: boolean, calls: WriteCall[]): Dispatcher {
22
+ // test-stub: die Screens rufen ausschließlich dispatcher.write — der Rest
23
+ // des Dispatcher-Contracts wird hier nicht gebraucht.
24
+ return {
25
+ write: async (type: string, payload: unknown) => {
26
+ calls.push({ type, payload });
27
+ return ok
28
+ ? { isSuccess: true, data: {} }
29
+ : { isSuccess: false, error: { reason: "invalid_or_expired_token", message: "nope" } };
30
+ },
31
+ } as unknown as Dispatcher;
32
+ }
33
+
34
+ function renderWith(ui: ReactElement, dispatcher: Dispatcher): void {
35
+ render(
36
+ <PrimitivesProvider value={defaultPrimitives}>
37
+ <LocaleProvider
38
+ resolver={resolver}
39
+ fallbackBundles={[defaultTranslations, kumikoDefaultTranslations]}
40
+ >
41
+ <DispatcherProvider dispatcher={dispatcher}>{ui}</DispatcherProvider>
42
+ </LocaleProvider>
43
+ </PrimitivesProvider>,
44
+ );
45
+ }
46
+
47
+ describe("RequestAccountDeletionScreen", () => {
48
+ test("Submit → write(request-deletion-by-email) + enumeration-safe Success", async () => {
49
+ const calls: WriteCall[] = [];
50
+ renderWith(<RequestAccountDeletionScreen />, makeDispatcher(true, calls));
51
+
52
+ fireEvent.change(screen.getByRole("textbox"), { target: { value: "a@b.com" } });
53
+ fireEvent.click(screen.getByRole("button"));
54
+
55
+ await waitFor(() => expect(screen.getByText(/Mail gesendet/)).toBeTruthy());
56
+ expect(calls).toHaveLength(1);
57
+ expect(calls[0]?.type).toBe("user-data-rights:write:request-deletion-by-email");
58
+ expect(calls[0]?.payload).toEqual({ email: "a@b.com" });
59
+ });
60
+
61
+ test("write-Failure → Error-Banner", async () => {
62
+ const calls: WriteCall[] = [];
63
+ renderWith(<RequestAccountDeletionScreen />, makeDispatcher(false, calls));
64
+
65
+ fireEvent.change(screen.getByRole("textbox"), { target: { value: "a@b.com" } });
66
+ fireEvent.click(screen.getByRole("button"));
67
+
68
+ await waitFor(() => expect(screen.getByText(/schief gegangen/)).toBeTruthy());
69
+ expect(screen.queryByText(/Mail gesendet/)).toBeNull();
70
+ });
71
+ });
72
+
73
+ describe("ConfirmAccountDeletionScreen", () => {
74
+ test("ohne ?token → missingToken, kein Confirm-Button", () => {
75
+ window.history.replaceState({}, "", "/delete-account/confirm");
76
+ renderWith(<ConfirmAccountDeletionScreen />, makeDispatcher(true, []));
77
+ expect(screen.getByText(/Kein Token/)).toBeTruthy();
78
+ expect(screen.queryByRole("button")).toBeNull();
79
+ });
80
+
81
+ test("mit ?token → Confirm dispatcht confirm-deletion-by-token + Success", async () => {
82
+ window.history.replaceState({}, "", "/delete-account/confirm?token=tok-123");
83
+ const calls: WriteCall[] = [];
84
+ renderWith(<ConfirmAccountDeletionScreen />, makeDispatcher(true, calls));
85
+
86
+ fireEvent.click(screen.getByRole("button"));
87
+
88
+ await waitFor(() => expect(screen.getByText(/vorgemerkt/)).toBeTruthy());
89
+ expect(calls).toHaveLength(1);
90
+ expect(calls[0]?.type).toBe("user-data-rights:write:confirm-deletion-by-token");
91
+ expect(calls[0]?.payload).toEqual({ token: "tok-123" });
92
+ });
93
+
94
+ test("write-Failure → invalidToken-Banner, kein Success", async () => {
95
+ window.history.replaceState({}, "", "/delete-account/confirm?token=bad");
96
+ renderWith(<ConfirmAccountDeletionScreen />, makeDispatcher(false, []));
97
+
98
+ fireEvent.click(screen.getByRole("button"));
99
+
100
+ await waitFor(() => expect(screen.getByText(/ungültig oder abgelaufen/)).toBeTruthy());
101
+ expect(screen.queryByText(/vorgemerkt/)).toBeNull();
102
+ });
103
+ });
@@ -0,0 +1,85 @@
1
+ // @runtime client
2
+ // ConfirmAccountDeletionScreen — anonymer Apex-Screen Schritt 2. Liest das
3
+ // `?token` aus der Verify-Link-URL und dispatcht beim Bestätigen
4
+ // user-data-rights:write:confirm-deletion-by-token → startet die Grace-Period.
5
+ //
6
+ // App mountet den Screen unter der deletionVerifyUrl-Route (z.B.
7
+ // /delete-account/confirm) via createPublicSurface.
8
+
9
+ import { useDispatcher, usePrimitives, useTranslation } from "@cosmicdrift/kumiko-renderer";
10
+ import { type ReactNode, useState } from "react";
11
+
12
+ const CONFIRM_BY_TOKEN = "user-data-rights:write:confirm-deletion-by-token";
13
+
14
+ type Phase = "idle" | "submitting" | "success" | "missing" | "invalid";
15
+
16
+ function readToken(): string {
17
+ if (typeof window === "undefined") return "";
18
+ return new URLSearchParams(window.location.search).get("token") ?? "";
19
+ }
20
+
21
+ export type ConfirmAccountDeletionScreenProps = {
22
+ readonly title?: string;
23
+ };
24
+
25
+ export function ConfirmAccountDeletionScreen({
26
+ title,
27
+ }: ConfirmAccountDeletionScreenProps): ReactNode {
28
+ const t = useTranslation();
29
+ const dispatcher = useDispatcher();
30
+ const { Button, Banner } = usePrimitives();
31
+ const [token] = useState(readToken);
32
+ const [phase, setPhase] = useState<Phase>(token.length > 0 ? "idle" : "missing");
33
+
34
+ const doConfirm = async (): Promise<void> => {
35
+ setPhase("submitting");
36
+ try {
37
+ const res = await dispatcher.write(CONFIRM_BY_TOKEN, { token });
38
+ setPhase(res.isSuccess ? "success" : "invalid");
39
+ } catch {
40
+ setPhase("invalid");
41
+ }
42
+ };
43
+
44
+ return (
45
+ <div className="w-full max-w-sm mx-auto rounded-lg border bg-card text-card-foreground shadow-sm">
46
+ <div className="flex flex-col space-y-1.5 p-6 pb-4">
47
+ <h1 className="text-xl font-semibold tracking-tight">
48
+ {title ?? t("userDataRights.deletion.confirm.title")}
49
+ </h1>
50
+ </div>
51
+ <div className="p-6 pt-0 flex flex-col gap-4">
52
+ {phase === "success" ? (
53
+ <Banner variant="info">
54
+ <p className="font-medium text-foreground">
55
+ {t("userDataRights.deletion.confirm.successTitle")}
56
+ </p>
57
+ <p className="mt-1">{t("userDataRights.deletion.confirm.successBody")}</p>
58
+ </Banner>
59
+ ) : phase === "missing" ? (
60
+ <Banner variant="error">{t("userDataRights.deletion.confirm.missingToken")}</Banner>
61
+ ) : (
62
+ <>
63
+ <p className="text-sm text-muted-foreground">
64
+ {t("userDataRights.deletion.confirm.intro")}
65
+ </p>
66
+ {phase === "invalid" && (
67
+ <Banner variant="error">{t("userDataRights.deletion.confirm.invalidToken")}</Banner>
68
+ )}
69
+ <Button
70
+ type="button"
71
+ variant="danger"
72
+ loading={phase === "submitting"}
73
+ disabled={phase === "submitting"}
74
+ onClick={() => void doConfirm()}
75
+ >
76
+ {phase === "submitting"
77
+ ? t("userDataRights.deletion.confirm.submitting")
78
+ : t("userDataRights.deletion.confirm.submit")}
79
+ </Button>
80
+ </>
81
+ )}
82
+ </div>
83
+ </div>
84
+ );
85
+ }
@@ -0,0 +1,60 @@
1
+ // @runtime client
2
+ // Default-Bundle für die Apex-Deletion-Screens. Apps hängen es als
3
+ // Fallback-Bundle in den LocaleProvider (createPublicSurface clientFeatures
4
+ // oder direkt) und können einzelne Keys überschreiben. Keys: `userDataRights.
5
+ // deletion.<step>.<slug>`.
6
+
7
+ import type { TranslationsByLocale } from "@cosmicdrift/kumiko-renderer";
8
+
9
+ export const defaultTranslations: TranslationsByLocale = {
10
+ de: {
11
+ "userDataRights.deletion.request.title": "Account-Löschung beantragen",
12
+ "userDataRights.deletion.request.intro":
13
+ "Gib die E-Mail-Adresse deines Kontos ein. Falls ein Konto existiert, schicken wir dir einen Bestätigungs-Link zum Löschen.",
14
+ "userDataRights.deletion.request.email": "E-Mail",
15
+ "userDataRights.deletion.request.submit": "Bestätigungs-Link anfordern",
16
+ "userDataRights.deletion.request.submitting": "…",
17
+ "userDataRights.deletion.request.successTitle": "Mail gesendet",
18
+ "userDataRights.deletion.request.successBody":
19
+ "Falls die E-Mail in unserem System existiert, ist eine Nachricht mit einem Bestätigungs-Link unterwegs. Bitte schau in deinen Posteingang.",
20
+ "userDataRights.deletion.request.error": "Etwas ist schief gegangen. Bitte erneut versuchen.",
21
+ "userDataRights.deletion.confirm.title": "Account-Löschung bestätigen",
22
+ "userDataRights.deletion.confirm.intro":
23
+ "Mit dem Bestätigen startet die Lösch-Frist. Bis sie abläuft kannst du die Löschung im eingeloggten Account wieder abbrechen.",
24
+ "userDataRights.deletion.confirm.submit": "Löschung bestätigen",
25
+ "userDataRights.deletion.confirm.submitting": "…",
26
+ "userDataRights.deletion.confirm.successTitle": "Löschung vorgemerkt",
27
+ "userDataRights.deletion.confirm.successBody":
28
+ "Dein Account wird nach Ablauf der Frist gelöscht. Du kannst die Löschung bis dahin im eingeloggten Account abbrechen.",
29
+ "userDataRights.deletion.confirm.invalidToken":
30
+ "Der Link ist ungültig oder abgelaufen. Bitte fordere einen neuen an.",
31
+ "userDataRights.deletion.confirm.missingToken":
32
+ "Kein Token im Link gefunden. Bitte öffne den Link aus der E-Mail erneut.",
33
+ "userDataRights.deletion.confirm.error": "Etwas ist schief gegangen. Bitte erneut versuchen.",
34
+ },
35
+ en: {
36
+ "userDataRights.deletion.request.title": "Request account deletion",
37
+ "userDataRights.deletion.request.intro":
38
+ "Enter the email address of your account. If an account exists, we'll send you a confirmation link to delete it.",
39
+ "userDataRights.deletion.request.email": "Email",
40
+ "userDataRights.deletion.request.submit": "Request confirmation link",
41
+ "userDataRights.deletion.request.submitting": "…",
42
+ "userDataRights.deletion.request.successTitle": "Email sent",
43
+ "userDataRights.deletion.request.successBody":
44
+ "If the email exists in our system, a message with a confirmation link is on its way. Please check your inbox.",
45
+ "userDataRights.deletion.request.error": "Something went wrong. Please try again.",
46
+ "userDataRights.deletion.confirm.title": "Confirm account deletion",
47
+ "userDataRights.deletion.confirm.intro":
48
+ "Confirming starts the deletion grace period. Until it ends you can cancel the deletion from your logged-in account.",
49
+ "userDataRights.deletion.confirm.submit": "Confirm deletion",
50
+ "userDataRights.deletion.confirm.submitting": "…",
51
+ "userDataRights.deletion.confirm.successTitle": "Deletion scheduled",
52
+ "userDataRights.deletion.confirm.successBody":
53
+ "Your account will be deleted after the grace period. You can cancel the deletion from your logged-in account until then.",
54
+ "userDataRights.deletion.confirm.invalidToken":
55
+ "The link is invalid or expired. Please request a new one.",
56
+ "userDataRights.deletion.confirm.missingToken":
57
+ "No token found in the link. Please open the link from the email again.",
58
+ "userDataRights.deletion.confirm.error": "Something went wrong. Please try again.",
59
+ },
60
+ };
@@ -0,0 +1,12 @@
1
+ // @runtime client
2
+ // Public exports für die Browser-Seite des user-data-rights Features —
3
+ // die anonymen Apex-Deletion-Screens. Konsumiert via Sub-Path-Export
4
+ // `@cosmicdrift/kumiko-bundled-features/user-data-rights/web`. Die Server-
5
+ // Seite (defineFeature, Handler) lebt unter `.../user-data-rights` und hat
6
+ // keine React-/DOM-Deps.
7
+
8
+ export type { ConfirmAccountDeletionScreenProps } from "./confirm-deletion-screen";
9
+ export { ConfirmAccountDeletionScreen } from "./confirm-deletion-screen";
10
+ export { defaultTranslations } from "./i18n";
11
+ export type { RequestAccountDeletionScreenProps } from "./request-deletion-screen";
12
+ export { RequestAccountDeletionScreen } from "./request-deletion-screen";
@@ -0,0 +1,96 @@
1
+ // @runtime client
2
+ // RequestAccountDeletionScreen — anonymer Apex-Screen Schritt 1. Email-Form
3
+ // → user-data-rights:write:request-deletion-by-email. Enumeration-safe: zeigt
4
+ // unconditional ein "Falls Account existiert, Mail unterwegs"-Confirm, auch
5
+ // wenn der Server intern erkannt hat dass die Email nicht existiert.
6
+ //
7
+ // App mountet den Screen unter einer Apex-Route (z.B. /delete-account) via
8
+ // createPublicSurface; die Page-Shell liefert die Chrome.
9
+
10
+ import { useDispatcher, usePrimitives, useTranslation } from "@cosmicdrift/kumiko-renderer";
11
+ import { type FormEvent, type ReactNode, useState } from "react";
12
+
13
+ const REQUEST_BY_EMAIL = "user-data-rights:write:request-deletion-by-email";
14
+
15
+ export type RequestAccountDeletionScreenProps = {
16
+ readonly title?: string;
17
+ };
18
+
19
+ export function RequestAccountDeletionScreen({
20
+ title,
21
+ }: RequestAccountDeletionScreenProps): ReactNode {
22
+ const t = useTranslation();
23
+ const dispatcher = useDispatcher();
24
+ const { Form, Field, Input, Button, Banner } = usePrimitives();
25
+ const [email, setEmail] = useState("");
26
+ const [submitting, setSubmitting] = useState(false);
27
+ const [done, setDone] = useState(false);
28
+ const [error, setError] = useState<string | null>(null);
29
+
30
+ const doSubmit = async (): Promise<void> => {
31
+ setSubmitting(true);
32
+ setError(null);
33
+ try {
34
+ const res = await dispatcher.write(REQUEST_BY_EMAIL, { email });
35
+ if (res.isSuccess) {
36
+ setDone(true);
37
+ } else {
38
+ setError(t("userDataRights.deletion.request.error"));
39
+ }
40
+ } catch {
41
+ setError(t("userDataRights.deletion.request.error"));
42
+ } finally {
43
+ setSubmitting(false);
44
+ }
45
+ };
46
+
47
+ const onSubmit = (e?: FormEvent): void => {
48
+ e?.preventDefault();
49
+ void doSubmit();
50
+ };
51
+
52
+ return (
53
+ <div className="w-full max-w-sm mx-auto rounded-lg border bg-card text-card-foreground shadow-sm">
54
+ <div className="flex flex-col space-y-1.5 p-6 pb-4">
55
+ <h1 className="text-xl font-semibold tracking-tight">
56
+ {title ?? t("userDataRights.deletion.request.title")}
57
+ </h1>
58
+ </div>
59
+ {done ? (
60
+ <div className="p-6 pt-0">
61
+ <Banner variant="info">
62
+ <p className="font-medium text-foreground">
63
+ {t("userDataRights.deletion.request.successTitle")}
64
+ </p>
65
+ <p className="mt-1">{t("userDataRights.deletion.request.successBody")}</p>
66
+ </Banner>
67
+ </div>
68
+ ) : (
69
+ <div className="p-6 pt-0 flex flex-col gap-4">
70
+ <p className="text-sm text-muted-foreground">
71
+ {t("userDataRights.deletion.request.intro")}
72
+ </p>
73
+ <Form onSubmit={onSubmit}>
74
+ <Field id="delete-email" label={t("userDataRights.deletion.request.email")} required>
75
+ <Input
76
+ kind="text"
77
+ id="delete-email"
78
+ name="delete-email"
79
+ value={email}
80
+ onChange={setEmail}
81
+ disabled={submitting}
82
+ required
83
+ />
84
+ </Field>
85
+ {error !== null && <Banner variant="error">{error}</Banner>}
86
+ <Button type="submit" loading={submitting} disabled={submitting}>
87
+ {submitting
88
+ ? t("userDataRights.deletion.request.submitting")
89
+ : t("userDataRights.deletion.request.submit")}
90
+ </Button>
91
+ </Form>
92
+ </div>
93
+ )}
94
+ </div>
95
+ );
96
+ }