@cosmicdrift/kumiko-bundled-features 0.174.0 → 0.176.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 +8 -10
- package/src/__tests__/boot-seed-contract.integration.test.ts +18 -14
- package/src/auth-email-password/__tests__/login-gates.test.ts +12 -6
- package/src/auth-email-password/seeding.ts +4 -2
- package/src/auth-mfa/__tests__/token-secrets.test.ts +19 -0
- package/src/auth-mfa/handlers/reencrypt.job.ts +11 -20
- package/src/auth-mfa/token-secrets.ts +16 -14
- package/src/billing-foundation/subscription-tier-sync.ts +8 -1
- package/src/cap-counter/__tests__/stock-cap-guard.integration.test.ts +98 -0
- package/src/cap-counter/stock-cap-guard.ts +1 -1
- package/src/config/handlers/reencrypt.job.ts +7 -18
- package/src/delivery/delivery-service.ts +1 -1
- package/src/document-ingest-foundation/__tests__/feature.integration.test.ts +47 -11
- package/src/document-ingest-foundation/__tests__/feature.test.ts +6 -2
- package/src/document-ingest-foundation/events.ts +15 -0
- package/src/document-ingest-foundation/feature.ts +27 -2
- package/src/inbound-mail-foundation/watch-supervisor.ts +11 -2
- package/src/jobs/handlers/catalog.query.ts +1 -4
- package/src/jobs/handlers/trigger.write.ts +2 -1
- package/src/jobs/is-manual-trigger.ts +5 -0
- package/src/legal-pages/README.md +26 -25
- package/src/legal-pages/__tests__/legal-pages.integration.test.ts +93 -61
- package/src/legal-pages/feature.ts +40 -31
- package/src/legal-pages/web/__tests__/client-plugin.test.ts +2 -2
- package/src/legal-pages/web/client-plugin.ts +3 -3
- package/src/managed-pages/screens/branding-screen.ts +1 -1
- package/src/presets/dsgvo-self-service.ts +10 -10
- package/src/seo/__tests__/robots-txt.test.ts +21 -0
- package/src/seo/__tests__/seo.integration.test.ts +13 -13
- package/src/seo/robots-txt.ts +21 -1
- package/src/sessions/handlers/revoke-all-for-user.write.ts +10 -1
- package/src/shared/classify-stored-envelope.ts +21 -0
- package/src/shared/index.ts +4 -0
- package/src/template-resolver/README.md +30 -1
- package/src/{text-content → template-resolver}/__tests__/seed-legal-content.integration.test.ts +32 -21
- package/src/template-resolver/__tests__/text-blocks.integration.test.ts +563 -0
- package/src/template-resolver/api.ts +11 -7
- package/src/template-resolver/constants.ts +22 -0
- package/src/template-resolver/feature.ts +16 -1
- package/src/template-resolver/handlers/by-slug.query.ts +54 -0
- package/src/template-resolver/handlers/by-tenant.query.ts +57 -0
- package/src/template-resolver/handlers/set.write.ts +110 -0
- package/src/template-resolver/handlers/shared.ts +13 -2
- package/src/template-resolver/handlers/upsert-system.write.ts +2 -0
- package/src/template-resolver/handlers/upsert-tenant.write.ts +2 -0
- package/src/template-resolver/index.ts +7 -0
- package/src/template-resolver/qualified-names.ts +3 -0
- package/src/template-resolver/seeding.ts +126 -2
- package/src/template-resolver/table.ts +11 -2
- package/src/{text-content → template-resolver}/web/__tests__/client-plugin.test.tsx +26 -19
- package/src/{text-content → template-resolver}/web/__tests__/editor-read-only.test.tsx +10 -10
- package/src/{text-content → template-resolver}/web/__tests__/group-blocks.test.ts +34 -34
- package/src/template-resolver/web/client-plugin.tsx +352 -0
- package/src/template-resolver/web/index.ts +8 -0
- package/src/tenant-settings/__tests__/tenant-settings.integration.test.ts +9 -0
- package/src/tenant-settings/tenant-defaults.ts +3 -0
- package/src/tier-engine/compose-app.ts +2 -13
- package/src/user/__tests__/seed-testing.integration.test.ts +25 -0
- package/src/user/seeding.ts +25 -1
- package/src/user-data-rights/__tests__/anonymous-deletion-kms.integration.test.ts +1 -1
- package/src/user-data-rights/__tests__/anonymous-deletion.integration.test.ts +2 -2
- package/src/user-data-rights/__tests__/request-deletion-url.test.ts +11 -8
- package/src/user-data-rights/feature.ts +3 -2
- package/src/user-data-rights/handlers/request-deletion-by-email.write.ts +8 -6
- package/src/user-data-rights/web/__tests__/deletion-screens.test.tsx +5 -5
- package/src/user-data-rights/web/confirm-deletion-screen.tsx +3 -2
- package/src/text-content/README.md +0 -197
- package/src/text-content/__tests__/text-content.integration.test.ts +0 -503
- package/src/text-content/api.ts +0 -86
- package/src/text-content/changes.json +0 -14
- package/src/text-content/constants.ts +0 -21
- package/src/text-content/feature.ts +0 -53
- package/src/text-content/handlers/by-slug.query.ts +0 -55
- package/src/text-content/handlers/by-tenant.query.ts +0 -60
- package/src/text-content/handlers/set.write.ts +0 -141
- package/src/text-content/index.ts +0 -14
- package/src/text-content/seeding.ts +0 -139
- package/src/text-content/table.ts +0 -53
- package/src/text-content/web/client-plugin.tsx +0 -391
- package/src/text-content/web/index.ts +0 -8
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { describe, expect, mock, test } from "bun:test";
|
|
2
|
-
// mock.module
|
|
3
|
-
//
|
|
4
|
-
//
|
|
2
|
+
// mock.module replaces imports for all consumers — static imports
|
|
3
|
+
// before mock.module still see the mocked version because Bun
|
|
4
|
+
// intercepts at the loader level. useShellUser is a mock object here.
|
|
5
5
|
import { useShellUser } from "@cosmicdrift/kumiko-bundled-features/auth-email-password/web";
|
|
6
6
|
import {
|
|
7
7
|
createStaticLocaleResolver,
|
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
import { defaultPrimitives } from "@cosmicdrift/kumiko-renderer-web";
|
|
12
12
|
import { act, fireEvent, render, screen } from "@testing-library/react";
|
|
13
13
|
import type { ReactNode } from "react";
|
|
14
|
-
import {
|
|
14
|
+
import { textBlocksClient } from "../client-plugin";
|
|
15
15
|
|
|
16
16
|
mock.module("@cosmicdrift/kumiko-bundled-features/auth-email-password/web", () => ({
|
|
17
17
|
useShellUser: mock(),
|
|
@@ -24,9 +24,9 @@ mock.module("@cosmicdrift/kumiko-renderer", () => ({
|
|
|
24
24
|
useQuery: mock(() => ({
|
|
25
25
|
data: {
|
|
26
26
|
slug: "imprint",
|
|
27
|
-
|
|
27
|
+
locale: "de",
|
|
28
28
|
title: "Impressum",
|
|
29
|
-
|
|
29
|
+
content: "Inhalt",
|
|
30
30
|
folder: "legal",
|
|
31
31
|
},
|
|
32
32
|
loading: false,
|
|
@@ -38,14 +38,14 @@ mock.module("@cosmicdrift/kumiko-renderer", () => ({
|
|
|
38
38
|
const { useDispatcher } = await import("@cosmicdrift/kumiko-renderer");
|
|
39
39
|
|
|
40
40
|
const TARGET = {
|
|
41
|
-
featureId: "
|
|
41
|
+
featureId: "template-resolver",
|
|
42
42
|
action: "edit",
|
|
43
|
-
args: { slug: "imprint",
|
|
43
|
+
args: { slug: "imprint", locale: "de" },
|
|
44
44
|
} as const;
|
|
45
45
|
|
|
46
46
|
function getEditor() {
|
|
47
|
-
const def =
|
|
48
|
-
const Editor = def.resolvers?.["
|
|
47
|
+
const def = textBlocksClient();
|
|
48
|
+
const Editor = def.resolvers?.["template-resolver:edit"];
|
|
49
49
|
if (!Editor) throw new Error("Editor not registered");
|
|
50
50
|
return Editor;
|
|
51
51
|
}
|
|
@@ -2,16 +2,16 @@ import { describe, expect, test } from "bun:test";
|
|
|
2
2
|
import type { TreeNode } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
3
|
import { type BlockSummary, groupBlocksByFolder } from "../client-plugin";
|
|
4
4
|
|
|
5
|
-
// TreeNode.children
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
5
|
+
// TreeNode.children is `readonly TreeNode[] | TreeChildrenSubscribe` —
|
|
6
|
+
// in the provider output, the subscribe form is only meant for
|
|
7
|
+
// deferred children; groupBlocksByFolder produces exclusively static
|
|
8
|
+
// array children. TypeGuard instead of an as-cast (memory `[Type Assertions]`).
|
|
9
9
|
function childrenArray(children: TreeNode["children"] | undefined): readonly TreeNode[] {
|
|
10
10
|
if (!Array.isArray(children)) throw new Error("expected static children-array");
|
|
11
11
|
return children;
|
|
12
12
|
}
|
|
13
|
-
// V.1.5d
|
|
14
|
-
//
|
|
13
|
+
// V.1.5d helper: groupBlocksByFolder now returns a single "Content" wrapper
|
|
14
|
+
// folder. Tests want to check the content UNDER the wrapper.
|
|
15
15
|
function inside(result: readonly TreeNode[]): readonly TreeNode[] {
|
|
16
16
|
expect(result).toHaveLength(1);
|
|
17
17
|
const wrapper = result[0];
|
|
@@ -19,19 +19,19 @@ function inside(result: readonly TreeNode[]): readonly TreeNode[] {
|
|
|
19
19
|
expect(wrapper?.icon).toBe("folder");
|
|
20
20
|
return childrenArray(wrapper?.children);
|
|
21
21
|
}
|
|
22
|
-
// Helper: BlockSummary
|
|
22
|
+
// Helper: BlockSummary with defaults for the fields not relevant to the test.
|
|
23
23
|
function block(opts: {
|
|
24
24
|
slug: string;
|
|
25
25
|
folder?: string | null;
|
|
26
|
-
|
|
26
|
+
content?: string | null;
|
|
27
27
|
title?: string;
|
|
28
28
|
}): BlockSummary {
|
|
29
29
|
return {
|
|
30
30
|
slug: opts.slug,
|
|
31
|
-
|
|
31
|
+
locale: "de",
|
|
32
32
|
title: opts.title ?? opts.slug,
|
|
33
|
-
//
|
|
34
|
-
|
|
33
|
+
// Not ?? — null should be passed through (state="stub" test).
|
|
34
|
+
content: opts.content === undefined ? "irgendwas" : opts.content,
|
|
35
35
|
folder: opts.folder === undefined ? null : opts.folder,
|
|
36
36
|
updatedAt: "2026-05-19T00:00:00Z",
|
|
37
37
|
};
|
|
@@ -48,9 +48,9 @@ describe("groupBlocksByFolder", () => {
|
|
|
48
48
|
if (!root) return;
|
|
49
49
|
expect(root.label).toBe("imprint");
|
|
50
50
|
expect(root.target).toEqual({
|
|
51
|
-
featureId: "
|
|
51
|
+
featureId: "template-resolver",
|
|
52
52
|
action: "edit",
|
|
53
|
-
args: { slug: "imprint",
|
|
53
|
+
args: { slug: "imprint", locale: "de" },
|
|
54
54
|
});
|
|
55
55
|
expect(root.children).toBeUndefined();
|
|
56
56
|
expect(root.icon).toBe("file");
|
|
@@ -71,7 +71,7 @@ describe("groupBlocksByFolder", () => {
|
|
|
71
71
|
expect(child).toBeDefined();
|
|
72
72
|
if (!child) return;
|
|
73
73
|
expect(child.label).toBe("Hero");
|
|
74
|
-
expect(child.target?.args).toEqual({ slug: "hero",
|
|
74
|
+
expect(child.target?.args).toEqual({ slug: "hero", locale: "de" });
|
|
75
75
|
});
|
|
76
76
|
test("mehrere Slugs gleicher Folder → ein Folder mit mehreren children", () => {
|
|
77
77
|
const nodes = inside(
|
|
@@ -116,12 +116,12 @@ describe("groupBlocksByFolder", () => {
|
|
|
116
116
|
const folderLabels = nodes.map((n) => n.label);
|
|
117
117
|
expect(folderLabels).toEqual(["apple", "mango", "zebra"]);
|
|
118
118
|
});
|
|
119
|
-
test('
|
|
120
|
-
const nodes = inside(groupBlocksByFolder([block({ slug: "draft",
|
|
119
|
+
test('content=null → state="stub" (Designer-Hinweis dass Slug existiert aber leer)', () => {
|
|
120
|
+
const nodes = inside(groupBlocksByFolder([block({ slug: "draft", content: null })]));
|
|
121
121
|
expect(nodes[0]?.state).toBe("stub");
|
|
122
122
|
});
|
|
123
|
-
test('
|
|
124
|
-
const nodes = inside(groupBlocksByFolder([block({ slug: "imprint",
|
|
123
|
+
test('content=string → state="filled"', () => {
|
|
124
|
+
const nodes = inside(groupBlocksByFolder([block({ slug: "imprint", content: "content" })]));
|
|
125
125
|
expect(nodes[0]?.state).toBe("filled");
|
|
126
126
|
});
|
|
127
127
|
test("title leer → fallback auf slug als label", () => {
|
|
@@ -144,11 +144,11 @@ describe("groupBlocksByFolder", () => {
|
|
|
144
144
|
const marketingChildren = childrenArray(marketingFolder?.children);
|
|
145
145
|
expect(marketingChildren).toHaveLength(1);
|
|
146
146
|
expect(marketingChildren[0]?.label).toBe("Hero");
|
|
147
|
-
expect(marketingChildren[0]?.target?.args).toEqual({ slug: "hero",
|
|
147
|
+
expect(marketingChildren[0]?.target?.args).toEqual({ slug: "hero", locale: "de" });
|
|
148
148
|
});
|
|
149
149
|
test("V.1.6a shared folder-prefix → ein gemeinsamer parent", () => {
|
|
150
|
-
//
|
|
151
|
-
// page/hero + page/cta + page/marketing/banner → 1× page
|
|
150
|
+
// Two blocks with a nested path share the first segments.
|
|
151
|
+
// page/hero + page/cta + page/marketing/banner → 1× page folder with
|
|
152
152
|
// 3 children (2 leaves + 1 sub-folder).
|
|
153
153
|
const nodes = inside(
|
|
154
154
|
groupBlocksByFolder([
|
|
@@ -161,7 +161,7 @@ describe("groupBlocksByFolder", () => {
|
|
|
161
161
|
const pageFolder = nodes[0];
|
|
162
162
|
expect(pageFolder?.label).toBe("page");
|
|
163
163
|
const pageChildren = childrenArray(pageFolder?.children);
|
|
164
|
-
// Leaves first (Hero, CTA),
|
|
164
|
+
// Leaves first (Hero, CTA), then sub-folder (marketing alphabetically).
|
|
165
165
|
expect(pageChildren.map((c) => c.label)).toEqual(["Hero", "CTA", "marketing"]);
|
|
166
166
|
expect(pageChildren[2]?.icon).toBe("folder");
|
|
167
167
|
const marketingChildren = childrenArray(pageChildren[2]?.children);
|
|
@@ -169,11 +169,11 @@ describe("groupBlocksByFolder", () => {
|
|
|
169
169
|
expect(marketingChildren[0]?.label).toBe("Banner");
|
|
170
170
|
});
|
|
171
171
|
test("V.1.6a folder/leaf-collision: gleicher Name auf gleicher Ebene", () => {
|
|
172
|
-
// Edge
|
|
173
|
-
// + block
|
|
174
|
-
//
|
|
175
|
-
//
|
|
176
|
-
//
|
|
172
|
+
// Edge case (advisor-flagged): block with folder=null, slug="page"
|
|
173
|
+
// + block with folder="page" → "page" exists both as a leaf-root AND
|
|
174
|
+
// as a folder. Both stay visible; the folder has a chevron + folder
|
|
175
|
+
// icon, the leaf has a target and is clickable. The renderer pattern
|
|
176
|
+
// makes it visually clear that these are two different things.
|
|
177
177
|
const nodes = inside(
|
|
178
178
|
groupBlocksByFolder([
|
|
179
179
|
block({ slug: "page", folder: null, title: "Page-Root" }),
|
|
@@ -197,27 +197,27 @@ describe("groupBlocksByFolder", () => {
|
|
|
197
197
|
"00000000-0000-4000-8000-000000000000",
|
|
198
198
|
),
|
|
199
199
|
);
|
|
200
|
-
// Root-
|
|
200
|
+
// Root-leaf carries the override.
|
|
201
201
|
expect(nodes[0]?.target?.args).toEqual({
|
|
202
202
|
slug: "imprint",
|
|
203
|
-
|
|
203
|
+
locale: "de",
|
|
204
204
|
tenantIdOverride: "00000000-0000-4000-8000-000000000000",
|
|
205
205
|
});
|
|
206
|
-
//
|
|
206
|
+
// The leaf inside the folder too (passed through recursively).
|
|
207
207
|
const folderChild = childrenArray(nodes[1]?.children)[0];
|
|
208
208
|
expect(folderChild?.target?.args).toEqual({
|
|
209
209
|
slug: "hero",
|
|
210
|
-
|
|
210
|
+
locale: "de",
|
|
211
211
|
tenantIdOverride: "00000000-0000-4000-8000-000000000000",
|
|
212
212
|
});
|
|
213
213
|
});
|
|
214
214
|
test("ohne tenantIdOverride bleiben die Args frei davon (Default: Session-Tenant)", () => {
|
|
215
215
|
const nodes = inside(groupBlocksByFolder([block({ slug: "imprint", folder: null })]));
|
|
216
|
-
expect(nodes[0]?.target?.args).toEqual({ slug: "imprint",
|
|
216
|
+
expect(nodes[0]?.target?.args).toEqual({ slug: "imprint", locale: "de" });
|
|
217
217
|
});
|
|
218
218
|
test("Wrapper-Folder 'Content' umschließt alle blocks", () => {
|
|
219
|
-
// V.1.5d
|
|
220
|
-
//
|
|
219
|
+
// V.1.5d wrapper convention: groupBlocksByFolder returns ONE node
|
|
220
|
+
// (the wrapper), content sits one level deeper.
|
|
221
221
|
const result = groupBlocksByFolder([block({ slug: "imprint" })]);
|
|
222
222
|
expect(result).toHaveLength(1);
|
|
223
223
|
const wrapper = result[0];
|
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
// @runtime client
|
|
2
|
+
// Client feature factory for the text-block content tree. Apps mount it via
|
|
3
|
+
// createKumikoApp({ clientFeatures: [textBlocksClient()] }); it loads the
|
|
4
|
+
// blocks from the by-tenant query, groups them by their `folder` field and
|
|
5
|
+
// emits them as TreeNode[], plus the editor behind the edit action.
|
|
6
|
+
//
|
|
7
|
+
// **Folder grouping**: block.folder !== null puts the node under a container
|
|
8
|
+
// node labelled with the folder; folder === null keeps it at the root.
|
|
9
|
+
//
|
|
10
|
+
// **State**: TreeNode.state = "filled" when content is set, otherwise "stub"
|
|
11
|
+
// (a hint that the slug exists but is empty).
|
|
12
|
+
|
|
13
|
+
import { useShellUser } from "@cosmicdrift/kumiko-bundled-features/auth-email-password/web";
|
|
14
|
+
import { CSRF_HEADER_NAME, readCsrfToken } from "@cosmicdrift/kumiko-dispatcher-live";
|
|
15
|
+
import type {
|
|
16
|
+
TargetRef,
|
|
17
|
+
TreeChildrenSubscribe,
|
|
18
|
+
TreeNode,
|
|
19
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
20
|
+
import { useDispatcher, usePrimitives, useQuery } from "@cosmicdrift/kumiko-renderer";
|
|
21
|
+
import type { ClientFeatureDefinition } from "@cosmicdrift/kumiko-renderer-web";
|
|
22
|
+
import { type FormEvent, type ReactNode, useEffect, useState } from "react";
|
|
23
|
+
import { TemplateResolverHandlers, TemplateResolverQueries } from "../qualified-names";
|
|
24
|
+
|
|
25
|
+
// Exported for the unit test — groupBlocksByFolder is a pure function.
|
|
26
|
+
export type BlockSummary = {
|
|
27
|
+
readonly slug: string;
|
|
28
|
+
readonly locale: string;
|
|
29
|
+
readonly title: string | null;
|
|
30
|
+
readonly content: string | null;
|
|
31
|
+
readonly folder: string | null;
|
|
32
|
+
readonly updatedAt: string;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
type ByTenantResponse = {
|
|
36
|
+
readonly data: { readonly blocks: readonly BlockSummary[] };
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
// Multi-level folder splitting: `folder` is a `/`-separated path, the tree
|
|
40
|
+
// splits per segment and creates nested folder nodes:
|
|
41
|
+
// block.folder = null → root leaf
|
|
42
|
+
// block.folder = "page" → folder page > leaf
|
|
43
|
+
// block.folder = "page/marketing" → folder page > folder marketing > leaf
|
|
44
|
+
//
|
|
45
|
+
// **Folder/leaf collision**: a block with `folder=null, slug="page"` and
|
|
46
|
+
// another with `folder="page"` both live on the same level — the folder with
|
|
47
|
+
// children plus a clickable leaf. Visually distinct (chevron + folder icon),
|
|
48
|
+
// no crash, no surprise.
|
|
49
|
+
|
|
50
|
+
type FolderNode = {
|
|
51
|
+
readonly leaves: TreeNode[];
|
|
52
|
+
readonly subFolders: Map<string, FolderNode>;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
function newFolderNode(): FolderNode {
|
|
56
|
+
return { leaves: [], subFolders: new Map() };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function attachBlock(root: FolderNode, block: BlockSummary, tenantIdOverride?: string): void {
|
|
60
|
+
const leaf: TreeNode = {
|
|
61
|
+
label: block.title || block.slug,
|
|
62
|
+
icon: "file",
|
|
63
|
+
target: {
|
|
64
|
+
featureId: "template-resolver",
|
|
65
|
+
action: "edit",
|
|
66
|
+
// tenantIdOverride travels with the node → the editor's by-slug read +
|
|
67
|
+
// set write target the same tenant the tree was loaded from (SystemAdmin
|
|
68
|
+
// editing SYSTEM-tenant content). Omitted → session tenant.
|
|
69
|
+
args: {
|
|
70
|
+
slug: block.slug,
|
|
71
|
+
locale: block.locale,
|
|
72
|
+
...(tenantIdOverride !== undefined && { tenantIdOverride }),
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
state: block.content ? "filled" : "stub",
|
|
76
|
+
};
|
|
77
|
+
if (block.folder === null) {
|
|
78
|
+
root.leaves.push(leaf);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
let cur = root;
|
|
82
|
+
for (const segment of block.folder.split("/")) {
|
|
83
|
+
let next = cur.subFolders.get(segment);
|
|
84
|
+
if (next === undefined) {
|
|
85
|
+
next = newFolderNode();
|
|
86
|
+
cur.subFolders.set(segment, next);
|
|
87
|
+
}
|
|
88
|
+
cur = next;
|
|
89
|
+
}
|
|
90
|
+
cur.leaves.push(leaf);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function renderFolderNode(node: FolderNode): TreeNode[] {
|
|
94
|
+
// Leaves first (root items), then folders alphabetically.
|
|
95
|
+
const result: TreeNode[] = [...node.leaves];
|
|
96
|
+
for (const folderName of [...node.subFolders.keys()].sort()) {
|
|
97
|
+
const subNode = node.subFolders.get(folderName);
|
|
98
|
+
if (subNode === undefined) continue;
|
|
99
|
+
result.push({
|
|
100
|
+
label: folderName,
|
|
101
|
+
icon: "folder",
|
|
102
|
+
state: "filled",
|
|
103
|
+
children: renderFolderNode(subNode),
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
return result;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function groupBlocksByFolder(
|
|
110
|
+
blocks: readonly BlockSummary[],
|
|
111
|
+
tenantIdOverride?: string,
|
|
112
|
+
): readonly TreeNode[] {
|
|
113
|
+
const root = newFolderNode();
|
|
114
|
+
for (const block of blocks) {
|
|
115
|
+
attachBlock(root, block, tenantIdOverride);
|
|
116
|
+
}
|
|
117
|
+
const rendered = renderFolderNode(root);
|
|
118
|
+
if (rendered.length === 0) return [];
|
|
119
|
+
return [
|
|
120
|
+
{
|
|
121
|
+
label: "Content",
|
|
122
|
+
icon: "folder",
|
|
123
|
+
state: "filled",
|
|
124
|
+
children: rendered,
|
|
125
|
+
},
|
|
126
|
+
];
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// tenantIdOverride (SystemAdmin-only) lets an app point the content tree at a
|
|
130
|
+
// tenant other than the session's — publicstatus seeds marketing/legal blocks
|
|
131
|
+
// on SYSTEM_TENANT_ID, so its SystemAdmin must read that tenant, not their own.
|
|
132
|
+
function makeTreeProvider(tenantIdOverride?: string): TreeChildrenSubscribe {
|
|
133
|
+
return () => (emit, emitError) => {
|
|
134
|
+
// CSRF header is mandatory on authenticated requests (auth-middleware
|
|
135
|
+
// double-submit pattern). Anonymous/pre-login has no token → header
|
|
136
|
+
// omitted → the server takes the anonymous path.
|
|
137
|
+
const headers: Record<string, string> = { "content-type": "application/json" };
|
|
138
|
+
const csrf = readCsrfToken();
|
|
139
|
+
if (csrf !== undefined) headers[CSRF_HEADER_NAME] = csrf;
|
|
140
|
+
fetch("/api/query", {
|
|
141
|
+
method: "POST",
|
|
142
|
+
headers,
|
|
143
|
+
body: JSON.stringify({
|
|
144
|
+
type: TemplateResolverQueries.byTenant,
|
|
145
|
+
payload: tenantIdOverride !== undefined ? { tenantIdOverride } : {},
|
|
146
|
+
}),
|
|
147
|
+
})
|
|
148
|
+
.then(async (r) => {
|
|
149
|
+
if (!r.ok) {
|
|
150
|
+
// 403 (CSRF/auth) or 5xx — explicit error instead of silent empty.
|
|
151
|
+
const text = await r.text().catch(() => r.statusText);
|
|
152
|
+
throw new Error(`text-block load failed: ${r.status} ${text}`);
|
|
153
|
+
}
|
|
154
|
+
return r.json();
|
|
155
|
+
})
|
|
156
|
+
.then((data: ByTenantResponse) => {
|
|
157
|
+
// The app-side r.nav node IS the "Content" container → the provider's
|
|
158
|
+
// children are the folders/leaves below it, not the wrapper.
|
|
159
|
+
const content = groupBlocksByFolder(data.data.blocks, tenantIdOverride)[0];
|
|
160
|
+
emit(content !== undefined && Array.isArray(content.children) ? content.children : []);
|
|
161
|
+
})
|
|
162
|
+
.catch((e) => {
|
|
163
|
+
// Explicit error signal via emitError; ProviderBranch shows a banner
|
|
164
|
+
// plus retry. Falls back to emit([]) for consumers without emitError.
|
|
165
|
+
if (emitError) {
|
|
166
|
+
emitError(e instanceof Error ? e : new Error(String(e)));
|
|
167
|
+
} else {
|
|
168
|
+
emit([]);
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
return () => {};
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Edit form: loads the current values via by-slug, lets TenantAdmin and
|
|
176
|
+
// SystemAdmin edit title + content, dispatches set on submit. Other users see
|
|
177
|
+
// the form read-only with a hint banner — write permission stays opinionated
|
|
178
|
+
// TenantAdmin-only, apps widen it via dual-role mapping if they want to.
|
|
179
|
+
|
|
180
|
+
type TextBlock = {
|
|
181
|
+
readonly slug: string;
|
|
182
|
+
readonly locale: string;
|
|
183
|
+
readonly title: string | null;
|
|
184
|
+
readonly content: string | null;
|
|
185
|
+
readonly folder: string | null;
|
|
186
|
+
readonly updatedAt: string;
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
type SetResponse = { readonly slug: string; readonly locale: string; readonly isNew: boolean };
|
|
190
|
+
|
|
191
|
+
function TextBlockEditor({
|
|
192
|
+
target,
|
|
193
|
+
}: {
|
|
194
|
+
readonly target: TargetRef;
|
|
195
|
+
// onClose stays part of the resolver contract (the visual panel passes it)
|
|
196
|
+
// but is unused: tree selection navigates, there is no close button.
|
|
197
|
+
readonly onClose: () => void;
|
|
198
|
+
}): ReactNode {
|
|
199
|
+
// @cast-boundary visual-tree-args — TargetRef.args is erased to
|
|
200
|
+
// Record<string, unknown>; the resolver knows the action shape (see the
|
|
201
|
+
// treeActions.edit definition in feature.ts) and de-erases per action, the
|
|
202
|
+
// same way event payloads do. Optional chaining absorbs missing fields so a
|
|
203
|
+
// hand-edited URL cannot crash the editor.
|
|
204
|
+
const args = target.args as
|
|
205
|
+
| { slug?: string; locale?: string; tenantIdOverride?: string }
|
|
206
|
+
| undefined;
|
|
207
|
+
const slug = args?.slug ?? "";
|
|
208
|
+
const locale = args?.locale ?? "";
|
|
209
|
+
const tenantIdOverride = args?.tenantIdOverride;
|
|
210
|
+
|
|
211
|
+
const { Form, Field, Input, Button, Banner } = usePrimitives();
|
|
212
|
+
const dispatcher = useDispatcher();
|
|
213
|
+
const user = useShellUser();
|
|
214
|
+
const canWrite =
|
|
215
|
+
user?.roles.includes("TenantAdmin") === true || user?.roles.includes("SystemAdmin") === true;
|
|
216
|
+
|
|
217
|
+
const {
|
|
218
|
+
data: loaded,
|
|
219
|
+
loading,
|
|
220
|
+
error: loadError,
|
|
221
|
+
} = useQuery<TextBlock | null>(
|
|
222
|
+
TemplateResolverQueries.bySlug,
|
|
223
|
+
{ slug, locale, ...(tenantIdOverride !== undefined && { tenantIdOverride }) },
|
|
224
|
+
{ enabled: slug !== "" && locale !== "" },
|
|
225
|
+
);
|
|
226
|
+
|
|
227
|
+
const [title, setTitle] = useState("");
|
|
228
|
+
const [content, setContent] = useState("");
|
|
229
|
+
const [submitting, setSubmitting] = useState(false);
|
|
230
|
+
const [saveError, setSaveError] = useState<string | null>(null);
|
|
231
|
+
const [savedMsg, setSavedMsg] = useState<string | null>(null);
|
|
232
|
+
|
|
233
|
+
// Sync loaded data into the form state. loaded === null means the block does
|
|
234
|
+
// not exist yet — empty form for the create flow.
|
|
235
|
+
useEffect(() => {
|
|
236
|
+
if (loading) return;
|
|
237
|
+
setTitle(loaded?.title ?? "");
|
|
238
|
+
setContent(loaded?.content ?? "");
|
|
239
|
+
setSaveError(null);
|
|
240
|
+
setSavedMsg(null);
|
|
241
|
+
}, [loading, loaded]);
|
|
242
|
+
|
|
243
|
+
const handleSave = async (): Promise<void> => {
|
|
244
|
+
setSubmitting(true);
|
|
245
|
+
setSaveError(null);
|
|
246
|
+
setSavedMsg(null);
|
|
247
|
+
try {
|
|
248
|
+
const result = await dispatcher.write<SetResponse>(TemplateResolverHandlers.set, {
|
|
249
|
+
slug,
|
|
250
|
+
locale,
|
|
251
|
+
title,
|
|
252
|
+
content: content.length > 0 ? content : null,
|
|
253
|
+
folder: loaded?.folder ?? null,
|
|
254
|
+
...(tenantIdOverride !== undefined && { tenantIdOverride }),
|
|
255
|
+
});
|
|
256
|
+
if (result.isSuccess) {
|
|
257
|
+
setSavedMsg(result.data.isNew ? "Neu angelegt." : "Gespeichert.");
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
setSaveError(result.error.message ?? result.error.code ?? "Speichern fehlgeschlagen.");
|
|
261
|
+
} catch (e) {
|
|
262
|
+
// Network blip / dispatcher throw — otherwise submitting stays true and
|
|
263
|
+
// the save button is locked forever with no feedback.
|
|
264
|
+
setSaveError(e instanceof Error ? e.message : "Netzwerkfehler beim Speichern.");
|
|
265
|
+
} finally {
|
|
266
|
+
setSubmitting(false);
|
|
267
|
+
}
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
const onSubmit = (e?: FormEvent): void => {
|
|
271
|
+
e?.preventDefault();
|
|
272
|
+
void handleSave();
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
const disabled = submitting || loading || !canWrite;
|
|
276
|
+
|
|
277
|
+
return (
|
|
278
|
+
<Form
|
|
279
|
+
onSubmit={onSubmit}
|
|
280
|
+
testId="text-block-editor"
|
|
281
|
+
title={title || slug || "—"}
|
|
282
|
+
subtitle={locale !== "" ? `${slug} · (${locale})` : slug}
|
|
283
|
+
actions={
|
|
284
|
+
canWrite ? (
|
|
285
|
+
<Button type="submit" loading={submitting} disabled={disabled}>
|
|
286
|
+
{submitting ? "Speichern…" : "Speichern"}
|
|
287
|
+
</Button>
|
|
288
|
+
) : undefined
|
|
289
|
+
}
|
|
290
|
+
>
|
|
291
|
+
{loading && <Banner variant="loading">Lädt aktuellen Stand…</Banner>}
|
|
292
|
+
{loadError !== null && (
|
|
293
|
+
<Banner variant="error">Konnte Block nicht laden: {loadError.code}</Banner>
|
|
294
|
+
)}
|
|
295
|
+
{!canWrite && !loading && (
|
|
296
|
+
<Banner variant="info">
|
|
297
|
+
Read-only — TenantAdmin- oder SystemAdmin-Rolle für Änderungen erforderlich.
|
|
298
|
+
</Banner>
|
|
299
|
+
)}
|
|
300
|
+
<Field id="text-block-title" label="Titel" required>
|
|
301
|
+
<Input
|
|
302
|
+
kind="text"
|
|
303
|
+
id="text-block-title"
|
|
304
|
+
name="text-block-title"
|
|
305
|
+
value={title}
|
|
306
|
+
onChange={setTitle}
|
|
307
|
+
disabled={disabled}
|
|
308
|
+
required
|
|
309
|
+
/>
|
|
310
|
+
</Field>
|
|
311
|
+
<Field id="text-block-content" label="Inhalt">
|
|
312
|
+
<Input
|
|
313
|
+
kind="textarea"
|
|
314
|
+
id="text-block-content"
|
|
315
|
+
name="text-block-content"
|
|
316
|
+
value={content}
|
|
317
|
+
onChange={setContent}
|
|
318
|
+
disabled={disabled}
|
|
319
|
+
rows={14}
|
|
320
|
+
/>
|
|
321
|
+
</Field>
|
|
322
|
+
{saveError !== null && <Banner variant="error">{saveError}</Banner>}
|
|
323
|
+
{savedMsg !== null && <Banner variant="info">{savedMsg}</Banner>}
|
|
324
|
+
</Form>
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// `navId` = the QN of the r.nav({ provider: true }) node the app registers for
|
|
329
|
+
// the content tree (e.g. "publicstatus:nav:content"). The app owns the node's
|
|
330
|
+
// label/icon/access, the feature only supplies children plus the editor.
|
|
331
|
+
// Without navId: resolver only, no sidebar node (server-only consumers such as
|
|
332
|
+
// money-horse leak nothing).
|
|
333
|
+
// `tenantId` (SystemAdmin-only): which tenant the tree and editor serve —
|
|
334
|
+
// SYSTEM_TENANT_ID for apps that seed marketing/legal there. Omit for the
|
|
335
|
+
// session tenant (default, no cross-tenant access).
|
|
336
|
+
export function textBlocksClient(opts?: {
|
|
337
|
+
readonly navId?: string;
|
|
338
|
+
readonly tenantId?: string;
|
|
339
|
+
}): ClientFeatureDefinition {
|
|
340
|
+
const navId = opts?.navId;
|
|
341
|
+
return {
|
|
342
|
+
name: "template-resolver",
|
|
343
|
+
...(navId !== undefined && {
|
|
344
|
+
navProviders: { [navId]: makeTreeProvider(opts?.tenantId) },
|
|
345
|
+
// SSE refresh: every template-resource event re-fires the provider.
|
|
346
|
+
navEntities: { [navId]: ["template-resource"] },
|
|
347
|
+
}),
|
|
348
|
+
resolvers: {
|
|
349
|
+
"template-resolver:edit": TextBlockEditor,
|
|
350
|
+
},
|
|
351
|
+
};
|
|
352
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// @runtime client
|
|
2
|
+
// Public exports for the browser side of template-resolver. Consumed through
|
|
3
|
+
// the sub-path export `@cosmicdrift/kumiko-bundled-features/template-resolver/web`
|
|
4
|
+
// — the server side (defineFeature) lives in
|
|
5
|
+
// `@cosmicdrift/kumiko-bundled-features/template-resolver` and has no React or
|
|
6
|
+
// DOM deps.
|
|
7
|
+
|
|
8
|
+
export { textBlocksClient } from "./client-plugin";
|
|
@@ -47,6 +47,15 @@ test("defineCreateWithTenantDefaults throws on an unknown currency field", () =>
|
|
|
47
47
|
).toThrow(/unknown field "notARealField"/);
|
|
48
48
|
});
|
|
49
49
|
|
|
50
|
+
test("defineCreateWithTenantDefaults throws on an unknown locale field", () => {
|
|
51
|
+
expect(() =>
|
|
52
|
+
defineCreateWithTenantDefaults("invoice", invoiceEntity, {
|
|
53
|
+
access: ACCESS,
|
|
54
|
+
localeField: "languge",
|
|
55
|
+
}),
|
|
56
|
+
).toThrow(/unknown field "languge"/);
|
|
57
|
+
});
|
|
58
|
+
|
|
50
59
|
describe("without tenant-settings mount", () => {
|
|
51
60
|
const invoiceFeature = defineFeature("invoice", (r) => {
|
|
52
61
|
r.entity("invoice", invoiceEntity);
|
|
@@ -42,6 +42,9 @@ export function defineCreateWithTenantDefaults(
|
|
|
42
42
|
const isRequired = "required" in def && def.required === true;
|
|
43
43
|
relax[field] = isRequired ? OPTIONAL_CURRENCY_MONEY : OPTIONAL_CURRENCY_MONEY.optional();
|
|
44
44
|
}
|
|
45
|
+
if (options.localeField && !entity.fields[options.localeField]) {
|
|
46
|
+
throw new Error(`defineCreateWithTenantDefaults: unknown field "${options.localeField}"`);
|
|
47
|
+
}
|
|
45
48
|
const baseSchema = buildInsertSchema(entity);
|
|
46
49
|
const schema = Object.keys(relax).length > 0 ? baseSchema.extend(relax) : baseSchema;
|
|
47
50
|
|
|
@@ -85,19 +85,8 @@ export type ComposedApp<TCaps extends Readonly<Record<string, unknown>>> = {
|
|
|
85
85
|
* is safer than silently mounting the wrong feature-set and hoping nobody
|
|
86
86
|
* notices when "Pro" turns out to mean "Free".
|
|
87
87
|
*
|
|
88
|
-
* **No production callers, and that is not rot
|
|
89
|
-
*
|
|
90
|
-
* which features a given tenant may see out of everything mounted, is
|
|
91
|
-
* `createTierEngineFeature`'s `resolver(tenantId) => ReadonlySet<string>`
|
|
92
|
-
* (feature.ts). Both read the same tierMap at opposite ends. Apps use the
|
|
93
|
-
* resolver today; nobody composes per-tenant feature sets at boot yet, so an
|
|
94
|
-
* app that only needs caps merges them locally rather than constructing a
|
|
95
|
-
* featureRegistry it has no other use for (kumiko-studio's
|
|
96
|
-
* `resolvePlatformCaps`, studio#154/1 → PR #162 — a deliberate, typed copy of
|
|
97
|
-
* step 4, not a workaround). Whether the boot-time half ever gets a caller is
|
|
98
|
-
* a product question: kumiko-studio's BYO pivot (Sprint 8a Phase 3a)
|
|
99
|
-
* differentiates tiers by CAPS, not by feature set. Delete this only once no
|
|
100
|
-
* app plans to differentiate by feature set — the engine is app-agnostic.
|
|
88
|
+
* **No production callers, and that is not rot** — see
|
|
89
|
+
* docs/reference/tier-composition-boot-vs-runtime.md for why.
|
|
101
90
|
*/
|
|
102
91
|
export function composeApp<TCaps extends Readonly<Record<string, unknown>>>(
|
|
103
92
|
input: ComposeAppInput<TCaps>,
|
|
@@ -104,6 +104,31 @@ describe("seedUser", () => {
|
|
|
104
104
|
expect(row?.["passwordHash"]).toBeNull();
|
|
105
105
|
});
|
|
106
106
|
|
|
107
|
+
test("emailVerified:true reconciled auf einem bereits geseedeten User (#1687)", async () => {
|
|
108
|
+
const first = await seedUser(stack.db, {
|
|
109
|
+
email: "frank@example.com",
|
|
110
|
+
displayName: "Frank",
|
|
111
|
+
});
|
|
112
|
+
const [rowBefore] = await selectMany(stack.db, userTable, { id: first.id });
|
|
113
|
+
expect(rowBefore?.["emailVerified"]).not.toBe(true);
|
|
114
|
+
|
|
115
|
+
const second = await seedUser(stack.db, {
|
|
116
|
+
email: "frank@example.com",
|
|
117
|
+
displayName: "Frank",
|
|
118
|
+
emailVerified: true,
|
|
119
|
+
});
|
|
120
|
+
expect(second.id).toBe(first.id);
|
|
121
|
+
|
|
122
|
+
const [rowAfter] = await selectMany(stack.db, userTable, { id: first.id });
|
|
123
|
+
expect(rowAfter?.["emailVerified"]).toBe(true);
|
|
124
|
+
|
|
125
|
+
const updated = await selectMany(stack.db, eventsTable, {
|
|
126
|
+
aggregateType: "user",
|
|
127
|
+
type: "user.updated",
|
|
128
|
+
});
|
|
129
|
+
expect(updated).toHaveLength(1);
|
|
130
|
+
});
|
|
131
|
+
|
|
107
132
|
test("default `by` ist TestUsers.systemAdmin (für audit-trail)", async () => {
|
|
108
133
|
const { id: userId } = await seedUser(stack.db, {
|
|
109
134
|
email: "eve@example.com",
|