@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
@@ -0,0 +1,82 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { validateBoot } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { rolesOf } from "@cosmicdrift/kumiko-framework/testing";
4
+ import { createConfigFeature } from "../../config";
5
+ import { createTenantFeature } from "../../tenant/feature";
6
+ import type { TierMap } from "../compose-app";
7
+ import { TIER_ADMIN_SCREEN_ID } from "../constants";
8
+ import { createTierEngineFeature } from "../feature";
9
+
10
+ const SYSTEM_ADMIN_ROLES = ["SystemAdmin"] as const;
11
+
12
+ type TestCaps = { readonly maxItems: number };
13
+ const TEST_TIER_MAP: TierMap<TestCaps> = {
14
+ free: { features: [], caps: { maxItems: 1 } },
15
+ pro: { features: ["feat-pro"], caps: { maxItems: 5 } },
16
+ };
17
+
18
+ describe("tier-engine tier-admin screen + handler access alignment", () => {
19
+ test("boot-validates with the tier-admin actionForm registered (tierMap configured)", () => {
20
+ const features = [
21
+ createConfigFeature(),
22
+ createTenantFeature(),
23
+ createTierEngineFeature({ tierMap: TEST_TIER_MAP }),
24
+ ];
25
+ expect(() => validateBoot(features)).not.toThrow();
26
+ });
27
+
28
+ test("boot-validates in storage-only mode with tier-admin screen showing the no-tiers message (no tierMap)", () => {
29
+ const features = [createConfigFeature(), createTenantFeature(), createTierEngineFeature()];
30
+ expect(() => validateBoot(features)).not.toThrow();
31
+ const tierEngine = createTierEngineFeature();
32
+ const screen = tierEngine.screens[TIER_ADMIN_SCREEN_ID];
33
+ expect(screen).toBeDefined();
34
+ if (screen && "layout" in screen) {
35
+ expect(screen.layout.sections[0]).toMatchObject({ description: "tier-admin.error.noTiers" });
36
+ }
37
+ if (screen && "fields" in screen) {
38
+ expect(screen.fields["tier"]).toEqual({ type: "select", options: [], required: true });
39
+ }
40
+ });
41
+
42
+ test("tier-admin is a SystemAdmin-gated actionForm dispatching set-tenant-tier", () => {
43
+ const tierEngine = createTierEngineFeature({ tierMap: TEST_TIER_MAP });
44
+ const screen = tierEngine.screens[TIER_ADMIN_SCREEN_ID];
45
+ expect(screen?.type).toBe("actionForm");
46
+ if (screen && "handler" in screen) {
47
+ expect(screen.handler).toBe("tier-engine:write:set-tenant-tier");
48
+ }
49
+ if (screen && "access" in screen && screen.access && "roles" in screen.access) {
50
+ expect(screen.access.roles).toEqual(SYSTEM_ADMIN_ROLES);
51
+ }
52
+ if (screen && "layout" in screen) {
53
+ expect(screen.layout.sections[0]).toMatchObject({ description: "tier-admin.explainer" });
54
+ }
55
+ });
56
+
57
+ test("tier-admin's tier field options come from the tierMap", () => {
58
+ const tierEngine = createTierEngineFeature({ tierMap: TEST_TIER_MAP });
59
+ const screen = tierEngine.screens[TIER_ADMIN_SCREEN_ID];
60
+ if (screen && "fields" in screen) {
61
+ expect(screen.fields["tier"]).toEqual({
62
+ type: "select",
63
+ options: ["free", "pro"],
64
+ required: true,
65
+ });
66
+ expect(screen.fields["tenantId"]).toEqual({
67
+ type: "reference",
68
+ entity: "tenant:tenant",
69
+ labelField: "name",
70
+ required: true,
71
+ });
72
+ }
73
+ });
74
+
75
+ test("set-tenant-tier write + get-tenant-tier/tier-options reads stay SystemAdmin-only", () => {
76
+ const tierEngine = createTierEngineFeature({ tierMap: TEST_TIER_MAP });
77
+ const roles = [...SYSTEM_ADMIN_ROLES];
78
+ expect(rolesOf(tierEngine.writeHandlers["set-tenant-tier"]?.access)).toEqual(roles);
79
+ expect(rolesOf(tierEngine.queryHandlers["get-tenant-tier"]?.access)).toEqual(roles);
80
+ expect(rolesOf(tierEngine.queryHandlers["tier-options"]?.access)).toEqual(roles);
81
+ });
82
+ });
@@ -1 +1,9 @@
1
- []
1
+ [
2
+ {
3
+ "version": "0.241.0",
4
+ "type": "breaking",
5
+ "title": "TierAdminScreen removed; tier-admin is now a declarative actionForm (fw#2312).",
6
+ "detail": "`tier-engine:screen:tier-admin` is now a declarative `actionForm` (a `reference` field for the tenant, a `select` field for the tier) instead of a custom React component: it dispatches `set-tenant-tier` directly, so the renderer's generic form handles tenant lookup, validation and submit. Two behaviors are intentionally not carried over: the current tier of the selected tenant is no longer shown before submit (declarative forms have no dependent-query support), and the success state no longer names the newly assigned tier (a generic actionForm success doesn't surface write-response data). Both are visible again after a page reload / re-navigation — the assignment itself is unchanged.",
7
+ "migration": "Breaking only if you imported `TierAdminScreen` directly (no shipped consumer app did — all reference the screen by its qualified id `tier-engine:screen:tier-admin`). Remove `import { TierAdminScreen } from \"@cosmicdrift/kumiko-bundled-features/tier-engine/web\"` and any `components: { TierAdminScreen: ... }` entry in your renderer setup — the bundled feature registers the `tier-admin` screen itself now, nothing left for an app to wire."
8
+ }
9
+ ]
@@ -1,9 +1,9 @@
1
1
  // @runtime client
