@cosmicdrift/kumiko-bundled-features 0.130.0 → 0.130.2

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.
@@ -53,7 +53,7 @@ describe("groupBlocksByFolder", () => {
53
53
  args: { slug: "imprint", lang: "de" },
54
54
  });
55
55
  expect(root.children).toBeUndefined();
56
- expect(root.icon).toBeUndefined();
56
+ expect(root.icon).toBe("file");
57
57
  });
58
58
  test('folder="page" → Folder-Container mit child', () => {
59
59
  const nodes = inside(
@@ -101,7 +101,7 @@ describe("groupBlocksByFolder", () => {
101
101
  );
102
102
  expect(nodes).toHaveLength(2);
103
103
  expect(nodes[0]?.label).toBe("imprint");
104
- expect(nodes[0]?.icon).toBeUndefined();
104
+ expect(nodes[0]?.icon).toBe("file");
105
105
  expect(nodes[1]?.label).toBe("page");
106
106
  expect(nodes[1]?.icon).toBe("folder");
107
107
  });
@@ -184,12 +184,37 @@ describe("groupBlocksByFolder", () => {
184
184
  const leaf = nodes[0];
185
185
  const folder = nodes[1];
186
186
  expect(leaf?.label).toBe("Page-Root");
187
- expect(leaf?.icon).toBeUndefined();
187
+ expect(leaf?.icon).toBe("file");
188
188
  expect(leaf?.target).toBeDefined();
189
189
  expect(folder?.label).toBe("page");
190
190
  expect(folder?.icon).toBe("folder");
191
191
  expect(folder?.target).toBeUndefined();
192
192
  });
193
+ test("tenantIdOverride wird in die Edit-Target-Args gereicht (SystemAdmin SYSTEM-Tenant-Content)", () => {
194
+ const nodes = inside(
195
+ groupBlocksByFolder(
196
+ [block({ slug: "imprint", folder: null }), block({ slug: "hero", folder: "page" })],
197
+ "00000000-0000-4000-8000-000000000000",
198
+ ),
199
+ );
200
+ // Root-Leaf trägt den Override.
201
+ expect(nodes[0]?.target?.args).toEqual({
202
+ slug: "imprint",
203
+ lang: "de",
204
+ tenantIdOverride: "00000000-0000-4000-8000-000000000000",
205
+ });
206
+ // Auch der Leaf im Folder (rekursiv durchgereicht).
207
+ const folderChild = childrenArray(nodes[1]?.children)[0];
208
+ expect(folderChild?.target?.args).toEqual({
209
+ slug: "hero",
210
+ lang: "de",
211
+ tenantIdOverride: "00000000-0000-4000-8000-000000000000",
212
+ });
213
+ });
214
+ test("ohne tenantIdOverride bleiben die Args frei davon (Default: Session-Tenant)", () => {
215
+ const nodes = inside(groupBlocksByFolder([block({ slug: "imprint", folder: null })]));
216
+ expect(nodes[0]?.target?.args).toEqual({ slug: "imprint", lang: "de" });
217
+ });
193
218
  test("Wrapper-Folder 'Content' umschließt alle blocks", () => {
194
219
  // V.1.5d Wrapper-Convention: groupBlocksByFolder gibt EINEN Knoten
195
220
  // zurück (den Wrapper), Inhalt liegt eine Ebene tiefer.
@@ -74,13 +74,21 @@ function newFolderNode(): FolderNode {
74
74
  return { leaves: [], subFolders: new Map() };
75
75
  }
76
76
 
77
- function attachBlock(root: FolderNode, block: BlockSummary): void {
77
+ function attachBlock(root: FolderNode, block: BlockSummary, tenantIdOverride?: string): void {
78
78
  const leaf: TreeNode = {
79
79
  label: block.title || block.slug,
80
+ icon: "file",
80
81
  target: {
81
82
  featureId: "text-content",
82
83
  action: "edit",
83
- args: { slug: block.slug, lang: block.lang },
84
+ // tenantIdOverride travels with the node → the editor's by-slug read +
85
+ // set write target the same tenant the tree was loaded from (SystemAdmin
86
+ // editing SYSTEM-tenant content). Omitted → session tenant.
87
+ args: {
88
+ slug: block.slug,
89
+ lang: block.lang,
90
+ ...(tenantIdOverride !== undefined && { tenantIdOverride }),
91
+ },
84
92
  },
85
93
  state: block.body ? "filled" : "stub",
86
94
  };
@@ -116,10 +124,13 @@ function renderFolderNode(node: FolderNode): TreeNode[] {
116
124
  return result;
117
125
  }
118
126
 
119
- export function groupBlocksByFolder(blocks: readonly BlockSummary[]): readonly TreeNode[] {
127
+ export function groupBlocksByFolder(
128
+ blocks: readonly BlockSummary[],
129
+ tenantIdOverride?: string,
130
+ ): readonly TreeNode[] {
120
131
  const root = newFolderNode();
121
132
  for (const block of blocks) {
122
- attachBlock(root, block);
133
+ attachBlock(root, block, tenantIdOverride);
123
134
  }
124
135
  const rendered = renderFolderNode(root);
125
136
  if (rendered.length === 0) return [];
@@ -133,47 +144,52 @@ export function groupBlocksByFolder(blocks: readonly BlockSummary[]): readonly T
133
144
  ];
134
145
  }
135
146
 
136
- const treeProvider: TreeChildrenSubscribe = () => (emit, emitError) => {
137
- // CSRF-Header bei authenticated requests pflicht (auth-middleware
138
- // double-submit pattern). Anonymous/Pre-Login wäre csrf-token=undefined
139
- // header weggelassen → server lässt die anonymous-Variante durch.
140
- const headers: Record<string, string> = { "content-type": "application/json" };
141
- const csrf = readCsrfToken();
142
- if (csrf !== undefined) headers[CSRF_HEADER_NAME] = csrf;
143
- fetch("/api/query", {
144
- method: "POST",
145
- headers,
146
- body: JSON.stringify({
147
- type: TextContentQueries.byTenant,
148
- payload: {},
149
- }),
150
- })
151
- .then(async (r) => {
152
- if (!r.ok) {
153
- // 403 (CSRF/auth) oder 5xx — explicit error statt silent empty.
154
- const text = await r.text().catch(() => r.statusText);
155
- throw new Error(`text-content load failed: ${r.status} ${text}`);
156
- }
157
- return r.json();
158
- })
159
- .then((data: ByTenantResponse) => {
160
- // Der App-seitige r.nav-Knoten IST der "Content"-Container → die
161
- // Provider-Kinder sind die Folder/Leaves darunter, nicht der Wrapper.
162
- const content = groupBlocksByFolder(data.data.blocks)[0];
163
- emit(content !== undefined && Array.isArray(content.children) ? content.children : []);
147
+ // tenantIdOverride (SystemAdmin-only) lets an app point the Content tree at a
148
+ // tenant other than the session's — publicstatus seeds marketing/legal blocks
149
+ // on SYSTEM_TENANT_ID, so its SystemAdmin must read that tenant, not their own.
150
+ function makeTreeProvider(tenantIdOverride?: string): TreeChildrenSubscribe {
151
+ return () => (emit, emitError) => {
152
+ // CSRF-Header bei authenticated requests pflicht (auth-middleware
153
+ // double-submit pattern). Anonymous/Pre-Login wäre csrf-token=undefined
154
+ // → header weggelassen → server lässt die anonymous-Variante durch.
155
+ const headers: Record<string, string> = { "content-type": "application/json" };
156
+ const csrf = readCsrfToken();
157
+ if (csrf !== undefined) headers[CSRF_HEADER_NAME] = csrf;
158
+ fetch("/api/query", {
159
+ method: "POST",
160
+ headers,
161
+ body: JSON.stringify({
162
+ type: TextContentQueries.byTenant,
163
+ payload: tenantIdOverride !== undefined ? { tenantIdOverride } : {},
164
+ }),
164
165
  })
165
- .catch((e) => {
166
- // V.1.4: explicit error-Signal via emitError. ProviderBranch zeigt
167
- // Banner + Retry-Button. Fallback auf emit([]) wenn der Consumer
168
- // kein emitError unterstützt (Tests etc.).
169
- if (emitError) {
170
- emitError(e instanceof Error ? e : new Error(String(e)));
171
- } else {
172
- emit([]);
173
- }
174
- });
175
- return () => {};
176
- };
166
+ .then(async (r) => {
167
+ if (!r.ok) {
168
+ // 403 (CSRF/auth) oder 5xx explicit error statt silent empty.
169
+ const text = await r.text().catch(() => r.statusText);
170
+ throw new Error(`text-content load failed: ${r.status} ${text}`);
171
+ }
172
+ return r.json();
173
+ })
174
+ .then((data: ByTenantResponse) => {
175
+ // Der App-seitige r.nav-Knoten IST der "Content"-Container → die
176
+ // Provider-Kinder sind die Folder/Leaves darunter, nicht der Wrapper.
177
+ const content = groupBlocksByFolder(data.data.blocks, tenantIdOverride)[0];
178
+ emit(content !== undefined && Array.isArray(content.children) ? content.children : []);
179
+ })
180
+ .catch((e) => {
181
+ // V.1.4: explicit error-Signal via emitError. ProviderBranch zeigt
182
+ // Banner + Retry-Button. Fallback auf emit([]) wenn der Consumer
183
+ // kein emitError unterstützt (Tests etc.).
184
+ if (emitError) {
185
+ emitError(e instanceof Error ? e : new Error(String(e)));
186
+ } else {
187
+ emit([]);
188
+ }
189
+ });
190
+ return () => {};
191
+ };
192
+ }
177
193
 
178
194
  // V.1.3 echte Edit-Form: lädt aktuelle Werte via by-slug-query, lässt
179
195
  // TenantAdmin/SystemAdmin title + body editieren, dispatcht set-write
@@ -202,9 +218,11 @@ type SetResponse = { readonly slug: string; readonly lang: string; readonly isNe
202
218
 
203
219
  function TextContentEditor({
204
220
  target,
205
- onClose,
206
221
  }: {
207
222
  readonly target: TargetRef;
223
+ // onClose bleibt Teil des Resolver-Contracts (Visual-Panel liefert es),
224
+ // wird aber nicht mehr gebraucht: die Tree-Auswahl navigiert, kein
225
+ // Schließen-Button mehr. Nicht destrukturiert → kein unused binding.
208
226
  readonly onClose: () => void;
209
227
  }): ReactNode {
210
228
  // @cast-boundary visual-tree-args — TargetRef.args ist erased zu
@@ -213,9 +231,12 @@ function TextContentEditor({
213
231
  // zu Event-Payloads. Optional-Chain absorbiert fehlende Felder ohne
214
232
  // throw, damit der Editor auch bei manuellem URL-Tampering nicht
215
233
  // crasht (TargetRef könnte aus old localStorage / URL-State stammen).
216
- const args = target.args as { slug?: string; lang?: string } | undefined;
234
+ const args = target.args as
235
+ | { slug?: string; lang?: string; tenantIdOverride?: string }
236
+ | undefined;
217
237
  const slug = args?.slug ?? "";
218
238
  const lang = args?.lang ?? "";
239
+ const tenantIdOverride = args?.tenantIdOverride;
219
240
 
220
241
  const { Form, Field, Input, Button, Banner } = usePrimitives();
221
242
  const dispatcher = useDispatcher();
@@ -232,7 +253,7 @@ function TextContentEditor({
232
253
  error: loadError,
233
254
  } = useQuery<TextBlock | null>(
234
255
  TextContentQueries.bySlug,
235
- { slug, lang },
256
+ { slug, lang, ...(tenantIdOverride !== undefined && { tenantIdOverride }) },
236
257
  { enabled: slug !== "" && lang !== "" },
237
258
  );
238
259
 
@@ -266,6 +287,7 @@ function TextContentEditor({
266
287
  lang,
267
288
  title,
268
289
  body: body.length > 0 ? body : null,
290
+ ...(tenantIdOverride !== undefined && { tenantIdOverride }),
269
291
  });
270
292
  if (result.isSuccess) {
271
293
  setSavedMsg(result.data.isNew ? "Neu angelegt." : "Gespeichert.");
@@ -290,61 +312,53 @@ function TextContentEditor({
290
312
  const disabled = submitting || loading || !canWrite;
291
313
 
292
314
  return (
293
- <div className="flex h-full flex-col">
294
- <header className="flex items-center justify-between border-b px-6 py-4">
295
- <div>
296
- <h2 className="text-lg font-semibold">Text-Block bearbeiten</h2>
297
- <p className="text-xs text-muted-foreground">
298
- {slug || "—"} ({lang || "—"})
299
- </p>
300
- </div>
301
- <Button variant="secondary" onClick={onClose}>
302
- schlie&szlig;en
303
- </Button>
304
- </header>
305
- <div className="flex-1 overflow-y-auto px-6 py-6">
306
- <Form onSubmit={onSubmit}>
307
- {loading && <Banner variant="loading">Lädt aktuellen Stand…</Banner>}
308
- {loadError !== null && (
309
- <Banner variant="error">Konnte Block nicht laden: {loadError.code}</Banner>
310
- )}
311
- {!canWrite && !loading && (
312
- <Banner variant="info">
313
- Read-only — TenantAdmin- oder SystemAdmin-Rolle f&uuml;r &Auml;nderungen erforderlich.
314
- </Banner>
315
- )}
316
- <Field id="text-content-title" label="Titel" required>
317
- <Input
318
- kind="text"
319
- id="text-content-title"
320
- name="text-content-title"
321
- value={title}
322
- onChange={setTitle}
323
- disabled={disabled}
324
- required
325
- />
326
- </Field>
327
- <Field id="text-content-body" label="Inhalt">
328
- <Input
329
- kind="textarea"
330
- id="text-content-body"
331
- name="text-content-body"
332
- value={body}
333
- onChange={setBody}
334
- disabled={disabled}
335
- rows={14}
336
- />
337
- </Field>
338
- {saveError !== null && <Banner variant="error">{saveError}</Banner>}
339
- {savedMsg !== null && <Banner variant="info">{savedMsg}</Banner>}
340
- {canWrite && (
341
- <Button type="submit" loading={submitting} disabled={disabled}>
342
- {submitting ? "Speichern…" : "Speichern"}
343
- </Button>
344
- )}
345
- </Form>
346
- </div>
347
- </div>
315
+ <Form
316
+ onSubmit={onSubmit}
317
+ testId="text-content-editor"
318
+ title={title || slug || "—"}
319
+ subtitle={lang !== "" ? `(${lang})` : undefined}
320
+ actions={
321
+ canWrite ? (
322
+ <Button type="submit" loading={submitting} disabled={disabled}>
323
+ {submitting ? "Speichern…" : "Speichern"}
324
+ </Button>
325
+ ) : undefined
326
+ }
327
+ >
328
+ {loading && <Banner variant="loading">Lädt aktuellen Stand…</Banner>}
329
+ {loadError !== null && (
330
+ <Banner variant="error">Konnte Block nicht laden: {loadError.code}</Banner>
331
+ )}
332
+ {!canWrite && !loading && (
333
+ <Banner variant="info">
334
+ Read-only — TenantAdmin- oder SystemAdmin-Rolle f&uuml;r &Auml;nderungen erforderlich.
335
+ </Banner>
336
+ )}
337
+ <Field id="text-content-title" label="Titel" required>
338
+ <Input
339
+ kind="text"
340
+ id="text-content-title"
341
+ name="text-content-title"
342
+ value={title}
343
+ onChange={setTitle}
344
+ disabled={disabled}
345
+ required
346
+ />
347
+ </Field>
348
+ <Field id="text-content-body" label="Inhalt">
349
+ <Input
350
+ kind="textarea"
351
+ id="text-content-body"
352
+ name="text-content-body"
353
+ value={body}
354
+ onChange={setBody}
355
+ disabled={disabled}
356
+ rows={14}
357
+ />
358
+ </Field>
359
+ {saveError !== null && <Banner variant="error">{saveError}</Banner>}
360
+ {savedMsg !== null && <Banner variant="info">{savedMsg}</Banner>}
361
+ </Form>
348
362
  );
349
363
  }
350
364
 
@@ -353,12 +367,18 @@ function TextContentEditor({
353
367
  // Label/Icon/Access des Knotens (managed-pages-Konvention) — das Feature
354
368
  // liefert nur die Kinder + den Editor. Ohne navId: nur der Resolver, kein
355
369
  // Sidebar-Knoten (server-only-Consumer wie money-horse leaken nichts).
356
- export function textContentClient(opts?: { readonly navId?: string }): ClientFeatureDefinition {
370
+ // `tenantId` (SystemAdmin-only): welchen Tenant der Content-Tree + Editor
371
+ // bedienen — SYSTEM_TENANT_ID für Apps, die Marketing/Legal dort seeden.
372
+ // Weglassen → Session-Tenant (Default; kein Cross-Tenant-Zugriff).
373
+ export function textContentClient(opts?: {
374
+ readonly navId?: string;
375
+ readonly tenantId?: string;
376
+ }): ClientFeatureDefinition {
357
377
  const navId = opts?.navId;
358
378
  return {
359
379
  name: "text-content",
360
380
  ...(navId !== undefined && {
361
- navProviders: { [navId]: treeProvider },
381
+ navProviders: { [navId]: makeTreeProvider(opts?.tenantId) },
362
382
  // SSE-Refresh: jedes text-block-Event re-fired den Provider → Tree live.
363
383
  navEntities: { [navId]: ["text-block"] },
364
384
  }),
@@ -93,8 +93,6 @@ export function TierAdminScreen(): ReactNode {
93
93
 
94
94
  return (
95
95
  <FormScreenShell testId="tier-admin-screen" className="flex flex-col gap-6">
96
- <Text variant="small">{t("tier-admin.explainer")}</Text>
97
-
98
96
  {tenantsQuery.error !== null && (
99
97
  <Banner variant="error" testId="tier-admin-load-error">
100
98
  {t("tier-admin.error.load")}
@@ -123,6 +121,8 @@ export function TierAdminScreen(): ReactNode {
123
121
  </Button>
124
122
  }
125
123
  >
124
+ <Text variant="small">{t("tier-admin.explainer")}</Text>
125
+
126
126
  <Field id="tier-admin-tenant" label={t("tier-admin.tenant.label")} required>
127
127
  <Input
128
128
  kind="select"
@@ -1,3 +1,4 @@
1
+ // @runtime client
1
2
  // Feature name
2
3
  export const USER_FEATURE = "user" as const;
3
4
 
@@ -267,12 +267,7 @@ export function createUserDataRightsFeature(opts: UserDataRightsOptions = {}): F
267
267
  access: { roles: ["SystemAdmin"] },
268
268
  });
269
269
 
270
- r.translations({
271
- keys: {
272
- ...USER_DATA_RIGHTS_I18N,
273
- "user-data-rights:nav.exportJobs": { de: "DSGVO-Exporte", en: "GDPR exports" },
274
- },
275
- });
270
+ r.translations({ keys: USER_DATA_RIGHTS_I18N });
276
271
 
277
272
  // Dormant Self-Service-Screen (Art. 15/17/18/20): Export, Aktivitäts-
278
273
  // protokoll, Einschränkung, Löschung in einem Screen. Kein r.nav — die
@@ -1,6 +1,10 @@
1
+ // @runtime client
2
+ // Server + client i18n for user-data-rights (operator screens + nav). Pure
3
+ // data, importable from the web bundle (web/i18n.ts derives the client keys).
1
4
  type LocalizedString = { readonly de: string; readonly en: string };
2
5
 
3
6
  export const USER_DATA_RIGHTS_I18N: Readonly<Record<string, LocalizedString>> = {
7
+ "user-data-rights:nav.exportJobs": { de: "DSGVO-Exporte", en: "GDPR exports" },
4
8
  "screen:export-job-list.title": { de: "DSGVO-Exporte", en: "GDPR exports" },
5
9
  "screen:export-job-detail.title": { de: "Export-Job", en: "Export job" },
6
10
  "screen:download-attempt-list.title": { de: "Download-Versuche", en: "Download attempts" },
@@ -5,8 +5,24 @@
5
5
  // deletion.<step>.<slug>`.
6
6
 
7
7
  import type { TranslationsByLocale } from "@cosmicdrift/kumiko-renderer";
8
+ import { USER_DATA_RIGHTS_I18N } from "../i18n";
8
9
 
9
- export const defaultTranslations: TranslationsByLocale = {
10
+ const LOCALES = ["de", "en"] as const;
11
+
12
+ // Operator keys (export-job list/detail titles + entity field labels + nav)
13
+ // live in the server i18n; the SystemAdmin entityList screens render them
14
+ // client-side, so the web bundle must carry them too — otherwise raw keys
15
+ // show. Derive from the single server source instead of duplicating.
16
+ const operatorTranslations: TranslationsByLocale = Object.fromEntries(
17
+ LOCALES.map((locale) => [
18
+ locale,
19
+ Object.fromEntries(
20
+ Object.entries(USER_DATA_RIGHTS_I18N).map(([key, value]) => [key, value[locale]]),
21
+ ),
22
+ ]),
23
+ );
24
+
25
+ const apexTranslations: TranslationsByLocale = {
10
26
  de: {
11
27
  "userDataRights.deletion.request.title": "Account-Löschung beantragen",
12
28
  "userDataRights.deletion.request.intro":
@@ -152,3 +168,8 @@ export const defaultTranslations: TranslationsByLocale = {
152
168
  "The download is currently unavailable due to a server configuration issue. The operator has been notified.",
153
169
  },
154
170
  };
171
+
172
+ export const defaultTranslations: TranslationsByLocale = {
173
+ de: { ...apexTranslations["de"], ...operatorTranslations["de"] },
174
+ en: { ...apexTranslations["en"], ...operatorTranslations["en"] },
175
+ };