@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 +6 -6
- package/src/user-data-rights/__tests__/run-export-jobs-cron-context.integration.test.ts +148 -0
- package/src/user-data-rights/feature.ts +29 -10
- package/src/user-data-rights/web/__tests__/privacy-center-screen.test.tsx +1 -24
- package/src/user-data-rights/web/client-plugin.tsx +12 -1
- package/src/user-data-rights/web/privacy-center-screen.tsx +41 -80
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "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.
|
|
88
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
89
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
90
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
91
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
87
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.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
|
|
299
|
-
|
|
300
|
-
|
|
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:
|
|
306
|
-
registry:
|
|
307
|
-
buildStorageProvider: async (tenantId) =>
|
|
308
|
-
|
|
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:
|
|
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]:
|
|
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),
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
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
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
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
|
|
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
|
-
<
|
|
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
|
-
</
|
|
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
|
|
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
|
-
<
|
|
246
|
-
|
|
247
|
-
|
|
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
|
-
</
|
|
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
|
|
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
|
-
<
|
|
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
|
-
</
|
|
325
|
+
</Section>
|
|
368
326
|
);
|
|
369
327
|
}
|
|
370
328
|
|
|
371
|
-
export function PrivacyCenterScreen(
|
|
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
|
}
|