2
- // Pure string-literal QNs/ids — von Server (feature.ts) UND Client
3
- // (web/tier-admin-screen, client-plugin) importiert. Als `client` markiert,
4
- // die im kumiko-Isolation-Modell permissivste Kategorie (runtime darf
5
- // client importieren, client nur client) — sonst wirft der Runtime-
6
- // Isolation-Guard auf den Client-Imports. Muster wie text-content/constants.
2
+ // Pure string-literal QNs/ids — imported by both server (feature.ts) and
3
+ // client (web/client-plugin.tsx). Marked `client`, the most permissive
4
+ // category in the kumiko isolation model (runtime may import client, client
5
+ // may only import client) — otherwise the runtime isolation guard throws on
6
+ // the client imports. Same pattern as text-content/constants.
7
7
  // Feature name
8
8
  export const TIER_ENGINE_FEATURE = "tier-engine" as const;
9
9
 
@@ -69,7 +69,7 @@ import { z } from "zod";
69
69
  import { tenantTable } from "../tenant";
70
70
  import { tierAssignmentAggregateId } from "./aggregate-id";
71
71
  import type { TierMap } from "./compose-app";
72
- import { TIER_ADMIN_SCREEN_ID, TIER_ENGINE_FEATURE } from "./constants";
72
+ import { TIER_ADMIN_SCREEN_ID, TIER_ENGINE_FEATURE, TierEngineHandlers } from "./constants";
73
73
  import { tierAssignmentEntity } from "./entity";
74
74
  import { getActiveTierQuery } from "./handlers/active-tier.query";
75
75
  import { getTenantTierQuery } from "./handlers/get-tenant-tier.query";
