@cosmicdrift/kumiko-renderer 0.156.3 → 0.157.1

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-renderer",
3
- "version": "0.156.3",
3
+ "version": "0.157.1",
4
4
  "description": "Platform-agnostic React renderer for Kumiko screens. Contains the shared logic — primitives-contract, hooks, KumikoScreen, navigation & SSE abstractions — that any platform-specific renderer (web, native) composes. No DOM, no EventSource, no react-dom.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -15,8 +15,8 @@
15
15
  }
16
16
  },
17
17
  "dependencies": {
18
- "@cosmicdrift/kumiko-framework": "0.156.3",
19
- "@cosmicdrift/kumiko-headless": "0.156.3",
18
+ "@cosmicdrift/kumiko-framework": "0.157.1",
19
+ "@cosmicdrift/kumiko-headless": "0.157.1",
20
20
  "react": "^19.2.6",
21
21
  "temporal-polyfill": "^0.3.2"
22
22
  },
@@ -56,4 +56,17 @@ describe("kumikoDefaultTranslations covers every error i18nKey", () => {
56
56
  expect(de?.["errors.download.urlMissing"]).toBeTruthy();
57
57
  expect(en?.["errors.download.urlMissing"]).toBeTruthy();
58
58
  });
59
+
60
+ // Client-emitted (dispatcher-live error-mapping) — network/abort never
61
+ // reach the server, so no error class mints these; still rendered through
62
+ // this last-resort bundle whenever a feature reads res.error.i18nKey raw.
63
+ test("dispatcher.errors.network has de+en default", () => {
64
+ expect(de?.["dispatcher.errors.network"]).toBeTruthy();
65
+ expect(en?.["dispatcher.errors.network"]).toBeTruthy();
66
+ });
67
+
68
+ test("dispatcher.errors.aborted has de+en default", () => {
69
+ expect(de?.["dispatcher.errors.aborted"]).toBeTruthy();
70
+ expect(en?.["dispatcher.errors.aborted"]).toBeTruthy();
71
+ });
59
72
  });
@@ -1,5 +1,6 @@
1
1
  import type { ConfigCascade } from "@cosmicdrift/kumiko-framework/engine";
2
2
  import type {
3
+ AccessRule,
3
4
  ActionFormScreenDefinition,
4
5
  ConfigEditScreenDefinition,
5
6
  DashboardScreenDefinition,
@@ -29,6 +30,7 @@ import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } fro
29
30
  import { RenderEdit } from "../components/render-edit";
30
31
  import { RenderList, type ToolbarActionButton } from "../components/render-list";
31
32
  import { useDispatcher, useOptionalDispatcher } from "../context/dispatcher-context";
33
+ import { useUserRoles } from "../context/user-roles-context";
32
34
  import { useListUrlState } from "../hooks/use-list-url-state";
33
35
  import { useQuery } from "../hooks/use-query";
34
36
  import { useTranslation } from "../i18n";
@@ -112,6 +114,22 @@ export function qualifyNavId(featureName: string, navId: string): string {
112
114
  return `${featureName}:nav:${navId}`;
113
115
  }
114
116
 
