@cosmicdrift/kumiko-bundled-features 0.71.0 → 0.73.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 +10 -7
- package/src/auth-email-password/feature.ts +16 -0
- package/src/billing-foundation/feature.ts +5 -0
- package/src/custom-fields/web/__tests__/custom-fields-form-section.test.tsx +30 -32
- package/src/delivery/feature.ts +5 -0
- package/src/feature-toggles/feature.ts +5 -0
- package/src/mail-foundation/__tests__/mail-foundation.integration.test.ts +33 -0
- package/src/rate-limiting/__tests__/rate-limiting.integration.test.ts +11 -0
- package/src/sessions/feature.ts +5 -0
- package/src/tenant/__tests__/tenant.integration.test.ts +65 -0
- package/src/tenant/feature.ts +27 -0
- package/src/tenant/screens.ts +46 -0
- package/src/user/__tests__/admin-screens.boot.test.ts +73 -0
- package/src/user/feature.ts +13 -0
- package/src/user/screens.ts +57 -0
- package/src/user-data-rights/web/__tests__/public-deletion-gate.test.tsx +96 -0
- package/src/user-data-rights/web/client-plugin.tsx +9 -1
- package/src/user-data-rights/web/index.ts +1 -0
- package/src/user-data-rights/web/public-deletion-gate.tsx +42 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.73.0",
|
|
4
4
|
"description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -84,11 +84,11 @@
|
|
|
84
84
|
"./step-dispatcher": "./src/step-dispatcher/index.ts"
|
|
85
85
|
},
|
|
86
86
|
"dependencies": {
|
|
87
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
88
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
89
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
90
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
91
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
87
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.73.0",
|
|
88
|
+
"@cosmicdrift/kumiko-framework": "0.73.0",
|
|
89
|
+
"@cosmicdrift/kumiko-headless": "0.73.0",
|
|
90
|
+
"@cosmicdrift/kumiko-renderer": "0.73.0",
|
|
91
|
+
"@cosmicdrift/kumiko-renderer-web": "0.73.0",
|
|
92
92
|
"@mollie/api-client": "^4.5.0",
|
|
93
93
|
"@node-rs/argon2": "^2.0.2",
|
|
94
94
|
"@types/nodemailer": "^8.0.0",
|
|
@@ -108,5 +108,8 @@
|
|
|
108
108
|
"src",
|
|
109
109
|
"README.md",
|
|
110
110
|
"LICENSE"
|
|
111
|
-
]
|
|
111
|
+
],
|
|
112
|
+
"devDependencies": {
|
|
113
|
+
"@testing-library/user-event": "^14.6.1"
|
|
114
|
+
}
|
|
112
115
|
}
|
|
@@ -128,6 +128,22 @@ export function createAuthEmailPasswordFeature(
|
|
|
128
128
|
r.describe(
|
|
129
129
|
"Provides email+password authentication: the always-on handlers are `login`, `changePassword`, and `logout`; optional flows \u2014 password reset, email verification, magic-link self-signup, and tenant invite \u2014 are registered only when you pass their respective option objects (`passwordReset`, `emailVerification`, `signup`, `invite`) to `createAuthEmailPasswordFeature(opts)`. Each opt-in flow uses HMAC-signed or opaque-random tokens delivered via callback (e.g. `sendResetEmail`) so the feature stays transport-agnostic. Requires the `user` and `tenant` features, and declares `JWT_SECRET` (\u2265 32 chars) in `authEmailPasswordEnvSchema` so a missing secret surfaces at boot validation rather than on the first login attempt.",
|
|
130
130
|
);
|
|
131
|
+
r.uiHints({
|
|
132
|
+
displayLabel: "Auth \u00b7 Email + Password",
|
|
133
|
+
category: "identity",
|
|
134
|
+
recommended: true,
|
|
135
|
+
configurableOptions: [
|
|
136
|
+
{ key: "passwordReset", label: "Password-Reset-Flow", type: "boolean", default: true },
|
|
137
|
+
{
|
|
138
|
+
key: "emailVerification",
|
|
139
|
+
label: "Email-Verification-Flow",
|
|
140
|
+
type: "boolean",
|
|
141
|
+
default: true,
|
|
142
|
+
},
|
|
143
|
+
{ key: "signup", label: "Self-Signup-Flow", type: "boolean", default: false },
|
|
144
|
+
{ key: "invite", label: "Tenant-Invite-Flow", type: "boolean", default: false },
|
|
145
|
+
],
|
|
146
|
+
});
|
|
131
147
|
r.requires("user");
|
|
132
148
|
r.requires("tenant");
|
|
133
149
|
r.envSchema(authEmailPasswordEnvSchema);
|
|
@@ -74,6 +74,11 @@ export const billingFoundationFeature = defineFeature(BILLING_FOUNDATION_FEATURE
|
|
|
74
74
|
r.describe(
|
|
75
75
|
"Plugin host for subscription billing \u2014 manages the `read_subscriptions` projection table and exposes 5 domain events (subscription created/updated/canceled, invoice paid/failed) appended by the foundation's own `billing-foundation:write:process-event` write-handler after provider plugins verify and normalize each webhook. Also ships `billing-foundation:write:create-checkout-session` and `billing-foundation:write:create-portal-session` write-handlers, a `billing-foundation:query:subscription:list` query handler, and a `createSubscriptionWebhookHandler` factory for the `/api/subscription/webhook/:providerName` route. Low-level building block \u2014 use `subscription-stripe` or `subscription-mollie` unless you are writing a new payment provider.",
|
|
76
76
|
);
|
|
77
|
+
r.uiHints({
|
|
78
|
+
displayLabel: "Billing \u00b7 Foundation",
|
|
79
|
+
category: "billing",
|
|
80
|
+
recommended: false,
|
|
81
|
+
});
|
|
77
82
|
// 5 fine-grained domain-events. Alle 5 nutzen denselben payload-
|
|
78
83
|
// shape (= subscription-state-snapshot); der event-type taggt was
|
|
79
84
|
// passiert ist. Future-consumer (billing-history, accounting)
|
|
@@ -6,7 +6,8 @@ import {
|
|
|
6
6
|
PrimitivesProvider,
|
|
7
7
|
} from "@cosmicdrift/kumiko-renderer";
|
|
8
8
|
import { defaultPrimitives } from "@cosmicdrift/kumiko-renderer-web";
|
|
9
|
-
import {
|
|
9
|
+
import { render, screen, waitFor } from "@testing-library/react";
|
|
10
|
+
import userEvent from "@testing-library/user-event";
|
|
10
11
|
import type { ReactNode } from "react";
|
|
11
12
|
import { CustomFieldsFormSection } from "../custom-fields-form-section";
|
|
12
13
|
import { defaultTranslations } from "../i18n";
|
|
@@ -51,6 +52,7 @@ function Wrapper({ children }: { readonly children: ReactNode }): ReactNode {
|
|
|
51
52
|
|
|
52
53
|
describe("CustomFieldsFormSection", () => {
|
|
53
54
|
test("renders an input per matching fieldDefinition and dispatches set-custom-field on save", async () => {
|
|
55
|
+
const user = userEvent.setup();
|
|
54
56
|
mockedQueryRows = [
|
|
55
57
|
{
|
|
56
58
|
id: "f1",
|
|
@@ -94,10 +96,10 @@ describe("CustomFieldsFormSection", () => {
|
|
|
94
96
|
expect(document.getElementById("custom-field-rootCause")).toBeNull();
|
|
95
97
|
|
|
96
98
|
// Type in vendor; tier left empty (should be skipped on save).
|
|
97
|
-
|
|
99
|
+
await user.type(vendorInput, "Hetzner");
|
|
98
100
|
|
|
99
101
|
const saveBtn = screen.getByTestId("custom-fields-form-save");
|
|
100
|
-
|
|
102
|
+
await user.click(saveBtn);
|
|
101
103
|
// waitFor statt fester Promise.resolve()-Ticks — robust gegen zusätzliche
|
|
102
104
|
// Microtasks im async handleSave-Loop (z.B. ein neuer dispatch-Wrapper).
|
|
103
105
|
await waitFor(() => expect(dispatchSpy).toHaveBeenCalledTimes(1));
|
|
@@ -111,6 +113,7 @@ describe("CustomFieldsFormSection", () => {
|
|
|
111
113
|
});
|
|
112
114
|
|
|
113
115
|
test("pre-fills inputs from initialValues (Edit zeigt den Bestand, nicht write-only)", async () => {
|
|
116
|
+
const user = userEvent.setup();
|
|
114
117
|
mockedQueryRows = [
|
|
115
118
|
{
|
|
116
119
|
id: "f1",
|
|
@@ -153,12 +156,11 @@ describe("CustomFieldsFormSection", () => {
|
|
|
153
156
|
expect(saveBtn.disabled).toBe(true);
|
|
154
157
|
|
|
155
158
|
// Nur das geänderte Feld wird geschrieben, nicht der unveränderte Bestand.
|
|
156
|
-
|
|
159
|
+
await user.clear(vendorInput);
|
|
160
|
+
await user.type(vendorInput, "Netcup");
|
|
157
161
|
expect(saveBtn.disabled).toBe(false);
|
|
158
|
-
|
|
159
|
-
await
|
|
160
|
-
await Promise.resolve();
|
|
161
|
-
expect(dispatchSpy).toHaveBeenCalledTimes(1);
|
|
162
|
+
await user.click(saveBtn);
|
|
163
|
+
await waitFor(() => expect(dispatchSpy).toHaveBeenCalledTimes(1));
|
|
162
164
|
expect(dispatchSpy).toHaveBeenCalledWith("custom-fields:write:set-custom-field", {
|
|
163
165
|
entityName: "component",
|
|
164
166
|
entityId: "row-42",
|
|
@@ -237,6 +239,7 @@ describe("CustomFieldsFormSection", () => {
|
|
|
237
239
|
|
|
238
240
|
describe("CustomFieldsFormSection — clear-Pfad", () => {
|
|
239
241
|
test("Leeren eines gespeicherten Werts dispatched clear-custom-field (nicht skip)", async () => {
|
|
242
|
+
const user = userEvent.setup();
|
|
240
243
|
mockedQueryRows = [
|
|
241
244
|
{
|
|
242
245
|
id: "f1",
|
|
@@ -262,12 +265,9 @@ describe("CustomFieldsFormSection — clear-Pfad", () => {
|
|
|
262
265
|
const vendorInput = document.getElementById("custom-field-vendor") as HTMLInputElement;
|
|
263
266
|
expect(vendorInput.value).toBe("Hetzner");
|
|
264
267
|
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
await
|
|
268
|
-
await Promise.resolve();
|
|
269
|
-
|
|
270
|
-
expect(dispatchSpy).toHaveBeenCalledTimes(1);
|
|
268
|
+
await user.clear(vendorInput);
|
|
269
|
+
await user.click(screen.getByTestId("custom-fields-form-save"));
|
|
270
|
+
await waitFor(() => expect(dispatchSpy).toHaveBeenCalledTimes(1));
|
|
271
271
|
expect(dispatchSpy).toHaveBeenCalledWith("custom-fields:write:clear-custom-field", {
|
|
272
272
|
entityName: "component",
|
|
273
273
|
entityId: "row-42",
|
|
@@ -276,6 +276,7 @@ describe("CustomFieldsFormSection — clear-Pfad", () => {
|
|
|
276
276
|
});
|
|
277
277
|
|
|
278
278
|
test("unveränderter Bestandswert wird beim Save NICHT erneut geschrieben", async () => {
|
|
279
|
+
const user = userEvent.setup();
|
|
279
280
|
mockedQueryRows = [
|
|
280
281
|
{
|
|
281
282
|
id: "f1",
|
|
@@ -300,18 +301,16 @@ describe("CustomFieldsFormSection — clear-Pfad", () => {
|
|
|
300
301
|
|
|
301
302
|
const vendorInput = document.getElementById("custom-field-vendor") as HTMLInputElement;
|
|
302
303
|
// Tippen + zurück auf den Bestandswert → nicht dirty, kein Write.
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
await
|
|
307
|
-
await Promise.resolve();
|
|
308
|
-
|
|
309
|
-
expect(dispatchSpy).not.toHaveBeenCalled();
|
|
304
|
+
await user.type(vendorInput, "2");
|
|
305
|
+
await user.type(vendorInput, "{Backspace}");
|
|
306
|
+
await user.click(screen.getByTestId("custom-fields-form-save"));
|
|
307
|
+
await waitFor(() => expect(dispatchSpy).not.toHaveBeenCalled());
|
|
310
308
|
});
|
|
311
309
|
});
|
|
312
310
|
|
|
313
311
|
describe("CustomFieldsFormSection — boolean/date-Pfade", () => {
|
|
314
312
|
test("boolean: Bestand wird als true/false-String angezeigt, Save coerced zu boolean", async () => {
|
|
313
|
+
const user = userEvent.setup();
|
|
315
314
|
mockedQueryRows = [
|
|
316
315
|
{
|
|
317
316
|
id: "f1",
|
|
@@ -340,17 +339,16 @@ describe("CustomFieldsFormSection — boolean/date-Pfade", () => {
|
|
|
340
339
|
if (checkbox === null) throw new Error("boolean checkbox not rendered");
|
|
341
340
|
expect(checkbox.getAttribute("aria-checked")).toBe("true");
|
|
342
341
|
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
await
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
});
|
|
342
|
+
await user.click(checkbox);
|
|
343
|
+
await user.click(screen.getByTestId("custom-fields-form-save"));
|
|
344
|
+
await waitFor(() =>
|
|
345
|
+
expect(dispatchSpy).toHaveBeenCalledWith("custom-fields:write:set-custom-field", {
|
|
346
|
+
entityName: "component",
|
|
347
|
+
entityId: "row-42",
|
|
348
|
+
fieldKey: "active",
|
|
349
|
+
value: false,
|
|
350
|
+
}),
|
|
351
|
+
);
|
|
354
352
|
});
|
|
355
353
|
|
|
356
354
|
test("date: Bestand erreicht das DateInput-Textfeld (locale-numerisch)", () => {
|
package/src/delivery/feature.ts
CHANGED
|
@@ -18,6 +18,11 @@ export function createDeliveryFeature(): FeatureDefinition {
|
|
|
18
18
|
r.describe(
|
|
19
19
|
"The notification dispatch core: call `ctx.notify(notificationType, { to, route, data, priority, idempotencyKey })` from any handler to fan out a notification across all registered channels (email, in-app, push). It stores per-user channel preferences in the `notification-preference` entity, logs every attempt to `read_delivery_attempts`, and enforces idempotency and rate-limiting \u2014 add `channel-email`, `channel-in-app`, or `channel-push` on top to actually send anything.",
|
|
20
20
|
);
|
|
21
|
+
r.uiHints({
|
|
22
|
+
displayLabel: "Notifications \u00b7 Dispatch Core",
|
|
23
|
+
category: "notifications",
|
|
24
|
+
recommended: true,
|
|
25
|
+
});
|
|
21
26
|
r.systemScope();
|
|
22
27
|
// Backing table: the (tenant,user,type,channel) uniqueIndex lives only on
|
|
23
28
|
// the physical table, not on the entity fields, so the generator would
|
|
@@ -40,6 +40,11 @@ export function createFeatureTogglesFeature(
|
|
|
40
40
|
r.describe(
|
|
41
41
|
'Persists per-feature enabled/disabled state in the `read_global_feature_state` table and exposes a `set` write-handler plus `list`/`registered` query-handlers so operators can flip features at runtime without redeploying. Each API instance keeps an in-memory `GlobalFeatureToggleRuntime` snapshot (initialize it via `createFeatureToggleRuntime`, pass a `() => runtime` accessor to `createFeatureTogglesFeature`) that the dispatcher gate reads on every request; a `toggle-cache-sync` multi-stream projection with `delivery: "per-instance"` syncs the snapshot across instances whenever a `toggle-set` event is appended.',
|
|
42
42
|
);
|
|
43
|
+
r.uiHints({
|
|
44
|
+
displayLabel: "Feature Toggles · Operator Switches",
|
|
45
|
+
category: "operations",
|
|
46
|
+
recommended: false,
|
|
47
|
+
});
|
|
43
48
|
r.systemScope();
|
|
44
49
|
|
|
45
50
|
// Toggle-change domain event. The event ends up in the events-table
|
|
@@ -32,6 +32,7 @@ import { ConfigHandlers } from "../../config/constants";
|
|
|
32
32
|
import { createConfigAccessorFactory } from "../../config/feature";
|
|
33
33
|
import { type ConfigResolver, createConfigResolver } from "../../config/resolver";
|
|
34
34
|
import { configValuesTable } from "../../config/table";
|
|
35
|
+
import { clearInbox, getInbox, mailTransportInMemoryFeature } from "../../mail-transport-inmemory";
|
|
35
36
|
import { mailTransportSmtpFeature, SMTP_PASSWORD } from "../../mail-transport-smtp";
|
|
36
37
|
import { createSecretsContext, createSecretsFeature, tenantSecretsTable } from "../../secrets";
|
|
37
38
|
import { createTenantFeature } from "../../tenant/feature";
|
|
@@ -58,6 +59,22 @@ const testProbeFeature = defineFeature("mail-test", (r) => {
|
|
|
58
59
|
},
|
|
59
60
|
}),
|
|
60
61
|
);
|
|
62
|
+
r.writeHandler(
|
|
63
|
+
defineWriteHandler({
|
|
64
|
+
name: "send",
|
|
65
|
+
schema: z.object({ to: z.string(), subject: z.string(), html: z.string() }),
|
|
66
|
+
access: { roles: ["TenantAdmin", "SystemAdmin"] },
|
|
67
|
+
handler: async (event, ctx) => {
|
|
68
|
+
const transport = await createTransportForTenant(
|
|
69
|
+
ctx,
|
|
70
|
+
event.user.tenantId,
|
|
71
|
+
"mail-test:write:send",
|
|
72
|
+
);
|
|
73
|
+
await transport.send(event.payload);
|
|
74
|
+
return { isSuccess: true, data: {} };
|
|
75
|
+
},
|
|
76
|
+
}),
|
|
77
|
+
);
|
|
61
78
|
});
|
|
62
79
|
|
|
63
80
|
// --- Setup ---
|
|
@@ -91,6 +108,7 @@ beforeAll(async () => {
|
|
|
91
108
|
createSecretsFeature(),
|
|
92
109
|
mailFoundationFeature,
|
|
93
110
|
mailTransportSmtpFeature,
|
|
111
|
+
mailTransportInMemoryFeature,
|
|
94
112
|
testProbeFeature,
|
|
95
113
|
],
|
|
96
114
|
masterKeyProvider: providerRef,
|
|
@@ -251,3 +269,18 @@ describe("scenario 3: tenant isolation", () => {
|
|
|
251
269
|
expect(b["hasSend"]).toBe(true);
|
|
252
270
|
});
|
|
253
271
|
});
|
|
272
|
+
|
|
273
|
+
// --- Scenario 4: the in-memory transport plugin actually delivers ---
|
|
274
|
+
|
|
275
|
+
describe("scenario 4: in-memory transport dispatch", () => {
|
|
276
|
+
test("provider=inmemory → a sent mail lands in that tenant's inbox", async () => {
|
|
277
|
+
const admin = adminFor(406);
|
|
278
|
+
clearInbox(admin.tenantId);
|
|
279
|
+
await setConfig(admin, "mail-foundation:config:provider", "inmemory");
|
|
280
|
+
|
|
281
|
+
const message = { to: "user@acme.test", subject: "Hi", html: "<p>hello</p>" };
|
|
282
|
+
await stack.http.writeOk("mail-test:write:send", message, admin);
|
|
283
|
+
|
|
284
|
+
expect(getInbox(admin.tenantId)).toEqual([message]);
|
|
285
|
+
});
|
|
286
|
+
});
|
|
@@ -71,6 +71,17 @@ describe("rate-limiting feature — status query", () => {
|
|
|
71
71
|
expect(status.remaining).toBe(2);
|
|
72
72
|
});
|
|
73
73
|
|
|
74
|
+
test("blocks with 429 once the bucket is drained", async () => {
|
|
75
|
+
// The status query only *reports* state; this proves enforcement —
|
|
76
|
+
// the L3 hook actually rejects traffic over the limit, not just counts.
|
|
77
|
+
for (let i = 0; i < 5; i++) {
|
|
78
|
+
const ok = await stack.http.query("rl-probe:query:ping", {}, admin);
|
|
79
|
+
expect(ok.status).toBe(200);
|
|
80
|
+
}
|
|
81
|
+
const blocked = await stack.http.query("rl-probe:query:ping", {}, admin);
|
|
82
|
+
expect(blocked.status).toBe(429);
|
|
83
|
+
});
|
|
84
|
+
|
|
74
85
|
test("status access requires Admin/SystemAdmin", async () => {
|
|
75
86
|
const guest = TestUsers.user;
|
|
76
87
|
const res = await stack.http.query(
|
package/src/sessions/feature.ts
CHANGED
|
@@ -43,6 +43,11 @@ export function createSessionsFeature(options?: SessionsFeatureOptions): Feature
|
|
|
43
43
|
r.describe(
|
|
44
44
|
"Tracks signed-in clients in the `read_user_sessions` table (one row per JWT, keyed by the `sid`/`jti` claim) and exposes handlers for `mine` (list your sessions), `revoke`, and `revokeAllOthers`. Session creation and revocation on the hot auth path are handled by `createSessionCallbacks()`, wired into `buildServer({ auth: { ... } })` outside the dispatcher; the feature also ships a manual-trigger cleanup job for pruning expired rows and an optional `autoRevokeOnPasswordChange` hook that mass-revokes all sessions for a user whenever their `passwordHash` changes.",
|
|
45
45
|
);
|
|
46
|
+
r.uiHints({
|
|
47
|
+
displayLabel: "Sessions · Server-side Logout",
|
|
48
|
+
category: "identity",
|
|
49
|
+
recommended: false,
|
|
50
|
+
});
|
|
46
51
|
// sessionChecker reads read_users on every authenticated request (status
|
|
47
52
|
// gate for locked accounts) — make that a boot-time dependency so a
|
|
48
53
|
// sessions-without-user wiring fails validateBoot instead of 500ing live.
|
|
@@ -368,3 +368,68 @@ describe("scenario 7: access rules on handlers", () => {
|
|
|
368
368
|
});
|
|
369
369
|
});
|
|
370
370
|
});
|
|
371
|
+
|
|
372
|
+
// --- Scenario 8: entityList/entityEdit convention QNs ---
|
|
373
|
+
//
|
|
374
|
+
// The SystemAdmin tenant-list/tenant-edit screens resolve data through the
|
|
375
|
+
// entity-suffixed convention QNs (tenant:query:tenant:{list,detail},
|
|
376
|
+
// tenant:write:tenant:update), which were added alongside the legacy
|
|
377
|
+
// tenant:query:list / tenant:write:update handlers. The boot-validator does NOT
|
|
378
|
+
// check that an entityEdit has a matching update/detail handler, so this is the
|
|
379
|
+
// only thing that proves the screens have a live data path. Literal QNs on
|
|
380
|
+
// purpose — they ARE the wire contract the renderer computes.
|
|
381
|
+
|
|
382
|
+
describe("scenario 8: entityList/entityEdit convention QNs", () => {
|
|
383
|
+
test("tenant:query:tenant:list returns all tenants for SystemAdmin (systemScope)", async () => {
|
|
384
|
+
const result = await stack.http.queryOk<{ rows: Record<string, unknown>[] }>(
|
|
385
|
+
"tenant:query:tenant:list",
|
|
386
|
+
{},
|
|
387
|
+
systemAdmin,
|
|
388
|
+
);
|
|
389
|
+
// acme + beta exist from earlier scenarios — systemScope yields all tenants.
|
|
390
|
+
expect(result.rows.length).toBeGreaterThanOrEqual(2);
|
|
391
|
+
expect(result.rows.map((r) => r["key"])).toEqual(expect.arrayContaining(["acme", "beta"]));
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
test("tenant:query:tenant:detail + tenant:write:tenant:update round-trip (entityEdit save persists)", async () => {
|
|
395
|
+
const created = await stack.http.writeOk(
|
|
396
|
+
"tenant:write:create",
|
|
397
|
+
{ key: "delta", name: "Delta" },
|
|
398
|
+
systemAdmin,
|
|
399
|
+
);
|
|
400
|
+
const id = (created!["data"] as Record<string, unknown>)["id"] as string;
|
|
401
|
+
|
|
402
|
+
// entityEdit loads the row via the detail QN, edits, then saves via update.
|
|
403
|
+
const loaded = await stack.http.queryOk<Record<string, unknown>>(
|
|
404
|
+
"tenant:query:tenant:detail",
|
|
405
|
+
{ id },
|
|
406
|
+
systemAdmin,
|
|
407
|
+
);
|
|
408
|
+
expect(loaded["name"]).toBe("Delta");
|
|
409
|
+
|
|
410
|
+
await stack.http.writeOk(
|
|
411
|
+
"tenant:write:tenant:update",
|
|
412
|
+
{ id, version: loaded["version"], changes: { name: "Delta GmbH" } },
|
|
413
|
+
systemAdmin,
|
|
414
|
+
);
|
|
415
|
+
|
|
416
|
+
const reloaded = await stack.http.queryOk<Record<string, unknown>>(
|
|
417
|
+
"tenant:query:tenant:detail",
|
|
418
|
+
{ id },
|
|
419
|
+
systemAdmin,
|
|
420
|
+
);
|
|
421
|
+
expect(reloaded["name"]).toBe("Delta GmbH");
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
test("the convention handlers are SystemAdmin-gated", () => {
|
|
425
|
+
expect(rolesOf(stack.registry.getQueryHandler("tenant:query:tenant:list")?.access)).toEqual([
|
|
426
|
+
"SystemAdmin",
|
|
427
|
+
]);
|
|
428
|
+
expect(rolesOf(stack.registry.getQueryHandler("tenant:query:tenant:detail")?.access)).toEqual([
|
|
429
|
+
"SystemAdmin",
|
|
430
|
+
]);
|
|
431
|
+
expect(rolesOf(stack.registry.getWriteHandler("tenant:write:tenant:update")?.access)).toEqual([
|
|
432
|
+
"SystemAdmin",
|
|
433
|
+
]);
|
|
434
|
+
});
|
|
435
|
+
});
|
package/src/tenant/feature.ts
CHANGED
|
@@ -2,6 +2,9 @@ import {
|
|
|
2
2
|
access,
|
|
3
3
|
createSystemConfig,
|
|
4
4
|
createTenantConfig,
|
|
5
|
+
defineEntityDetailHandler,
|
|
6
|
+
defineEntityListHandler,
|
|
7
|
+
defineEntityUpdateHandler,
|
|
5
8
|
defineFeature,
|
|
6
9
|
type FeatureDefinition,
|
|
7
10
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
@@ -22,6 +25,7 @@ import { updateMemberRolesWrite } from "./handlers/update-member-roles.write";
|
|
|
22
25
|
import { tenantInvitationEntity } from "./invitation-table";
|
|
23
26
|
import { tenantMembershipEntity } from "./membership-table";
|
|
24
27
|
import { tenantEntity } from "./schema/tenant";
|
|
28
|
+
import { tenantEditScreen, tenantListScreen } from "./screens";
|
|
25
29
|
|
|
26
30
|
export { tenantEntity, tenantTable } from "./schema/tenant";
|
|
27
31
|
|
|
@@ -32,6 +36,11 @@ export function createTenantFeature(): FeatureDefinition {
|
|
|
32
36
|
r.describe(
|
|
33
37
|
"Registers the three core multi-tenancy entities \u2014 `tenant`, `tenant-membership`, and `tenant-invitation` (DB tables `read_tenants`, `read_tenant_memberships`, and `read_tenant_invitations`) \u2014 along with write handlers for create/update/disable/enable/addMember/removeMember/updateMemberRoles and the matching queries. It also declares a set of per-tenant config keys (companyName, timezone, locale, SMTP credentials) and system-only keys (priceModel, maxUsers) via `r.config({ keys: { ... } })`. Use this feature in every multi-tenant app; membership resolution and invitation flows depend on it, and `auth-email-password` requires it.",
|
|
34
38
|
);
|
|
39
|
+
r.uiHints({
|
|
40
|
+
displayLabel: "Multi-Tenant Core",
|
|
41
|
+
category: "identity",
|
|
42
|
+
recommended: true,
|
|
43
|
+
});
|
|
35
44
|
r.systemScope();
|
|
36
45
|
r.requires("config");
|
|
37
46
|
r.entity("tenant", tenantEntity);
|
|
@@ -108,6 +117,24 @@ export function createTenantFeature(): FeatureDefinition {
|
|
|
108
117
|
invitations: r.queryHandler(invitationsQuery),
|
|
109
118
|
};
|
|
110
119
|
|
|
120
|
+
// Entity-convention handlers for the SystemAdmin entityList/entityEdit
|
|
121
|
+
// screens. The feature's original handlers predate the `<entity>:<verb>`
|
|
122
|
+
// naming (they sit on tenant:query:list / tenant:write:update); entityList/
|
|
123
|
+
// entityEdit resolve tenant:query:tenant:{list,detail} + tenant:write:tenant:
|
|
124
|
+
// update by convention, so these are added alongside (no rename = no break
|
|
125
|
+
// for existing callers). Cross-tenant because the feature is systemScope.
|
|
126
|
+
r.queryHandler(
|
|
127
|
+
defineEntityListHandler("tenant", tenantEntity, { access: { roles: ["SystemAdmin"] } }),
|
|
128
|
+
);
|
|
129
|
+
r.queryHandler(
|
|
130
|
+
defineEntityDetailHandler("tenant", tenantEntity, { access: { roles: ["SystemAdmin"] } }),
|
|
131
|
+
);
|
|
132
|
+
r.writeHandler(
|
|
133
|
+
defineEntityUpdateHandler("tenant", tenantEntity, { access: { roles: ["SystemAdmin"] } }),
|
|
134
|
+
);
|
|
135
|
+
r.screen(tenantListScreen);
|
|
136
|
+
r.screen(tenantEditScreen);
|
|
137
|
+
|
|
111
138
|
return { handlers, queries };
|
|
112
139
|
});
|
|
113
140
|
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
EntityEditScreenDefinition,
|
|
3
|
+
EntityListScreenDefinition,
|
|
4
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
5
|
+
|
|
6
|
+
// Cross-tenant SystemAdmin platform view of the tenants themselves. The tenant
|
|
7
|
+
// feature runs with `r.systemScope()`, so the entityList returns every tenant.
|
|
8
|
+
// Both screens are SystemAdmin-gated and inert until an app navs them.
|
|
9
|
+
//
|
|
10
|
+
// Backed by the entity-convention handlers registered in feature.ts
|
|
11
|
+
// (tenant:query:tenant:{list,detail}, tenant:write:tenant:update). The legacy
|
|
12
|
+
// `tenant:query:list` / `tenant:write:update` handlers stay for existing
|
|
13
|
+
// callers — these screens bind to the entity-suffixed QNs by convention.
|
|
14
|
+
|
|
15
|
+
export const tenantListScreen: EntityListScreenDefinition = {
|
|
16
|
+
id: "tenant-list",
|
|
17
|
+
type: "entityList",
|
|
18
|
+
entity: "tenant",
|
|
19
|
+
columns: ["key", "name", "isEnabled"],
|
|
20
|
+
rowActions: [
|
|
21
|
+
{
|
|
22
|
+
kind: "navigate",
|
|
23
|
+
id: "edit",
|
|
24
|
+
label: "kumiko.actions.edit",
|
|
25
|
+
screen: "tenant-edit",
|
|
26
|
+
entityId: "id",
|
|
27
|
+
},
|
|
28
|
+
],
|
|
29
|
+
searchable: false,
|
|
30
|
+
access: { roles: ["SystemAdmin"] },
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export const tenantEditScreen: EntityEditScreenDefinition = {
|
|
34
|
+
id: "tenant-edit",
|
|
35
|
+
type: "entityEdit",
|
|
36
|
+
entity: "tenant",
|
|
37
|
+
layout: {
|
|
38
|
+
// `key` is the unique admin-URL slug — shown in the list, not editable here.
|
|
39
|
+
sections: [{ columns: 2, fields: ["name", "isEnabled"] }],
|
|
40
|
+
},
|
|
41
|
+
// No raw tenant creation (onboarding owns membership/owner setup) and no
|
|
42
|
+
// hard delete (no tenant:write:tenant:delete — disable via isEnabled instead).
|
|
43
|
+
allowCreate: false,
|
|
44
|
+
allowDelete: false,
|
|
45
|
+
access: { roles: ["SystemAdmin"] },
|
|
46
|
+
};
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { validateBoot } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
+
import { createConfigFeature } from "../../config/feature";
|
|
4
|
+
import { createTenantFeature } from "../../tenant/feature";
|
|
5
|
+
import { createUserFeature } from "../feature";
|
|
6
|
+
|
|
7
|
+
// The SystemAdmin platform screens (entityList + entityEdit for user/tenant)
|
|
8
|
+
// must live IN the user/tenant features — the boot-validator forbids
|
|
9
|
+
// cross-feature screen ownership. The validator checks screen STRUCTURE
|
|
10
|
+
// (entity-local, columns/fields exist, rowAction targets resolve) but NOT that
|
|
11
|
+
// an entityEdit has a matching update/detail handler. That convention-QN wiring
|
|
12
|
+
// is the load-bearing part here, so it is asserted explicitly.
|
|
13
|
+
//
|
|
14
|
+
// QN convention (collectWriteHandlerQns / collectScreenQns): a handler keyed
|
|
15
|
+
// "<short>" in feature "<f>" resolves to "<f>:<kind>:<short>". entityList loads
|
|
16
|
+
// "<f>:query:<entity>:list", entityEdit loads "<f>:query:<entity>:detail" and
|
|
17
|
+
// saves via "<f>:write:<entity>:{create,update}".
|
|
18
|
+
|
|
19
|
+
describe("user + tenant SystemAdmin admin screens", () => {
|
|
20
|
+
const features = [createConfigFeature(), createUserFeature(), createTenantFeature()];
|
|
21
|
+
|
|
22
|
+
test("the assembled feature set boot-validates", () => {
|
|
23
|
+
expect(() => validateBoot(features)).not.toThrow();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("user feature ships SystemAdmin-gated list + edit screens", () => {
|
|
27
|
+
const user = createUserFeature();
|
|
28
|
+
expect(Object.keys(user.screens)).toEqual(expect.arrayContaining(["user-list", "user-edit"]));
|
|
29
|
+
const list = user.screens["user-list"];
|
|
30
|
+
expect(list?.type).toBe("entityList");
|
|
31
|
+
expect(list?.access).toEqual({ roles: ["SystemAdmin"] });
|
|
32
|
+
expect(user.screens["user-edit"]?.type).toBe("entityEdit");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("user list/detail/create/update handlers already sit on the screen QNs", () => {
|
|
36
|
+
const user = createUserFeature();
|
|
37
|
+
// → user:query:user:list, user:query:user:detail
|
|
38
|
+
expect(Object.keys(user.queryHandlers)).toEqual(
|
|
39
|
+
expect.arrayContaining(["user:list", "user:detail"]),
|
|
40
|
+
);
|
|
41
|
+
// → user:write:user:update (entityEdit save), user:write:user:create ("+ New")
|
|
42
|
+
expect(Object.keys(user.writeHandlers)).toEqual(
|
|
43
|
+
expect.arrayContaining(["user:update", "user:create"]),
|
|
44
|
+
);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("tenant feature ships list + edit screens (edit-only, no hard delete)", () => {
|
|
48
|
+
const tenant = createTenantFeature();
|
|
49
|
+
expect(Object.keys(tenant.screens)).toEqual(
|
|
50
|
+
expect.arrayContaining(["tenant-list", "tenant-edit"]),
|
|
51
|
+
);
|
|
52
|
+
const edit = tenant.screens["tenant-edit"];
|
|
53
|
+
expect(edit?.type).toBe("entityEdit");
|
|
54
|
+
if (edit?.type === "entityEdit") {
|
|
55
|
+
expect(edit.allowCreate).toBe(false);
|
|
56
|
+
expect(edit.allowDelete).toBe(false);
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("tenant gains entity-convention handlers without dropping the legacy ones", () => {
|
|
61
|
+
const tenant = createTenantFeature();
|
|
62
|
+
// New: entityList/entityEdit resolve tenant:query:tenant:{list,detail} +
|
|
63
|
+
// tenant:write:tenant:update (the legacy handlers are keyed "list"/"update"
|
|
64
|
+
// → tenant:query:list / tenant:write:update, which the convention misses).
|
|
65
|
+
expect(Object.keys(tenant.queryHandlers)).toEqual(
|
|
66
|
+
expect.arrayContaining(["tenant:list", "tenant:detail"]),
|
|
67
|
+
);
|
|
68
|
+
expect(Object.keys(tenant.writeHandlers)).toContain("tenant:update");
|
|
69
|
+
// Legacy handlers stay for existing callers (no rename = no break).
|
|
70
|
+
expect(Object.keys(tenant.queryHandlers)).toContain("list");
|
|
71
|
+
expect(Object.keys(tenant.writeHandlers)).toContain("update");
|
|
72
|
+
});
|
|
73
|
+
});
|
package/src/user/feature.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { listQuery } from "./handlers/list.query";
|
|
|
6
6
|
import { meQuery } from "./handlers/me.query";
|
|
7
7
|
import { updateWrite } from "./handlers/update.write";
|
|
8
8
|
import { userEntity } from "./schema/user";
|
|
9
|
+
import { userEditScreen, userListScreen } from "./screens";
|
|
9
10
|
|
|
10
11
|
// The user feature holds the cross-tenant user identity. `systemScope()` means
|
|
11
12
|
// queries and writes bypass the tenant filter — a user exists above any tenant.
|
|
@@ -15,6 +16,11 @@ export function createUserFeature(): FeatureDefinition {
|
|
|
15
16
|
r.describe(
|
|
16
17
|
"Manages the cross-tenant user identity: the `read_users` table holds each user's email, `displayName`, global `roles`, `emailVerified` flag, and lifecycle `status` (active / restricted / deletionRequested / deleted). Because users exist above any individual tenant, the feature runs with `r.systemScope()` \u2014 membership and tenant-specific roles live in the `tenant` feature instead. Add this feature whenever your app needs a persistent, tenant-agnostic user record that auth and GDPR pipelines can reference.",
|
|
17
18
|
);
|
|
19
|
+
r.uiHints({
|
|
20
|
+
displayLabel: "User Identity",
|
|
21
|
+
category: "identity",
|
|
22
|
+
recommended: true,
|
|
23
|
+
});
|
|
18
24
|
r.systemScope();
|
|
19
25
|
r.entity("user", userEntity);
|
|
20
26
|
|
|
@@ -30,6 +36,13 @@ export function createUserFeature(): FeatureDefinition {
|
|
|
30
36
|
findForAuth: r.queryHandler(findForAuthQuery),
|
|
31
37
|
};
|
|
32
38
|
|
|
39
|
+
// Cross-tenant SystemAdmin platform screens. Inert until an app navs them;
|
|
40
|
+
// list/detail/create/update handlers above already sit on the QNs that
|
|
41
|
+
// entityList/entityEdit resolve by convention (user:query:user:{list,detail},
|
|
42
|
+
// user:write:user:{create,update}).
|
|
43
|
+
r.screen(userListScreen);
|
|
44
|
+
r.screen(userEditScreen);
|
|
45
|
+
|
|
33
46
|
return { handlers, queries };
|
|
34
47
|
});
|
|
35
48
|
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
EntityEditScreenDefinition,
|
|
3
|
+
EntityListScreenDefinition,
|
|
4
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
5
|
+
|
|
6
|
+
// Cross-tenant platform admin view of the user identity. Because the user
|
|
7
|
+
// feature runs with `r.systemScope()`, the entityList query returns every
|
|
8
|
+
// user across all tenants — the SystemAdmin platform roster. Both screens are
|
|
9
|
+
// SystemAdmin-gated and stay inert until an app navs them (no auto-nav).
|
|
10
|
+
//
|
|
11
|
+
// Field labels come from the renderer's humanizeSlug fallback (no i18n keys
|
|
12
|
+
// registered) — "Display Name", "Email Verified" etc. Apps can override via
|
|
13
|
+
// their own translations under the `user:entity:user:field:*` convention.
|
|
14
|
+
|
|
15
|
+
export const userListScreen: EntityListScreenDefinition = {
|
|
16
|
+
id: "user-list",
|
|
17
|
+
type: "entityList",
|
|
18
|
+
entity: "user",
|
|
19
|
+
columns: ["email", "displayName", "status", "emailVerified"],
|
|
20
|
+
rowActions: [
|
|
21
|
+
{
|
|
22
|
+
kind: "navigate",
|
|
23
|
+
id: "edit",
|
|
24
|
+
label: "kumiko.actions.edit",
|
|
25
|
+
screen: "user-edit",
|
|
26
|
+
entityId: "id",
|
|
27
|
+
},
|
|
28
|
+
],
|
|
29
|
+
// No SearchAdapter assumption: search is opt-in per app infra, not a
|
|
30
|
+
// universal default for a bundled screen.
|
|
31
|
+
searchable: false,
|
|
32
|
+
access: { roles: ["SystemAdmin"] },
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export const userEditScreen: EntityEditScreenDefinition = {
|
|
36
|
+
id: "user-edit",
|
|
37
|
+
type: "entityEdit",
|
|
38
|
+
entity: "user",
|
|
39
|
+
layout: {
|
|
40
|
+
sections: [
|
|
41
|
+
{
|
|
42
|
+
columns: 2,
|
|
43
|
+
fields: ["email", "displayName", "locale", "emailVerified"],
|
|
44
|
+
},
|
|
45
|
+
],
|
|
46
|
+
},
|
|
47
|
+
// `roles` is deliberately NOT editable here: it is a raw JSON text column
|
|
48
|
+
// (`["SystemAdmin"]`) — a free-text input would let a typo corrupt the
|
|
49
|
+
// privilege column on a live platform. Role management needs a dedicated
|
|
50
|
+
// surface; the list still shows status for triage.
|
|
51
|
+
//
|
|
52
|
+
// Create dispatches user:write:user:create (email + displayName required —
|
|
53
|
+
// both in the form). Delete is suppressed: there is no user:write:user:delete
|
|
54
|
+
// — user removal is the GDPR status/forget flow, not a hard delete.
|
|
55
|
+
allowDelete: false,
|
|
56
|
+
access: { roles: ["SystemAdmin"] },
|
|
57
|
+
};
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// Gate path-routing: requestPath → request screen, confirmPath → confirm
|
|
2
|
+
// screen, sonst durch zu children. Bewusst SYNCHRON (kein fireEvent/waitFor) —
|
|
3
|
+
// der #457-CI-Flake trifft nur await-Assertions auf dem geteilten happy-dom-
|
|
4
|
+
// document; ein Render + Sync-Assert im selben Tick ist nicht exponiert.
|
|
5
|
+
// Provider-Wrapper lokal (Dependency-Richtung renderer-web → bundled-features
|
|
6
|
+
// verbietet test-utils-Import).
|
|
7
|
+
|
|
8
|
+
import { describe, expect, test } from "bun:test";
|
|
9
|
+
import type { Dispatcher } from "@cosmicdrift/kumiko-headless";
|
|
10
|
+
import {
|
|
11
|
+
createStaticLocaleResolver,
|
|
12
|
+
DispatcherProvider,
|
|
13
|
+
kumikoDefaultTranslations,
|
|
14
|
+
LocaleProvider,
|
|
15
|
+
PrimitivesProvider,
|
|
16
|
+
} from "@cosmicdrift/kumiko-renderer";
|
|
17
|
+
import { defaultPrimitives } from "@cosmicdrift/kumiko-renderer-web";
|
|
18
|
+
import { render, within } from "@testing-library/react";
|
|
19
|
+
import type { ReactNode } from "react";
|
|
20
|
+
import { defaultTranslations } from "../i18n";
|
|
21
|
+
import { makePublicDeletionGate } from "../public-deletion-gate";
|
|
22
|
+
|
|
23
|
+
const resolver = createStaticLocaleResolver({ locale: "de" });
|
|
24
|
+
const stubDispatcher = {
|
|
25
|
+
write: async () => ({ isSuccess: true, data: {} }),
|
|
26
|
+
} as unknown as Dispatcher;
|
|
27
|
+
|
|
28
|
+
const ROUTES = { requestPath: "/account/delete", confirmPath: "/account/delete/confirm" };
|
|
29
|
+
|
|
30
|
+
function renderGate(path: string, gate: ReactNode): ReturnType<typeof within> {
|
|
31
|
+
window.history.replaceState({}, "", path);
|
|
32
|
+
const { container } = render(
|
|
33
|
+
<PrimitivesProvider value={defaultPrimitives}>
|
|
34
|
+
<LocaleProvider
|
|
35
|
+
resolver={resolver}
|
|
36
|
+
fallbackBundles={[defaultTranslations, kumikoDefaultTranslations]}
|
|
37
|
+
>
|
|
38
|
+
<DispatcherProvider dispatcher={stubDispatcher}>{gate}</DispatcherProvider>
|
|
39
|
+
</LocaleProvider>
|
|
40
|
+
</PrimitivesProvider>,
|
|
41
|
+
);
|
|
42
|
+
return within(container);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
describe("makePublicDeletionGate", () => {
|
|
46
|
+
test("requestPath → request screen, children short-circuited", () => {
|
|
47
|
+
const Gate = makePublicDeletionGate(ROUTES);
|
|
48
|
+
const ui = renderGate(
|
|
49
|
+
"/account/delete",
|
|
50
|
+
<Gate>
|
|
51
|
+
<div data-testid="app">APP</div>
|
|
52
|
+
</Gate>,
|
|
53
|
+
);
|
|
54
|
+
expect(ui.getByText(/beantragen/)).toBeTruthy();
|
|
55
|
+
expect(ui.queryByTestId("app")).toBeNull();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("confirmPath → confirm screen, children short-circuited", () => {
|
|
59
|
+
const Gate = makePublicDeletionGate(ROUTES);
|
|
60
|
+
const ui = renderGate(
|
|
61
|
+
"/account/delete/confirm",
|
|
62
|
+
<Gate>
|
|
63
|
+
<div data-testid="app">APP</div>
|
|
64
|
+
</Gate>,
|
|
65
|
+
);
|
|
66
|
+
expect(ui.getByText(/bestätigen/)).toBeTruthy();
|
|
67
|
+
expect(ui.queryByTestId("app")).toBeNull();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("other path → children pass through, no deletion screen", () => {
|
|
71
|
+
const Gate = makePublicDeletionGate(ROUTES);
|
|
72
|
+
const ui = renderGate(
|
|
73
|
+
"/dashboard",
|
|
74
|
+
<Gate>
|
|
75
|
+
<div data-testid="app">APP</div>
|
|
76
|
+
</Gate>,
|
|
77
|
+
);
|
|
78
|
+
expect(ui.getByTestId("app")).toBeTruthy();
|
|
79
|
+
expect(ui.queryByText(/beantragen/)).toBeNull();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("custom shell wraps the matched screen", () => {
|
|
83
|
+
const Gate = makePublicDeletionGate({
|
|
84
|
+
...ROUTES,
|
|
85
|
+
shell: (screen) => <div data-testid="shell">{screen}</div>,
|
|
86
|
+
});
|
|
87
|
+
const ui = renderGate(
|
|
88
|
+
"/account/delete",
|
|
89
|
+
<Gate>
|
|
90
|
+
<span>APP</span>
|
|
91
|
+
</Gate>,
|
|
92
|
+
);
|
|
93
|
+
const shell = ui.getByTestId("shell");
|
|
94
|
+
expect(within(shell).getByText(/beantragen/)).toBeTruthy();
|
|
95
|
+
});
|
|
96
|
+
});
|
|
@@ -11,20 +11,28 @@ import type { ClientFeatureDefinition } from "@cosmicdrift/kumiko-renderer-web";
|
|
|
11
11
|
import { PRIVACY_CENTER_SCREEN_ID, USER_DATA_RIGHTS_FEATURE } from "../constants";
|
|
12
12
|
import { defaultTranslations } from "./i18n";
|
|
13
13
|
import { PrivacyCenterScreen } from "./privacy-center-screen";
|
|
14
|
+
import { makePublicDeletionGate, type PublicDeletionRoutes } from "./public-deletion-gate";
|
|
14
15
|
|
|
15
16
|
export type UserDataRightsClientOptions = {
|
|
16
17
|
/** Key-weise Overrides über die Default-Bundles (de/en). */
|
|
17
18
|
readonly translations?: TranslationsByLocale;
|
|
19
|
+
/** Wenn gesetzt: registriert die anonymen (login-freien) Lösch-Screens als
|
|
20
|
+
* Gate auf den angegebenen Pfaden. Weglassen → nur der eingeloggte
|
|
21
|
+
* privacy-center-Screen. Den Client VOR dem Auth-Client listen, sonst
|
|
22
|
+
* landet der anonyme Besucher auf der Login-Maske. */
|
|
23
|
+
readonly publicDeletion?: PublicDeletionRoutes;
|
|
18
24
|
};
|
|
19
25
|
|
|
20
26
|
export function userDataRightsClient(
|
|
21
27
|
options?: UserDataRightsClientOptions,
|
|
22
28
|
): ClientFeatureDefinition {
|
|
23
|
-
|
|
29
|
+
const base: ClientFeatureDefinition = {
|
|
24
30
|
name: USER_DATA_RIGHTS_FEATURE,
|
|
25
31
|
translations: mergeTranslations(defaultTranslations, options?.translations ?? {}),
|
|
26
32
|
components: {
|
|
27
33
|
[PRIVACY_CENTER_SCREEN_ID]: PrivacyCenterScreen,
|
|
28
34
|
},
|
|
29
35
|
};
|
|
36
|
+
if (options?.publicDeletion === undefined) return base;
|
|
37
|
+
return { ...base, gates: [makePublicDeletionGate(options.publicDeletion)] };
|
|
30
38
|
}
|
|
@@ -10,5 +10,6 @@ export type { ConfirmAccountDeletionScreenProps } from "./confirm-deletion-scree
|
|
|
10
10
|
export { ConfirmAccountDeletionScreen } from "./confirm-deletion-screen";
|
|
11
11
|
export { defaultTranslations } from "./i18n";
|
|
12
12
|
export { formatDate, PrivacyCenterScreen } from "./privacy-center-screen";
|
|
13
|
+
export { makePublicDeletionGate, type PublicDeletionRoutes } from "./public-deletion-gate";
|
|
13
14
|
export type { RequestAccountDeletionScreenProps } from "./request-deletion-screen";
|
|
14
15
|
export { RequestAccountDeletionScreen } from "./request-deletion-screen";
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// @runtime client
|
|
2
|
+
// Public-Gate für die anonyme Account-Löschung. Matcht window.location.pathname:
|
|
3
|
+
// requestPath → RequestAccountDeletionScreen, confirmPath →
|
|
4
|
+
// ConfirmAccountDeletionScreen, sonst durch zur App. Spiegelt makeAuthGate
|
|
5
|
+
// (auth-email-password) — userDataRightsClient hängt es als Gate ein, die App
|
|
6
|
+
// listet den Client VOR dem Auth-Client, damit ein anonymer Besucher die Lösch-
|
|
7
|
+
// Maske statt der Login-Maske sieht. Path-Match beim Render: Apex-Übergänge
|
|
8
|
+
// (der Verify-Link) sind Full-Page-Loads, kein Client-Router.
|
|
9
|
+
//
|
|
10
|
+
// confirmPath MUSS dem Pfad der server-seitigen deletionVerifyUrl entsprechen —
|
|
11
|
+
// der ConfirmScreen liest das ?token aus eben dieser URL.
|
|
12
|
+
|
|
13
|
+
import type { ComponentType, ReactNode } from "react";
|
|
14
|
+
import { ConfirmAccountDeletionScreen } from "./confirm-deletion-screen";
|
|
15
|
+
import { RequestAccountDeletionScreen } from "./request-deletion-screen";
|
|
16
|
+
|
|
17
|
+
export type PublicDeletionRoutes = {
|
|
18
|
+
/** Login-freie Route für die Email-Antrags-Maske (z.B. "/account/delete"). */
|
|
19
|
+
readonly requestPath: string;
|
|
20
|
+
/** Login-freie Route für die Token-Bestätigung; = Pfad der deletionVerifyUrl. */
|
|
21
|
+
readonly confirmPath: string;
|
|
22
|
+
/** Chrome um die Screen-Card. Default: vollflächig zentriert (wie der Auth-
|
|
23
|
+
* defaultShell). Apps reichen ihre eigene Shell (z.B. Marketing-Header). */
|
|
24
|
+
readonly shell?: (screen: ReactNode) => ReactNode;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const centeredShell = (screen: ReactNode): ReactNode => (
|
|
28
|
+
<div className="min-h-screen flex items-center justify-center bg-background px-4">{screen}</div>
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
export function makePublicDeletionGate(
|
|
32
|
+
routes: PublicDeletionRoutes,
|
|
33
|
+
): ComponentType<{ children: ReactNode }> {
|
|
34
|
+
const shell = routes.shell ?? centeredShell;
|
|
35
|
+
function PublicDeletionGate({ children }: { readonly children: ReactNode }): ReactNode {
|
|
36
|
+
const path = window.location.pathname;
|
|
37
|
+
if (path === routes.requestPath) return shell(<RequestAccountDeletionScreen />);
|
|
38
|
+
if (path === routes.confirmPath) return shell(<ConfirmAccountDeletionScreen />);
|
|
39
|
+
return <>{children}</>;
|
|
40
|
+
}
|
|
41
|
+
return PublicDeletionGate;
|
|
42
|
+
}
|