@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 +7 -6
- package/src/auth-email-password/web/__tests__/auth-shell.test.tsx +35 -0
- package/src/auth-email-password/web/auth-form-primitives.tsx +38 -14
- package/src/auth-email-password/web/index.ts +2 -0
- package/src/user-data-rights/__tests__/anonymous-deletion.integration.test.ts +281 -0
- package/src/user-data-rights/deletion-token.ts +33 -0
- package/src/user-data-rights/feature.ts +42 -1
- package/src/user-data-rights/handlers/confirm-deletion-by-token.write.ts +48 -0
- package/src/user-data-rights/handlers/deletion-grace-period.ts +68 -0
- package/src/user-data-rights/handlers/request-deletion-by-email.write.ts +94 -0
- package/src/user-data-rights/handlers/request-deletion.write.ts +14 -67
- package/src/user-data-rights/index.ts +1 -0
- package/src/user-data-rights/web/__tests__/deletion-screens.test.tsx +103 -0
- package/src/user-data-rights/web/confirm-deletion-screen.tsx +85 -0
- package/src/user-data-rights/web/i18n.ts +60 -0
- package/src/user-data-rights/web/index.ts +12 -0
- package/src/user-data-rights/web/request-deletion-screen.tsx +96 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "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.
|
|
80
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
81
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
82
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
83
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
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
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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 {
|
|
2
|
-
import {
|
|
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
|
|
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
|
|
30
|
-
// docs/plans/architecture/
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
const
|
|
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.
|
|
91
|
-
|
|
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
|
|
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: {
|
|
@@ -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
|
+
}
|