@@ -187,7 +187,7 @@ export function createTierEngineFeature<
187
187
  >(opts: CreateTierEngineOptions<TCaps> = {}): FeatureDefinition {
188
188
  return defineFeature(TIER_ENGINE_FEATURE, (r) => {
189
189
  r.describe(
190
- 'Stores a `tier-assignment` entity per tenant (which pricing tier is active) and, when configured with a `TierMap`, registers itself as the `tenantTierResolver` extension so the dispatcher automatically gates `r.toggleable()` features per tenant based on their assigned tier. Call `createTierEngineFeature({ defaultTier, tierMap })` to get full tier composition \u2014 including an `inTransaction` entity hook that atomically writes the default tier when a new tenant is created \u2014 or use `createTierEngineFeature()` without options for storage-only mode when you manage tier assignment yourself via `composeApp`. A SystemAdmin-only `set-tenant-tier` write plus `get-tenant-tier`/`tier-options` reads let an operator assign a tier to ANY tenant manually \u2014 without a billing purchase \u2014 stamping `source: "manual"` so a future Stripe\u2192tier sync won\'t overwrite the grant. Apps surface this via the `tier-admin` screen.',
190
+ 'Stores a `tier-assignment` entity per tenant (which pricing tier is active) and, when configured with a `TierMap`, registers itself as the `tenantTierResolver` extension so the dispatcher automatically gates `r.toggleable()` features per tenant based on their assigned tier. Call `createTierEngineFeature({ defaultTier, tierMap })` to get full tier composition \u2014 including an `inTransaction` entity hook that atomically writes the default tier when a new tenant is created \u2014 or use `createTierEngineFeature()` without options for storage-only mode when you manage tier assignment yourself via `composeApp`. A SystemAdmin-only `set-tenant-tier` write plus `get-tenant-tier`/`tier-options` reads let an operator assign a tier to ANY tenant manually \u2014 without a billing purchase \u2014 stamping `source: "manual"` so a future Stripe\u2192tier sync won\'t overwrite the grant. Apps surface this via the `tier-admin` screen, which is always registered — in storage-only mode (no `tierMap`) it shows an honest "no tiers configured" message instead of a tier dropdown.',
191
191
  );
192
192
  r.uiHints({
193
193
  displayLabel: "Tier Engine \u00b7 Plan Composition",
@@ -252,18 +252,44 @@ export function createTierEngineFeature<
252
252
  }),
253
253
  );
254
254
 
255
- // Custom React-Screen für den manuellen Grant. SystemAdmin-only fest
256
- // verdrahtet (Platform-Admin-Hoheit, nicht App-konfigurierbar). App
257
- // platziert ihn nur via r.nav("tier-engine:screen:tier-admin"); die
258
- // Komponente liefert tierEngineClient() aus dem ./web-subpath.
255
+ // Declarative form for the manual grant. SystemAdmin-only, hard-wired
256
+ // (platform-admin authority, not app-configurable). Apps place it only
257
+ // via r.nav("tier-engine:screen:tier-admin"); tier options are built
258
+ // statically from the tierMap closure. Always registered — even in
259
+ // storage-only mode (no tierMap) — so the screen shows an honest
260
+ // "no tiers configured" message instead of vanishing (samples like
261
+ // use-all-bundled deliberately mount storage-only to screenshot that
262
+ // empty state).
259
263
  r.translations({ keys: TIER_ENGINE_I18N });
260
- // kumiko-lint-ignore app-feature-structure Phase-3 conversion tracked in #2312
261
264
  r.screen({
262
265
  id: TIER_ADMIN_SCREEN_ID,
263
- type: "custom",
264
- renderer: { react: { __component: "TierAdminScreen" } },
266
+ type: "actionForm",
267
+ handler: TierEngineHandlers.setTenantTier,
268
+ fields: {
269
+ tenantId: {
270
+ type: "reference",
271
+ entity: "tenant:tenant",
272
+ labelField: "name",
273
+ required: true,
274
+ },
275
+ tier: {
276
+ type: "select",
277
+ options: opts.tierMap ? Object.keys(opts.tierMap) : [],
278
+ required: true,
279
+ },
280
+ },
281
+ layout: {
282
+ sections: [
283
+ {
284
+ fields: ["tenantId", "tier"],
285
+ description: opts.tierMap ? "tier-admin.explainer" : "tier-admin.error.noTiers",
286
+ },
287
+ ],
288
+ },
289
+ submitLabel: "tier-admin.submit",
290
+ cancelTarget: false,
265
291
  description:
266
- "Operator form that picks a tenant, shows its current tier and assigns a new one as a manual grant without a billing purchase.",
292
+ "Operator form that assigns a tier to any tenant as a manual grant without a billing purchase.",
267
293
  access: { roles: ["SystemAdmin"] },
268
294
  });
269
295
 
@@ -1,31 +1,28 @@
1
1
  // @runtime client
2
- // Default-Bundles für den TierAdminScreen. Werden vom tierEngineClient()
3
- // als Fallback-Bundle in den LocaleProvider gehängt — Apps überschreiben
4
- // einzelne Keys via `tierEngineClient({ translations })`.
2
+ // Server + client i18n for the tier-admin actionForm. Single source of
3
+ // truth: r.translations({ keys: TIER_ENGINE_I18N }) registers these
4
+ // server-side, defaultTranslations derives the client-side locale bundle
5
+ // from the same map so the two can't drift.
5
6
 
6
- import type { TranslationsByLocale } from "@cosmicdrift/kumiko-renderer";
7
+ import {
8
+ type TranslationsByLocale,
9
+ translationsByLocaleFromKeys,
10
+ } from "@cosmicdrift/kumiko-renderer";
7
11
 
8
12
  type LocalizedString = { readonly en: string };
9
13
 
10
14
  export const TIER_ENGINE_I18N: Readonly<Record<string, LocalizedString>> = {
11
15
  "screen:tier-admin.title": { en: "Assign tier manually" },
12
- };
13
-
14
- export const defaultTranslations: TranslationsByLocale = {
15
- en: {
16
- "screen:tier-admin.title": "Assign tier manually",
17
- "tier-admin.title": "Assign tier manually",
18
- "tier-admin.explainer":
19
- "Grant a tenant a tier without a purchase. The grant is marked as “manual” and a later billing sync won't overwrite it.",
20
- "tier-admin.tenant.label": "Tenant",
21
- "tier-admin.current.label": "Current tier",
22
- "tier-admin.current.none": "— none yet —",
23
- "tier-admin.tier.label": "New tier",
24
- "tier-admin.submit": "Assign tier",
25
- "tier-admin.success": "Assigned tier “{tier}”.",
26
- "tier-admin.error.generic": "Could not assign the tier.",
27
- "tier-admin.error.load": "Failed to load tenants.",
28
- "tier-admin.error.noTiers":
29
- "This app has no TierMap configured — there are no assignable tiers.",
16
+ "tier-admin.explainer": {
17
+ en: "Grant a tenant a tier without a purchase. The grant is marked as “manual” and a later billing sync won't overwrite it.",
30
18
  },
19
+ "tier-admin.error.noTiers": {
20
+ en: "This app has no TierMap configured — there are no assignable tiers.",
21
+ },
22
+ "tier-engine:entity:__action-form__:field:tenantId": { en: "Tenant" },
23
+ "tier-engine:entity:__action-form__:field:tier": { en: "New tier" },
24
+ "tier-admin.submit": { en: "Assign tier" },
31
25
  };
26
+
27
+ export const defaultTranslations: TranslationsByLocale =
28
+ translationsByLocaleFromKeys(TIER_ENGINE_I18N);
@@ -1,18 +1,15 @@
1
1
  // @runtime client
2
- // Client-Feature-Factory für tier-engine. Liefert den TierAdminScreen
3
- // (gemappt auf die Screen-id "tier-admin") + Default-Translations. Apps
4
- // hängen es in createKumikoApp({ clientFeatures: [tierEngineClient()] }) ein;
5
- // der Screen selbst wird server-seitig vom Feature als custom-Screen
6
- // registriert (r.screen), die App platziert ihn nur via r.nav.
2
+ // Client-feature factory for tier-engine. Supplies the default translations
3
+ // for the declarative tier-admin actionForm (registered server-side via
4
+ // r.screen in the feature); the app places the screen only via r.nav.
7
5
 
8
6
  import { mergeTranslations, type TranslationsByLocale } from "@cosmicdrift/kumiko-renderer";
9
7
  import type { ClientFeatureDefinition } from "@cosmicdrift/kumiko-renderer-web";
10
- import { TIER_ADMIN_SCREEN_ID, TIER_ENGINE_FEATURE } from "../constants";
8
+ import { TIER_ENGINE_FEATURE } from "../constants";
11
9
  import { defaultTranslations } from "../i18n";
12
- import { TierAdminScreen } from "./tier-admin-screen";
13
10
 
14
11
  export type TierEngineClientOptions = {
15
- /** Key-weise Overrides über die Default-Bundles (de/en). */
12
+ /** Per-key overrides over the default bundles (de/en). */
16
13
  readonly translations?: TranslationsByLocale;
17
14
  };
18
15
 
@@ -20,8 +17,5 @@ export function tierEngineClient(options?: TierEngineClientOptions): ClientFeatu
20
17
  return {
21
18
  name: TIER_ENGINE_FEATURE,
22
19
  translations: mergeTranslations(defaultTranslations, options?.translations ?? {}),
23
- components: {
24
- [TIER_ADMIN_SCREEN_ID]: TierAdminScreen,
25
- },
26
20
  };
27
21
  }
@@ -5,4 +5,3 @@
5
5
  // `@cosmicdrift/kumiko-bundled-features/tier-engine` und hat keine React-Deps.
6
6
 
7
7
  export { type TierEngineClientOptions, tierEngineClient } from "./client-plugin";
8
- export { TierAdminScreen } from "./tier-admin-screen";
@@ -77,3 +77,102 @@ describe("user-data-rights read-only inspector screens", () => {
77
77
  );
78
78
  });
79
79
  });