117
+ // Minimal role-gate for the screen-render path (#1203 — nav filtering via
118
+ // filterByAccess in workspace-shell.tsx hid role-gated screens from the
119
+ // menu, but a direct URL/screenQn hit reached KumikoScreen unchecked).
120
+ // Duplicated instead of imported from framework/engine's hasAccess (pulls
121
+ // server-side deps) — same bundle-purity reasoning as headless/nav's
122
+ // resolve.ts:userCanSee, which this mirrors.
123
+ function screenAccessAllows(
124
+ access: AccessRule | undefined,
125
+ userRoles: readonly string[] | undefined,
126
+ ): boolean {
127
+ if (!access) return true;
128
+ if ("openToAll" in access) return access.openToAll;
129
+ if (userRoles === undefined) return false;
130
+ return access.roles.some((role) => userRoles.includes(role));
131
+ }
132
+
115
133
  export function KumikoScreen({
116
134
  schema,
117
135
  qn,
@@ -121,6 +139,7 @@ export function KumikoScreen({
121
139
  onCopyLink,
122
140
  }: KumikoScreenProps): ReactNode {
123
141
  const { Banner, Text } = usePrimitives();
142
+ const userRoles = useUserRoles();
124
143
  const screen = useMemo(
125
144
  () =>
126
145
  schema.screens.find(
@@ -137,6 +156,14 @@ export function KumikoScreen({
137
156
  );
138
157
  }
139
158
 
159
+ if (!screenAccessAllows(screen.access, userRoles)) {
160
+ return (
161
+ <Banner padded variant="error" testId="kumiko-screen-access-denied">
162
+ Access denied: <Text variant="code">{qn}</Text>
163
+ </Banner>
164
+ );
165
+ }
166
+
140
167
  switch (screen.type) {
141
168
  case "entityEdit":
142
169
  return (
@@ -423,6 +450,8 @@ function EntityEditUpdateBody({
423
450
  readonly onCopyLink?: () => Promise<void> | void;
424
451
  }): ReactNode {
425
452
  const { Banner, Text } = usePrimitives();
453
+ const t = useTranslation();
454
+ const effectiveTranslate = translate ?? t;
426
455
  const detailQn = `${toKebab(schema.featureName)}:query:${toKebab(screen.entity)}:detail`;
427
456
  const detailQuery = useQuery<Readonly<Record<string, unknown>>>(detailQn, { id: entityId });
428
457
 
@@ -436,7 +465,7 @@ function EntityEditUpdateBody({
436
465
  if (detailQuery.error) {
437
466
  return (
438
467
  <Banner padded variant="error" testId="kumiko-screen-error">
439
- {detailQuery.error.i18nKey}
468
+ {dispatcherErrorText(detailQuery.error, effectiveTranslate)}
440
469
  </Banner>
441
470
  );
442
471
  }
@@ -1008,7 +1037,7 @@ function EntityListBody({
1008
1037
  if (rowsQuery.error) {
1009
1038
  return (
1010
1039
  <Banner padded variant="error" testId="kumiko-screen-error">
1011
- {rowsQuery.error.i18nKey}
1040
+ {dispatcherErrorText(rowsQuery.error, effectiveTranslate)}
1012
1041
  </Banner>
1013
1042
  );
1014
1043
  }
@@ -1241,7 +1270,7 @@ function ProjectionListBody({
1241
1270
  if (rowsQuery.error) {
1242
1271
  return (
1243
1272
  <Banner padded variant="error" testId="kumiko-screen-error">
1244
- {rowsQuery.error.i18nKey}
1273
+ {dispatcherErrorText(rowsQuery.error, effectiveTranslate)}
1245
1274
  </Banner>
1246
1275
  );
1247
1276
  }
@@ -1293,6 +1322,8 @@ function ProjectionDetailBody({
1293
1322
  readonly entityId?: string;
1294
1323
  }): ReactNode {
1295
1324
  const { Banner, Text } = usePrimitives();
1325
+ const t = useTranslation();
1326
+ const effectiveTranslate = translate ?? t;
1296
1327
  const nav = useNav();
1297
1328
  const idParam = screen.idParam ?? "id";
1298
1329
  const entity = useMemo(() => synthesizeProjectionDetailEntity(screen.layout), [screen.layout]);
@@ -1325,7 +1356,7 @@ function ProjectionDetailBody({
1325
1356
  if (detailQuery.error) {
1326
1357
  return (
1327
1358
  <Banner padded variant="error" testId="kumiko-screen-error">
1328
- {detailQuery.error.i18nKey}
1359
+ {dispatcherErrorText(detailQuery.error, effectiveTranslate)}
1329
1360
  </Banner>
1330
1361
  );
1331
1362
  }
@@ -1470,6 +1501,8 @@ function ConfigEditBody({
1470
1501
  readonly translate?: Translate;
1471
1502
  }): ReactNode {
1472
1503
  const { Banner, ConfigCascadeView } = usePrimitives();
1504
+ const t = useTranslation();
1505
+ const effectiveTranslate = translate ?? t;
1473
1506
  const dispatcher = useDispatcher();
1474
1507
 
1475
1508
  // Detail-Load: config:query:values returnt ALLE Keys des Tenants.
@@ -1594,7 +1627,7 @@ function ConfigEditBody({
1594
1627
  if (valuesQuery.error) {
1595
1628
  return (
1596
1629
  <Banner padded variant="error" testId="kumiko-screen-error">
1597
- {valuesQuery.error.i18nKey}
1630
+ {dispatcherErrorText(valuesQuery.error, effectiveTranslate)}
1598
1631
  </Banner>
1599
1632
  );
1600
1633
  }
@@ -0,0 +1,27 @@
1
+ import { createContext, type ReactNode, useContext } from "react";
2
+
3
+ // Threads the current user's roles through the tree so KumikoScreen can
4
+ // gate role-restricted screens at render time (see #1203 — nav filtering
5
+ // alone doesn't stop a direct-URL/screenQn hit on a role-gated screen).
6
+ // Apps wire this from their `shell` render-prop, the same place they
7
+ // already pass `user` to WorkspaceShell for nav filtering.
8
+ //
9
+ // undefined (no provider mounted, or `roles` not passed) means "roles
10
+ // unknown" — deliberately distinct from `[]` ("authenticated, no
11
+ // roles"). Both deny role-gated screens; only `undefined` also lets an
12
+ // app without any role-gated screens skip wiring this entirely.
13
+
14
+ const UserRolesContext = createContext<readonly string[] | undefined>(undefined);
15
+
16
+ export type UserRolesProviderProps = {
17
+ readonly roles: readonly string[] | undefined;
18
+ readonly children: ReactNode;
19
+ };
20
+
21
+ export function UserRolesProvider({ roles, children }: UserRolesProviderProps): ReactNode {
22
+ return <UserRolesContext value={roles}>{children}</UserRolesContext>;
23
+ }
24
+
25
+ export function useUserRoles(): readonly string[] | undefined {
26
+ return useContext(UserRolesContext);
27
+ }
@@ -148,6 +148,9 @@ export const kumikoDefaultTranslations: TranslationsByLocale = {
148
148
  "Limit erreicht. Bitte Tarif upgraden oder auf die nächste Periode warten.",
149
149
  "errors.download.urlMissing": "Download nicht verfügbar — bitte versuche es erneut.",
150
150
  "auth.errors.originNotAllowed": "Zugriff von dieser Herkunft ist nicht erlaubt.",
151
+ "dispatcher.errors.network":
152
+ "Netzwerkfehler. Bitte überprüfe deine Verbindung und versuche es erneut.",
153
+ "dispatcher.errors.aborted": "Anfrage abgebrochen.",
151
154
  },
152
155
  en: {
153
156
  "kumiko.actions.save": "Save",
@@ -258,5 +261,7 @@ export const kumikoDefaultTranslations: TranslationsByLocale = {
258
261
  "errors.cap.exceeded": "Limit reached. Upgrade your plan or wait for the next period.",
259
262
  "errors.download.urlMissing": "Download unavailable — please try again.",
260
263
  "auth.errors.originNotAllowed": "Requests from this origin are not allowed.",
264
+ "dispatcher.errors.network": "Network error. Please check your connection and try again.",
265
+ "dispatcher.errors.aborted": "Request was cancelled.",
261
266
  },
262
267
  };
package/src/index.ts CHANGED
@@ -59,6 +59,8 @@ export {
59
59
  useDispatcherStatus,
60
60
  useOptionalDispatcher,
61
61
  } from "./context/dispatcher-context";
62
+ export type { UserRolesProviderProps } from "./context/user-roles-context";
63
+ export { UserRolesProvider, useUserRoles } from "./context/user-roles-context";
62
64
  export { formatWhen } from "./format-when";
63
65
  export {
64
66
  REFERENCE_COMBOBOX_LIMIT,