@cosmicdrift/kumiko-bundled-features 0.64.0 → 0.66.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.
Files changed (60) hide show
  1. package/package.json +6 -6
  2. package/src/auth-email-password/handlers/token-request-handler.ts +1 -0
  3. package/src/config/__tests__/write-helpers.test.ts +152 -0
  4. package/src/config/handlers/readiness.query.ts +1 -0
  5. package/src/config/read-redaction.ts +0 -1
  6. package/src/custom-fields/__tests__/custom-fields.integration.test.ts +195 -1
  7. package/src/custom-fields/__tests__/feature.test.ts +1 -4
  8. package/src/custom-fields/__tests__/field-write-access.test.ts +34 -0
  9. package/src/custom-fields/__tests__/parse-serialized-field.test.ts +45 -0
  10. package/src/custom-fields/__tests__/user-data-rights.integration.test.ts +17 -0
  11. package/src/custom-fields/db/queries/quota.ts +3 -1
  12. package/src/custom-fields/entity.ts +10 -3
  13. package/src/custom-fields/events.ts +4 -1
  14. package/src/custom-fields/feature.ts +1 -5
  15. package/src/custom-fields/handlers/define-system-field.write.ts +4 -3
  16. package/src/custom-fields/handlers/define-tenant-field.write.ts +4 -3
  17. package/src/custom-fields/handlers/set-custom-field.write.ts +44 -2
  18. package/src/custom-fields/handlers/update-tenant-field.write.ts +17 -0
  19. package/src/custom-fields/lib/define-or-resurrect.ts +52 -0
  20. package/src/custom-fields/web/__tests__/custom-fields-form-section.test.tsx +6 -4
  21. package/src/custom-fields/wire-for-entity.ts +7 -0
  22. package/src/files-provider-s3/__tests__/s3-provider.test.ts +7 -8
  23. package/src/files-provider-s3/s3-provider.ts +2 -4
  24. package/src/legal-pages/web/__tests__/client-plugin.test.ts +53 -0
  25. package/src/legal-pages/web/client-plugin.ts +9 -10
  26. package/src/managed-pages/handlers/set.write.ts +4 -11
  27. package/src/sessions/__tests__/rebuild-survival.integration.test.ts +97 -0
  28. package/src/sessions/feature.ts +16 -3
  29. package/src/tags/__tests__/tags.integration.test.ts +30 -1
  30. package/src/tags/entity.ts +8 -0
  31. package/src/tags/handlers/assign-tag.write.ts +20 -5
  32. package/src/tags/web/__tests__/tag-section.test.tsx +26 -27
  33. package/src/tags/web/i18n.ts +6 -2
  34. package/src/tags/web/tag-section.tsx +87 -76
  35. package/src/text-content/web/__tests__/client-plugin.test.tsx +65 -0
  36. package/src/text-content/web/client-plugin.tsx +16 -13
  37. package/src/tier-engine/__tests__/resolver.integration.test.ts +67 -0
  38. package/src/tier-engine/__tests__/trial.test.ts +27 -0
  39. package/src/tier-engine/entity.ts +8 -0
  40. package/src/tier-engine/feature.ts +49 -9
  41. package/src/tier-engine/handlers/get-tenant-tier.query.ts +1 -9
  42. package/src/tier-engine/handlers/set-tenant-tier.write.ts +1 -9
  43. package/src/tier-engine/index.ts +1 -0
  44. package/src/tier-engine/trial.ts +26 -0
  45. package/src/user-data-rights/__tests__/read-users-rebuild-survives-lifecycle.integration.test.ts +233 -0
  46. package/src/user-data-rights/constants.ts +48 -0
  47. package/src/user-data-rights/feature.ts +15 -0
  48. package/src/user-data-rights/handlers/cancel-deletion.write.ts +10 -14
  49. package/src/user-data-rights/handlers/deletion-grace-period.ts +6 -7
  50. package/src/user-data-rights/handlers/lift-restriction.write.ts +3 -2
  51. package/src/user-data-rights/handlers/request-deletion-by-email.write.ts +5 -7
  52. package/src/user-data-rights/handlers/restrict-account.write.ts +3 -7
  53. package/src/user-data-rights/index.ts +3 -0
  54. package/src/user-data-rights/lib/update-user-lifecycle.ts +100 -0
  55. package/src/user-data-rights/run-forget-cleanup.ts +3 -2
  56. package/src/user-data-rights/web/__tests__/privacy-center-screen.test.tsx +256 -0
  57. package/src/user-data-rights/web/client-plugin.tsx +30 -0
  58. package/src/user-data-rights/web/i18n.ts +95 -0
  59. package/src/user-data-rights/web/index.ts +2 -0
  60. package/src/user-data-rights/web/privacy-center-screen.tsx +403 -0
