@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
|
@@ -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
|
-
//
|
|
256
|
-
//
|
|
257
|
-
//
|
|
258
|
-
//
|
|
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: "
|
|
264
|
-
|
|
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
|
|
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
|
|
package/src/tier-engine/i18n.ts
CHANGED
|
@@ -1,31 +1,28 @@
|
|
|
1
1
|
// @runtime client
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
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
|
|
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-
|
|
3
|
-
//
|
|
4
|
-
//
|
|
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 {
|
|
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
|
-
/**
|
|
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
|
}
|
|
@@ -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
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
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
|
-
//
|
|
10
|
-
//
|
|
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 {
|
|
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
|
-
//
|
|
287
|
-
//
|
|
288
|
-
// its logged-in area
|
|
289
|
-
//
|
|
290
|
-
//
|
|
291
|
-
//
|
|
292
|
-
//
|
|
293
|
-
//
|
|
294
|
-
//
|
|
295
|
-
//
|
|
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: "
|
|
299
|
-
|
|
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
|