@cosmicdrift/kumiko-renderer-web 0.154.1 → 0.155.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-renderer-web",
3
- "version": "0.154.1",
3
+ "version": "0.155.0",
4
4
  "description": "Web-platform bindings for @cosmicdrift/kumiko-renderer. HTML default-primitives, browser history-based navigation, EventSource-backed live events, and a one-call createKumikoApp that mounts the whole stack via react-dom.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -16,9 +16,9 @@
16
16
  "./styles.css": "./src/styles.css"
17
17
  },
18
18
  "dependencies": {
19
- "@cosmicdrift/kumiko-dispatcher-live": "0.154.1",
20
- "@cosmicdrift/kumiko-headless": "0.154.1",
21
- "@cosmicdrift/kumiko-renderer": "0.154.1",
19
+ "@cosmicdrift/kumiko-dispatcher-live": "0.155.0",
20
+ "@cosmicdrift/kumiko-headless": "0.155.0",
21
+ "@cosmicdrift/kumiko-renderer": "0.155.0",
22
22
  "@radix-ui/react-dialog": "^1.1.15",
23
23
  "@radix-ui/react-dropdown-menu": "^2.1.16",
24
24
  "@radix-ui/react-label": "^2.1.8",
@@ -9,13 +9,23 @@ import type {
9
9
  AppSchema,
10
10
  FeatureSchema,
11
11
  NavDefinition,
12
+ ScreenDefinition,
12
13
  } from "@cosmicdrift/kumiko-framework/ui-types";
14
+ import { resolveNavigation } from "@cosmicdrift/kumiko-headless";
13
15
  import { buildNavRegistrySlice, buildNavRegistrySliceForApp } from "../nav-tree";
14
16
 
15
17
  function feature(navs: readonly NavDefinition[], featureName = "tasks"): FeatureSchema {
16
18
  return { featureName, entities: {}, screens: [], navs };
17
19
  }
18
20
 
21
+ function featureWithScreens(
22
+ navs: readonly NavDefinition[],
23
+ screens: readonly ScreenDefinition[],
24
+ featureName = "tasks",
25
+ ): FeatureSchema {
26
+ return { featureName, entities: {}, screens, navs };
27
+ }
28
+
19
29
  describe("buildNavRegistrySlice", () => {
20
30
  test("qualifiziert Nav-IDs zu feature:nav:id", () => {
21
31
  const slice = buildNavRegistrySlice(feature([{ id: "main", label: "Main" }]));
@@ -70,4 +80,80 @@ describe("buildNavRegistrySliceForApp", () => {
70
80
  const slice = buildNavRegistrySliceForApp(app);
71
81
  expect(slice.topLevel.map((n) => n.id)).toEqual(["shop:nav:catalog", "admin:nav:users"]);
72
82
  });
83
+
84
+ test("nav ohne eigene access erbt access vom referenzierten Screen (#1099)", () => {
85
+ const app: AppSchema = {
86
+ features: [
87
+ featureWithScreens(
88
+ [{ id: "members", label: "Members", screen: "members" }],
89
+ [
90
+ {
91
+ id: "members",
92
+ type: "custom",
93
+ renderer: { react: { __component: "Members" } },
94
+ access: { roles: ["Admin"] },
95
+ },
96
+ ],
97
+ ),
98
+ ],
99
+ };
100
+ const slice = buildNavRegistrySliceForApp(app);
101
+ expect(slice.topLevel[0]?.access).toEqual({ roles: ["Admin"] });
102
+ });
103
+
104
+ test("nav mit eigener access ignoriert die Screen-Access (expliziter Override gewinnt)", () => {
105
+ const app: AppSchema = {
106
+ features: [
107
+ featureWithScreens(
108
+ [
109
+ {
110
+ id: "members",
111
+ label: "Members",
112
+ screen: "members",
113
+ access: { roles: ["Admin", "Editor"] },
114
+ },
115
+ ],
116
+ [
117
+ {
118
+ id: "members",
119
+ type: "custom",
120
+ renderer: { react: { __component: "Members" } },
121
+ access: { roles: ["Admin"] },
122
+ },
123
+ ],
124
+ ),
125
+ ],
126
+ };
127
+ const slice = buildNavRegistrySliceForApp(app);
128
+ expect(slice.topLevel[0]?.access).toEqual({ roles: ["Admin", "Editor"] });
129
+ });
130
+
131
+ test("nav ohne screen bleibt ohne access (nichts zum Erben da)", () => {
132
+ const app: AppSchema = {
133
+ features: [feature([{ id: "group", label: "Group" }])],
134
+ };
135
+ const slice = buildNavRegistrySliceForApp(app);
136
+ expect(slice.topLevel[0]?.access).toBeUndefined();
137
+ });
138
+
139
+ test("end-to-end: resolveNavigation versteckt den Eintrag fuer eine Rolle, die den Ziel-Screen nicht sehen darf", () => {
140
+ const app: AppSchema = {
141
+ features: [
142
+ featureWithScreens(
143
+ [{ id: "members", label: "Members", screen: "members" }],
144
+ [
145
+ {
146
+ id: "members",
147
+ type: "custom",
148
+ renderer: { react: { __component: "Members" } },
149
+ access: { roles: ["Admin"] },
150
+ },
151
+ ],
152
+ ),
153
+ ],
154
+ };
155
+ const source = buildNavRegistrySliceForApp(app);
156
+ const tree = resolveNavigation({ source, user: { id: "u1", roles: ["Editor"] } });
157
+ expect(tree).toEqual([]);
158
+ });
73
159
  });
@@ -11,7 +11,12 @@
11
11
  // auf/zu. State lebt lokal im NavTree (useState); Default expanded
12
12
  // für alles, Caller kann später localStorage-Persistenz drüberlegen.
13
13
 
14
- import type { TargetRef, TreeAction, TreeNode } from "@cosmicdrift/kumiko-framework/engine";
14
+ import type {
15
+ AccessRule,
16
+ TargetRef,
17
+ TreeAction,
18
+ TreeNode,
19
+ } from "@cosmicdrift/kumiko-framework/engine";
15
20
  import type { NavDefinition } from "@cosmicdrift/kumiko-framework/ui-types";
16
21
  import type { NavNode, NavRegistrySlice } from "@cosmicdrift/kumiko-headless";
17
22
  import { resolveNavigation } from "@cosmicdrift/kumiko-headless";
@@ -816,14 +821,24 @@ export function buildNavRegistrySliceForApp(
816
821
  app: AppSchema,
817
822
  allowedNavQns?: ReadonlySet<string>,
818
823
  ): NavRegistrySlice {
824
+ const screenAccessByQn = buildScreenAccessMap(app);
819
825
  const qualified: NavDefinition[] = [];
820
826
  for (const feature of app.features) {
821
827
  for (const n of feature.navs ?? []) {
828
+ const screen =
829
+ n.screen !== undefined ? qualifyScreenId(feature.featureName, n.screen) : undefined;
830
+ // Nav entries without their own `access` inherit the referenced
831
+ // screen's access rule — otherwise a broader workspace/parent access
832
+ // lets a role see a nav entry whose target screen 403s them (#1099).
833
+ // An explicit `n.access` always wins; inheritance only fills the gap.
834
+ const inheritedAccess =
835
+ n.access ?? (screen !== undefined ? screenAccessByQn.get(screen) : undefined);
822
836
  qualified.push({
823
837
  ...n,
824
838
  id: qualifyNavId(feature.featureName, n.id),
825
839
  ...(n.parent !== undefined && { parent: qualifyNavId(feature.featureName, n.parent) }),
826
- ...(n.screen !== undefined && { screen: qualifyScreenId(feature.featureName, n.screen) }),
840
+ ...(screen !== undefined && { screen }),
841
+ ...(inheritedAccess !== undefined && { access: inheritedAccess }),
827
842
  });
828
843
  }
829
844
  }
@@ -863,6 +878,16 @@ function qualifyScreenId(feature: string, id: string): string {
863
878
  return id.includes(":screen:") ? id : `${feature}:screen:${id}`;
864
879
  }
865
880
 
881
+ function buildScreenAccessMap(app: AppSchema): Map<string, AccessRule | undefined> {
882
+ const map = new Map<string, AccessRule | undefined>();
883
+ for (const feature of app.features) {
884
+ for (const s of feature.screens) {
885
+ map.set(qualifyScreenId(feature.featureName, s.id), s.access);
886
+ }
887
+ }
888
+ return map;
889
+ }
890
+
866
891
  // `lastSegment` lebt jetzt in @cosmicdrift/kumiko-renderer (./app/qn) — eine
867
892
  // Quelle, beide Pakete teilen sie.
868
893
  export { lastSegment };