@cosmicdrift/kumiko-bundled-features 0.240.0 → 0.241.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 +9 -9
- package/src/admin-shell/__tests__/admin-shell-security.integration.test.ts +16 -7
- package/src/admin-shell/__tests__/overview-allowlist.test.ts +30 -0
- package/src/admin-shell/__tests__/overview-screens.boot.test.ts +31 -23
- package/src/admin-shell/changes.json +6 -0
- package/src/admin-shell/feature.ts +60 -6
- package/src/admin-shell/i18n.ts +0 -7
- package/src/admin-shell/web/client-plugin.tsx +1 -11
- package/src/audit/__tests__/audit-screens.boot.test.ts +26 -6
- package/src/audit/changes.json +7 -0
- package/src/audit/feature.ts +57 -7
- package/src/audit/handlers/list.query.ts +50 -14
- package/src/audit/i18n.ts +3 -18
- package/src/audit/web/client-plugin.tsx +1 -7
- package/src/audit/web/index.ts +0 -2
- package/src/config/__tests__/config.integration.test.ts +31 -0
- package/src/config/handlers/readiness.query.ts +8 -1
- package/src/jobs/__tests__/jobs-screens.boot.test.ts +7 -2
- package/src/jobs/__tests__/jobs-security.integration.test.ts +41 -2
- package/src/jobs/changes.json +7 -0
- package/src/jobs/feature.ts +101 -9
- package/src/jobs/handlers/list.query.ts +21 -4
- package/src/jobs/handlers/trigger.write.ts +16 -3
- package/src/jobs/i18n.ts +3 -25
- package/src/jobs/index.ts +1 -0
- package/src/jobs/web/client-plugin.tsx +1 -7
- package/src/jobs/web/index.ts +0 -2
- package/src/tenant/__tests__/tenant.integration.test.ts +32 -0
- package/src/tenant/handlers/list.query.ts +1 -0
- package/src/tier-engine/__tests__/drift.test.ts +1 -1
- package/src/tier-engine/__tests__/tier-engine-screens.boot.test.ts +82 -0
- package/src/tier-engine/changes.json +9 -1
- package/src/tier-engine/constants.ts +5 -5
- package/src/tier-engine/feature.ts +36 -10
- package/src/tier-engine/i18n.ts +19 -22
- package/src/tier-engine/web/client-plugin.tsx +5 -11
- package/src/tier-engine/web/index.ts +0 -1
- package/src/user-data-rights/__tests__/inspector-screens.boot.test.ts +99 -0
- package/src/user-data-rights/changes.json +7 -0
- package/src/user-data-rights/constants.ts +20 -6
- package/src/user-data-rights/feature.ts +113 -14
- package/src/user-data-rights/web/__tests__/privacy-center-screen.test.tsx +29 -71
- package/src/user-data-rights/web/client-plugin.tsx +19 -21
- package/src/user-data-rights/web/i18n.ts +6 -11
- package/src/user-data-rights/web/index.ts +1 -1
- package/src/user-data-rights/web/privacy-center-screen.tsx +32 -245
- package/src/user-profile/__tests__/profile-screen.boot.test.ts +97 -0
- package/src/user-profile/__tests__/profile-screen.test.tsx +140 -104
- package/src/user-profile/changes.json +7 -0
- package/src/user-profile/constants.ts +15 -7
- package/src/user-profile/feature.ts +96 -9
- package/src/user-profile/i18n.ts +8 -12
- package/src/user-profile/web/client-plugin.ts +22 -5
- package/src/user-profile/web/index.ts +1 -1
- package/src/user-profile/web/profile-screen.tsx +122 -265
- package/src/admin-shell/web/overview-layout.tsx +0 -69
- package/src/admin-shell/web/overview-query.ts +0 -15
- package/src/admin-shell/web/platform-overview-screen.tsx +0 -91
- package/src/admin-shell/web/tenant-overview-screen.tsx +0 -91
- package/src/audit/__tests__/audit-log-screen.test.tsx +0 -302
- package/src/audit/web/audit-log-detail-screen.tsx +0 -169
- package/src/audit/web/audit-log-screen.tsx +0 -290
- package/src/jobs/web/__tests__/job-runs-screen.test.tsx +0 -225
- package/src/jobs/web/job-run-detail-screen.tsx +0 -181
- package/src/jobs/web/job-runs-screen.tsx +0 -290
- package/src/tier-engine/web/tier-admin-screen.tsx +0 -168
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
// Render
|
|
2
|
-
//
|
|
3
|
-
// (
|
|
4
|
-
//
|
|
5
|
-
//
|
|
1
|
+
// Render test against real i18n bundles (catches missing keys — the Section
|
|
2
|
+
// must never show raw "userDataRights.privacyCenter.*" keys) plus QN wiring
|
|
3
|
+
// (the dispatched query/handler names). Restriction/deletion (status-driven
|
|
4
|
+
// branches) moved to declarative fields/actions with fw#2312 (feature.ts,
|
|
5
|
+
// booted in inspector-screens.boot.test.ts) — only Export (ExportSection) is
|
|
6
|
+
// still a React component. Provider wrapper is local (the renderer-web →
|
|
7
|
+
// bundled-features dependency direction forbids importing test-utils).
|
|
6
8
|
|
|
7
9
|
import { describe, expect, spyOn, test } from "bun:test";
|
|
8
10
|
import { createStore, type Dispatcher, type DispatcherStatus } from "@cosmicdrift/kumiko-headless";
|
|
@@ -28,7 +30,7 @@ import {
|
|
|
28
30
|
} from "../../constants";
|
|
29
31
|
import { EXPORT_JOB_STATUS as SCHEMA_EXPORT_JOB_STATUS } from "../../schema/export-job";
|
|
30
32
|
import { defaultTranslations } from "../i18n";
|
|
31
|
-
import {
|
|
33
|
+
import { ExportSection, formatDate } from "../privacy-center-screen";
|
|
32
34
|
|
|
33
35
|
const stubLiveEvents: LiveEventSubscriber = () => () => {};
|
|
34
36
|
const stubTokens = {
|
|
@@ -40,7 +42,6 @@ const stubTokens = {
|
|
|
40
42
|
const stubResolver = createStaticLocaleResolver();
|
|
41
43
|
|
|
42
44
|
type QueryResponses = {
|
|
43
|
-
readonly me: Record<string, unknown>;
|
|
44
45
|
readonly exportStatus?: unknown;
|
|
45
46
|
readonly auditLog?: unknown;
|
|
46
47
|
/** Signed URL returned by downloadByJob — drives postWithDownload navigation. */
|
|
@@ -55,7 +56,6 @@ function makeDispatcher(
|
|
|
55
56
|
const statusStore = createStore<DispatcherStatus>("online");
|
|
56
57
|
const query = (async (type: string, payload: unknown) => {
|
|
57
58
|
queries.push({ type, payload });
|
|
58
|
-
if (type === USER_ME_QUERY) return { isSuccess: true, data: responses.me };
|
|
59
59
|
if (type === UserDataRightsQueries.exportStatus) {
|
|
60
60
|
return { isSuccess: true, data: responses.exportStatus ?? { hasJob: false } };
|
|
61
61
|
}
|
|
@@ -85,10 +85,7 @@ function makeDispatcher(
|
|
|
85
85
|
} as unknown as Dispatcher; // @cast-boundary test-stub
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
-
function
|
|
89
|
-
responses: QueryResponses,
|
|
90
|
-
opts: { readonly showDeletion?: boolean } = {},
|
|
91
|
-
): {
|
|
88
|
+
function renderExportSection(responses: QueryResponses): {
|
|
92
89
|
view: ReturnType<typeof render>;
|
|
93
90
|
writes: Array<{ type: string; payload: unknown }>;
|
|
94
91
|
queries: Array<{ type: string; payload: unknown }>;
|
|
@@ -111,22 +108,21 @@ function renderCenter(
|
|
|
111
108
|
</LocaleProvider>
|
|
112
109
|
</TokensProvider>
|
|
113
110
|
);
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
111
|
+
// ExportSection renders no Section/title of its own in production (the
|
|
112
|
+
// renderer's ExtensionSectionMount supplies that) — a plain testId wrapper
|
|
113
|
+
// here is test-only scaffolding to detect "mounted".
|
|
114
|
+
const view = render(
|
|
115
|
+
<div data-testid="export-section-root">
|
|
116
|
+
<ExportSection />
|
|
117
|
+
</div>,
|
|
118
|
+
{ wrapper },
|
|
119
|
+
);
|
|
117
120
|
return { view, writes, queries };
|
|
118
121
|
}
|
|
119
122
|
|
|
120
|
-
const activeMe = {
|
|
121
|
-
id: "00000000-0000-4000-8000-000000000042",
|
|
122
|
-
email: "marc@example.com",
|
|
123
|
-
status: "active",
|
|
124
|
-
gracePeriodEnd: null,
|
|
125
|
-
};
|
|
126
|
-
|
|
127
123
|
async function waitForMount(view: ReturnType<typeof render>): Promise<void> {
|
|
128
124
|
await waitFor(() => {
|
|
129
|
-
if (view.queryByTestId("
|
|
125
|
+
if (view.queryByTestId("export-section-root") === null) {
|
|
130
126
|
throw new Error("not mounted yet");
|
|
131
127
|
}
|
|
132
128
|
});
|
|
@@ -151,22 +147,16 @@ async function waitForDownloadReady(view: ReturnType<typeof render>): Promise<vo
|
|
|
151
147
|
// plus accumulated global DOM/event state across ~30 prior DOM test files corrupts
|
|
152
148
|
// these in-flight renders (#457-class). A fresh process has no such accumulation.
|
|
153
149
|
// The QN-Drift-Pins + formatDate describes below are pure-logic and CI-stable.
|
|
154
|
-
describe("
|
|
155
|
-
test("
|
|
156
|
-
const { view } =
|
|
150
|
+
describe("ExportSection", () => {
|
|
151
|
+
test("idle: Intro + Request-Button, Texte übersetzt (keine rohen Keys)", async () => {
|
|
152
|
+
const { view } = renderExportSection({});
|
|
157
153
|
await waitForMount(view);
|
|
158
|
-
expect(view.getByTestId("privacy-export")).toBeTruthy();
|
|
159
|
-
expect(view.getByTestId("privacy-restriction")).toBeTruthy();
|
|
160
|
-
expect(view.getByTestId("privacy-deletion")).toBeTruthy();
|
|
161
154
|
expect(view.getByTestId("privacy-export-request")).toBeTruthy();
|
|
162
|
-
expect(view.getByTestId("privacy-restriction-restrict")).toBeTruthy();
|
|
163
|
-
expect(view.getByTestId("privacy-deletion-delete")).toBeTruthy();
|
|
164
155
|
expect(view.container.textContent).not.toContain("userDataRights.privacyCenter");
|
|
165
156
|
});
|
|
166
157
|
|
|
167
158
|
test("export done: Download-Button + Verfügbar-bis-Datum", async () => {
|
|
168
|
-
const { view } =
|
|
169
|
-
me: activeMe,
|
|
159
|
+
const { view } = renderExportSection({
|
|
170
160
|
exportStatus: {
|
|
171
161
|
hasJob: true,
|
|
172
162
|
job: { id: "job-123", status: EXPORT_JOB_STATUS.Done, expiresAt: "2026-07-11T00:00:00Z" },
|
|
@@ -180,8 +170,7 @@ describe("PrivacyCenterScreen", () => {
|
|
|
180
170
|
});
|
|
181
171
|
|
|
182
172
|
test("export failed: Fehler-Banner + Re-Request möglich", async () => {
|
|
183
|
-
const { view } =
|
|
184
|
-
me: activeMe,
|
|
173
|
+
const { view } = renderExportSection({
|
|
185
174
|
exportStatus: { hasJob: true, job: { id: "job-9", status: EXPORT_JOB_STATUS.Failed } },
|
|
186
175
|
});
|
|
187
176
|
await waitForTestId(view, "privacy-export-failed");
|
|
@@ -190,8 +179,7 @@ describe("PrivacyCenterScreen", () => {
|
|
|
190
179
|
});
|
|
191
180
|
|
|
192
181
|
test("export pending: in-progress Banner, kein Request-Button", async () => {
|
|
193
|
-
const { view } =
|
|
194
|
-
me: activeMe,
|
|
182
|
+
const { view } = renderExportSection({
|
|
195
183
|
exportStatus: { hasJob: true, job: { id: "job-1", status: EXPORT_JOB_STATUS.Pending } },
|
|
196
184
|
});
|
|
197
185
|
await waitForTestId(view, "privacy-export-pending");
|
|
@@ -199,28 +187,8 @@ describe("PrivacyCenterScreen", () => {
|
|
|
199
187
|
expect(view.queryByTestId("privacy-export-request")).toBeNull();
|
|
200
188
|
});
|
|
201
189
|
|
|
202
|
-
test("deletionRequested: Frist-Banner + Abbrechen statt Lösch-Button", async () => {
|
|
203
|
-
const { view } = renderCenter({
|
|
204
|
-
me: { ...activeMe, status: "deletionRequested", gracePeriodEnd: "2026-07-11T00:00:00Z" },
|
|
205
|
-
});
|
|
206
|
-
await waitForMount(view);
|
|
207
|
-
const banner = view.getByTestId("privacy-deletion-requested");
|
|
208
|
-
expect(banner.textContent).toContain("2026-07-11");
|
|
209
|
-
expect(banner.textContent).not.toContain("{date}");
|
|
210
|
-
expect(banner.textContent).not.toContain("T00:00");
|
|
211
|
-
expect(view.queryByTestId("privacy-deletion-delete")).toBeNull();
|
|
212
|
-
expect(view.getByTestId("privacy-deletion-cancel")).toBeTruthy();
|
|
213
|
-
});
|
|
214
|
-
|
|
215
|
-
test("restricted: Info-Banner statt Einschränken-Button", async () => {
|
|
216
|
-
const { view } = renderCenter({ me: { ...activeMe, status: "restricted" } });
|
|
217
|
-
await waitForMount(view);
|
|
218
|
-
expect(view.getByTestId("privacy-restriction-active")).toBeTruthy();
|
|
219
|
-
expect(view.queryByTestId("privacy-restriction-restrict")).toBeNull();
|
|
220
|
-
});
|
|
221
|
-
|
|
222
190
|
test("Export-Request dispatcht den korrekten Handler-QN", async () => {
|
|
223
|
-
const { view, writes } =
|
|
191
|
+
const { view, writes } = renderExportSection({});
|
|
224
192
|
await waitForMount(view);
|
|
225
193
|
fireEvent.click(view.getByTestId("privacy-export-request"));
|
|
226
194
|
await waitFor(() => {
|
|
@@ -229,16 +197,8 @@ describe("PrivacyCenterScreen", () => {
|
|
|
229
197
|
expect(writes[0]?.type).toBe(UserDataRightsHandlers.requestExport);
|
|
230
198
|
});
|
|
231
199
|
|
|
232
|
-
test("showDeletion=false: keine Lösch-Sektion", async () => {
|
|
233
|
-
const { view } = renderCenter({ me: activeMe }, { showDeletion: false });
|
|
234
|
-
await waitForMount(view);
|
|
235
|
-
expect(view.queryByTestId("privacy-deletion")).toBeNull();
|
|
236
|
-
expect(view.queryByTestId("privacy-deletion-delete")).toBeNull();
|
|
237
|
-
});
|
|
238
|
-
|
|
239
200
|
test("Download-Button dispatcht downloadByJob mit der korrekten jobId", async () => {
|
|
240
|
-
const { view, queries } =
|
|
241
|
-
me: activeMe,
|
|
201
|
+
const { view, queries } = renderExportSection({
|
|
242
202
|
exportStatus: {
|
|
243
203
|
hasJob: true,
|
|
244
204
|
job: { id: "job-123", status: EXPORT_JOB_STATUS.Done, expiresAt: "2026-07-11T00:00:00Z" },
|
|
@@ -259,8 +219,7 @@ describe("PrivacyCenterScreen", () => {
|
|
|
259
219
|
const signedUrl = "https://cdn.test/exports/job-123.zip?sig=abc";
|
|
260
220
|
const assign = spyOn(window.location, "assign").mockImplementation(() => {});
|
|
261
221
|
try {
|
|
262
|
-
const { view } =
|
|
263
|
-
me: activeMe,
|
|
222
|
+
const { view } = renderExportSection({
|
|
264
223
|
downloadUrl: signedUrl,
|
|
265
224
|
exportStatus: {
|
|
266
225
|
hasJob: true,
|
|
@@ -281,8 +240,7 @@ describe("PrivacyCenterScreen", () => {
|
|
|
281
240
|
test("Download-Button does not navigate when downloadByJob returns no url", async () => {
|
|
282
241
|
const assign = spyOn(window.location, "assign").mockImplementation(() => {});
|
|
283
242
|
try {
|
|
284
|
-
const { view } =
|
|
285
|
-
me: activeMe,
|
|
243
|
+
const { view } = renderExportSection({
|
|
286
244
|
downloadUrl: undefined,
|
|
287
245
|
exportStatus: {
|
|
288
246
|
hasJob: true,
|
|
@@ -1,16 +1,25 @@
|
|
|
1
1
|
// @runtime client
|
|
2
|
-
// Client-
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
2
|
+
// Client-feature factory for user-data-rights. Provides the ExportSection
|
|
3
|
+
// extension component for the privacy-center screen (registered server-side
|
|
4
|
+
// declaratively as `type: "projectionDetail"`, see feature.ts — restriction
|
|
5
|
+
// and deletion are fields/actions on the screen itself and need no client
|
|
6
|
+
// component) + default translations. Apps wire it in via
|
|
7
|
+
// createKumikoApp({ clientFeatures: [userDataRightsClient()] }); the screen
|
|
8
|
+
// has no own r.nav, the app places it via r.nav.
|
|
9
|
+
//
|
|
10
|
+
// fw#2312: the former `privacyCenter: { showDeletion }` option is gone — a
|
|
11
|
+
// declarative screen is now registered server-side once for all apps, so a
|
|
12
|
+
// client prop can no longer toggle its sections/actions per app. Its
|
|
13
|
+
// replacement is the server-side
|
|
14
|
+
// `UserDataRightsOptions.privacyCenterShowDeletion` (createUserDataRightsFeature,
|
|
15
|
+
// ../feature.ts) — BREAKING, consumers with `showDeletion: false` (e.g.
|
|
16
|
+
// money-horse) must carry the option over at feature setup.
|
|
8
17
|
|
|
9
18
|
import { mergeTranslations, type TranslationsByLocale } from "@cosmicdrift/kumiko-renderer";
|
|
10
19
|
import type { ClientFeatureDefinition } from "@cosmicdrift/kumiko-renderer-web";
|
|
11
|
-
import {
|
|
20
|
+
import { EXPORT_SECTION_EXTENSION_NAME, USER_DATA_RIGHTS_FEATURE } from "../constants";
|
|
12
21
|
import { defaultTranslations } from "./i18n";
|
|
13
|
-
import {
|
|
22
|
+
import { ExportSection } from "./privacy-center-screen";
|
|
14
23
|
import { makePublicDeletionGate, type PublicDeletionRoutes } from "./public-deletion-gate";
|
|
15
24
|
|
|
16
25
|
export type UserDataRightsClientOptions = {
|
|
@@ -21,27 +30,16 @@ export type UserDataRightsClientOptions = {
|
|
|
21
30
|
* privacy-center-Screen. Den Client VOR dem Auth-Client listen, sonst
|
|
22
31
|
* landet der anonyme Besucher auf der Login-Maske. */
|
|
23
32
|
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
|
-
};
|
|
31
33
|
};
|
|
32
34
|
|
|
33
35
|
export function userDataRightsClient(
|
|
34
36
|
options?: UserDataRightsClientOptions,
|
|
35
37
|
): ClientFeatureDefinition {
|
|
36
|
-
const showDeletion = options?.privacyCenter?.showDeletion ?? true;
|
|
37
|
-
const privacyCenter = showDeletion
|
|
38
|
-
? PrivacyCenterScreen
|
|
39
|
-
: () => <PrivacyCenterScreen showDeletion={false} />;
|
|
40
38
|
const base: ClientFeatureDefinition = {
|
|
41
39
|
name: USER_DATA_RIGHTS_FEATURE,
|
|
42
40
|
translations: mergeTranslations(defaultTranslations, options?.translations ?? {}),
|
|
43
|
-
|
|
44
|
-
[
|
|
41
|
+
extensionSectionComponents: {
|
|
42
|
+
[EXPORT_SECTION_EXTENSION_NAME]: ExportSection,
|
|
45
43
|
},
|
|
46
44
|
};
|
|
47
45
|
if (options?.publicDeletion === undefined) return base;
|
|
@@ -37,12 +37,13 @@ const apexTranslations: TranslationsByLocale = {
|
|
|
37
37
|
"No token found in the link. Please open the link from the email again.",
|
|
38
38
|
"userDataRights.deletion.confirm.error": "Something went wrong. Please try again.",
|
|
39
39
|
|
|
40
|
-
"userDataRights.privacyCenter.title": "Privacy",
|
|
41
|
-
"userDataRights.privacyCenter.intro":
|
|
42
|
-
"Manage your GDPR rights: access, export, restrict, and delete your account.",
|
|
43
|
-
"userDataRights.privacyCenter.loading": "Loading …",
|
|
44
|
-
"userDataRights.privacyCenter.loadError": "Your data could not be loaded.",
|
|
45
40
|
"userDataRights.privacyCenter.errors.generic": "Something went wrong. Please try again.",
|
|
41
|
+
"userDataRights.privacyCenter.field.status": "Account status",
|
|
42
|
+
"userDataRights.privacyCenter.field.gracePeriodEnd": "Scheduled deletion date",
|
|
43
|
+
"userDataRights.privacyCenter.field.status.option.active": "Active",
|
|
44
|
+
"userDataRights.privacyCenter.field.status.option.restricted": "Restricted",
|
|
45
|
+
"userDataRights.privacyCenter.field.status.option.deletionRequested": "Deletion requested",
|
|
46
|
+
"userDataRights.privacyCenter.field.status.option.deleted": "Deleted",
|
|
46
47
|
|
|
47
48
|
"userDataRights.privacyCenter.export.title": "Export your data (Art. 20)",
|
|
48
49
|
"userDataRights.privacyCenter.export.intro":
|
|
@@ -62,20 +63,14 @@ const apexTranslations: TranslationsByLocale = {
|
|
|
62
63
|
"userDataRights.privacyCenter.restriction.explainer":
|
|
63
64
|
"Freeze your account: processing of your data is paused and you are signed out. Lifting the restriction afterwards is only possible via support.",
|
|
64
65
|
"userDataRights.privacyCenter.restriction.restrict": "Restrict account",
|
|
65
|
-
"userDataRights.privacyCenter.restriction.dialogTitle": "Restrict your account?",
|
|
66
66
|
"userDataRights.privacyCenter.restriction.dialogDescription":
|
|
67
67
|
"You will be signed out immediately and cannot sign in again until support lifts the restriction.",
|
|
68
|
-
"userDataRights.privacyCenter.restriction.restricted":
|
|
69
|
-
"Your account is restricted. Contact support to lift the restriction.",
|
|
70
68
|
|
|
71
69
|
"userDataRights.privacyCenter.deletion.title": "Delete account (Art. 17)",
|
|
72
70
|
"userDataRights.privacyCenter.deletion.explainer":
|
|
73
71
|
"Request deletion of your account. Until the grace period ends you can cancel the deletion.",
|
|
74
72
|
"userDataRights.privacyCenter.deletion.delete": "Delete account",
|
|
75
|
-
"userDataRights.privacyCenter.deletion.requested": "Your account will be deleted on {date}.",
|
|
76
73
|
"userDataRights.privacyCenter.deletion.cancel": "Cancel deletion",
|
|
77
|
-
"userDataRights.privacyCenter.deletion.cancelSuccess": "The deletion was cancelled.",
|
|
78
|
-
"userDataRights.privacyCenter.deletion.dialogTitle": "Delete your account?",
|
|
79
74
|
"userDataRights.privacyCenter.deletion.dialogDescription":
|
|
80
75
|
"Confirming starts the deletion grace period. You can cancel the deletion until it ends.",
|
|
81
76
|
|
|
@@ -9,7 +9,7 @@ export { type UserDataRightsClientOptions, userDataRightsClient } from "./client
|
|
|
9
9
|
export type { ConfirmAccountDeletionScreenProps } from "./confirm-deletion-screen";
|
|
10
10
|
export { ConfirmAccountDeletionScreen } from "./confirm-deletion-screen";
|
|
11
11
|
export { defaultTranslations } from "./i18n";
|
|
12
|
-
export { formatDate
|
|
12
|
+
export { formatDate } from "./privacy-center-screen";
|
|
13
13
|
export { makePublicDeletionGate, type PublicDeletionRoutes } from "./public-deletion-gate";
|
|
14
14
|
export type { RequestAccountDeletionScreenProps } from "./request-deletion-screen";
|
|
15
15
|
export { RequestAccountDeletionScreen } from "./request-deletion-screen";
|
|
@@ -1,17 +1,14 @@
|
|
|
1
1
|
// @runtime client
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
// (
|
|
6
|
-
//
|
|
2
|
+
// Export section (Art. 20) — the one part of user-data-rights' privacy-center
|
|
3
|
+
// screen that stays custom (fw#2312). Registered as an EditExtensionSection
|
|
4
|
+
// component (see web/client-plugin.tsx); the projectionDetail screen
|
|
5
|
+
// (feature.ts) supplies the surrounding Section chrome (title, testId), so
|
|
6
|
+
// this component renders only its own body content.
|
|
7
7
|
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
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.
|
|
8
|
+
// Export needs async-job polling (worker-Lane-Cron, ~1 Min) plus a
|
|
9
|
+
// signed-URL download — logic a declarative field/action can't express.
|
|
10
|
+
// Restriction (Art. 18) and Deletion (Art. 17) moved onto the screen's
|
|
11
|
+
// declarative fields/actions instead (feature.ts).
|
|
15
12
|
|
|
16
13
|
import {
|
|
17
14
|
useDispatcher,
|
|
@@ -19,30 +16,20 @@ import {
|
|
|
19
16
|
useQuery,
|
|
20
17
|
useTranslation,
|
|
21
18
|
} from "@cosmicdrift/kumiko-renderer";
|
|
22
|
-
import {
|
|
19
|
+
import { postWithDownload } from "@cosmicdrift/kumiko-renderer-web";
|
|
23
20
|
import { type ReactNode, useEffect, useState } from "react";
|
|
24
21
|
import {
|
|
25
22
|
EXPORT_JOB_STATUS,
|
|
26
23
|
type ExportJobStatus,
|
|
27
|
-
USER_ME_QUERY,
|
|
28
24
|
UserDataRightsHandlers,
|
|
29
25
|
UserDataRightsQueries,
|
|
30
26
|
} from "../constants";
|
|
31
27
|
|
|
32
|
-
const STATUS_DELETION_REQUESTED = "deletionRequested";
|
|
33
|
-
const STATUS_RESTRICTED = "restricted";
|
|
34
28
|
// Export-Job läuft async (worker-Lane-Cron, ~1 Min). Solange er pending/running
|
|
35
29
|
// ist, pollt der Screen den Status, damit der Download ohne manuellen Reload
|
|
36
30
|
// erscheint.
|
|
37
31
|
const EXPORT_POLL_MS = 4000;
|
|
38
32
|
|
|
39
|
-
type MeRow = {
|
|
40
|
-
readonly id: string;
|
|
41
|
-
readonly email: string;
|
|
42
|
-
readonly status?: string;
|
|
43
|
-
readonly gracePeriodEnd?: string | null;
|
|
44
|
-
};
|
|
45
|
-
|
|
46
33
|
type ExportJob = {
|
|
47
34
|
readonly id: string;
|
|
48
35
|
readonly status: ExportJobStatus;
|
|
@@ -86,9 +73,14 @@ function StatusBanner({ status }: { readonly status: SectionStatus }): ReactNode
|
|
|
86
73
|
return null;
|
|
87
74
|
}
|
|
88
75
|
|
|
89
|
-
|
|
76
|
+
// Mounted by the renderer's ExtensionSectionMount (already wrapped in
|
|
77
|
+
// <Section title={...} testId="section-extension-...">) — takes no props of
|
|
78
|
+
// its own, ExtensionSectionProps (entityName/entityId/...) are unused since
|
|
79
|
+
// export status/actions are scoped to the current user session, not to a
|
|
80
|
+
// host entity row.
|
|
81
|
+
export function ExportSection(): ReactNode {
|
|
90
82
|
const t = useTranslation();
|
|
91
|
-
const {
|
|
83
|
+
const { Button, Banner } = usePrimitives();
|
|
92
84
|
const dispatcher = useDispatcher();
|
|
93
85
|
const statusQuery = useQuery<ExportStatusResult | null>(UserDataRightsQueries.exportStatus, {});
|
|
94
86
|
const [status, setStatus] = useState<SectionStatus>({ kind: "idle" });
|
|
@@ -131,26 +123,7 @@ function ExportSection(): ReactNode {
|
|
|
131
123
|
}, [inProgress, refetch]);
|
|
132
124
|
|
|
133
125
|
return (
|
|
134
|
-
|
|
135
|
-
title={t("userDataRights.privacyCenter.export.title")}
|
|
136
|
-
testId="privacy-export"
|
|
137
|
-
actions={
|
|
138
|
-
!inProgress ? (
|
|
139
|
-
<Button
|
|
140
|
-
onClick={() => void request()}
|
|
141
|
-
disabled={submitting}
|
|
142
|
-
loading={submitting}
|
|
143
|
-
testId="privacy-export-request"
|
|
144
|
-
>
|
|
145
|
-
{done
|
|
146
|
-
? t("userDataRights.privacyCenter.export.requestNew")
|
|
147
|
-
: submitting
|
|
148
|
-
? t("userDataRights.privacyCenter.export.requesting")
|
|
149
|
-
: t("userDataRights.privacyCenter.export.request")}
|
|
150
|
-
</Button>
|
|
151
|
-
) : undefined
|
|
152
|
-
}
|
|
153
|
-
>
|
|
126
|
+
<>
|
|
154
127
|
<p className="text-sm text-muted-foreground">
|
|
155
128
|
{t("userDataRights.privacyCenter.export.intro")}
|
|
156
129
|
</p>
|
|
@@ -190,209 +163,23 @@ function ExportSection(): ReactNode {
|
|
|
190
163
|
</div>
|
|
191
164
|
</Banner>
|
|
192
165
|
)}
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
);
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
function RestrictionSection({
|
|
199
|
-
me,
|
|
200
|
-
onChanged,
|
|
201
|
-
}: {
|
|
202
|
-
readonly me: MeRow;
|
|
203
|
-
readonly onChanged: () => void;
|
|
204
|
-
}): ReactNode {
|
|
205
|
-
const t = useTranslation();
|
|
206
|
-
const { Section, Button, Banner, Dialog } = usePrimitives();
|
|
207
|
-
const dispatcher = useDispatcher();
|
|
208
|
-
const [dialogOpen, setDialogOpen] = useState(false);
|
|
209
|
-
const [status, setStatus] = useState<SectionStatus>({ kind: "idle" });
|
|
210
|
-
const restricted = me.status === STATUS_RESTRICTED;
|
|
211
|
-
|
|
212
|
-
// Erfolg ⇒ alle Sessions revoked, der User wird abgemeldet. onChanged ist
|
|
213
|
-
// best-effort (das anschließende Refetch läuft typisch in 401 + Logout-
|
|
214
|
-
// Redirect der App-Auth-Schicht).
|
|
215
|
-
const restrict = async (): Promise<void> => {
|
|
216
|
-
const res = await dispatcher.write(UserDataRightsHandlers.restrictAccount, {});
|
|
217
|
-
if (!res.isSuccess) {
|
|
218
|
-
setStatus({ kind: "error", messageKey: failureKey(res.error) });
|
|
219
|
-
return;
|
|
220
|
-
}
|
|
221
|
-
onChanged();
|
|
222
|
-
};
|
|
223
|
-
|
|
224
|
-
return (
|
|
225
|
-
<Section
|
|
226
|
-
title={t("userDataRights.privacyCenter.restriction.title")}
|
|
227
|
-
testId="privacy-restriction"
|
|
228
|
-
variant="destructive"
|
|
229
|
-
actions={
|
|
230
|
-
restricted ? undefined : (
|
|
166
|
+
{!inProgress && (
|
|
167
|
+
<div className="mt-2">
|
|
231
168
|
<Button
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
{t("userDataRights.privacyCenter.restriction.restrict")}
|
|
237
|
-
</Button>
|
|
238
|
-
)
|
|
239
|
-
}
|
|
240
|
-
>
|
|
241
|
-
{restricted ? (
|
|
242
|
-
<Banner variant="error" testId="privacy-restriction-active">
|
|
243
|
-
{t("userDataRights.privacyCenter.restriction.restricted")}
|
|
244
|
-
</Banner>
|
|
245
|
-
) : (
|
|
246
|
-
<>
|
|
247
|
-
<p className="text-sm text-muted-foreground">
|
|
248
|
-
{t("userDataRights.privacyCenter.restriction.explainer")}
|
|
249
|
-
</p>
|
|
250
|
-
<StatusBanner status={status} />
|
|
251
|
-
<Dialog
|
|
252
|
-
open={dialogOpen}
|
|
253
|
-
onOpenChange={setDialogOpen}
|
|
254
|
-
title={t("userDataRights.privacyCenter.restriction.dialogTitle")}
|
|
255
|
-
description={t("userDataRights.privacyCenter.restriction.dialogDescription")}
|
|
256
|
-
variant="danger"
|
|
257
|
-
confirmLabel={t("userDataRights.privacyCenter.restriction.restrict")}
|
|
258
|
-
onConfirm={restrict}
|
|
259
|
-
testId="privacy-restriction-dialog"
|
|
260
|
-
/>
|
|
261
|
-
</>
|
|
262
|
-
)}
|
|
263
|
-
</Section>
|
|
264
|
-
);
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
function DeletionSection({
|
|
268
|
-
me,
|
|
269
|
-
onChanged,
|
|
270
|
-
}: {
|
|
271
|
-
readonly me: MeRow;
|
|
272
|
-
readonly onChanged: () => void;
|
|
273
|
-
}): ReactNode {
|
|
274
|
-
const t = useTranslation();
|
|
275
|
-
const { Section, Button, Banner, Dialog } = usePrimitives();
|
|
276
|
-
const dispatcher = useDispatcher();
|
|
277
|
-
const [dialogOpen, setDialogOpen] = useState(false);
|
|
278
|
-
const [status, setStatus] = useState<SectionStatus>({ kind: "idle" });
|
|
279
|
-
const deletionRequested = me.status === STATUS_DELETION_REQUESTED;
|
|
280
|
-
|
|
281
|
-
const requestDeletion = async (): Promise<void> => {
|
|
282
|
-
const res = await dispatcher.write(UserDataRightsHandlers.requestDeletion, {});
|
|
283
|
-
if (!res.isSuccess) {
|
|
284
|
-
setStatus({ kind: "error", messageKey: failureKey(res.error) });
|
|
285
|
-
return;
|
|
286
|
-
}
|
|
287
|
-
setStatus({ kind: "idle" });
|
|
288
|
-
onChanged();
|
|
289
|
-
};
|
|
290
|
-
|
|
291
|
-
const cancelDeletion = async (): Promise<void> => {
|
|
292
|
-
const res = await dispatcher.write(UserDataRightsHandlers.cancelDeletion, {});
|
|
293
|
-
if (!res.isSuccess) {
|
|
294
|
-
setStatus({ kind: "error", messageKey: failureKey(res.error) });
|
|
295
|
-
return;
|
|
296
|
-
}
|
|
297
|
-
setStatus({
|
|
298
|
-
kind: "success",
|
|
299
|
-
messageKey: "userDataRights.privacyCenter.deletion.cancelSuccess",
|
|
300
|
-
});
|
|
301
|
-
onChanged();
|
|
302
|
-
};
|
|
303
|
-
|
|
304
|
-
return (
|
|
305
|
-
<Section
|
|
306
|
-
title={t("userDataRights.privacyCenter.deletion.title")}
|
|
307
|
-
testId="privacy-deletion"
|
|
308
|
-
variant="destructive"
|
|
309
|
-
actions={
|
|
310
|
-
deletionRequested ? (
|
|
311
|
-
<Button
|
|
312
|
-
variant="secondary"
|
|
313
|
-
onClick={() => void cancelDeletion()}
|
|
314
|
-
testId="privacy-deletion-cancel"
|
|
315
|
-
>
|
|
316
|
-
{t("userDataRights.privacyCenter.deletion.cancel")}
|
|
317
|
-
</Button>
|
|
318
|
-
) : (
|
|
319
|
-
<Button
|
|
320
|
-
variant="danger"
|
|
321
|
-
onClick={() => setDialogOpen(true)}
|
|
322
|
-
testId="privacy-deletion-delete"
|
|
169
|
+
onClick={() => void request()}
|
|
170
|
+
disabled={submitting}
|
|
171
|
+
loading={submitting}
|
|
172
|
+
testId="privacy-export-request"
|
|
323
173
|
>
|
|
324
|
-
{
|
|
174
|
+
{done
|
|
175
|
+
? t("userDataRights.privacyCenter.export.requestNew")
|
|
176
|
+
: submitting
|
|
177
|
+
? t("userDataRights.privacyCenter.export.requesting")
|
|
178
|
+
: t("userDataRights.privacyCenter.export.request")}
|
|
325
179
|
</Button>
|
|
326
|
-
|
|
327
|
-
}
|
|
328
|
-
>
|
|
329
|
-
{deletionRequested ? (
|
|
330
|
-
<>
|
|
331
|
-
<Banner variant="error" testId="privacy-deletion-requested">
|
|
332
|
-
{t("userDataRights.privacyCenter.deletion.requested", {
|
|
333
|
-
date: formatDate(me.gracePeriodEnd),
|
|
334
|
-
})}
|
|
335
|
-
</Banner>
|
|
336
|
-
<StatusBanner status={status} />
|
|
337
|
-
</>
|
|
338
|
-
) : (
|
|
339
|
-
<>
|
|
340
|
-
<p className="text-sm text-muted-foreground">
|
|
341
|
-
{t("userDataRights.privacyCenter.deletion.explainer")}
|
|
342
|
-
</p>
|
|
343
|
-
<StatusBanner status={status} />
|
|
344
|
-
<Dialog
|
|
345
|
-
open={dialogOpen}
|
|
346
|
-
onOpenChange={setDialogOpen}
|
|
347
|
-
title={t("userDataRights.privacyCenter.deletion.dialogTitle")}
|
|
348
|
-
description={t("userDataRights.privacyCenter.deletion.dialogDescription")}
|
|
349
|
-
variant="danger"
|
|
350
|
-
confirmLabel={t("userDataRights.privacyCenter.deletion.delete")}
|
|
351
|
-
onConfirm={requestDeletion}
|
|
352
|
-
testId="privacy-deletion-dialog"
|
|
353
|
-
/>
|
|
354
|
-
</>
|
|
180
|
+
</div>
|
|
355
181
|
)}
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
export function PrivacyCenterScreen({
|
|
361
|
-
showDeletion = true,
|
|
362
|
-
}: {
|
|
363
|
-
readonly showDeletion?: boolean;
|
|
364
|
-
} = {}): ReactNode {
|
|
365
|
-
const t = useTranslation();
|
|
366
|
-
const { Banner, Heading } = usePrimitives();
|
|
367
|
-
const meQuery = useQuery<MeRow | null>(USER_ME_QUERY, {});
|
|
368
|
-
|
|
369
|
-
if (meQuery.error) {
|
|
370
|
-
return (
|
|
371
|
-
<Banner padded variant="error" testId="privacy-error">
|
|
372
|
-
{t("userDataRights.privacyCenter.loadError")}
|
|
373
|
-
</Banner>
|
|
374
|
-
);
|
|
375
|
-
}
|
|
376
|
-
const me = meQuery.data;
|
|
377
|
-
if (me === null || me === undefined) {
|
|
378
|
-
return (
|
|
379
|
-
<Banner padded variant="loading" testId="privacy-loading">
|
|
380
|
-
{t("userDataRights.privacyCenter.loading")}
|
|
381
|
-
</Banner>
|
|
382
|
-
);
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
const refetch = (): void => {
|
|
386
|
-
void meQuery.refetch?.();
|
|
387
|
-
};
|
|
388
|
-
|
|
389
|
-
return (
|
|
390
|
-
<FormScreenShell className="flex flex-col gap-6" testId="privacy-center-screen">
|
|
391
|
-
<Heading variant="page">{t("userDataRights.privacyCenter.title")}</Heading>
|
|
392
|
-
<p className="text-sm text-muted-foreground">{t("userDataRights.privacyCenter.intro")}</p>
|
|
393
|
-
<ExportSection />
|
|
394
|
-
<RestrictionSection me={me} onChanged={refetch} />
|
|
395
|
-
{showDeletion && <DeletionSection me={me} onChanged={refetch} />}
|
|
396
|
-
</FormScreenShell>
|
|
182
|
+
<StatusBanner status={status} />
|
|
183
|
+
</>
|
|
397
184
|
);
|
|
398
185
|
}
|