@cosmicdrift/kumiko-bundled-features 0.79.2 → 0.80.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 +6 -6
- package/src/auth-email-password/web/auth-form-primitives.tsx +22 -8
- package/src/user-data-rights/__tests__/download.integration.test.ts +11 -8
- package/src/user-data-rights/constants.ts +1 -7
- package/src/user-data-rights/feature.ts +10 -45
- package/src/user-data-rights/handlers/download-by-job.query.ts +10 -10
- package/src/user-data-rights/web/__tests__/privacy-center-screen.test.tsx +2 -3
- package/src/user-data-rights/web/confirm-deletion-screen.tsx +15 -8
- package/src/user-data-rights/web/privacy-center-screen.tsx +73 -45
- package/src/user-data-rights/web/request-deletion-screen.tsx +15 -8
- package/src/user-profile/web/profile-screen.tsx +47 -45
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.80.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>",
|
|
@@ -84,11 +84,11 @@
|
|
|
84
84
|
"./step-dispatcher": "./src/step-dispatcher/index.ts"
|
|
85
85
|
},
|
|
86
86
|
"dependencies": {
|
|
87
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
88
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
89
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
90
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
91
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
87
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.80.0",
|
|
88
|
+
"@cosmicdrift/kumiko-framework": "0.80.0",
|
|
89
|
+
"@cosmicdrift/kumiko-headless": "0.80.0",
|
|
90
|
+
"@cosmicdrift/kumiko-renderer": "0.80.0",
|
|
91
|
+
"@cosmicdrift/kumiko-renderer-web": "0.80.0",
|
|
92
92
|
"@mollie/api-client": "^4.5.0",
|
|
93
93
|
"@node-rs/argon2": "^2.0.2",
|
|
94
94
|
"@types/nodemailer": "^8.0.0",
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
// authMutedLinkClass — Subtle-Link-Style.
|
|
16
16
|
// parseUrlToken — URL-Param-Helper (window.location.search).
|
|
17
17
|
|
|
18
|
+
import { usePrimitives } from "@cosmicdrift/kumiko-renderer";
|
|
18
19
|
import { BareFormProvider, cn } from "@cosmicdrift/kumiko-renderer-web";
|
|
19
20
|
import { createContext, type ReactNode, useContext } from "react";
|
|
20
21
|
|
|
@@ -51,17 +52,30 @@ export type AuthCardProps = {
|
|
|
51
52
|
};
|
|
52
53
|
|
|
53
54
|
export function AuthCard({ title, subtitle, children }: AuthCardProps): ReactNode {
|
|
55
|
+
const { Card } = usePrimitives();
|
|
54
56
|
const shell = useAuthShell() ?? defaultAuthShell;
|
|
57
|
+
// h1 (Seiten-Hauptüberschrift) via Header-Slot erhalten — die Card-Default-
|
|
58
|
+
// Header wäre h3. padded:false = Form sitzt randlos wie bisher (bare form).
|
|
55
59
|
const card = (
|
|
56
|
-
<
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
60
|
+
<Card
|
|
61
|
+
className="w-full max-w-sm"
|
|
62
|
+
options={{ padded: false }}
|
|
63
|
+
slots={{
|
|
64
|
+
header:
|
|
65
|
+
title !== undefined || subtitle !== undefined ? (
|
|
66
|
+
<div className="flex flex-col space-y-1.5 p-6 pb-4">
|
|
67
|
+
{title !== undefined && (
|
|
68
|
+
<h1 className="text-xl font-semibold tracking-tight">{title}</h1>
|
|
69
|
+
)}
|
|
70
|
+
{subtitle !== undefined && (
|
|
71
|
+
<p className="text-sm text-muted-foreground">{subtitle}</p>
|
|
72
|
+
)}
|
|
73
|
+
</div>
|
|
74
|
+
) : undefined,
|
|
75
|
+
}}
|
|
76
|
+
>
|
|
63
77
|
<BareFormProvider>{children}</BareFormProvider>
|
|
64
|
-
</
|
|
78
|
+
</Card>
|
|
65
79
|
);
|
|
66
80
|
return shell(card);
|
|
67
81
|
}
|
|
@@ -374,19 +374,22 @@ describe("download-by-token :: error paths", () => {
|
|
|
374
374
|
});
|
|
375
375
|
|
|
376
376
|
describe("download-by-job :: happy path", () => {
|
|
377
|
-
test("session-auth: Job-Owner → returns signed URL + audit", async () => {
|
|
377
|
+
test("session-auth: Job-Owner → returns signed URL + audit (IP aus X-Forwarded-For)", async () => {
|
|
378
378
|
const { jobId } = await seedDoneJobWithToken();
|
|
379
379
|
|
|
380
|
-
|
|
380
|
+
// Der UI-Klick laeuft als direkter download-by-job-Query (Client traegt
|
|
381
|
+
// X-CSRF-Token). Die Audit-IP kommt server-trusted aus dem RequestContext
|
|
382
|
+
// (X-Forwarded-For, erster Hop), nicht aus einem vom Client mitgeschickten
|
|
383
|
+
// Feld.
|
|
384
|
+
const res = await stack.http.queryWithHeaders(
|
|
381
385
|
"user-data-rights:query:download-by-job",
|
|
382
|
-
{
|
|
383
|
-
jobId,
|
|
384
|
-
auditMeta: { ip: "10.0.0.5", userAgent: "Mozilla/5.0" },
|
|
385
|
-
},
|
|
386
|
+
{ jobId },
|
|
386
387
|
aliceUser,
|
|
388
|
+
{ "x-forwarded-for": "10.0.0.5, 10.0.0.1" },
|
|
387
389
|
);
|
|
388
|
-
|
|
389
|
-
|
|
390
|
+
expect(res.status).toBe(200);
|
|
391
|
+
const body = (await res.json()) as { data: { url: string } };
|
|
392
|
+
expect(body.data.url).toMatch(/^memory:\/\//);
|
|
390
393
|
|
|
391
394
|
// UI-Klick zaehlt auch als Use → audit-row updated
|
|
392
395
|
const [row] = (await selectMany(stack.db, exportDownloadTokensTable, { jobId })) as Array<{
|
|
@@ -13,6 +13,7 @@ export const PRIVACY_CENTER_SCREEN_ID = "privacy-center" as const;
|
|
|
13
13
|
export const UserDataRightsQueries = {
|
|
14
14
|
exportStatus: "user-data-rights:query:export-status",
|
|
15
15
|
myAuditLog: "user-data-rights:query:my-audit-log",
|
|
16
|
+
downloadByJob: "user-data-rights:query:download-by-job",
|
|
16
17
|
} as const;
|
|
17
18
|
|
|
18
19
|
export const UserDataRightsHandlers = {
|
|
@@ -28,13 +29,6 @@ export const UserDataRightsHandlers = {
|
|
|
28
29
|
// Screen-Test vergleicht gegen UserQueries.me.
|
|
29
30
|
export const USER_ME_QUERY = "user:query:user:me" as const;
|
|
30
31
|
|
|
31
|
-
// Download-Pfad des fertigen Export-Bundles: der dokumentierte UI-Klick-Pfad
|
|
32
|
-
// (r.httpRoute in feature.ts), der per 302 auf die signed Storage-URL
|
|
33
|
-
// weiterleitet. Anchor-navigierbar (Cookie-Auth wird mitgesendet).
|
|
34
|
-
export function userExportByJobPath(jobId: string): string {
|
|
35
|
-
return `/user-export/by-job/${jobId}`;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
32
|
// Client-safe Mirror von EXPORT_JOB_STATUS (schema/export-job.ts ist
|
|
39
33
|
// server-only via Drizzle-Import). Drift-Schutz: der Screen-Test vergleicht
|
|
40
34
|
// gegen die Schema-Originale.
|
|
@@ -220,17 +220,18 @@ export function createUserDataRightsFeature(opts: UserDataRightsOptions = {}): F
|
|
|
220
220
|
access: { openToAll: true },
|
|
221
221
|
});
|
|
222
222
|
|
|
223
|
-
//
|
|
223
|
+
// Magic-Link-Pfad (anonymous): GET /user-export/by-token?token=<plain>.
|
|
224
|
+
// Ruft via app.fetch /api/query → success: 302-Redirect zur signed-URL →
|
|
225
|
+
// Browser folgt → Download startet beim Object-Store. Bei error:
|
|
226
|
+
// passthrough (404/410/501) als JSON.
|
|
224
227
|
//
|
|
225
|
-
//
|
|
226
|
-
//
|
|
227
|
-
// Bei error: passthrough (404/410/501) als JSON.
|
|
228
|
+
// Path liegt AUSSERHALB /api/* weil r.httpRoute den /api-namespace nicht
|
|
229
|
+
// claimen darf (reserved fuer write/query/batch/auth/sse-dispatcher).
|
|
228
230
|
//
|
|
229
|
-
//
|
|
230
|
-
//
|
|
231
|
-
//
|
|
232
|
-
//
|
|
233
|
-
// dispatcher).
|
|
231
|
+
// Der Session-Pfad (eingeloggter Download) braucht KEINEN httpRoute-
|
|
232
|
+
// Wrapper: der Client ruft download-by-job direkt via Dispatcher (traegt
|
|
233
|
+
// X-CSRF-Token mit) und navigiert auf die zurueckgegebene signed-URL —
|
|
234
|
+
// siehe postWithDownload im privacy-center-screen.
|
|
234
235
|
r.httpRoute({
|
|
235
236
|
method: "GET",
|
|
236
237
|
path: "/user-export/by-token",
|
|
@@ -255,33 +256,6 @@ export function createUserDataRightsFeature(opts: UserDataRightsOptions = {}): F
|
|
|
255
256
|
},
|
|
256
257
|
});
|
|
257
258
|
|
|
258
|
-
// **Session-Pfad (auth):** GET /user-export/by-job/:jobId
|
|
259
|
-
r.httpRoute({
|
|
260
|
-
method: "GET",
|
|
261
|
-
path: "/user-export/by-job/:jobId",
|
|
262
|
-
handler: async (c, { app }) => {
|
|
263
|
-
const url = new URL(c.req.url);
|
|
264
|
-
const jobId = c.req.param("jobId");
|
|
265
|
-
if (!jobId) {
|
|
266
|
-
return c.json({ error: "missing_job_id" }, 400);
|
|
267
|
-
}
|
|
268
|
-
const queryRes = await app.fetch(
|
|
269
|
-
new Request(`${url.origin}/api/query`, {
|
|
270
|
-
method: "POST",
|
|
271
|
-
headers: {
|
|
272
|
-
"content-type": "application/json",
|
|
273
|
-
...forwardAuthHeaders(c.req.raw.headers),
|
|
274
|
-
},
|
|
275
|
-
body: JSON.stringify({
|
|
276
|
-
type: "user-data-rights:query:download-by-job",
|
|
277
|
-
payload: { jobId, auditMeta: extractAuditMeta(c.req.raw.headers) },
|
|
278
|
-
}),
|
|
279
|
-
}),
|
|
280
|
-
);
|
|
281
|
-
return mapQueryResponseToRedirect(c, queryRes);
|
|
282
|
-
},
|
|
283
|
-
});
|
|
284
|
-
|
|
285
259
|
// S2.U3 Atom 3b — Worker fuer Async Export-Pipeline. Cron-getriggert.
|
|
286
260
|
r.job(
|
|
287
261
|
"run-export-jobs",
|
|
@@ -363,15 +337,6 @@ async function mapQueryResponseToRedirect(
|
|
|
363
337
|
return c.redirect(body.data.url, 302);
|
|
364
338
|
}
|
|
365
339
|
|
|
366
|
-
function forwardAuthHeaders(headers: Headers): Record<string, string> {
|
|
367
|
-
const out: Record<string, string> = {};
|
|
368
|
-
const auth = headers.get("authorization");
|
|
369
|
-
if (auth) out["authorization"] = auth;
|
|
370
|
-
const cookie = headers.get("cookie");
|
|
371
|
-
if (cookie) out["cookie"] = cookie;
|
|
372
|
-
return out;
|
|
373
|
-
}
|
|
374
|
-
|
|
375
340
|
// Extract Audit-Meta (IP + UA) aus den HTTP-Headers + steck es in die
|
|
376
341
|
// query-payload. Der httpRoute-Wrapper ist trusted-source — er hat den
|
|
377
342
|
// raw-request gesehen, nicht der direkter /api/query-Caller. User der
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
// 6. Audit-Update: useCount + 1, IP, UA, lastUsedAt (best-effort)
|
|
24
24
|
// 7. Return {url, expiresAt}
|
|
25
25
|
|
|
26
|
+
import { requestContext } from "@cosmicdrift/kumiko-framework/api";
|
|
26
27
|
import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
27
28
|
import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
28
29
|
import { NotFoundError, UnprocessableError } from "@cosmicdrift/kumiko-framework/errors";
|
|
@@ -54,12 +55,6 @@ export const downloadByJobQuery = defineQueryHandler({
|
|
|
54
55
|
name: "download-by-job",
|
|
55
56
|
schema: z.object({
|
|
56
57
|
jobId: z.string().min(1, "jobId required"),
|
|
57
|
-
auditMeta: z
|
|
58
|
-
.object({
|
|
59
|
-
ip: z.string().nullable(),
|
|
60
|
-
userAgent: z.string().nullable(),
|
|
61
|
-
})
|
|
62
|
-
.optional(),
|
|
63
58
|
}),
|
|
64
59
|
access: { openToAll: true }, // openToAll = auth-required, kein anonymous
|
|
65
60
|
handler: async (query, ctx) => {
|
|
@@ -68,8 +63,13 @@ export const downloadByJobQuery = defineQueryHandler({
|
|
|
68
63
|
const userId = query.user.id;
|
|
69
64
|
const jobId = query.payload.jobId;
|
|
70
65
|
const tenantId = query.user.tenantId;
|
|
71
|
-
|
|
72
|
-
|
|
66
|
+
// IP aus dem request-scoped Kontext (von requestIdMiddleware aus
|
|
67
|
+
// x-forwarded-for befuellt) — server-trusted, anders als ein vom Client
|
|
68
|
+
// mitgeschickter Wert. UA steht nicht im RequestContext; der Audit-Row
|
|
69
|
+
// laesst sie null (best-effort, via requestId in den Server-Logs
|
|
70
|
+
// cross-referenzierbar).
|
|
71
|
+
const auditIp = requestContext.get()?.ip ?? null;
|
|
72
|
+
const auditUa: string | null = null;
|
|
73
73
|
|
|
74
74
|
// Step 1-2: job-lookup + cross-user-isolation
|
|
75
75
|
// ctx.db.raw weil tenant-agnostisch — Alice in Tenant B sucht den
|
|
@@ -179,8 +179,8 @@ export const downloadByJobQuery = defineQueryHandler({
|
|
|
179
179
|
tokenUseCount: tokenRow.useCount ?? 0,
|
|
180
180
|
tenantId: jobRow.requestedFromTenantId,
|
|
181
181
|
now,
|
|
182
|
-
ip:
|
|
183
|
-
userAgent:
|
|
182
|
+
ip: auditIp,
|
|
183
|
+
userAgent: auditUa,
|
|
184
184
|
});
|
|
185
185
|
}
|
|
186
186
|
// Wenn tokenRow fehlt (sollte nicht passieren wenn Atom 4a sauber
|
|
@@ -139,7 +139,7 @@ describe.skip("PrivacyCenterScreen", () => {
|
|
|
139
139
|
expect(view.container.textContent).not.toContain("userDataRights.privacyCenter");
|
|
140
140
|
});
|
|
141
141
|
|
|
142
|
-
test("export done: Download-
|
|
142
|
+
test("export done: Download-Button + Verfügbar-bis-Datum", async () => {
|
|
143
143
|
const { view } = renderCenter({
|
|
144
144
|
me: activeMe,
|
|
145
145
|
exportStatus: {
|
|
@@ -148,8 +148,7 @@ describe.skip("PrivacyCenterScreen", () => {
|
|
|
148
148
|
},
|
|
149
149
|
});
|
|
150
150
|
await waitForMount(view);
|
|
151
|
-
|
|
152
|
-
expect(link.getAttribute("href")).toBe("/user-export/by-job/job-123");
|
|
151
|
+
expect(view.getByTestId("privacy-export-download")).toBeTruthy();
|
|
153
152
|
const ready = view.getByTestId("privacy-export-ready");
|
|
154
153
|
expect(ready.textContent).toContain("2026-07-11");
|
|
155
154
|
expect(ready.textContent).not.toContain("T00:00");
|
|
@@ -27,7 +27,7 @@ export function ConfirmAccountDeletionScreen({
|
|
|
27
27
|
}: ConfirmAccountDeletionScreenProps): ReactNode {
|
|
28
28
|
const t = useTranslation();
|
|
29
29
|
const dispatcher = useDispatcher();
|
|
30
|
-
const { Button, Banner } = usePrimitives();
|
|
30
|
+
const { Button, Banner, Card } = usePrimitives();
|
|
31
31
|
const [token] = useState(readToken);
|
|
32
32
|
const [phase, setPhase] = useState<Phase>(token.length > 0 ? "idle" : "missing");
|
|
33
33
|
|
|
@@ -42,12 +42,19 @@ export function ConfirmAccountDeletionScreen({
|
|
|
42
42
|
};
|
|
43
43
|
|
|
44
44
|
return (
|
|
45
|
-
<
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
45
|
+
<Card
|
|
46
|
+
className="w-full max-w-sm mx-auto"
|
|
47
|
+
options={{ padded: false }}
|
|
48
|
+
slots={{
|
|
49
|
+
header: (
|
|
50
|
+
<div className="flex flex-col space-y-1.5 p-6 pb-4">
|
|
51
|
+
<h1 className="text-xl font-semibold tracking-tight">
|
|
52
|
+
{title ?? t("userDataRights.deletion.confirm.title")}
|
|
53
|
+
</h1>
|
|
54
|
+
</div>
|
|
55
|
+
),
|
|
56
|
+
}}
|
|
57
|
+
>
|
|
51
58
|
<div className="p-6 pt-0 flex flex-col gap-4">
|
|
52
59
|
{phase === "success" ? (
|
|
53
60
|
<Banner variant="info">
|
|
@@ -83,6 +90,6 @@ export function ConfirmAccountDeletionScreen({
|
|
|
83
90
|
</>
|
|
84
91
|
)}
|
|
85
92
|
</div>
|
|
86
|
-
</
|
|
93
|
+
</Card>
|
|
87
94
|
);
|
|
88
95
|
}
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
useQuery,
|
|
20
20
|
useTranslation,
|
|
21
21
|
} from "@cosmicdrift/kumiko-renderer";
|
|
22
|
+
import { postWithDownload } from "@cosmicdrift/kumiko-renderer-web";
|
|
22
23
|
import { type ReactNode, useEffect, useState } from "react";
|
|
23
24
|
import {
|
|
24
25
|
EXPORT_JOB_STATUS,
|
|
@@ -26,7 +27,6 @@ import {
|
|
|
26
27
|
USER_ME_QUERY,
|
|
27
28
|
UserDataRightsHandlers,
|
|
28
29
|
UserDataRightsQueries,
|
|
29
|
-
userExportByJobPath,
|
|
30
30
|
} from "../constants";
|
|
31
31
|
|
|
32
32
|
const STATUS_DELETION_REQUESTED = "deletionRequested";
|
|
@@ -104,6 +104,15 @@ function ExportSection(): ReactNode {
|
|
|
104
104
|
void statusQuery.refetch?.();
|
|
105
105
|
};
|
|
106
106
|
|
|
107
|
+
// Download laeuft ueber den Dispatcher (traegt X-CSRF-Token) statt ueber
|
|
108
|
+
// eine <a>-Navigation: download-by-job liefert eine signed URL zurueck, auf
|
|
109
|
+
// die postWithDownload den Browser navigiert (content-disposition:
|
|
110
|
+
// attachment → laedt herunter).
|
|
111
|
+
const downloadExport = async (jobId: string): Promise<void> => {
|
|
112
|
+
const err = await postWithDownload(dispatcher, UserDataRightsQueries.downloadByJob, { jobId });
|
|
113
|
+
if (err) setStatus({ kind: "error", messageKey: failureKey(err) });
|
|
114
|
+
};
|
|
115
|
+
|
|
107
116
|
const result = statusQuery.data;
|
|
108
117
|
const job = result && result.hasJob ? result.job : null;
|
|
109
118
|
const submitting = status.kind === "submitting";
|
|
@@ -121,7 +130,26 @@ function ExportSection(): ReactNode {
|
|
|
121
130
|
}, [inProgress, refetch]);
|
|
122
131
|
|
|
123
132
|
return (
|
|
124
|
-
<Section
|
|
133
|
+
<Section
|
|
134
|
+
title={t("userDataRights.privacyCenter.export.title")}
|
|
135
|
+
testId="privacy-export"
|
|
136
|
+
actions={
|
|
137
|
+
!inProgress ? (
|
|
138
|
+
<Button
|
|
139
|
+
onClick={() => void request()}
|
|
140
|
+
disabled={submitting}
|
|
141
|
+
loading={submitting}
|
|
142
|
+
testId="privacy-export-request"
|
|
143
|
+
>
|
|
144
|
+
{done
|
|
145
|
+
? t("userDataRights.privacyCenter.export.requestNew")
|
|
146
|
+
: submitting
|
|
147
|
+
? t("userDataRights.privacyCenter.export.requesting")
|
|
148
|
+
: t("userDataRights.privacyCenter.export.request")}
|
|
149
|
+
</Button>
|
|
150
|
+
) : undefined
|
|
151
|
+
}
|
|
152
|
+
>
|
|
125
153
|
<p className="text-sm text-muted-foreground">
|
|
126
154
|
{t("userDataRights.privacyCenter.export.intro")}
|
|
127
155
|
</p>
|
|
@@ -150,30 +178,18 @@ function ExportSection(): ReactNode {
|
|
|
150
178
|
})}
|
|
151
179
|
</p>
|
|
152
180
|
)}
|
|
153
|
-
<
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
181
|
+
<div className="mt-2">
|
|
182
|
+
<Button
|
|
183
|
+
variant="secondary"
|
|
184
|
+
onClick={() => void downloadExport(job.id)}
|
|
185
|
+
testId="privacy-export-download"
|
|
186
|
+
>
|
|
187
|
+
{t("userDataRights.privacyCenter.export.download")}
|
|
188
|
+
</Button>
|
|
189
|
+
</div>
|
|
160
190
|
</Banner>
|
|
161
191
|
)}
|
|
162
192
|
<StatusBanner status={status} />
|
|
163
|
-
{!inProgress && (
|
|
164
|
-
<Button
|
|
165
|
-
onClick={() => void request()}
|
|
166
|
-
disabled={submitting}
|
|
167
|
-
loading={submitting}
|
|
168
|
-
testId="privacy-export-request"
|
|
169
|
-
>
|
|
170
|
-
{done
|
|
171
|
-
? t("userDataRights.privacyCenter.export.requestNew")
|
|
172
|
-
: submitting
|
|
173
|
-
? t("userDataRights.privacyCenter.export.requesting")
|
|
174
|
-
: t("userDataRights.privacyCenter.export.request")}
|
|
175
|
-
</Button>
|
|
176
|
-
)}
|
|
177
193
|
</Section>
|
|
178
194
|
);
|
|
179
195
|
}
|
|
@@ -208,6 +224,17 @@ function RestrictionSection({
|
|
|
208
224
|
<Section
|
|
209
225
|
title={t("userDataRights.privacyCenter.restriction.title")}
|
|
210
226
|
testId="privacy-restriction"
|
|
227
|
+
actions={
|
|
228
|
+
restricted ? undefined : (
|
|
229
|
+
<Button
|
|
230
|
+
variant="danger"
|
|
231
|
+
onClick={() => setDialogOpen(true)}
|
|
232
|
+
testId="privacy-restriction-restrict"
|
|
233
|
+
>
|
|
234
|
+
{t("userDataRights.privacyCenter.restriction.restrict")}
|
|
235
|
+
</Button>
|
|
236
|
+
)
|
|
237
|
+
}
|
|
211
238
|
>
|
|
212
239
|
{restricted ? (
|
|
213
240
|
<Banner variant="error" testId="privacy-restriction-active">
|
|
@@ -219,13 +246,6 @@ function RestrictionSection({
|
|
|
219
246
|
{t("userDataRights.privacyCenter.restriction.explainer")}
|
|
220
247
|
</p>
|
|
221
248
|
<StatusBanner status={status} />
|
|
222
|
-
<Button
|
|
223
|
-
variant="danger"
|
|
224
|
-
onClick={() => setDialogOpen(true)}
|
|
225
|
-
testId="privacy-restriction-restrict"
|
|
226
|
-
>
|
|
227
|
-
{t("userDataRights.privacyCenter.restriction.restrict")}
|
|
228
|
-
</Button>
|
|
229
249
|
<Dialog
|
|
230
250
|
open={dialogOpen}
|
|
231
251
|
onOpenChange={setDialogOpen}
|
|
@@ -280,7 +300,29 @@ function DeletionSection({
|
|
|
280
300
|
};
|
|
281
301
|
|
|
282
302
|
return (
|
|
283
|
-
<Section
|
|
303
|
+
<Section
|
|
304
|
+
title={t("userDataRights.privacyCenter.deletion.title")}
|
|
305
|
+
testId="privacy-deletion"
|
|
306
|
+
actions={
|
|
307
|
+
deletionRequested ? (
|
|
308
|
+
<Button
|
|
309
|
+
variant="secondary"
|
|
310
|
+
onClick={() => void cancelDeletion()}
|
|
311
|
+
testId="privacy-deletion-cancel"
|
|
312
|
+
>
|
|
313
|
+
{t("userDataRights.privacyCenter.deletion.cancel")}
|
|
314
|
+
</Button>
|
|
315
|
+
) : (
|
|
316
|
+
<Button
|
|
317
|
+
variant="danger"
|
|
318
|
+
onClick={() => setDialogOpen(true)}
|
|
319
|
+
testId="privacy-deletion-delete"
|
|
320
|
+
>
|
|
321
|
+
{t("userDataRights.privacyCenter.deletion.delete")}
|
|
322
|
+
</Button>
|
|
323
|
+
)
|
|
324
|
+
}
|
|
325
|
+
>
|
|
284
326
|
{deletionRequested ? (
|
|
285
327
|
<>
|
|
286
328
|
<Banner variant="error" testId="privacy-deletion-requested">
|
|
@@ -289,13 +331,6 @@ function DeletionSection({
|
|
|
289
331
|
})}
|
|
290
332
|
</Banner>
|
|
291
333
|
<StatusBanner status={status} />
|
|
292
|
-
<Button
|
|
293
|
-
variant="secondary"
|
|
294
|
-
onClick={() => void cancelDeletion()}
|
|
295
|
-
testId="privacy-deletion-cancel"
|
|
296
|
-
>
|
|
297
|
-
{t("userDataRights.privacyCenter.deletion.cancel")}
|
|
298
|
-
</Button>
|
|
299
334
|
</>
|
|
300
335
|
) : (
|
|
301
336
|
<>
|
|
@@ -303,13 +338,6 @@ function DeletionSection({
|
|
|
303
338
|
{t("userDataRights.privacyCenter.deletion.explainer")}
|
|
304
339
|
</p>
|
|
305
340
|
<StatusBanner status={status} />
|
|
306
|
-
<Button
|
|
307
|
-
variant="danger"
|
|
308
|
-
onClick={() => setDialogOpen(true)}
|
|
309
|
-
testId="privacy-deletion-delete"
|
|
310
|
-
>
|
|
311
|
-
{t("userDataRights.privacyCenter.deletion.delete")}
|
|
312
|
-
</Button>
|
|
313
341
|
<Dialog
|
|
314
342
|
open={dialogOpen}
|
|
315
343
|
onOpenChange={setDialogOpen}
|
|
@@ -21,7 +21,7 @@ export function RequestAccountDeletionScreen({
|
|
|
21
21
|
}: RequestAccountDeletionScreenProps): ReactNode {
|
|
22
22
|
const t = useTranslation();
|
|
23
23
|
const dispatcher = useDispatcher();
|
|
24
|
-
const { Form, Field, Input, Button, Banner } = usePrimitives();
|
|
24
|
+
const { Form, Field, Input, Button, Banner, Card } = usePrimitives();
|
|
25
25
|
const [email, setEmail] = useState("");
|
|
26
26
|
const [submitting, setSubmitting] = useState(false);
|
|
27
27
|
const [done, setDone] = useState(false);
|
|
@@ -50,12 +50,19 @@ export function RequestAccountDeletionScreen({
|
|
|
50
50
|
};
|
|
51
51
|
|
|
52
52
|
return (
|
|
53
|
-
<
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
53
|
+
<Card
|
|
54
|
+
className="w-full max-w-sm mx-auto"
|
|
55
|
+
options={{ padded: false }}
|
|
56
|
+
slots={{
|
|
57
|
+
header: (
|
|
58
|
+
<div className="flex flex-col space-y-1.5 p-6 pb-4">
|
|
59
|
+
<h1 className="text-xl font-semibold tracking-tight">
|
|
60
|
+
{title ?? t("userDataRights.deletion.request.title")}
|
|
61
|
+
</h1>
|
|
62
|
+
</div>
|
|
63
|
+
),
|
|
64
|
+
}}
|
|
65
|
+
>
|
|
59
66
|
{done ? (
|
|
60
67
|
<div className="p-6 pt-0">
|
|
61
68
|
<Banner variant="info">
|
|
@@ -91,6 +98,6 @@ export function RequestAccountDeletionScreen({
|
|
|
91
98
|
</Form>
|
|
92
99
|
</div>
|
|
93
100
|
)}
|
|
94
|
-
</
|
|
101
|
+
</Card>
|
|
95
102
|
);
|
|
96
103
|
}
|
|
@@ -243,7 +243,7 @@ function DangerZoneSection({
|
|
|
243
243
|
readonly onChanged: () => void;
|
|
244
244
|
}): ReactNode {
|
|
245
245
|
const t = useTranslation();
|
|
246
|
-
const { Button, Banner, Dialog,
|
|
246
|
+
const { Button, Banner, Dialog, Card } = usePrimitives();
|
|
247
247
|
const dispatcher = useDispatcher();
|
|
248
248
|
const [dialogOpen, setDialogOpen] = useState(false);
|
|
249
249
|
const [status, setStatus] = useState<SectionStatus>({ kind: "idle" });
|
|
@@ -270,52 +270,54 @@ function DangerZoneSection({
|
|
|
270
270
|
onChanged();
|
|
271
271
|
};
|
|
272
272
|
|
|
273
|
+
const footer = deletionRequested ? (
|
|
274
|
+
<Button
|
|
275
|
+
variant="secondary"
|
|
276
|
+
onClick={() => void cancelDeletion()}
|
|
277
|
+
testId="profile-danger-cancel"
|
|
278
|
+
>
|
|
279
|
+
{t("profile.danger.cancelDeletion")}
|
|
280
|
+
</Button>
|
|
281
|
+
) : (
|
|
282
|
+
<Button variant="danger" onClick={() => setDialogOpen(true)} testId="profile-danger-delete">
|
|
283
|
+
{t("profile.danger.delete")}
|
|
284
|
+
</Button>
|
|
285
|
+
);
|
|
286
|
+
|
|
273
287
|
return (
|
|
274
|
-
<
|
|
275
|
-
|
|
276
|
-
className="
|
|
288
|
+
<Card
|
|
289
|
+
testId="profile-danger"
|
|
290
|
+
className="border-destructive/40"
|
|
291
|
+
slots={{ title: t("profile.danger.title"), footer }}
|
|
277
292
|
>
|
|
278
|
-
<
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
<Dialog
|
|
307
|
-
open={dialogOpen}
|
|
308
|
-
onOpenChange={setDialogOpen}
|
|
309
|
-
title={t("profile.danger.dialogTitle")}
|
|
310
|
-
description={t("profile.danger.dialogDescription")}
|
|
311
|
-
variant="danger"
|
|
312
|
-
confirmLabel={t("profile.danger.delete")}
|
|
313
|
-
onConfirm={requestDeletion}
|
|
314
|
-
testId="profile-danger-dialog"
|
|
315
|
-
/>
|
|
316
|
-
</>
|
|
317
|
-
)}
|
|
318
|
-
</section>
|
|
293
|
+
<div className="flex flex-col gap-4">
|
|
294
|
+
{deletionRequested ? (
|
|
295
|
+
<>
|
|
296
|
+
<Banner variant="error" testId="profile-danger-requested">
|
|
297
|
+
{t("profile.danger.requested", {
|
|
298
|
+
date: formatDeletionDate(me.gracePeriodEnd),
|
|
299
|
+
})}
|
|
300
|
+
</Banner>
|
|
301
|
+
<StatusBanner status={status} />
|
|
302
|
+
</>
|
|
303
|
+
) : (
|
|
304
|
+
<>
|
|
305
|
+
<p className="text-sm text-muted-foreground">{t("profile.danger.explainer")}</p>
|
|
306
|
+
<StatusBanner status={status} />
|
|
307
|
+
<Dialog
|
|
308
|
+
open={dialogOpen}
|
|
309
|
+
onOpenChange={setDialogOpen}
|
|
310
|
+
title={t("profile.danger.dialogTitle")}
|
|
311
|
+
description={t("profile.danger.dialogDescription")}
|
|
312
|
+
variant="danger"
|
|
313
|
+
confirmLabel={t("profile.danger.delete")}
|
|
314
|
+
onConfirm={requestDeletion}
|
|
315
|
+
testId="profile-danger-dialog"
|
|
316
|
+
/>
|
|
317
|
+
</>
|
|
318
|
+
)}
|
|
319
|
+
</div>
|
|
320
|
+
</Card>
|
|
319
321
|
);
|
|
320
322
|
}
|
|
321
323
|
|