80
+
81
+ // fw#2312 — privacy-center converted from a custom screen to a declarative
82
+ // projectionDetail (Restriction/Deletion fields+actions, Export stays a
83
+ // custom extension section). `validateBoot` above already proves the
84
+ // screen clears every boot-validator rule for projectionDetail extension
85
+ // sections (entityName set, contributesToFormSubmit not true) — these
86
+ // tests pin the GDPR-relevant shape the type system can't check itself:
87
+ // which handler each action dispatches, the confirm-dialog description,
88
+ // and the visibility conditions carried over from the pre-#2312 custom
89
+ // screen (restrict.tsx/deletion.tsx RestrictionSection/DeletionSection).
90
+ describe("privacy-center screen (fw#2312 projectionDetail conversion)", () => {
91
+ test("is a projectionDetail bound to the user's own `me` query", () => {
92
+ const f = createUserDataRightsFeature();
93
+ const screen = f.screens["privacy-center"];
94
+ expect(screen?.type).toBe("projectionDetail");
95
+ if (screen?.type === "projectionDetail") {
96
+ expect(screen.query).toBe("user:query:user:me");
97
+ expect(screen.access).toEqual({ openToAll: true });
98
+ }
99
+ });
100
+
101
+ test("Export section is a self-persisting extension scoped to export-job", () => {
102
+ const f = createUserDataRightsFeature();
103
+ const screen = f.screens["privacy-center"];
104
+ if (screen?.type !== "projectionDetail") throw new Error("expected projectionDetail");
105
+ const exportSection = screen.layout.sections.find((s) => s.kind === "extension");
106
+ expect(exportSection?.entityName).toBe("export-job");
107
+ expect(exportSection?.contributesToFormSubmit).not.toBe(true);
108
+ });
109
+
110
+ test("`status` renders exactly once across the screen, with a translated enum label", () => {
111
+ const f = createUserDataRightsFeature();
112
+ const screen = f.screens["privacy-center"];
113
+ if (screen?.type !== "projectionDetail") throw new Error("expected projectionDetail");
114
+ const statusFields = screen.layout.sections
115
+ .flatMap((s) => ("fields" in s ? s.fields : []))
116
+ .filter((field) =>
117
+ typeof field === "string" ? field === "status" : field.field === "status",
118
+ );
119
+ expect(statusFields).toHaveLength(1);
120
+ const statusField = statusFields[0];
121
+ expect(typeof statusField === "object" ? statusField.renderer : undefined).toEqual({
122
+ format: "enumOption",
123
+ keyPrefix: "userDataRights.privacyCenter.field.status.option.",
124
+ });
125
+ });
126
+
127
+ test("gracePeriodEnd is hidden unless a deletion is actually pending", () => {
128
+ const f = createUserDataRightsFeature();
129
+ const screen = f.screens["privacy-center"];
130
+ if (screen?.type !== "projectionDetail") throw new Error("expected projectionDetail");
131
+ const graceField = screen.layout.sections
132
+ .flatMap((s) => ("fields" in s ? s.fields : []))
133
+ .find((field) => typeof field === "object" && field.field === "gracePeriodEnd");
134
+ expect(typeof graceField === "object" ? graceField.visible : undefined).toEqual({
135
+ field: "status",
136
+ eq: "deletionRequested",
137
+ });
138
+ });
139
+
140
+ test("Restriction/Deletion actions dispatch the right handlers with the original confirm+visibility rules", () => {
141
+ const f = createUserDataRightsFeature();
142
+ const screen = f.screens["privacy-center"];
143
+ if (screen?.type !== "projectionDetail") throw new Error("expected projectionDetail");
144
+ const byId = Object.fromEntries((screen.actions ?? []).map((a) => [a.id, a]));
145
+
146
+ expect(byId["restrict"]).toMatchObject({
147
+ handler: "user-data-rights:write:restrict-account",
148
+ visible: { field: "status", ne: "restricted" },
149
+ });
150
+ expect(byId["restrict"]?.kind === "navigate" ? undefined : byId["restrict"]?.confirm).toBe(
151
+ "userDataRights.privacyCenter.restriction.dialogDescription",
152
+ );
153
+
154
+ expect(byId["request-deletion"]).toMatchObject({
155
+ handler: "user-data-rights:write:request-deletion",
156
+ visible: { field: "status", ne: "deletionRequested" },
157
+ });
158
+ // Cancel has no confirm — the pre-#2312 custom screen executed it directly.
159
+ expect(byId["cancel-deletion"]).toMatchObject({
160
+ handler: "user-data-rights:write:cancel-deletion",
161
+ visible: { field: "status", eq: "deletionRequested" },
162
+ });
163
+ expect((byId["cancel-deletion"] as { readonly confirm?: string }).confirm).toBeUndefined();
164
+ });
165
+
166
+ test("privacyCenterShowDeletion: false drops the deletion section + its actions", () => {
167
+ const f = createUserDataRightsFeature({ privacyCenterShowDeletion: false });
168
+ const screen = f.screens["privacy-center"];
169
+ if (screen?.type !== "projectionDetail") throw new Error("expected projectionDetail");
170
+ expect(
171
+ screen.layout.sections.some((s) => s.title === "userDataRights.privacyCenter.deletion.title"),
172
+ ).toBe(false);
173
+ const actionIds = (screen.actions ?? []).map((a) => a.id);
174
+ expect(actionIds).not.toContain("request-deletion");
175
+ expect(actionIds).not.toContain("cancel-deletion");
176
+ expect(actionIds).toContain("restrict");
177
+ });
178
+ });
@@ -1,4 +1,11 @@
1
1
  [
2
+ {
3
+ "version": "0.241.0",
4
+ "type": "breaking",
5
+ "title": "privacy-center is now a declarative screen; userDataRightsClient's privacyCenter.showDeletion option is removed (fw#2312).",
6
+ "detail": "`privacy-center` is now a declarative `projectionDetail` screen instead of a custom React component: the Restriction and Deletion sections render through the generic renderer (`EditFieldsSection` + `actions: RowAction[]`), preserving the original confirmation dialogs (`RowActionWriteHandler.confirm`) and visibility rules (`visible: {field, eq/ne}`) 1:1. The Export section (Art. 20) stays a custom `EditExtensionSection` — it needs polling + a signed-URL download — registered via the new `ClientFeatureDefinition.extensionSectionComponents` under `EXPORT_SECTION_EXTENSION_NAME`. `PrivacyCenterScreen` is a dead export (the renderer selects the screen by `screen.type`, not the client component registry). The single `status` field now renders through the `enumOption` format instead of the raw enum string; `gracePeriodEnd` is hidden when no deletion is pending instead of showing an empty date. Known UI regression, accepted for this pass: the confirm-dialog title is now always the action's `label` (the renderer hardcodes this) — the original's distinct `dialogTitle` copy and its dynamic composed banner sentence (e.g. \"Your account will be deleted on {date}\") are gone, replaced by the translated `status` label plus a separately labeled `gracePeriodEnd` date.",
7
+ "migration": "A declarative screen is registered once and can no longer be toggled per-app on the client: `userDataRightsClient(options)`'s `privacyCenter: { showDeletion }` option is removed. Move the flag server-side instead — replace `createUserDataRightsFeature({})` with `createUserDataRightsFeature({ privacyCenterShowDeletion: false })` (default `true`) — it conditionally omits the Deletion section and its `request-deletion`/`cancel-deletion` actions from the screen definition. Then drop the now-unused `privacyCenter` option from the matching `userDataRightsClient({ privacyCenter: { showDeletion: false } })` call. Known affected consumer: `money-horse` (`src/app/client-features.tsx:74`)."
8
+ },
2
9
  {
3
10
  "version": "0.165.1",
4
11
  "type": "fix",
@@ -1,15 +1,29 @@
1
1
  // @runtime client
2
- // Reine String-Konstanten — client-markiert, damit der PrivacyCenterScreen
3
- // (web/) sie importieren darf, ohne das runtime-Barrel des Features (und
4
- // damit dessen Server-/DOM-freien Code) zu ziehen. Runtime-Code
5
- // (feature.ts) darf client-Dateien ohnehin importieren.
2
+ // Reine String-Konstanten — client-markiert, damit web/ (z.B. ExportSection)
3
+ // sie importieren darf, ohne das runtime-Barrel des Features (und damit
4
+ // dessen Server-/DOM-freien Code) zu ziehen. Runtime-Code (feature.ts) darf
5
+ // client-Dateien ohnehin importieren.
6
6
 
7
7
  export const USER_DATA_RIGHTS_FEATURE = "user-data-rights" as const;
8
8
 
9
- // Dormant registriert (kein r.nav im Feature); Apps platzieren ihn via
10
- // r.nav. Qualifiziert: `user-data-rights:screen:privacy-center`.
9
+ // Registered without r.nav — apps place it via their own r.nav. Qualified:
10
+ // `user-data-rights:screen:privacy-center`.
11
11
  export const PRIVACY_CENTER_SCREEN_ID = "privacy-center" as const;
12
12
 
13
+ // Extension-section component name for the Export (Art. 20) section of the
14
+ // privacy-center screen — shared between feature.ts (screen def's
15
+ // `component: { react: { __component } }`) and web/client-plugin.tsx
16
+ // (extensionSectionComponents registration), same pattern as
17
+ // compliance-profiles' COMPLIANCE_PROFILE_CATALOG_EXTENSION_NAME.
18
+ export const EXPORT_SECTION_EXTENSION_NAME = "UserDataRightsExportSection" as const;
19
+
20
+ // enumOption keyPrefix for the privacy-center screen's `status` field
21
+ // (fw#2315 pattern) — resolves the raw user-lifecycle enum value
22
+ // (active/restricted/deletionRequested/deleted) to a translated label
23
+ // instead of showing the raw string.
24
+ export const STATUS_OPTION_KEY_PREFIX =
25
+ "userDataRights.privacyCenter.field.status.option." as const;
26
+
13
27
  export const UserDataRightsQueries = {
14
28
  exportStatus: "user-data-rights:query:export-status",
15
29
  myAuditLog: "user-data-rights:query:my-audit-log",
@@ -7,7 +7,13 @@ import {
7
7
  SYSTEM_USER_ID,
8
8
  } from "@cosmicdrift/kumiko-framework/engine";
9
9
  import { validateGdprHookCompleteness, validateGdprPiiHookCoverage } from "./boot-checks";
10
- import { PRIVACY_CENTER_SCREEN_ID } from "./constants";
10
+ import {
11
+ EXPORT_SECTION_EXTENSION_NAME,
12
+ PRIVACY_CENTER_SCREEN_ID,
13
+ STATUS_OPTION_KEY_PREFIX,
14
+ USER_ME_QUERY,
15
+ UserDataRightsHandlers,
16
+ } from "./constants";
11
17
  import { cancelDeletionWrite } from "./handlers/cancel-deletion.write";
12
18
  import { createConfirmDeletionByTokenHandler } from "./handlers/confirm-deletion-by-token.write";
13
19
  import { downloadAttemptListQuery } from "./handlers/download-attempt-list.query";
@@ -132,6 +138,17 @@ export type UserDataRightsOptions = {
132
138
  * wenn der jeweilige send*Email-Opt NICHT gesetzt ist; Export-ready braucht
133
139
  * zusaetzlich appExportDownloadUrl. */
134
140
  readonly mailDefaults?: GdprMailDefaults;
141
+ /** Hides the Art. 17 deletion section + its request-deletion/cancel-deletion
142
+ * actions on the privacy-center screen — for apps that already offer
143
+ * account deletion elsewhere (e.g. a profile danger-zone), against
144
+ * duplication. Default `true` (deletion section shown).
145
+ * BREAKING (fw#2312): replaces the removed client-side
146
+ * `userDataRightsClient({ privacyCenter: { showDeletion } })` option —
147
+ * the screen is now declared once, server-side (`type: "projectionDetail"`),
148
+ * so per-app visibility can only be a boot-time option, not a client-side
149
+ * component swap. Consumers that passed `showDeletion: false` client-side
150
+ * must move it here. */
151
+ readonly privacyCenterShowDeletion?: boolean;
135
152
  };
136
153
 
137
154
  export function createUserDataRightsFeature(opts: UserDataRightsOptions = {}): FeatureDefinition {
@@ -139,6 +156,7 @@ export function createUserDataRightsFeature(opts: UserDataRightsOptions = {}): F
139
156
  // process, not every run). Lives in the factory scope so the cron closure
140
157
  // shares it across runs.
141
158
  let warnedMissingExportUrl = false;
159
+ const showDeletion = opts.privacyCenterShowDeletion ?? true;
142
160
  return defineFeature("user-data-rights", (r) => {
143
161
  r.describe(
144
162
  'Implements GDPR Art. 15 (access / `my-audit-log` query), Art. 17 (erasure / `request-deletion` + `cancel-deletion`, plus the anonymous email-verified `request-deletion-by-email` + `confirm-deletion-by-token` flow for lockout-safe self-service, + cron cleanup with grace period), Art. 18 (restriction / `restrict-account` + `lift-restriction`), and Art. 20 (portability / async `request-export` \u2192 ZIP via `file-foundation`, Magic-Link download) as first-class HTTP handlers and cron jobs. Each domain feature opts in by calling `r.useExtension(EXT_USER_DATA, "<entity>", { export, delete })` \u2014 the feature then orchestrates the export and forget pipelines across all registered hooks automatically. When `mail-foundation` and a `mail-transport-*` are mounted, it also sends the four GDPR notifications (export ready/failed, deletion requested/executed) itself with no app callback code, rendered in each recipient’s locale. Requires `user`, `data-retention`, `compliance-profiles`, and `sessions`.',
@@ -283,24 +301,105 @@ export function createUserDataRightsFeature(opts: UserDataRightsOptions = {}): F
283
301
 
284
302
  r.translations({ keys: USER_DATA_RIGHTS_I18N });
285
303
 
286
- // Dormant self-service screen (Art. 15/17/18/20): export, activity log,
287
- // restriction, deletion in one screen. No r.nav — the app places it in
288
- // its logged-in area. The React component comes from userDataRightsClient()
289
- // (web/) client-side. access is openToAll because no app role name is
290
- // portable; the per-user handlers enforce auth server-side, and the
291
- // screen is invisible without r.nav until the app actively links it in
292
- // its authed area. dormant: true so createKumikoApp's boot diagnostic
293
- // (#2025) doesn't flag apps that haven't (yet) navved the screen as
294
- // having a missing client plugin (#2034).
295
- // kumiko-lint-ignore app-feature-structure Phase-3 conversion tracked in #2312
304
+ // Self-service screen (Art. 15/17/18/20): export, restriction, deletion
305
+ // in one projectionDetail screen bound to the user's own `me` row. No
306
+ // r.nav — the app places it in its logged-in area (createKumikoApp's
307
+ // missing-client-plugin boot diagnostic (#2025/#2034) only scans
308
+ // type: "custom" screens, so a non-navved projectionDetail never
309
+ // triggers it — no `dormant` flag needed, that field doesn't exist on
310
+ // this screen type). access is openToAll because no app role name is
311
+ // portable; the per-user handlers enforce auth server-side.
312
+ //
313
+ // Restriction/Deletion are declarative fields + actions; Export stays a
314
+ // custom extension section (web/client-plugin.tsx registers the
315
+ // component under EXPORT_SECTION_EXTENSION_NAME) since it needs
316
+ // async-job polling + a signed-URL download that a declarative action
317
+ // can't express. entityName: "export-job" names the domain entity the
318
+ // extension persists against (projectionDetail has no real entity of
319
+ // its own for the extension to default to).
296
320
  r.screen({
297
321
  id: PRIVACY_CENTER_SCREEN_ID,
298
- type: "custom",
299
- renderer: { react: { __component: "PrivacyCenterScreen" } },
322
+ type: "projectionDetail",
323
+ query: USER_ME_QUERY,
324
+ singleton: true,
300
325
  access: { openToAll: true },
301
- dormant: true,
302
326
  description:
303
327
  "Logged-in GDPR self-service page where a user requests and downloads a data export (Art. 20), restricts processing of their account (Art. 18) and requests its deletion (Art. 17).",
328
+ fieldLabels: {
329
+ status: "userDataRights.privacyCenter.field.status",
330
+ gracePeriodEnd: "userDataRights.privacyCenter.field.gracePeriodEnd",
331
+ },
332
+ layout: {
333
+ sections: [
334
+ {
335
+ kind: "extension",
336
+ title: "userDataRights.privacyCenter.export.title",
337
+ component: { react: { __component: EXPORT_SECTION_EXTENSION_NAME } },
338
+ entityName: "export-job",
339
+ },
340
+ {
341
+ // `status` lives here (not in Deletion) because this is the one
342
+ // section always on the screen — Deletion is dropped entirely
343
+ // when `showDeletion` is false, and status covers restriction
344
+ // AND deletion state, so it can't hang off the optional section.
345
+ title: "userDataRights.privacyCenter.restriction.title",
346
+ description: "userDataRights.privacyCenter.restriction.explainer",
347
+ fields: [
348
+ {
349
+ field: "status",
350
+ renderer: { format: "enumOption", keyPrefix: STATUS_OPTION_KEY_PREFIX },
351
+ },
352
+ ],
353
+ },
354
+ ...(showDeletion
355
+ ? [
356
+ {
357
+ title: "userDataRights.privacyCenter.deletion.title",
358
+ description: "userDataRights.privacyCenter.deletion.explainer",
359
+ // gracePeriodEnd is only meaningful once a deletion is
360
+ // actually pending — hide it instead of showing an empty
361
+ // date when status isn't deletionRequested.
362
+ fields: [
363
+ {
364
+ field: "gracePeriodEnd",
365
+ renderer: { format: "date" as const },
366
+ visible: { field: "status", eq: "deletionRequested" },
367
+ },
368
+ ],
369
+ },
370
+ ]
371
+ : []),
372
+ ],
373
+ },
374
+ actions: [
375
+ {
376
+ id: "restrict",
377
+ label: "userDataRights.privacyCenter.restriction.restrict",
378
+ handler: UserDataRightsHandlers.restrictAccount,
379
+ confirm: "userDataRights.privacyCenter.restriction.dialogDescription",
380
+ visible: { field: "status", ne: "restricted" },
381
+ style: "danger",
382
+ },
383
+ ...(showDeletion
384
+ ? [
385
+ {
386
+ id: "request-deletion",
387
+ label: "userDataRights.privacyCenter.deletion.delete",
388
+ handler: UserDataRightsHandlers.requestDeletion,
389
+ confirm: "userDataRights.privacyCenter.deletion.dialogDescription",
390
+ visible: { field: "status", ne: "deletionRequested" },
391
+ style: "danger" as const,
392
+ },
393
+ {
394
+ id: "cancel-deletion",
395
+ label: "userDataRights.privacyCenter.deletion.cancel",
396
+ handler: UserDataRightsHandlers.cancelDeletion,
397
+ visible: { field: "status", eq: "deletionRequested" },
398
+ style: "secondary" as const,
399
+ },
400
+ ]
401
+ : []),
402
+ ],
304
403
  });
305
404
 
306
405
  // Magic-link path (anonymous): the email link carries the token as a