@cosmicdrift/kumiko-bundled-features 0.77.1 → 0.79.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.77.1",
3
+ "version": "0.79.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.77.1",
88
- "@cosmicdrift/kumiko-framework": "0.77.1",
89
- "@cosmicdrift/kumiko-headless": "0.77.1",
90
- "@cosmicdrift/kumiko-renderer": "0.77.1",
91
- "@cosmicdrift/kumiko-renderer-web": "0.77.1",
87
+ "@cosmicdrift/kumiko-dispatcher-live": "0.79.0",
88
+ "@cosmicdrift/kumiko-framework": "0.79.0",
89
+ "@cosmicdrift/kumiko-headless": "0.79.0",
90
+ "@cosmicdrift/kumiko-renderer": "0.79.0",
91
+ "@cosmicdrift/kumiko-renderer-web": "0.79.0",
92
92
  "@mollie/api-client": "^4.5.0",
93
93
  "@node-rs/argon2": "^2.0.2",
94
94
  "@types/nodemailer": "^8.0.0",
@@ -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
  }