@cosmicdrift/kumiko-renderer 0.157.0 → 0.157.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-renderer",
3
- "version": "0.157.0",
3
+ "version": "0.157.2",
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.157.0",
19
- "@cosmicdrift/kumiko-headless": "0.157.0",
18
+ "@cosmicdrift/kumiko-framework": "0.157.2",
19
+ "@cosmicdrift/kumiko-headless": "0.157.2",
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 (
@@ -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,