@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.
|
|
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.
|
|
19
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
+
}
|
package/src/i18n-defaults.ts
CHANGED
|
@@ -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,
|