@@ -1,5 +1,8 @@
1
1
  export { createUserDataRightsFeature, type UserDataRightsOptions } from "./feature";
2
2
  export type { SendDeletionVerificationEmailFn } from "./handlers/request-deletion-by-email.write";
3
+ // #494 Bestandsdaten-Reconcile — Apps rufen das einmalig vor dem Re-Enable
4
+ // von read_users-Rebuilds (siehe lib-Doc).
5
+ export { backfillUserLifecycleEvents } from "./lib/update-user-lifecycle";
3
6
  export type {
4
7
  SendExportFailedEmailFn,
5
8
  SendExportReadyEmailFn,
@@ -0,0 +1,100 @@
1
+ import { fetchOne, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
2
+ import type { DbRunner } from "@cosmicdrift/kumiko-framework/db";
3
+ import { createEventStoreExecutor, createTenantDb } from "@cosmicdrift/kumiko-framework/db";
4
+ import { createSystemUser, type TenantId } from "@cosmicdrift/kumiko-framework/engine";
5
+ import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
6
+ import { USER_STATUS, userEntity, userTable } from "../../user";
7
+
8
+ // #494 — Lifecycle-Mutationen der user-Entity MUESSEN als `user.updated`-Event
9
+ // laufen. Roh per updateMany geschrieben, wischt ein read_users-Rebuild sie
10
+ // weg (er replayt nur `user.created` -> status faellt auf den Default zurueck;
11
+ // gracePeriodEnd/pendingDeletionRequestId/Deleted gehen verloren = DSGVO-
12
+ // Datenverlust).
13
+ //
14
+ // Das Event MUSS in denselben (tenant_id, aggregate_id)-Stream wie
15
+ // `user.created` landen, sonst splittet das Aggregat ueber Tenants und der
16
+ // Rebuild rekonstruiert nichts. Die user-Entity laeuft `r.systemScope()`, ihre
17
+ // Events landen aber auf einem konkreten Tenant-Stream (siehe
18
+ // auth-email-password/stream-tenant.ts; Root-Cause-Fix tracked in #497). Darum:
19
+ // Rescope auf den Stream-Tenant des Users — den des `user.created`-Events,
20
+ // NICHT `event.user.tenantId` (das ist der aktive Tenant zur Lifecycle-Zeit und
21
+ // kann abweichen).
22
+ const userExecutor = createEventStoreExecutor(userTable, userEntity, { entityName: "user" });
23
+
24
+ // Stream-Tenant = die framework-injizierte tenant_id der read_users-Row. Der
25
+ // Reducer setzt sie aus `user.created`.tenantId, ein Rebuild rekonstruiert sie
26
+ // daraus — sie IST also der Stream-Key des Aggregats. Die Row direkt zu lesen
27
+ // (statt das created-Event zu joinen) deckt auch direkt-geseedete Rows ab.
28
+ async function streamTenantOf(conn: DbRunner, userId: string): Promise<TenantId | null> {
29
+ const row = await fetchOne<{ tenantId?: string }>(conn, userTable, { id: userId });
30
+ // @cast-boundary db-row — tenant_id ist eine TenantId-shaped uuid-Spalte.
31
+ return row?.tenantId ? (row.tenantId as TenantId) : null;
32
+ }
33
+
34
+ // `conn` ist ctx.db.raw (regulaere Handler) ODER die offene tx (forget-cleanup
35
+ // Sub-Tx) — so bleibt der Event-Append atomar mit dem umgebenden Write.
36
+ export async function updateUserLifecycle(
37
+ conn: DbRunner,
38
+ userId: string,
39
+ changes: Record<string, unknown>,
40
+ ): Promise<void> {
41
+ const streamTenantId = await streamTenantOf(conn, userId);
42
+ if (!streamTenantId) {
43
+ throw new InternalError({
44
+ message: `read_users row ${userId} has no tenant_id — cannot rescope lifecycle write to its stream tenant`,
45
+ });
46
+ }
47
+
48
+ // Rescope BEIDE Achsen auf den Stream-Tenant: der db-Arg treibt loadById +
49
+ // den Stream-Read, der user-Arg die Event-tenantId + Ownership. Nur beide
50
+ // zusammen halten created + updated auf einem Stream.
51
+ const tenantDb = createTenantDb(conn, streamTenantId, "tenant");
52
+ const result = await userExecutor.update(
53
+ { id: userId, changes },
54
+ createSystemUser(streamTenantId),
55
+ tenantDb,
56
+ { skipOptimisticLock: true },
57
+ );
58
+
59
+ if (!result.isSuccess) {
60
+ throw new InternalError({
61
+ message: `user lifecycle update failed for ${userId}: ${result.error.code}`,
62
+ });
63
+ }
64
+ }
65
+
66
+ // #494 Bestandsdaten-Reconcile: Rows, deren Lifecycle-State der alte
67
+ // raw-updateMany-Pfad gesetzt hat, haben kein `user.updated`-Event — ein
68
+ // Rebuild wuerde sie auf die `user.created`-Defaults zuruecksetzen. Diese
69
+ // Funktion emittiert pro divergenter Row ein `user.updated` mit dem aktuellen
70
+ // Live-State, sodass Event-Log und Live-Tabelle wieder uebereinstimmen.
71
+ // MUSS einmalig ueber den Bestand laufen, BEVOR eine App read_users-Rebuilds
72
+ // re-enabled. Idempotent gegen State (ein zweiter Lauf haengt ein identisches
73
+ // user.updated an — harmlos, last-write-wins beim Replay).
74
+ // ponytail: full read_users-Scan, in JS gefiltert — einmalige Migration, kein
75
+ // Index/Streaming noetig. Bei Millionen-Rows: batchen.
76
+ export async function backfillUserLifecycleEvents(conn: DbRunner): Promise<number> {
77
+ const rows = (await selectMany(conn, userTable, {})) as Array<{
78
+ id: string;
79
+ status: string;
80
+ gracePeriodEnd: unknown;
81
+ pendingDeletionRequestId: unknown;
82
+ }>;
83
+
84
+ let backfilled = 0;
85
+ for (const row of rows) {
86
+ const divergent =
87
+ row.status !== USER_STATUS.Active ||
88
+ row.gracePeriodEnd != null ||
89
+ row.pendingDeletionRequestId != null;
90
+ if (!divergent) continue;
91
+
92
+ await updateUserLifecycle(conn, row.id, {
93
+ status: row.status,
94
+ gracePeriodEnd: row.gracePeriodEnd,
95
+ pendingDeletionRequestId: row.pendingDeletionRequestId,
96
+ });
97
+ backfilled++;
98
+ }
99
+ return backfilled;
100
+ }
@@ -32,7 +32,7 @@
32
32
  // gefailten Hooks bleibt im DeletionRequested-Status (next Lauf
33
33
  // retried automatisch).
34
34
 
35
- import { fetchOne, selectMany, updateMany } from "@cosmicdrift/kumiko-framework/bun-db";
35
+ import { fetchOne, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
36
36
  import type { DbRunner } from "@cosmicdrift/kumiko-framework/db";
37
37
  import {
38
38
  EXT_USER_DATA,
@@ -47,6 +47,7 @@ import { resolveRetentionPolicyForTenant } from "../data-retention";
47
47
  import { tenantMembershipsTable } from "../tenant";
48
48
  import { USER_STATUS, userTable } from "../user";
49
49
  import { selectUsersDueForForgetCleanup } from "./db/queries/forget-cleanup";
50
+ import { updateUserLifecycle } from "./lib/update-user-lifecycle";
50
51
 
51
52
  type Instant = InstanceType<ReturnType<typeof getTemporal>["Instant"]>;
52
53
 
@@ -281,7 +282,7 @@ async function processUser(args: {
281
282
  // geworfen hat, kommen wir hier nicht an — die Tx rollback'd
282
283
  // alles, der User bleibt im DeletionRequested-Status, naechster
283
284
  // Run retried.
284
- await updateMany(tx, userTable, { status: USER_STATUS.Deleted }, { id: userId });
285
+ await updateUserLifecycle(tx, userId, { status: USER_STATUS.Deleted });
285
286
  txSucceeded = true;
286
287
  });
287
288
  } catch (e) {
@@ -0,0 +1,256 @@
1
+ // Render-Test gegen echte i18n-Bundles (fängt fehlende Keys — der Screen
2
+ // darf nie rohe "userDataRights.privacyCenter.*"-Keys zeigen) plus QN-Wiring
3
+ // (die dispatchten Query-/Handler-Namen) und die status-getriebenen Branches.
4
+ // Provider-Wrapper lokal (Dependency-Richtung renderer-web → bundled-features
5
+ // verbietet test-utils-Import).
6
+
7
+ import { describe, expect, test } from "bun:test";
8
+ import { createStore, type Dispatcher, type DispatcherStatus } from "@cosmicdrift/kumiko-headless";
9
+ import {
10
+ createStaticLocaleResolver,
11
+ DispatcherProvider,
12
+ kumikoDefaultTranslations,
13
+ type LiveEventSubscriber,
14
+ LiveEventsProvider,
15
+ LocaleProvider,
16
+ PrimitivesProvider,
17
+ TokensProvider,
18
+ } from "@cosmicdrift/kumiko-renderer";
19
+ import { defaultPrimitives, defaultTokens } from "@cosmicdrift/kumiko-renderer-web";
20
+ import { fireEvent, render, waitFor } from "@testing-library/react";
21
+ import type { ReactNode } from "react";
22
+ import { UserQueries } from "../../../user";
23
+ import {
24
+ EXPORT_JOB_STATUS,
25
+ USER_ME_QUERY,
26
+ UserDataRightsHandlers,
27
+ UserDataRightsQueries,
28
+ } from "../../constants";
29
+ import { EXPORT_JOB_STATUS as SCHEMA_EXPORT_JOB_STATUS } from "../../schema/export-job";
30
+ import { defaultTranslations } from "../i18n";
31
+ import { formatDate, PrivacyCenterScreen } from "../privacy-center-screen";
32
+
33
+ const stubLiveEvents: LiveEventSubscriber = () => () => {};
34
+ const stubTokens = {
35
+ tokens: defaultTokens,
36
+ mode: "light" as const,
37
+ setMode: () => {},
38
+ toggleMode: () => {},
39
+ };
40
+ const stubResolver = createStaticLocaleResolver();
41
+
42
+ type QueryResponses = {
43
+ readonly me: Record<string, unknown>;
44
+ readonly exportStatus?: unknown;
45
+ readonly auditLog?: unknown;
46
+ };
47
+
48
+ function makeDispatcher(
49
+ responses: QueryResponses,
50
+ writes: Array<{ type: string; payload: unknown }>,
51
+ ): Dispatcher {
52
+ const statusStore = createStore<DispatcherStatus>("online");
53
+ const query = (async (type: string) => {
54
+ if (type === USER_ME_QUERY) return { isSuccess: true, data: responses.me };
55
+ if (type === UserDataRightsQueries.exportStatus) {
56
+ return { isSuccess: true, data: responses.exportStatus ?? { hasJob: false } };
57
+ }
58
+ if (type === UserDataRightsQueries.myAuditLog) {
59
+ return { isSuccess: true, data: responses.auditLog ?? { rows: [] } };
60
+ }
61
+ return { isSuccess: true, data: null };
62
+ }) as unknown as Dispatcher["query"];
63
+ const write = (async (type: string, payload: unknown) => {
64
+ writes.push({ type, payload });
65
+ return { isSuccess: true, data: {} };
66
+ }) as unknown as Dispatcher["write"];
67
+ return {
68
+ write,
69
+ query,
70
+ batch: (async () => ({ isSuccess: true, results: [] })) as unknown as Dispatcher["batch"],
71
+ statusStore,
72
+ pendingWrites: () => [],
73
+ pendingFiles: () => [],
74
+ } as unknown as Dispatcher; // @cast-boundary test-stub
75
+ }
76
+
77
+ function renderCenter(responses: QueryResponses): {
78
+ view: ReturnType<typeof render>;
79
+ writes: Array<{ type: string; payload: unknown }>;
80
+ } {
81
+ const writes: Array<{ type: string; payload: unknown }> = [];
82
+ const wrapper = ({ children }: { readonly children: ReactNode }): ReactNode => (
83
+ <TokensProvider value={stubTokens}>
84
+ <LocaleProvider
85
+ resolver={stubResolver}
86
+ fallbackBundles={[defaultTranslations, kumikoDefaultTranslations]}
87
+ >
88
+ <PrimitivesProvider value={defaultPrimitives}>
89
+ <LiveEventsProvider value={stubLiveEvents}>
90
+ <DispatcherProvider dispatcher={makeDispatcher(responses, writes)}>
91
+ {children}
92
+ </DispatcherProvider>
93
+ </LiveEventsProvider>
94
+ </PrimitivesProvider>
95
+ </LocaleProvider>
96
+ </TokensProvider>
97
+ );
98
+ const view = render(<PrivacyCenterScreen />, { wrapper });
99
+ return { view, writes };
100
+ }
101
+
102
+ const activeMe = {
103
+ id: "00000000-0000-4000-8000-000000000042",
104
+ email: "marc@example.com",
105
+ status: "active",
106
+ gracePeriodEnd: null,
107
+ };
108
+
109
+ async function waitForMount(view: ReturnType<typeof render>): Promise<void> {
110
+ await waitFor(() => {
111
+ if (view.queryByTestId("privacy-center-screen") === null) {
112
+ throw new Error("not mounted yet");
113
+ }
114
+ });
115
+ }
116
+
117
+ // QUARANTINED (#457-Klasse): diese Render-Tests laufen lokal grün (13/13 isoliert,
118
+ // 82/88 mit allen bundled-features-web-Tests parallel), failen aber auf CI-Linux
119
+ // unter bun-`concurrency=8`. Diagnose aus dem CI-Log: parallele Test-FILES teilen
120
+ // sich das eine globale happy-dom `document`; der globale `afterEach` aus
121
+ // `test-setup/dom.preload.ts` (`cleanup()` + `document.body.replaceChildren()`)
122
+ // eines parallel laufenden Tests wischt die in-flight gerenderte DOM eines anderen
123
+ // weg → die `await`-Assertions hier finden den Screen-Stand eines Nachbar-Tests
124
+ // (sichtbar: alle Fails zeigen denselben active-state Screen statt der eigenen
125
+ // Test-Daten). Nicht aus diesem File fixbar — gleiche Architektur-Flake, wegen der
126
+ // `deletion-screens.test.tsx` im selben Verzeichnis quarantäniert ist. Un-skip,
127
+ // sobald das framework-weite Test-Isolation-Problem (#457) gelöst ist. Die
128
+ // QN-Drift-Pins + formatDate unten decken die CI-stabile Verdrahtungs-Korrektheit ab.
129
+ describe.skip("PrivacyCenterScreen", () => {
130
+ test("aktiver User: alle vier Sektionen, Texte übersetzt (keine rohen Keys)", async () => {
131
+ const { view } = renderCenter({ me: activeMe });
132
+ await waitForMount(view);
133
+ expect(view.getByTestId("privacy-export")).toBeTruthy();
134
+ expect(view.getByTestId("privacy-audit")).toBeTruthy();
135
+ expect(view.getByTestId("privacy-restriction")).toBeTruthy();
136
+ expect(view.getByTestId("privacy-deletion")).toBeTruthy();
137
+ expect(view.getByTestId("privacy-export-request")).toBeTruthy();
138
+ expect(view.getByTestId("privacy-audit-empty")).toBeTruthy();
139
+ expect(view.getByTestId("privacy-restriction-restrict")).toBeTruthy();
140
+ expect(view.getByTestId("privacy-deletion-delete")).toBeTruthy();
141
+ expect(view.container.textContent).not.toContain("userDataRights.privacyCenter");
142
+ });
143
+
144
+ test("export done: Download-Link auf den by-job-Pfad + Verfügbar-bis-Datum", async () => {
145
+ const { view } = renderCenter({
146
+ me: activeMe,
147
+ exportStatus: {
148
+ hasJob: true,
149
+ job: { id: "job-123", status: EXPORT_JOB_STATUS.Done, expiresAt: "2026-07-11T00:00:00Z" },
150
+ },
151
+ });
152
+ await waitForMount(view);
153
+ const link = view.getByTestId("privacy-export-download") as HTMLAnchorElement;
154
+ expect(link.getAttribute("href")).toBe("/user-export/by-job/job-123");
155
+ const ready = view.getByTestId("privacy-export-ready");
156
+ expect(ready.textContent).toContain("2026-07-11");
157
+ expect(ready.textContent).not.toContain("T00:00");
158
+ });
159
+
160
+ test("export failed: Fehler-Banner + Re-Request möglich", async () => {
161
+ const { view } = renderCenter({
162
+ me: activeMe,
163
+ exportStatus: { hasJob: true, job: { id: "job-9", status: EXPORT_JOB_STATUS.Failed } },
164
+ });
165
+ await waitForMount(view);
166
+ expect(view.getByTestId("privacy-export-failed")).toBeTruthy();
167
+ expect(view.getByTestId("privacy-export-request")).toBeTruthy();
168
+ });
169
+
170
+ test("export pending: in-progress Banner, kein Request-Button", async () => {
171
+ const { view } = renderCenter({
172
+ me: activeMe,
173
+ exportStatus: { hasJob: true, job: { id: "job-1", status: EXPORT_JOB_STATUS.Pending } },
174
+ });
175
+ await waitForMount(view);
176
+ expect(view.getByTestId("privacy-export-pending")).toBeTruthy();
177
+ expect(view.queryByTestId("privacy-export-request")).toBeNull();
178
+ });
179
+
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
+ test("deletionRequested: Frist-Banner + Abbrechen statt Lösch-Button", async () => {
202
+ const { view } = renderCenter({
203
+ me: { ...activeMe, status: "deletionRequested", gracePeriodEnd: "2026-07-11T00:00:00Z" },
204
+ });
205
+ await waitForMount(view);
206
+ const banner = view.getByTestId("privacy-deletion-requested");
207
+ expect(banner.textContent).toContain("2026-07-11");
208
+ expect(banner.textContent).not.toContain("{date}");
209
+ expect(banner.textContent).not.toContain("T00:00");
210
+ expect(view.queryByTestId("privacy-deletion-delete")).toBeNull();
211
+ expect(view.getByTestId("privacy-deletion-cancel")).toBeTruthy();
212
+ });
213
+
214
+ test("restricted: Info-Banner statt Einschränken-Button", async () => {
215
+ const { view } = renderCenter({ me: { ...activeMe, status: "restricted" } });
216
+ await waitForMount(view);
217
+ expect(view.getByTestId("privacy-restriction-active")).toBeTruthy();
218
+ expect(view.queryByTestId("privacy-restriction-restrict")).toBeNull();
219
+ });
220
+
221
+ test("Export-Request dispatcht den korrekten Handler-QN", async () => {
222
+ const { view, writes } = renderCenter({ me: activeMe });
223
+ await waitForMount(view);
224
+ fireEvent.click(view.getByTestId("privacy-export-request"));
225
+ await waitFor(() => {
226
+ if (writes.length === 0) throw new Error("no write dispatched");
227
+ });
228
+ expect(writes[0]?.type).toBe(UserDataRightsHandlers.requestExport);
229
+ });
230
+ });
231
+
232
+ describe("QN-Drift-Pins (client-Konstanten vs. Feature-Originale)", () => {
233
+ test("USER_ME_QUERY spiegelt UserQueries.me", () => {
234
+ expect(USER_ME_QUERY).toBe(UserQueries.me);
235
+ });
236
+
237
+ test("EXPORT_JOB_STATUS-Mirror deckt sich mit dem Schema-Original", () => {
238
+ expect(EXPORT_JOB_STATUS).toEqual(SCHEMA_EXPORT_JOB_STATUS);
239
+ });
240
+ });
241
+
242
+ describe("formatDate", () => {
243
+ test("ISO instant → date part only (strips time + Z)", () => {
244
+ expect(formatDate("2026-07-11T00:00:00.000Z")).toBe("2026-07-11");
245
+ });
246
+
247
+ test("null / undefined / empty → em dash", () => {
248
+ expect(formatDate(null)).toBe("—");
249
+ expect(formatDate(undefined)).toBe("—");
250
+ expect(formatDate("")).toBe("—");
251
+ });
252
+
253
+ test("date-only string without time → returned as-is", () => {
254
+ expect(formatDate("2026-07-11")).toBe("2026-07-11");
255
+ });
256
+ });
@@ -0,0 +1,30 @@
1
+ // @runtime client
2
+ // Client-Feature-Factory für user-data-rights. Liefert den
3
+ // PrivacyCenterScreen (gemappt auf die Screen-id "privacy-center") +
4
+ // Default-Translations. Apps hängen es in
5
+ // createKumikoApp({ clientFeatures: [userDataRightsClient()] }) ein; der
6
+ // Screen wird server-seitig vom Feature dormant als custom-Screen
7
+ // registriert (r.screen), die App platziert ihn via r.nav.
8
+
9
+ import { mergeTranslations, type TranslationsByLocale } from "@cosmicdrift/kumiko-renderer";
10
+ import type { ClientFeatureDefinition } from "@cosmicdrift/kumiko-renderer-web";
11
+ import { PRIVACY_CENTER_SCREEN_ID, USER_DATA_RIGHTS_FEATURE } from "../constants";
12
+ import { defaultTranslations } from "./i18n";
13
+ import { PrivacyCenterScreen } from "./privacy-center-screen";
14
+
15
+ export type UserDataRightsClientOptions = {
16
+ /** Key-weise Overrides über die Default-Bundles (de/en). */
17
+ readonly translations?: TranslationsByLocale;
18
+ };
19
+
20
+ export function userDataRightsClient(
21
+ options?: UserDataRightsClientOptions,
22
+ ): ClientFeatureDefinition {
23
+ return {
24
+ name: USER_DATA_RIGHTS_FEATURE,
25
+ translations: mergeTranslations(defaultTranslations, options?.translations ?? {}),
26
+ components: {
27
+ [PRIVACY_CENTER_SCREEN_ID]: PrivacyCenterScreen,
28
+ },
29
+ };
30
+ }
@@ -31,6 +31,54 @@ export const defaultTranslations: TranslationsByLocale = {
31
31
  "userDataRights.deletion.confirm.missingToken":
32
32
  "Kein Token im Link gefunden. Bitte öffne den Link aus der E-Mail erneut.",
33
33
  "userDataRights.deletion.confirm.error": "Etwas ist schief gegangen. Bitte erneut versuchen.",
34
+
35
+ "userDataRights.privacyCenter.title": "Datenschutz",
36
+ "userDataRights.privacyCenter.intro":
37
+ "Verwalte deine Rechte nach DSGVO: Datenauskunft, Export, Einschränkung und Löschung deines Kontos.",
38
+ "userDataRights.privacyCenter.loading": "Lädt …",
39
+ "userDataRights.privacyCenter.loadError": "Deine Daten konnten nicht geladen werden.",
40
+ "userDataRights.privacyCenter.errors.generic":
41
+ "Etwas ist schief gegangen. Bitte erneut versuchen.",
42
+
43
+ "userDataRights.privacyCenter.export.title": "Daten exportieren (Art. 20)",
44
+ "userDataRights.privacyCenter.export.intro":
45
+ "Fordere eine Kopie deiner Daten an. Die Erstellung läuft im Hintergrund; sobald sie fertig ist, kannst du sie hier herunterladen.",
46
+ "userDataRights.privacyCenter.export.request": "Daten-Export anfordern",
47
+ "userDataRights.privacyCenter.export.requesting": "Wird angefordert …",
48
+ "userDataRights.privacyCenter.export.pending":
49
+ "Dein Export wird erstellt. Bitte später erneut schauen.",
50
+ "userDataRights.privacyCenter.export.failed":
51
+ "Die letzte Export-Erstellung ist fehlgeschlagen. Du kannst es erneut versuchen.",
52
+ "userDataRights.privacyCenter.export.ready": "Dein Export ist fertig.",
53
+ "userDataRights.privacyCenter.export.download": "Export herunterladen",
54
+ "userDataRights.privacyCenter.export.availableUntil": "Verfügbar bis {date}",
55
+ "userDataRights.privacyCenter.export.requestNew": "Neuen Export anfordern",
56
+
57
+ "userDataRights.privacyCenter.restriction.title": "Verarbeitung einschränken (Art. 18)",
58
+ "userDataRights.privacyCenter.restriction.explainer":
59
+ "Friere dein Konto ein: Die Verarbeitung deiner Daten wird pausiert und du wirst abgemeldet. Das Aufheben der Einschränkung ist danach nur über den Support möglich.",
60
+ "userDataRights.privacyCenter.restriction.restrict": "Konto einschränken",
61
+ "userDataRights.privacyCenter.restriction.dialogTitle": "Konto wirklich einschränken?",
62
+ "userDataRights.privacyCenter.restriction.dialogDescription":
63
+ "Du wirst sofort abgemeldet und kannst dich nicht mehr anmelden, bis der Support die Einschränkung aufhebt.",
64
+ "userDataRights.privacyCenter.restriction.restricted":
65
+ "Dein Konto ist eingeschränkt. Wende dich an den Support, um die Einschränkung aufzuheben.",
66
+
67
+ "userDataRights.privacyCenter.deletion.title": "Konto löschen (Art. 17)",
68
+ "userDataRights.privacyCenter.deletion.explainer":
69
+ "Beantrage die Löschung deines Kontos. Bis zum Ablauf der Frist kannst du die Löschung wieder abbrechen.",
70
+ "userDataRights.privacyCenter.deletion.delete": "Konto löschen",
71
+ "userDataRights.privacyCenter.deletion.requested": "Dein Konto wird am {date} gelöscht.",
72
+ "userDataRights.privacyCenter.deletion.cancel": "Löschung abbrechen",
73
+ "userDataRights.privacyCenter.deletion.cancelSuccess": "Die Löschung wurde abgebrochen.",
74
+ "userDataRights.privacyCenter.deletion.dialogTitle": "Konto wirklich löschen?",
75
+ "userDataRights.privacyCenter.deletion.dialogDescription":
76
+ "Mit dem Bestätigen startet die Lösch-Frist. Du kannst die Löschung bis zu ihrem Ablauf wieder abbrechen.",
77
+
78
+ "userDataRights.privacyCenter.audit.title": "Aktivitätsprotokoll (Art. 15)",
79
+ "userDataRights.privacyCenter.audit.intro":
80
+ "Die letzten Ereignisse zu deinem Konto über alle Tenants hinweg.",
81
+ "userDataRights.privacyCenter.audit.empty": "Noch keine Ereignisse.",
34
82
  },
35
83
  en: {
36
84
  "userDataRights.deletion.request.title": "Request account deletion",
@@ -56,5 +104,52 @@ export const defaultTranslations: TranslationsByLocale = {
56
104
  "userDataRights.deletion.confirm.missingToken":
57
105
  "No token found in the link. Please open the link from the email again.",
58
106
  "userDataRights.deletion.confirm.error": "Something went wrong. Please try again.",
107
+
108
+ "userDataRights.privacyCenter.title": "Privacy",
109
+ "userDataRights.privacyCenter.intro":
110
+ "Manage your GDPR rights: access, export, restrict, and delete your account.",
111
+ "userDataRights.privacyCenter.loading": "Loading …",
112
+ "userDataRights.privacyCenter.loadError": "Your data could not be loaded.",
113
+ "userDataRights.privacyCenter.errors.generic": "Something went wrong. Please try again.",
114
+
115
+ "userDataRights.privacyCenter.export.title": "Export your data (Art. 20)",
116
+ "userDataRights.privacyCenter.export.intro":
117
+ "Request a copy of your data. It is prepared in the background; once ready you can download it here.",
118
+ "userDataRights.privacyCenter.export.request": "Request data export",
119
+ "userDataRights.privacyCenter.export.requesting": "Requesting …",
120
+ "userDataRights.privacyCenter.export.pending":
121
+ "Your export is being prepared. Please check back later.",
122
+ "userDataRights.privacyCenter.export.failed":
123
+ "The last export failed to build. You can try again.",
124
+ "userDataRights.privacyCenter.export.ready": "Your export is ready.",
125
+ "userDataRights.privacyCenter.export.download": "Download export",
126
+ "userDataRights.privacyCenter.export.availableUntil": "Available until {date}",
127
+ "userDataRights.privacyCenter.export.requestNew": "Request a new export",
128
+
129
+ "userDataRights.privacyCenter.restriction.title": "Restrict processing (Art. 18)",
130
+ "userDataRights.privacyCenter.restriction.explainer":
131
+ "Freeze your account: processing of your data is paused and you are signed out. Lifting the restriction afterwards is only possible via support.",
132
+ "userDataRights.privacyCenter.restriction.restrict": "Restrict account",
133
+ "userDataRights.privacyCenter.restriction.dialogTitle": "Restrict your account?",
134
+ "userDataRights.privacyCenter.restriction.dialogDescription":
135
+ "You will be signed out immediately and cannot sign in again until support lifts the restriction.",
136
+ "userDataRights.privacyCenter.restriction.restricted":
137
+ "Your account is restricted. Contact support to lift the restriction.",
138
+
139
+ "userDataRights.privacyCenter.deletion.title": "Delete account (Art. 17)",
140
+ "userDataRights.privacyCenter.deletion.explainer":
141
+ "Request deletion of your account. Until the grace period ends you can cancel the deletion.",
142
+ "userDataRights.privacyCenter.deletion.delete": "Delete account",
143
+ "userDataRights.privacyCenter.deletion.requested": "Your account will be deleted on {date}.",
144
+ "userDataRights.privacyCenter.deletion.cancel": "Cancel deletion",
145
+ "userDataRights.privacyCenter.deletion.cancelSuccess": "The deletion was cancelled.",
146
+ "userDataRights.privacyCenter.deletion.dialogTitle": "Delete your account?",
147
+ "userDataRights.privacyCenter.deletion.dialogDescription":
148
+ "Confirming starts the deletion grace period. You can cancel the deletion until it ends.",
149
+
150
+ "userDataRights.privacyCenter.audit.title": "Activity log (Art. 15)",
151
+ "userDataRights.privacyCenter.audit.intro":
152
+ "The most recent events on your account across all tenants.",
153
+ "userDataRights.privacyCenter.audit.empty": "No events yet.",
59
154
  },
60
155
  };
@@ -5,8 +5,10 @@
5
5
  // Seite (defineFeature, Handler) lebt unter `.../user-data-rights` und hat
6
6
  // keine React-/DOM-Deps.
7
7
 
8
+ export { type UserDataRightsClientOptions, userDataRightsClient } from "./client-plugin";
8
9
  export type { ConfirmAccountDeletionScreenProps } from "./confirm-deletion-screen";
9
10
  export { ConfirmAccountDeletionScreen } from "./confirm-deletion-screen";
10
11
  export { defaultTranslations } from "./i18n";
12
+ export { formatDate, PrivacyCenterScreen } from "./privacy-center-screen";
11
13
  export type { RequestAccountDeletionScreenProps } from "./request-deletion-screen";
12
14
  export { RequestAccountDeletionScreen } from "./request-deletion-screen";