@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.79.2",
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.79.2",
88
- "@cosmicdrift/kumiko-framework": "0.79.2",
89
- "@cosmicdrift/kumiko-headless": "0.79.2",
90
- "@cosmicdrift/kumiko-renderer": "0.79.2",
91
- "@cosmicdrift/kumiko-renderer-web": "0.79.2",
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
- <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
- )}
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
- </div>
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
- const result = await stack.http.queryOk<{ url: string }>(
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
- expect(result.url).toMatch(/^memory:\/\//);
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
- // r.httpRoute-Wrapper: Magic-Link-Pfad (anonymous) + UI-Klick-Pfad.
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
- // Beide rufen via app.fetch /api/query wenn success: 302-Redirect
226
- // zur signed-URL Browser folgt → Download startet beim Object-Store.
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
- // **Token-Pfad (anonymous):** GET /user-export/by-token?token=<plain>
230
- //
231
- // Path liegt AUSSERHALB /api/* weil r.httpRoute den /api-namespace
232
- // nicht claimen darf (reserved fuer write/query/batch/auth/sse-
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
- const auditIp = query.payload.auditMeta?.ip ?? null;
72
- const auditUa = query.payload.auditMeta?.userAgent ?? null;
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: query.payload.auditMeta?.ip ?? null,
183
- userAgent: query.payload.auditMeta?.userAgent ?? null,
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-Link auf den by-job-Pfad + Verfügbar-bis-Datum", async () => {
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
- const link = view.getByTestId("privacy-export-download") as HTMLAnchorElement;
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
- <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>
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
- </div>
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 title={t("userDataRights.privacyCenter.export.title")} testId="privacy-export">
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
- <a
154
- href={userExportByJobPath(job.id)}
155
- data-testid="privacy-export-download"
156
- className="mt-2 inline-block font-medium underline"
157
- >
158
- {t("userDataRights.privacyCenter.export.download")}
159
- </a>
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 title={t("userDataRights.privacyCenter.deletion.title")} testId="privacy-deletion">
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
- <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>
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
- </div>
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, Heading } = usePrimitives();
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
- <section
275
- data-testid="profile-danger"
276
- className="flex flex-col gap-4 rounded-lg border border-destructive/40 bg-card p-6"
288
+ <Card
289
+ testId="profile-danger"
290
+ className="border-destructive/40"
291
+ slots={{ title: t("profile.danger.title"), footer }}
277
292
  >
278
- <Heading variant="section">{t("profile.danger.title")}</Heading>
279
- {deletionRequested ? (
280
- <>
281
- <Banner variant="error" testId="profile-danger-requested">
282
- {t("profile.danger.requested", {
283
- date: formatDeletionDate(me.gracePeriodEnd),
284
- })}
285
- </Banner>
286
- <StatusBanner status={status} />
287
- <Button
288
- variant="secondary"
289
- onClick={() => void cancelDeletion()}
290
- testId="profile-danger-cancel"
291
- >
292
- {t("profile.danger.cancelDeletion")}
293
- </Button>
294
- </>
295
- ) : (
296
- <>
297
- <p className="text-sm text-muted-foreground">{t("profile.danger.explainer")}</p>
298
- <StatusBanner status={status} />
299
- <Button
300
- variant="danger"
301
- onClick={() => setDialogOpen(true)}
302
- testId="profile-danger-delete"
303
- >
304
- {t("profile.danger.delete")}
305
- </Button>
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