@cosmicdrift/kumiko-bundled-features 0.239.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.
Files changed (68) hide show
  1. package/package.json +11 -11
  2. package/src/admin-shell/__tests__/admin-shell-security.integration.test.ts +16 -7
  3. package/src/admin-shell/__tests__/overview-allowlist.test.ts +30 -0
  4. package/src/admin-shell/__tests__/overview-screens.boot.test.ts +31 -23
  5. package/src/admin-shell/changes.json +6 -0
  6. package/src/admin-shell/feature.ts +60 -6
  7. package/src/admin-shell/i18n.ts +0 -7
  8. package/src/admin-shell/web/client-plugin.tsx +1 -11
  9. package/src/audit/__tests__/audit-screens.boot.test.ts +26 -6
  10. package/src/audit/changes.json +7 -0
  11. package/src/audit/feature.ts +57 -7
  12. package/src/audit/handlers/list.query.ts +50 -14
  13. package/src/audit/i18n.ts +3 -18
  14. package/src/audit/web/client-plugin.tsx +1 -7
  15. package/src/audit/web/index.ts +0 -2
  16. package/src/auth-mfa/web/mfa-enable-screen.tsx +1 -5
  17. package/src/config/__tests__/config.integration.test.ts +31 -0
  18. package/src/config/handlers/readiness.query.ts +8 -1
  19. package/src/jobs/__tests__/jobs-screens.boot.test.ts +7 -2
  20. package/src/jobs/__tests__/jobs-security.integration.test.ts +41 -2
  21. package/src/jobs/changes.json +7 -0
  22. package/src/jobs/feature.ts +101 -9
  23. package/src/jobs/handlers/list.query.ts +21 -4
  24. package/src/jobs/handlers/trigger.write.ts +16 -3
  25. package/src/jobs/i18n.ts +3 -25
  26. package/src/jobs/index.ts +1 -0
  27. package/src/jobs/web/client-plugin.tsx +1 -7
  28. package/src/jobs/web/index.ts +0 -2
  29. package/src/personal-access-tokens/web/pat-tokens-screen.tsx +1 -1
  30. package/src/tenant/__tests__/tenant.integration.test.ts +32 -0
  31. package/src/tenant/handlers/list.query.ts +1 -0
  32. package/src/tier-engine/__tests__/drift.test.ts +1 -1
  33. package/src/tier-engine/__tests__/tier-engine-screens.boot.test.ts +82 -0
  34. package/src/tier-engine/changes.json +9 -1
  35. package/src/tier-engine/constants.ts +5 -5
  36. package/src/tier-engine/feature.ts +36 -10
  37. package/src/tier-engine/i18n.ts +19 -22
  38. package/src/tier-engine/web/client-plugin.tsx +5 -11
  39. package/src/tier-engine/web/index.ts +0 -1
  40. package/src/user-data-rights/__tests__/inspector-screens.boot.test.ts +99 -0
  41. package/src/user-data-rights/changes.json +7 -0
  42. package/src/user-data-rights/constants.ts +20 -6
  43. package/src/user-data-rights/feature.ts +113 -14
  44. package/src/user-data-rights/web/__tests__/privacy-center-screen.test.tsx +29 -71
  45. package/src/user-data-rights/web/client-plugin.tsx +19 -21
  46. package/src/user-data-rights/web/i18n.ts +6 -11
  47. package/src/user-data-rights/web/index.ts +1 -1
  48. package/src/user-data-rights/web/privacy-center-screen.tsx +32 -245
  49. package/src/user-profile/__tests__/profile-screen.boot.test.ts +97 -0
  50. package/src/user-profile/__tests__/profile-screen.test.tsx +140 -104
  51. package/src/user-profile/changes.json +7 -0
  52. package/src/user-profile/constants.ts +15 -7
  53. package/src/user-profile/feature.ts +96 -9
  54. package/src/user-profile/i18n.ts +8 -12
  55. package/src/user-profile/web/client-plugin.ts +22 -5
  56. package/src/user-profile/web/index.ts +1 -1
  57. package/src/user-profile/web/profile-screen.tsx +122 -265
  58. package/src/admin-shell/web/overview-layout.tsx +0 -69
  59. package/src/admin-shell/web/overview-query.ts +0 -15
  60. package/src/admin-shell/web/platform-overview-screen.tsx +0 -91
  61. package/src/admin-shell/web/tenant-overview-screen.tsx +0 -91
  62. package/src/audit/__tests__/audit-log-screen.test.tsx +0 -302
  63. package/src/audit/web/audit-log-detail-screen.tsx +0 -173
  64. package/src/audit/web/audit-log-screen.tsx +0 -290
  65. package/src/jobs/web/__tests__/job-runs-screen.test.tsx +0 -225
  66. package/src/jobs/web/job-run-detail-screen.tsx +0 -181
  67. package/src/jobs/web/job-runs-screen.tsx +0 -290
  68. package/src/tier-engine/web/tier-admin-screen.tsx +0 -168
@@ -1,8 +1,10 @@
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).
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 { formatDate, PrivacyCenterScreen } from "../privacy-center-screen";
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 renderCenter(
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
- const view = render(<PrivacyCenterScreen showDeletion={opts.showDeletion ?? true} />, {
115
- wrapper,
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("privacy-center-screen") === null) {
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("PrivacyCenterScreen", () => {
155
- test("aktiver User: Export/Einschränken/Löschen-Sektionen, Texte übersetzt (keine rohen Keys)", async () => {
156
- const { view } = renderCenter({ me: activeMe });
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 } = renderCenter({
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 } = renderCenter({
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 } = renderCenter({
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 } = renderCenter({ me: activeMe });
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 } = renderCenter({
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 } = renderCenter({
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 } = renderCenter({
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-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.
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 { PRIVACY_CENTER_SCREEN_ID, USER_DATA_RIGHTS_FEATURE } from "../constants";
20
+ import { EXPORT_SECTION_EXTENSION_NAME, USER_DATA_RIGHTS_FEATURE } from "../constants";
12
21
  import { defaultTranslations } from "./i18n";
13
- import { PrivacyCenterScreen } from "./privacy-center-screen";
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
- components: {
44
- [PRIVACY_CENTER_SCREEN_ID]: privacyCenter,
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, PrivacyCenterScreen } from "./privacy-center-screen";
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
- // PrivacyCenterScreen — eingeloggte DSGVO-Self-Service-Seite: Datenexport
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.
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
- // `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.
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 { FormScreenShell, postWithDownload } from "@cosmicdrift/kumiko-renderer-web";
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
- function ExportSection(): ReactNode {
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 { Section, Button, Banner } = usePrimitives();
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
- <Section
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
- <StatusBanner status={status} />
194
- </Section>
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
- variant="danger"
233
- onClick={() => setDialogOpen(true)}
234
- testId="privacy-restriction-restrict"
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
- {t("userDataRights.privacyCenter.deletion.delete")}
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
- </Section>
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
  }