@cosmicdrift/kumiko-bundled-features 0.64.0 → 0.65.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 +6 -6
- package/src/config/__tests__/write-helpers.test.ts +152 -0
- package/src/config/read-redaction.ts +0 -1
- package/src/custom-fields/__tests__/custom-fields.integration.test.ts +195 -1
- package/src/custom-fields/__tests__/feature.test.ts +1 -4
- package/src/custom-fields/__tests__/field-write-access.test.ts +34 -0
- package/src/custom-fields/__tests__/parse-serialized-field.test.ts +45 -0
- package/src/custom-fields/__tests__/user-data-rights.integration.test.ts +17 -0
- package/src/custom-fields/db/queries/quota.ts +3 -1
- package/src/custom-fields/entity.ts +10 -3
- package/src/custom-fields/events.ts +4 -1
- package/src/custom-fields/feature.ts +1 -5
- package/src/custom-fields/handlers/define-system-field.write.ts +4 -3
- package/src/custom-fields/handlers/define-tenant-field.write.ts +4 -3
- package/src/custom-fields/handlers/set-custom-field.write.ts +44 -2
- package/src/custom-fields/handlers/update-tenant-field.write.ts +17 -0
- package/src/custom-fields/lib/define-or-resurrect.ts +52 -0
- package/src/custom-fields/wire-for-entity.ts +7 -0
- package/src/files-provider-s3/__tests__/s3-provider.test.ts +7 -8
- package/src/files-provider-s3/s3-provider.ts +2 -4
- package/src/managed-pages/handlers/set.write.ts +4 -11
- package/src/sessions/__tests__/rebuild-survival.integration.test.ts +97 -0
- package/src/sessions/feature.ts +16 -3
- package/src/tags/__tests__/tags.integration.test.ts +30 -1
- package/src/tags/entity.ts +8 -0
- package/src/tags/handlers/assign-tag.write.ts +20 -5
- package/src/tags/web/__tests__/tag-section.test.tsx +26 -27
- package/src/tags/web/i18n.ts +6 -2
- package/src/tags/web/tag-section.tsx +87 -76
- package/src/tier-engine/__tests__/resolver.integration.test.ts +67 -0
- package/src/tier-engine/__tests__/trial.test.ts +27 -0
- package/src/tier-engine/entity.ts +8 -0
- package/src/tier-engine/feature.ts +49 -9
- package/src/tier-engine/handlers/get-tenant-tier.query.ts +1 -9
- package/src/tier-engine/handlers/set-tenant-tier.write.ts +1 -9
- package/src/tier-engine/index.ts +1 -0
- package/src/tier-engine/trial.ts +26 -0
- package/src/user-data-rights/__tests__/read-users-rebuild-survives-lifecycle.integration.test.ts +233 -0
- package/src/user-data-rights/constants.ts +48 -0
- package/src/user-data-rights/feature.ts +15 -0
- package/src/user-data-rights/handlers/cancel-deletion.write.ts +10 -14
- package/src/user-data-rights/handlers/deletion-grace-period.ts +6 -7
- package/src/user-data-rights/handlers/lift-restriction.write.ts +3 -2
- package/src/user-data-rights/handlers/request-deletion-by-email.write.ts +5 -7
- package/src/user-data-rights/handlers/restrict-account.write.ts +3 -7
- package/src/user-data-rights/index.ts +3 -0
- package/src/user-data-rights/lib/update-user-lifecycle.ts +100 -0
- package/src/user-data-rights/run-forget-cleanup.ts +3 -2
- package/src/user-data-rights/web/__tests__/privacy-center-screen.test.tsx +256 -0
- package/src/user-data-rights/web/client-plugin.tsx +30 -0
- package/src/user-data-rights/web/i18n.ts +95 -0
- package/src/user-data-rights/web/index.ts +2 -0
- package/src/user-data-rights/web/privacy-center-screen.tsx +403 -0
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
// @runtime client
|
|
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.
|
|
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.
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
useDispatcher,
|
|
14
|
+
usePrimitives,
|
|
15
|
+
useQuery,
|
|
16
|
+
useTranslation,
|
|
17
|
+
} from "@cosmicdrift/kumiko-renderer";
|
|
18
|
+
import { type ReactNode, useState } from "react";
|
|
19
|
+
import {
|
|
20
|
+
EXPORT_JOB_STATUS,
|
|
21
|
+
type ExportJobStatus,
|
|
22
|
+
USER_ME_QUERY,
|
|
23
|
+
UserDataRightsHandlers,
|
|
24
|
+
UserDataRightsQueries,
|
|
25
|
+
userExportByJobPath,
|
|
26
|
+
} from "../constants";
|
|
27
|
+
|
|
28
|
+
const STATUS_DELETION_REQUESTED = "deletionRequested";
|
|
29
|
+
const STATUS_RESTRICTED = "restricted";
|
|
30
|
+
|
|
31
|
+
type MeRow = {
|
|
32
|
+
readonly id: string;
|
|
33
|
+
readonly email: string;
|
|
34
|
+
readonly status?: string;
|
|
35
|
+
readonly gracePeriodEnd?: string | null;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
type ExportJob = {
|
|
39
|
+
readonly id: string;
|
|
40
|
+
readonly status: ExportJobStatus;
|
|
41
|
+
readonly expiresAt?: string | null;
|
|
42
|
+
readonly errorMessage?: string | null;
|
|
43
|
+
};
|
|
44
|
+
type ExportStatusResult =
|
|
45
|
+
| { readonly hasJob: false }
|
|
46
|
+
| { readonly hasJob: true; readonly job: ExportJob };
|
|
47
|
+
|
|
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
|
+
type SectionStatus =
|
|
57
|
+
| { kind: "idle" }
|
|
58
|
+
| { kind: "submitting" }
|
|
59
|
+
| { kind: "success"; messageKey: string }
|
|
60
|
+
| { kind: "error"; messageKey: string };
|
|
61
|
+
|
|
62
|
+
// Dispatcher-Failures tragen i18nKey nur wenn der Handler einen setzt —
|
|
63
|
+
// Boundary-Read mit generischem Fallback.
|
|
64
|
+
function failureKey(error: unknown): string {
|
|
65
|
+
const key = (error as { i18nKey?: unknown } | null)?.i18nKey; // @cast-boundary dispatcher-error
|
|
66
|
+
return typeof key === "string" ? key : "userDataRights.privacyCenter.errors.generic";
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Roher ISO-Instant → nur der Datums-Teil ist relevant; reiner String-Slice,
|
|
70
|
+
// kein Date-API (no-date-api-Guard) und universell. Leer/null → "—".
|
|
71
|
+
export function formatDate(iso: string | null | undefined): string {
|
|
72
|
+
if (!iso) return "—";
|
|
73
|
+
const tIndex = iso.indexOf("T");
|
|
74
|
+
return tIndex > 0 ? iso.slice(0, tIndex) : iso;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function StatusBanner({ status }: { readonly status: SectionStatus }): ReactNode {
|
|
78
|
+
const t = useTranslation();
|
|
79
|
+
const { Banner } = usePrimitives();
|
|
80
|
+
if (status.kind === "success") {
|
|
81
|
+
return <Banner variant="info">{t(status.messageKey)}</Banner>;
|
|
82
|
+
}
|
|
83
|
+
if (status.kind === "error") {
|
|
84
|
+
return <Banner variant="error">{t(status.messageKey)}</Banner>;
|
|
85
|
+
}
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function ExportSection(): ReactNode {
|
|
90
|
+
const t = useTranslation();
|
|
91
|
+
const { Button, Banner, Heading } = usePrimitives();
|
|
92
|
+
const dispatcher = useDispatcher();
|
|
93
|
+
const statusQuery = useQuery<ExportStatusResult | null>(UserDataRightsQueries.exportStatus, {});
|
|
94
|
+
const [status, setStatus] = useState<SectionStatus>({ kind: "idle" });
|
|
95
|
+
|
|
96
|
+
const request = async (): Promise<void> => {
|
|
97
|
+
setStatus({ kind: "submitting" });
|
|
98
|
+
const res = await dispatcher.write(UserDataRightsHandlers.requestExport, {});
|
|
99
|
+
if (!res.isSuccess) {
|
|
100
|
+
setStatus({ kind: "error", messageKey: failureKey(res.error) });
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
setStatus({ kind: "idle" });
|
|
104
|
+
void statusQuery.refetch?.();
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const result = statusQuery.data;
|
|
108
|
+
const job = result && result.hasJob ? result.job : null;
|
|
109
|
+
const submitting = status.kind === "submitting";
|
|
110
|
+
const inProgress =
|
|
111
|
+
job?.status === EXPORT_JOB_STATUS.Pending || job?.status === EXPORT_JOB_STATUS.Running;
|
|
112
|
+
const done = job?.status === EXPORT_JOB_STATUS.Done;
|
|
113
|
+
const failed = job?.status === EXPORT_JOB_STATUS.Failed;
|
|
114
|
+
|
|
115
|
+
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>
|
|
121
|
+
<p className="text-sm text-muted-foreground">
|
|
122
|
+
{t("userDataRights.privacyCenter.export.intro")}
|
|
123
|
+
</p>
|
|
124
|
+
{inProgress && (
|
|
125
|
+
<Banner variant="info" testId="privacy-export-pending">
|
|
126
|
+
{t("userDataRights.privacyCenter.export.pending")}
|
|
127
|
+
</Banner>
|
|
128
|
+
)}
|
|
129
|
+
{failed && (
|
|
130
|
+
<Banner variant="error" testId="privacy-export-failed">
|
|
131
|
+
{t("userDataRights.privacyCenter.export.failed")}
|
|
132
|
+
</Banner>
|
|
133
|
+
)}
|
|
134
|
+
{done && job && (
|
|
135
|
+
<Banner variant="info" testId="privacy-export-ready">
|
|
136
|
+
<p className="font-medium text-foreground">
|
|
137
|
+
{t("userDataRights.privacyCenter.export.ready")}
|
|
138
|
+
</p>
|
|
139
|
+
{job.expiresAt && (
|
|
140
|
+
<p className="mt-1">
|
|
141
|
+
{t("userDataRights.privacyCenter.export.availableUntil", {
|
|
142
|
+
date: formatDate(job.expiresAt),
|
|
143
|
+
})}
|
|
144
|
+
</p>
|
|
145
|
+
)}
|
|
146
|
+
<a
|
|
147
|
+
href={userExportByJobPath(job.id)}
|
|
148
|
+
data-testid="privacy-export-download"
|
|
149
|
+
className="mt-2 inline-block font-medium underline"
|
|
150
|
+
>
|
|
151
|
+
{t("userDataRights.privacyCenter.export.download")}
|
|
152
|
+
</a>
|
|
153
|
+
</Banner>
|
|
154
|
+
)}
|
|
155
|
+
<StatusBanner status={status} />
|
|
156
|
+
{!inProgress && (
|
|
157
|
+
<Button
|
|
158
|
+
onClick={() => void request()}
|
|
159
|
+
disabled={submitting}
|
|
160
|
+
loading={submitting}
|
|
161
|
+
testId="privacy-export-request"
|
|
162
|
+
>
|
|
163
|
+
{done
|
|
164
|
+
? t("userDataRights.privacyCenter.export.requestNew")
|
|
165
|
+
: submitting
|
|
166
|
+
? t("userDataRights.privacyCenter.export.requesting")
|
|
167
|
+
: t("userDataRights.privacyCenter.export.request")}
|
|
168
|
+
</Button>
|
|
169
|
+
)}
|
|
170
|
+
</section>
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function AuditSection(): ReactNode {
|
|
175
|
+
const t = useTranslation();
|
|
176
|
+
const { Banner, Heading } = usePrimitives();
|
|
177
|
+
const logQuery = useQuery<AuditLogResult | null>(UserDataRightsQueries.myAuditLog, {});
|
|
178
|
+
const rows = logQuery.data?.rows ?? [];
|
|
179
|
+
|
|
180
|
+
return (
|
|
181
|
+
<section
|
|
182
|
+
data-testid="privacy-audit"
|
|
183
|
+
className="flex flex-col gap-4 rounded-lg border bg-card p-6"
|
|
184
|
+
>
|
|
185
|
+
<Heading variant="section">{t("userDataRights.privacyCenter.audit.title")}</Heading>
|
|
186
|
+
<p className="text-sm text-muted-foreground">
|
|
187
|
+
{t("userDataRights.privacyCenter.audit.intro")}
|
|
188
|
+
</p>
|
|
189
|
+
{logQuery.error && (
|
|
190
|
+
<Banner variant="error">{t("userDataRights.privacyCenter.errors.generic")}</Banner>
|
|
191
|
+
)}
|
|
192
|
+
{rows.length === 0 ? (
|
|
193
|
+
<p className="text-sm text-muted-foreground" data-testid="privacy-audit-empty">
|
|
194
|
+
{t("userDataRights.privacyCenter.audit.empty")}
|
|
195
|
+
</p>
|
|
196
|
+
) : (
|
|
197
|
+
<ul className="flex flex-col divide-y text-sm" data-testid="privacy-audit-list">
|
|
198
|
+
{rows.map((row) => (
|
|
199
|
+
<li
|
|
200
|
+
key={row.id}
|
|
201
|
+
data-testid="privacy-audit-row"
|
|
202
|
+
className="flex items-center justify-between gap-4 py-2"
|
|
203
|
+
>
|
|
204
|
+
<span className="font-medium">{row.type}</span>
|
|
205
|
+
<span className="text-muted-foreground">{row.aggregateType}</span>
|
|
206
|
+
<span className="text-muted-foreground">{formatDate(row.createdAt)}</span>
|
|
207
|
+
</li>
|
|
208
|
+
))}
|
|
209
|
+
</ul>
|
|
210
|
+
)}
|
|
211
|
+
</section>
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function RestrictionSection({
|
|
216
|
+
me,
|
|
217
|
+
onChanged,
|
|
218
|
+
}: {
|
|
219
|
+
readonly me: MeRow;
|
|
220
|
+
readonly onChanged: () => void;
|
|
221
|
+
}): ReactNode {
|
|
222
|
+
const t = useTranslation();
|
|
223
|
+
const { Button, Banner, Dialog, Heading } = usePrimitives();
|
|
224
|
+
const dispatcher = useDispatcher();
|
|
225
|
+
const [dialogOpen, setDialogOpen] = useState(false);
|
|
226
|
+
const [status, setStatus] = useState<SectionStatus>({ kind: "idle" });
|
|
227
|
+
const restricted = me.status === STATUS_RESTRICTED;
|
|
228
|
+
|
|
229
|
+
// Erfolg ⇒ alle Sessions revoked, der User wird abgemeldet. onChanged ist
|
|
230
|
+
// best-effort (das anschließende Refetch läuft typisch in 401 + Logout-
|
|
231
|
+
// Redirect der App-Auth-Schicht).
|
|
232
|
+
const restrict = async (): Promise<void> => {
|
|
233
|
+
const res = await dispatcher.write(UserDataRightsHandlers.restrictAccount, {});
|
|
234
|
+
if (!res.isSuccess) {
|
|
235
|
+
setStatus({ kind: "error", messageKey: failureKey(res.error) });
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
onChanged();
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
return (
|
|
242
|
+
<section
|
|
243
|
+
data-testid="privacy-restriction"
|
|
244
|
+
className="flex flex-col gap-4 rounded-lg border border-destructive/40 bg-card p-6"
|
|
245
|
+
>
|
|
246
|
+
<Heading variant="section">{t("userDataRights.privacyCenter.restriction.title")}</Heading>
|
|
247
|
+
{restricted ? (
|
|
248
|
+
<Banner variant="error" testId="privacy-restriction-active">
|
|
249
|
+
{t("userDataRights.privacyCenter.restriction.restricted")}
|
|
250
|
+
</Banner>
|
|
251
|
+
) : (
|
|
252
|
+
<>
|
|
253
|
+
<p className="text-sm text-muted-foreground">
|
|
254
|
+
{t("userDataRights.privacyCenter.restriction.explainer")}
|
|
255
|
+
</p>
|
|
256
|
+
<StatusBanner status={status} />
|
|
257
|
+
<Button
|
|
258
|
+
variant="danger"
|
|
259
|
+
onClick={() => setDialogOpen(true)}
|
|
260
|
+
testId="privacy-restriction-restrict"
|
|
261
|
+
>
|
|
262
|
+
{t("userDataRights.privacyCenter.restriction.restrict")}
|
|
263
|
+
</Button>
|
|
264
|
+
<Dialog
|
|
265
|
+
open={dialogOpen}
|
|
266
|
+
onOpenChange={setDialogOpen}
|
|
267
|
+
title={t("userDataRights.privacyCenter.restriction.dialogTitle")}
|
|
268
|
+
description={t("userDataRights.privacyCenter.restriction.dialogDescription")}
|
|
269
|
+
variant="danger"
|
|
270
|
+
confirmLabel={t("userDataRights.privacyCenter.restriction.restrict")}
|
|
271
|
+
onConfirm={restrict}
|
|
272
|
+
testId="privacy-restriction-dialog"
|
|
273
|
+
/>
|
|
274
|
+
</>
|
|
275
|
+
)}
|
|
276
|
+
</section>
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function DeletionSection({
|
|
281
|
+
me,
|
|
282
|
+
onChanged,
|
|
283
|
+
}: {
|
|
284
|
+
readonly me: MeRow;
|
|
285
|
+
readonly onChanged: () => void;
|
|
286
|
+
}): ReactNode {
|
|
287
|
+
const t = useTranslation();
|
|
288
|
+
const { Button, Banner, Dialog, Heading } = usePrimitives();
|
|
289
|
+
const dispatcher = useDispatcher();
|
|
290
|
+
const [dialogOpen, setDialogOpen] = useState(false);
|
|
291
|
+
const [status, setStatus] = useState<SectionStatus>({ kind: "idle" });
|
|
292
|
+
const deletionRequested = me.status === STATUS_DELETION_REQUESTED;
|
|
293
|
+
|
|
294
|
+
const requestDeletion = async (): Promise<void> => {
|
|
295
|
+
const res = await dispatcher.write(UserDataRightsHandlers.requestDeletion, {});
|
|
296
|
+
if (!res.isSuccess) {
|
|
297
|
+
setStatus({ kind: "error", messageKey: failureKey(res.error) });
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
setStatus({ kind: "idle" });
|
|
301
|
+
onChanged();
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
const cancelDeletion = async (): Promise<void> => {
|
|
305
|
+
const res = await dispatcher.write(UserDataRightsHandlers.cancelDeletion, {});
|
|
306
|
+
if (!res.isSuccess) {
|
|
307
|
+
setStatus({ kind: "error", messageKey: failureKey(res.error) });
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
setStatus({
|
|
311
|
+
kind: "success",
|
|
312
|
+
messageKey: "userDataRights.privacyCenter.deletion.cancelSuccess",
|
|
313
|
+
});
|
|
314
|
+
onChanged();
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
return (
|
|
318
|
+
<section
|
|
319
|
+
data-testid="privacy-deletion"
|
|
320
|
+
className="flex flex-col gap-4 rounded-lg border border-destructive/40 bg-card p-6"
|
|
321
|
+
>
|
|
322
|
+
<Heading variant="section">{t("userDataRights.privacyCenter.deletion.title")}</Heading>
|
|
323
|
+
{deletionRequested ? (
|
|
324
|
+
<>
|
|
325
|
+
<Banner variant="error" testId="privacy-deletion-requested">
|
|
326
|
+
{t("userDataRights.privacyCenter.deletion.requested", {
|
|
327
|
+
date: formatDate(me.gracePeriodEnd),
|
|
328
|
+
})}
|
|
329
|
+
</Banner>
|
|
330
|
+
<StatusBanner status={status} />
|
|
331
|
+
<Button
|
|
332
|
+
variant="secondary"
|
|
333
|
+
onClick={() => void cancelDeletion()}
|
|
334
|
+
testId="privacy-deletion-cancel"
|
|
335
|
+
>
|
|
336
|
+
{t("userDataRights.privacyCenter.deletion.cancel")}
|
|
337
|
+
</Button>
|
|
338
|
+
</>
|
|
339
|
+
) : (
|
|
340
|
+
<>
|
|
341
|
+
<p className="text-sm text-muted-foreground">
|
|
342
|
+
{t("userDataRights.privacyCenter.deletion.explainer")}
|
|
343
|
+
</p>
|
|
344
|
+
<StatusBanner status={status} />
|
|
345
|
+
<Button
|
|
346
|
+
variant="danger"
|
|
347
|
+
onClick={() => setDialogOpen(true)}
|
|
348
|
+
testId="privacy-deletion-delete"
|
|
349
|
+
>
|
|
350
|
+
{t("userDataRights.privacyCenter.deletion.delete")}
|
|
351
|
+
</Button>
|
|
352
|
+
<Dialog
|
|
353
|
+
open={dialogOpen}
|
|
354
|
+
onOpenChange={setDialogOpen}
|
|
355
|
+
title={t("userDataRights.privacyCenter.deletion.dialogTitle")}
|
|
356
|
+
description={t("userDataRights.privacyCenter.deletion.dialogDescription")}
|
|
357
|
+
variant="danger"
|
|
358
|
+
confirmLabel={t("userDataRights.privacyCenter.deletion.delete")}
|
|
359
|
+
onConfirm={requestDeletion}
|
|
360
|
+
testId="privacy-deletion-dialog"
|
|
361
|
+
/>
|
|
362
|
+
</>
|
|
363
|
+
)}
|
|
364
|
+
</section>
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
export function PrivacyCenterScreen(): ReactNode {
|
|
369
|
+
const t = useTranslation();
|
|
370
|
+
const { Banner, Heading } = usePrimitives();
|
|
371
|
+
const meQuery = useQuery<MeRow | null>(USER_ME_QUERY, {});
|
|
372
|
+
|
|
373
|
+
if (meQuery.error) {
|
|
374
|
+
return (
|
|
375
|
+
<Banner padded variant="error" testId="privacy-error">
|
|
376
|
+
{t("userDataRights.privacyCenter.loadError")}
|
|
377
|
+
</Banner>
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
const me = meQuery.data;
|
|
381
|
+
if (me === null || me === undefined) {
|
|
382
|
+
return (
|
|
383
|
+
<Banner padded variant="loading" testId="privacy-loading">
|
|
384
|
+
{t("userDataRights.privacyCenter.loading")}
|
|
385
|
+
</Banner>
|
|
386
|
+
);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const refetch = (): void => {
|
|
390
|
+
void meQuery.refetch?.();
|
|
391
|
+
};
|
|
392
|
+
|
|
393
|
+
return (
|
|
394
|
+
<div className="p-6 flex flex-col gap-6 max-w-2xl" data-testid="privacy-center-screen">
|
|
395
|
+
<Heading variant="page">{t("userDataRights.privacyCenter.title")}</Heading>
|
|
396
|
+
<p className="text-sm text-muted-foreground">{t("userDataRights.privacyCenter.intro")}</p>
|
|
397
|
+
<ExportSection />
|
|
398
|
+
<AuditSection />
|
|
399
|
+
<RestrictionSection me={me} onChanged={refetch} />
|
|
400
|
+
<DeletionSection me={me} onChanged={refetch} />
|
|
401
|
+
</div>
|
|
402
|
+
);
|
|
403
|
+
}
|