@cosmicdrift/kumiko-bundled-features 0.78.0 → 0.79.2

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.78.0",
3
+ "version": "0.79.2",
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.78.0",
88
- "@cosmicdrift/kumiko-framework": "0.78.0",
89
- "@cosmicdrift/kumiko-headless": "0.78.0",
90
- "@cosmicdrift/kumiko-renderer": "0.78.0",
91
- "@cosmicdrift/kumiko-renderer-web": "0.78.0",
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",
92
92
  "@mollie/api-client": "^4.5.0",
93
93
  "@node-rs/argon2": "^2.0.2",
94
94
  "@types/nodemailer": "^8.0.0",
@@ -0,0 +1,148 @@
1
+ // Treibt den ECHTEN registrierten Export-Cron-Job (r.job "run-export-jobs")
2
+ // über seinen Job-Kontext — so wie der Job-Runner ihn in prod aufruft:
3
+ // `ctx.configResolver` gesetzt (App-Override provider=inmemory), aber KEIN
4
+ // per-request `ctx.config` (das baut nur der HTTP-Dispatcher).
5
+ //
6
+ // Der bestehende run-export-jobs-Test reicht `buildStorageProvider` MANUELL —
7
+ // und übersprang damit genau diesen Pfad: der Wrapper baut providerCtx aus dem
8
+ // Job-Kontext. Ohne den configResolver→ConfigAccessor-Bau wirft
9
+ // createFileProviderForTenant "ctx.config is missing" (genau der prod-Bug, der
10
+ // jeden Export auf "failed" setzte).
11
+
12
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
13
+ import { asRawClient, insertOne, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
14
+ import { SYSTEM_USER_ID } from "@cosmicdrift/kumiko-framework/engine";
15
+ import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
16
+ import {
17
+ setupTestStack,
18
+ type TestStack,
19
+ unsafeCreateEntityTable,
20
+ unsafePushTables,
21
+ } from "@cosmicdrift/kumiko-framework/stack";
22
+ import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
23
+ import {
24
+ createComplianceProfilesFeature,
25
+ tenantComplianceProfileEntity,
26
+ } from "../../compliance-profiles";
27
+ import { configValuesTable, createConfigFeature, createConfigResolver } from "../../config";
28
+ import { createDataRetentionFeature } from "../../data-retention";
29
+ import { fileFoundationFeature } from "../../file-foundation";
30
+ import { fileProviderInMemoryFeature } from "../../file-provider-inmemory";
31
+ import { createSessionsFeature } from "../../sessions";
32
+ import { createUserFeature, USER_STATUS, userEntity, userTable } from "../../user";
33
+ import { createUserDataRightsFeature } from "../feature";
34
+ import { exportDownloadTokenEntity } from "../schema/download-token";
35
+ import { EXPORT_JOB_STATUS, exportJobEntity, exportJobsTable } from "../schema/export-job";
36
+
37
+ const TENANT = "00000000-0000-4000-8000-0000000009a1";
38
+ const USER_ID = "00000000-0000-4000-8000-0000000009b1";
39
+ const JOB_QN = "user-data-rights:job:run-export-jobs";
40
+
41
+ // App-weiter Override wie money-horse's cashColtConfigResolver — provider=inmemory
42
+ // ohne per-Tenant-config-Row. Der Job-Kontext trägt DIESEN resolver, kein config.
43
+ const configResolver = createConfigResolver({
44
+ appOverrides: new Map([["file-foundation:config:provider", "inmemory"]]),
45
+ });
46
+
47
+ let stack: TestStack;
48
+
49
+ beforeAll(async () => {
50
+ stack = await setupTestStack({
51
+ features: [
52
+ createConfigFeature(),
53
+ createUserFeature(),
54
+ createDataRetentionFeature(),
55
+ createComplianceProfilesFeature(),
56
+ fileFoundationFeature,
57
+ fileProviderInMemoryFeature,
58
+ createSessionsFeature(),
59
+ createUserDataRightsFeature(),
60
+ ],
61
+ });
62
+ await createEventsTable(stack.db);
63
+ await unsafePushTables(stack.db, { configValuesTable });
64
+ await unsafeCreateEntityTable(stack.db, exportJobEntity);
65
+ await unsafeCreateEntityTable(stack.db, exportDownloadTokenEntity);
66
+ await unsafeCreateEntityTable(stack.db, tenantComplianceProfileEntity);
67
+ await unsafeCreateEntityTable(stack.db, userEntity);
68
+ await asRawClient(stack.db).unsafe(`
69
+ CREATE TABLE IF NOT EXISTS read_tenant_memberships (
70
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
71
+ tenant_id UUID NOT NULL,
72
+ user_id TEXT NOT NULL,
73
+ version INTEGER NOT NULL DEFAULT 0,
74
+ inserted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
75
+ modified_at TIMESTAMPTZ NOT NULL DEFAULT now(),
76
+ inserted_by_id TEXT,
77
+ modified_by_id TEXT,
78
+ is_deleted BOOLEAN NOT NULL DEFAULT false,
79
+ deleted_at TIMESTAMPTZ,
80
+ deleted_by_id TEXT,
81
+ roles TEXT NOT NULL DEFAULT '[]',
82
+ UNIQUE(user_id, tenant_id)
83
+ )
84
+ `);
85
+ });
86
+
87
+ afterAll(async () => {
88
+ await stack.cleanup();
89
+ });
90
+
91
+ beforeEach(async () => {
92
+ const raw = asRawClient(stack.db);
93
+ await raw.unsafe("DELETE FROM read_export_jobs");
94
+ await raw.unsafe("DELETE FROM read_users");
95
+ await raw.unsafe("DELETE FROM read_tenant_memberships");
96
+ await insertOne(stack.db, userTable, {
97
+ id: USER_ID,
98
+ tenantId: TENANT,
99
+ email: "export-cron@example.test",
100
+ passwordHash: "hashed",
101
+ displayName: "Cron Export",
102
+ locale: "de",
103
+ emailVerified: true,
104
+ roles: '["Member"]',
105
+ status: USER_STATUS.Active,
106
+ });
107
+ await raw.unsafe(
108
+ `INSERT INTO read_tenant_memberships (tenant_id, user_id, roles) VALUES ('${TENANT}', '${USER_ID}', '["Member"]')`,
109
+ );
110
+ });
111
+
112
+ // Seedet einen pending Export-Job über den echten request-export-Handler.
113
+ async function seedPendingJob(): Promise<string> {
114
+ const res = await stack.http.writeOk<{ jobId: string }>(
115
+ "user-data-rights:write:request-export",
116
+ {},
117
+ { id: USER_ID, tenantId: TENANT, roles: ["Member"] },
118
+ );
119
+ return res.jobId;
120
+ }
121
+
122
+ describe("run-export-jobs cron-context", () => {
123
+ test("Cron-Job-Kontext (configResolver, KEIN config) → Export läuft durch, bytesWritten > 0", async () => {
124
+ const jobId = await seedPendingJob();
125
+ const job = stack.registry.getJob(JOB_QN);
126
+ expect(job).toBeDefined();
127
+
128
+ // EXAKT der prod-Job-Kontext: configResolver gesetzt, config undefined.
129
+ const jobCtx = {
130
+ db: stack.db,
131
+ registry: stack.registry,
132
+ configResolver,
133
+ _userId: SYSTEM_USER_ID,
134
+ now: getTemporal().Now.instant(),
135
+ };
136
+ // Vor dem Fix wirft der Wrapper hier "ctx.config is missing".
137
+ await job?.handler({}, jobCtx as never);
138
+
139
+ const [row] = (await selectMany(stack.db, exportJobsTable, { id: jobId })) as Array<{
140
+ status: string;
141
+ bytesWritten: number | null;
142
+ errorMessage: string | null;
143
+ }>;
144
+ expect(row?.errorMessage).toBeNull();
145
+ expect(row?.status).toBe(EXPORT_JOB_STATUS.Done);
146
+ expect(row?.bytesWritten ?? 0).toBeGreaterThan(0);
147
+ });
148
+ });
@@ -4,6 +4,7 @@ import {
4
4
  type FeatureDefinition,
5
5
  SYSTEM_USER_ID,
6
6
  } from "@cosmicdrift/kumiko-framework/engine";
7
+ import { createConfigAccessor } from "../config";
7
8
  import { createFileProviderForTenant } from "../file-foundation";
8
9
  import { PRIVACY_CENTER_SCREEN_ID } from "./constants";
9
10
  import { cancelDeletionWrite } from "./handlers/cancel-deletion.write";
@@ -295,17 +296,35 @@ export function createUserDataRightsFeature(opts: UserDataRightsOptions = {}): F
295
296
  // SYSTEM_USER_ID ist die framework-weite Konvention. Der job-
296
297
  // Discriminator wird via handlerName="user-data-rights:run-export-
297
298
  // jobs" im Secret-Read-Audit erfasst.
298
- const providerCtx = {
299
- config: ctx.config,
300
- registry: ctx.registry,
301
- secrets: ctx.secrets,
302
- _userId: ctx._userId ?? SYSTEM_USER_ID,
303
- };
299
+ const exportUserId = ctx._userId ?? SYSTEM_USER_ID;
300
+ const exportDb = ctx.db as import("@cosmicdrift/kumiko-framework/db").DbConnection; // @cast-boundary db-operator
301
+ const exportRegistry = ctx.registry;
304
302
  await runExportJobs({
305
- db: ctx.db as import("@cosmicdrift/kumiko-framework/db").DbConnection, // @cast-boundary db-operator
306
- registry: ctx.registry,
307
- buildStorageProvider: async (tenantId) =>
308
- createFileProviderForTenant(providerCtx, tenantId, "user-data-rights:run-export-jobs"), // @wrapper-known semantic-alias
303
+ db: exportDb,
304
+ registry: exportRegistry,
305
+ buildStorageProvider: async (tenantId) => {
306
+ // ctx.config (per-request ConfigAccessor) existiert nur im HTTP-
307
+ // Dispatcher; der Cron-Job-Kontext trägt ctx.configResolver. Den
308
+ // per-Tenant-Accessor daraus bauen (wie der HTTP-Pfad via
309
+ // _configAccessorFactory) — sonst wirft createFileProviderForTenant
310
+ // "ctx.config is missing" und jeder Export landet auf failed.
311
+ const config =
312
+ ctx.config ??
313
+ (ctx.configResolver
314
+ ? createConfigAccessor(
315
+ exportRegistry,
316
+ ctx.configResolver,
317
+ tenantId as Parameters<typeof createConfigAccessor>[2],
318
+ exportUserId,
319
+ exportDb,
320
+ )
321
+ : undefined);
322
+ return createFileProviderForTenant(
323
+ { config, registry: exportRegistry, secrets: ctx.secrets, _userId: exportUserId },
324
+ tenantId,
325
+ "user-data-rights:run-export-jobs",
326
+ );
327
+ },
309
328
  now: T.Now.instant(),
310
329
  // Atom 5 — App-Author-Callbacks fuer Email-Notification.
311
330
  // Optional: wenn nicht gesetzt, kein Email; User pollt
@@ -127,15 +127,13 @@ async function waitForMount(view: ReturnType<typeof render>): Promise<void> {
127
127
  // sobald das framework-weite Test-Isolation-Problem (#457) gelöst ist. Die
128
128
  // QN-Drift-Pins + formatDate unten decken die CI-stabile Verdrahtungs-Korrektheit ab.
129
129
  describe.skip("PrivacyCenterScreen", () => {
130
- test("aktiver User: alle vier Sektionen, Texte übersetzt (keine rohen Keys)", async () => {
130
+ test("aktiver User: Export/Einschränken/Löschen-Sektionen, Texte übersetzt (keine rohen Keys)", async () => {
131
131
  const { view } = renderCenter({ me: activeMe });
132
132
  await waitForMount(view);
133
133
  expect(view.getByTestId("privacy-export")).toBeTruthy();
134
- expect(view.getByTestId("privacy-audit")).toBeTruthy();
135
134
  expect(view.getByTestId("privacy-restriction")).toBeTruthy();
136
135
  expect(view.getByTestId("privacy-deletion")).toBeTruthy();
137
136
  expect(view.getByTestId("privacy-export-request")).toBeTruthy();
138
- expect(view.getByTestId("privacy-audit-empty")).toBeTruthy();
139
137
  expect(view.getByTestId("privacy-restriction-restrict")).toBeTruthy();
140
138
  expect(view.getByTestId("privacy-deletion-delete")).toBeTruthy();
141
139
  expect(view.container.textContent).not.toContain("userDataRights.privacyCenter");
@@ -177,27 +175,6 @@ describe.skip("PrivacyCenterScreen", () => {
177
175
  expect(view.queryByTestId("privacy-export-request")).toBeNull();
178
176
  });
179
177
 
180
- test("audit rows rendern mit type + datum", async () => {
181
- const { view } = renderCenter({
182
- me: activeMe,
183
- auditLog: {
184
- rows: [
185
- {
186
- id: "ev-1",
187
- type: "user.created",
188
- aggregateType: "user",
189
- createdAt: "2026-06-01T08:00:00Z",
190
- },
191
- ],
192
- },
193
- });
194
- await waitForMount(view);
195
- const rows = view.getAllByTestId("privacy-audit-row");
196
- expect(rows.length).toBe(1);
197
- expect(rows[0]?.textContent).toContain("user.created");
198
- expect(rows[0]?.textContent).toContain("2026-06-01");
199
- });
200
-
201
178
  test("deletionRequested: Frist-Banner + Abbrechen statt Lösch-Button", async () => {
202
179
  const { view } = renderCenter({
203
180
  me: { ...activeMe, status: "deletionRequested", gracePeriodEnd: "2026-07-11T00:00:00Z" },
@@ -21,16 +21,27 @@ export type UserDataRightsClientOptions = {
21
21
  * privacy-center-Screen. Den Client VOR dem Auth-Client listen, sonst
22
22
  * landet der anonyme Besucher auf der Login-Maske. */
23
23
  readonly publicDeletion?: PublicDeletionRoutes;
24
+ /** Konfiguration des eingeloggten privacy-center-Screens. */
25
+ readonly privacyCenter?: {
26
+ /** `false` blendet die Konto-Lösch-Sektion aus — für Apps, die die Löschung
27
+ * schon woanders anbieten (z.B. Profil-DangerZone), gegen Doppelung.
28
+ * Default `true`. */
29
+ readonly showDeletion?: boolean;
30
+ };
24
31
  };
25
32
 
26
33
  export function userDataRightsClient(
27
34
  options?: UserDataRightsClientOptions,
28
35
  ): ClientFeatureDefinition {
36
+ const showDeletion = options?.privacyCenter?.showDeletion ?? true;
37
+ const privacyCenter = showDeletion
38
+ ? PrivacyCenterScreen
39
+ : () => <PrivacyCenterScreen showDeletion={false} />;
29
40
  const base: ClientFeatureDefinition = {
30
41
  name: USER_DATA_RIGHTS_FEATURE,
31
42
  translations: mergeTranslations(defaultTranslations, options?.translations ?? {}),
32
43
  components: {
33
- [PRIVACY_CENTER_SCREEN_ID]: PrivacyCenterScreen,
44
+ [PRIVACY_CENTER_SCREEN_ID]: privacyCenter,
34
45
  },
35
46
  };
36
47
  if (options?.publicDeletion === undefined) return base;
@@ -1,13 +1,17 @@
1
1
  // @runtime client
2
2
  // PrivacyCenterScreen — eingeloggte DSGVO-Self-Service-Seite: Datenexport
3
- // (Art. 20), Aktivitätsprotokoll (Art. 15), Verarbeitung einschränken
4
- // (Art. 18) und Konto löschen (Art. 17) in einem Screen. Das Feature
5
- // registriert ihn dormant als custom-Screen (r.screen, kein r.nav); die App
6
- // platziert ihn via r.nav im eingeloggten Bereich.
3
+ // (Art. 20), Verarbeitung einschränken (Art. 18) und Konto löschen (Art. 17)
4
+ // in einem Screen. Das Feature registriert ihn dormant als custom-Screen
5
+ // (r.screen, kein r.nav); die App platziert ihn via r.nav im eingeloggten
6
+ // Bereich.
7
7
  //
8
- // Art. 18 Lift ist hier bewusst NICHT actionbar: ein eingeschränktes Konto
9
- // ist vom Login geblockt und erreicht diesen Screen gar nicht erst — das
10
- // Aufheben läuft über Support / Magic-Link, nicht über die Self-Service-UI.
8
+ // `showDeletion=false` blendet die Lösch-Sektion aus für Apps, die die
9
+ // Konto-Löschung bereits an anderer Stelle anbieten (z.B. Profil-DangerZone),
10
+ // damit sie nicht doppelt erscheint.
11
+ //
12
+ // Art. 18 Lift ist hier bewusst NICHT actionbar: ein eingeschränktes Konto ist
13
+ // vom Login geblockt und erreicht diesen Screen gar nicht erst — das Aufheben
14
+ // läuft über Support / Magic-Link, nicht über die Self-Service-UI.
11
15
 
12
16
  import {
13
17
  useDispatcher,
@@ -15,7 +19,7 @@ import {
15
19
  useQuery,
16
20
  useTranslation,
17
21
  } from "@cosmicdrift/kumiko-renderer";
18
- import { type ReactNode, useState } from "react";
22
+ import { type ReactNode, useEffect, useState } from "react";
19
23
  import {
20
24
  EXPORT_JOB_STATUS,
21
25
  type ExportJobStatus,
@@ -27,6 +31,10 @@ import {
27
31
 
28
32
  const STATUS_DELETION_REQUESTED = "deletionRequested";
29
33
  const STATUS_RESTRICTED = "restricted";
34
+ // Export-Job läuft async (worker-Lane-Cron, ~1 Min). Solange er pending/running
35
+ // ist, pollt der Screen den Status, damit der Download ohne manuellen Reload
36
+ // erscheint.
37
+ const EXPORT_POLL_MS = 4000;
30
38
 
31
39
  type MeRow = {
32
40
  readonly id: string;
@@ -45,14 +53,6 @@ type ExportStatusResult =
45
53
  | { readonly hasJob: false }
46
54
  | { readonly hasJob: true; readonly job: ExportJob };
47
55
 
48
- type AuditRow = {
49
- readonly id: string;
50
- readonly type: string;
51
- readonly aggregateType: string;
52
- readonly createdAt: string;
53
- };
54
- type AuditLogResult = { readonly rows: readonly AuditRow[] };
55
-
56
56
  type SectionStatus =
57
57
  | { kind: "idle" }
58
58
  | { kind: "submitting" }
@@ -88,7 +88,7 @@ function StatusBanner({ status }: { readonly status: SectionStatus }): ReactNode
88
88
 
89
89
  function ExportSection(): ReactNode {
90
90
  const t = useTranslation();
91
- const { Button, Banner, Heading } = usePrimitives();
91
+ const { Section, Button, Banner } = usePrimitives();
92
92
  const dispatcher = useDispatcher();
93
93
  const statusQuery = useQuery<ExportStatusResult | null>(UserDataRightsQueries.exportStatus, {});
94
94
  const [status, setStatus] = useState<SectionStatus>({ kind: "idle" });
@@ -112,12 +112,16 @@ function ExportSection(): ReactNode {
112
112
  const done = job?.status === EXPORT_JOB_STATUS.Done;
113
113
  const failed = job?.status === EXPORT_JOB_STATUS.Failed;
114
114
 
115
+ // Solange der Job läuft: pollen bis Done/Failed, dann auto-Stop.
116
+ const refetch = statusQuery.refetch;
117
+ useEffect(() => {
118
+ if (!inProgress || !refetch) return;
119
+ const id = setInterval(() => void refetch(), EXPORT_POLL_MS);
120
+ return () => clearInterval(id);
121
+ }, [inProgress, refetch]);
122
+
115
123
  return (
116
- <section
117
- data-testid="privacy-export"
118
- className="flex flex-col gap-4 rounded-lg border bg-card p-6"
119
- >
120
- <Heading variant="section">{t("userDataRights.privacyCenter.export.title")}</Heading>
124
+ <Section title={t("userDataRights.privacyCenter.export.title")} testId="privacy-export">
121
125
  <p className="text-sm text-muted-foreground">
122
126
  {t("userDataRights.privacyCenter.export.intro")}
123
127
  </p>
@@ -170,48 +174,7 @@ function ExportSection(): ReactNode {
170
174
  : t("userDataRights.privacyCenter.export.request")}
171
175
  </Button>
172
176
  )}
173
- </section>
174
- );
175
- }
176
-
177
- function AuditSection(): ReactNode {
178
- const t = useTranslation();
179
- const { Banner, Heading } = usePrimitives();
180
- const logQuery = useQuery<AuditLogResult | null>(UserDataRightsQueries.myAuditLog, {});
181
- const rows = logQuery.data?.rows ?? [];
182
-
183
- return (
184
- <section
185
- data-testid="privacy-audit"
186
- className="flex flex-col gap-4 rounded-lg border bg-card p-6"
187
- >
188
- <Heading variant="section">{t("userDataRights.privacyCenter.audit.title")}</Heading>
189
- <p className="text-sm text-muted-foreground">
190
- {t("userDataRights.privacyCenter.audit.intro")}
191
- </p>
192
- {logQuery.error && (
193
- <Banner variant="error">{t("userDataRights.privacyCenter.errors.generic")}</Banner>
194
- )}
195
- {logQuery.error ? null : rows.length === 0 ? (
196
- <p className="text-sm text-muted-foreground" data-testid="privacy-audit-empty">
197
- {t("userDataRights.privacyCenter.audit.empty")}
198
- </p>
199
- ) : (
200
- <ul className="flex flex-col divide-y text-sm" data-testid="privacy-audit-list">
201
- {rows.map((row) => (
202
- <li
203
- key={row.id}
204
- data-testid="privacy-audit-row"
205
- className="flex items-center justify-between gap-4 py-2"
206
- >
207
- <span className="font-medium">{row.type}</span>
208
- <span className="text-muted-foreground">{row.aggregateType}</span>
209
- <span className="text-muted-foreground">{formatDate(row.createdAt)}</span>
210
- </li>
211
- ))}
212
- </ul>
213
- )}
214
- </section>
177
+ </Section>
215
178
  );
216
179
  }
217
180
 
@@ -223,7 +186,7 @@ function RestrictionSection({
223
186
  readonly onChanged: () => void;
224
187
  }): ReactNode {
225
188
  const t = useTranslation();
226
- const { Button, Banner, Dialog, Heading } = usePrimitives();
189
+ const { Section, Button, Banner, Dialog } = usePrimitives();
227
190
  const dispatcher = useDispatcher();
228
191
  const [dialogOpen, setDialogOpen] = useState(false);
229
192
  const [status, setStatus] = useState<SectionStatus>({ kind: "idle" });
@@ -242,11 +205,10 @@ function RestrictionSection({
242
205
  };
243
206
 
244
207
  return (
245
- <section
246
- data-testid="privacy-restriction"
247
- className="flex flex-col gap-4 rounded-lg border border-destructive/40 bg-card p-6"
208
+ <Section
209
+ title={t("userDataRights.privacyCenter.restriction.title")}
210
+ testId="privacy-restriction"
248
211
  >
249
- <Heading variant="section">{t("userDataRights.privacyCenter.restriction.title")}</Heading>
250
212
  {restricted ? (
251
213
  <Banner variant="error" testId="privacy-restriction-active">
252
214
  {t("userDataRights.privacyCenter.restriction.restricted")}
@@ -276,7 +238,7 @@ function RestrictionSection({
276
238
  />
277
239
  </>
278
240
  )}
279
- </section>
241
+ </Section>
280
242
  );
281
243
  }
282
244
 
@@ -288,7 +250,7 @@ function DeletionSection({
288
250
  readonly onChanged: () => void;
289
251
  }): ReactNode {
290
252
  const t = useTranslation();
291
- const { Button, Banner, Dialog, Heading } = usePrimitives();
253
+ const { Section, Button, Banner, Dialog } = usePrimitives();
292
254
  const dispatcher = useDispatcher();
293
255
  const [dialogOpen, setDialogOpen] = useState(false);
294
256
  const [status, setStatus] = useState<SectionStatus>({ kind: "idle" });
@@ -318,11 +280,7 @@ function DeletionSection({
318
280
  };
319
281
 
320
282
  return (
321
- <section
322
- data-testid="privacy-deletion"
323
- className="flex flex-col gap-4 rounded-lg border border-destructive/40 bg-card p-6"
324
- >
325
- <Heading variant="section">{t("userDataRights.privacyCenter.deletion.title")}</Heading>
283
+ <Section title={t("userDataRights.privacyCenter.deletion.title")} testId="privacy-deletion">
326
284
  {deletionRequested ? (
327
285
  <>
328
286
  <Banner variant="error" testId="privacy-deletion-requested">
@@ -364,11 +322,15 @@ function DeletionSection({
364
322
  />
365
323
  </>
366
324
  )}
367
- </section>
325
+ </Section>
368
326
  );
369
327
  }
370
328
 
371
- export function PrivacyCenterScreen(): ReactNode {
329
+ export function PrivacyCenterScreen({
330
+ showDeletion = true,
331
+ }: {
332
+ readonly showDeletion?: boolean;
333
+ } = {}): ReactNode {
372
334
  const t = useTranslation();
373
335
  const { Banner, Heading } = usePrimitives();
374
336
  const meQuery = useQuery<MeRow | null>(USER_ME_QUERY, {});
@@ -398,9 +360,8 @@ export function PrivacyCenterScreen(): ReactNode {
398
360
  <Heading variant="page">{t("userDataRights.privacyCenter.title")}</Heading>
399
361
  <p className="text-sm text-muted-foreground">{t("userDataRights.privacyCenter.intro")}</p>
400
362
  <ExportSection />
401
- <AuditSection />
402
363
  <RestrictionSection me={me} onChanged={refetch} />
403
- <DeletionSection me={me} onChanged={refetch} />
364
+ {showDeletion && <DeletionSection me={me} onChanged={refetch} />}
404
365
  </div>
405
366
  );
406
367
  }