@eintrek/erp-shell 0.1.24 → 0.1.25

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.
@@ -31,13 +31,12 @@ export interface PermissionProviderProps {
31
31
  * Permission gate for a page subtree. Looks up the current pathname in the
32
32
  * permission rules map (live rules from `PermissionRulesProvider`, layered
33
33
  * over the optional `fallbackRulesMap`), passes the resolved required roles
34
- * into the host's `usePermission` probe, and renders one of three states:
34
+ * into the host's `usePermission` probe, and renders one of:
35
35
  *
36
- * 1. while rules or probe are still loading → centered spinner card
37
- * 2. probe says no accesscentered "ไม่มีสิทธิ์" card
38
- * 3. otherwise → children
39
- *
40
- * Role codes are intentionally not shown on the deny screen (they are
41
- * internal identifiers like `fin_controller` and confuse end users).
36
+ * 1. rules / probe still loading → `null` (let Suspense / loading.tsx show)
37
+ * 2. no rule covers this path deny (fail closed)
38
+ * 3. rule is explicit public (`[]`) → children
39
+ * 4. probe says no access → deny card
40
+ * 5. otherwise children
42
41
  */
43
42
  export declare function PermissionProvider({ children, usePermission, fallbackRulesMap, }: PermissionProviderProps): import("react/jsx-runtime").JSX.Element | null;
@@ -9,14 +9,31 @@ import type { PermissionRulesMap } from "../providers/permission-rules-context";
9
9
  * the same management screen with different filters.
10
10
  */
11
11
  export declare function normalizePathnameForPermissionRules(pathname: string): string;
12
+ export interface UiRoutePermissionLookup {
13
+ /** True when an exact, pattern, or restrictive parent rule was found. */
14
+ matched: boolean;
15
+ /**
16
+ * Required child-group segments. Empty array means the route is
17
+ * explicitly public (configured as `required_child_groups: []`).
18
+ * When `matched` is false this is always `[]` and must be treated as deny.
19
+ */
20
+ roles: string[];
21
+ }
12
22
  /**
13
- * Returns the required child-group segments for a page pathname (the user's
14
- * JWT must contain at least one matching group). An exact match on the
15
- * normalized path wins; otherwise the function falls back to a segment-by-
16
- * segment pattern match where `[param]` placeholders accept any non-empty
17
- * value. Returns an empty array when no rule covers the path.
23
+ * Resolve UI route permissions for a pathname.
18
24
  *
19
- * `rulesMap` is the merged map apps that want a fallback should layer it
20
- * here (`{ ...fallbackMap, ...liveMap }`) before calling.
25
+ * - Exact / `[param]` pattern match matched
26
+ * - Parent-prefix inherit only when the parent rule has a **non-empty**
27
+ * role list (restrictive inherit). Explicitly public parents (`[]`) do
28
+ * not open all children.
29
+ * - No match → `{ matched: false }` (fail closed — never treat as public)
30
+ */
31
+ export declare function resolveUiRoutePermission(pathname: string, rulesMap: PermissionRulesMap): UiRoutePermissionLookup;
32
+ /**
33
+ * Returns the required child-group segments for a page pathname.
34
+ *
35
+ * Prefer {@link resolveUiRoutePermission} when you need to distinguish
36
+ * "explicitly public (`[]`)" from "no rule (deny)". This helper returns
37
+ * `[]` for both cases and is kept for backward compatibility.
21
38
  */
22
39
  export declare function getRequiredChildGroups(pathname: string, rulesMap: PermissionRulesMap): string[];
package/dist/index.d.ts CHANGED
@@ -21,7 +21,7 @@ export { AppSidebar, type AppSidebarProps } from "./navigation/app-sidebar";
21
21
  export { AppLayoutShell, type AppLayoutShellProps, type AppLayoutShellOrganization, } from "./navigation/app-layout-shell";
22
22
  export { PermissionGateShell, type PermissionGateShellProps, type PermissionGateSkeletonVariant, } from "./auth/permission-gate-shell";
23
23
  export { PermissionProvider, type PermissionProviderProps, type PermissionProbe, type UsePermissionHook, } from "./auth/permission-provider";
24
- export { getRequiredChildGroups, normalizePathnameForPermissionRules, } from "./auth/permission-utils";
24
+ export { getRequiredChildGroups, normalizePathnameForPermissionRules, resolveUiRoutePermission, type UiRoutePermissionLookup, } from "./auth/permission-utils";
25
25
  export * from "./navigation/navigation-helpers";
26
26
  export type { Module, SubPage, SubModule, Tool, Company, NavigationData, } from "./navigation/navigation-types";
27
27
  export { canAccessNavPermissionRoute, filterNavigationToolsByPermission, filterNavigationModulesByPermission, getActiveModule, getActiveSubModule, buildUrlWithCode, } from "./navigation/navigation-data-helpers";
package/dist/index.esm.js CHANGED
@@ -604,12 +604,19 @@ SidebarMenuSubButton.displayName = "SidebarMenuSubButton";
604
604
 
605
605
  /**
606
606
  * True when the user's child-group segments include at least one of the
607
- * groups required for `permissionRouteKey`. Missing key / empty required
608
- * list both fall open (no restriction).
607
+ * groups required for `permissionRouteKey`.
608
+ *
609
+ * Fail-closed:
610
+ * - Nav item without `permissionRouteKey` → deny (must declare a key)
611
+ * - Key absent from `rulesMap` → deny (unknown route is not public)
612
+ * - Key present with `required_child_groups: []` → allow (explicit public)
609
613
  */
610
614
  function canAccessNavPermissionRoute(permissionRouteKey, rulesMap, userChildSegments) {
611
615
  if (!permissionRouteKey)
612
- return true;
616
+ return false;
617
+ if (!Object.prototype.hasOwnProperty.call(rulesMap, permissionRouteKey)) {
618
+ return false;
619
+ }
613
620
  const required = rulesMap[permissionRouteKey] ?? [];
614
621
  if (required.length === 0)
615
622
  return true;
@@ -1197,8 +1204,8 @@ function PermissionRulesProvider({ children, data, isLoading = false, isError =
1197
1204
  return (jsx(PermissionRulesContext.Provider, { value: value, children: children }));
1198
1205
  }
1199
1206
  /**
1200
- * Read the permission rules. Returns empty maps when used outside the
1201
- * provider (lets pages render without a hard-fail in tests).
1207
+ * Read the permission rules. Outside the provider, report loading so
1208
+ * fail-closed consumers (nav / gates) do not treat empty maps as "allow all".
1202
1209
  */
1203
1210
  function usePermissionRules() {
1204
1211
  const ctx = useContext(PermissionRulesContext);
@@ -1206,7 +1213,7 @@ function usePermissionRules() {
1206
1213
  return {
1207
1214
  rulesMap: {},
1208
1215
  policiesMap: {},
1209
- isLoading: false,
1216
+ isLoading: true,
1210
1217
  isError: false,
1211
1218
  updatedAt: null,
1212
1219
  };
@@ -1228,20 +1235,22 @@ function normalizePathnameForPermissionRules(pathname) {
1228
1235
  const path = parts.length <= 1 ? "/" : "/" + parts.slice(1).join("/");
1229
1236
  return path.replace(/\/user-role\/(purchase-doc|sales-doc)$/, "/user-role/members");
1230
1237
  }
1238
+ function hasRule(rulesMap, routeKey) {
1239
+ return Object.prototype.hasOwnProperty.call(rulesMap, routeKey);
1240
+ }
1231
1241
  /**
1232
- * Returns the required child-group segments for a page pathname (the user's
1233
- * JWT must contain at least one matching group). An exact match on the
1234
- * normalized path wins; otherwise the function falls back to a segment-by-
1235
- * segment pattern match where `[param]` placeholders accept any non-empty
1236
- * value. Returns an empty array when no rule covers the path.
1242
+ * Resolve UI route permissions for a pathname.
1237
1243
  *
1238
- * `rulesMap` is the merged map apps that want a fallback should layer it
1239
- * here (`{ ...fallbackMap, ...liveMap }`) before calling.
1244
+ * - Exact / `[param]` pattern match matched
1245
+ * - Parent-prefix inherit only when the parent rule has a **non-empty**
1246
+ * role list (restrictive inherit). Explicitly public parents (`[]`) do
1247
+ * not open all children.
1248
+ * - No match → `{ matched: false }` (fail closed — never treat as public)
1240
1249
  */
1241
- function getRequiredChildGroups(pathname, rulesMap) {
1250
+ function resolveUiRoutePermission(pathname, rulesMap) {
1242
1251
  const normalized = normalizePathnameForPermissionRules(pathname);
1243
- if (rulesMap[normalized]) {
1244
- return rulesMap[normalized];
1252
+ if (hasRule(rulesMap, normalized)) {
1253
+ return { matched: true, roles: rulesMap[normalized] ?? [] };
1245
1254
  }
1246
1255
  const pathSegments = normalized.split("/").filter(Boolean);
1247
1256
  for (const [pattern, roles] of Object.entries(rulesMap)) {
@@ -1264,24 +1273,43 @@ function getRequiredChildGroups(pathname, rulesMap) {
1264
1273
  }
1265
1274
  }
1266
1275
  if (matches) {
1267
- return roles;
1276
+ return { matched: true, roles: roles ?? [] };
1268
1277
  }
1269
1278
  }
1270
- return [];
1279
+ // Parent-prefix: /leave/types/abc → /leave/types (non-empty roles only)
1280
+ for (let len = pathSegments.length - 1; len >= 1; len--) {
1281
+ const parent = "/" + pathSegments.slice(0, len).join("/");
1282
+ if (hasRule(rulesMap, parent) && (rulesMap[parent]?.length ?? 0) > 0) {
1283
+ return { matched: true, roles: rulesMap[parent] ?? [] };
1284
+ }
1285
+ }
1286
+ return { matched: false, roles: [] };
1287
+ }
1288
+ /**
1289
+ * Returns the required child-group segments for a page pathname.
1290
+ *
1291
+ * Prefer {@link resolveUiRoutePermission} when you need to distinguish
1292
+ * "explicitly public (`[]`)" from "no rule (deny)". This helper returns
1293
+ * `[]` for both cases and is kept for backward compatibility.
1294
+ */
1295
+ function getRequiredChildGroups(pathname, rulesMap) {
1296
+ return resolveUiRoutePermission(pathname, rulesMap).roles;
1271
1297
  }
1272
1298
 
1299
+ function DeniedCard() {
1300
+ return (jsx(Card, { className: "p-6", children: jsxs("div", { className: "flex flex-col items-center justify-center gap-3 text-center", children: [jsx(AlertCircle, { className: "h-8 w-8 text-destructive" }), jsxs("div", { className: "space-y-1", children: [jsx("h3", { className: "text-lg font-semibold text-destructive", children: "\u0E44\u0E21\u0E48\u0E21\u0E35\u0E2A\u0E34\u0E17\u0E18\u0E34\u0E4C\u0E40\u0E02\u0E49\u0E32\u0E16\u0E36\u0E07" }), jsx("p", { className: "text-sm text-muted-foreground", children: "\u0E04\u0E38\u0E13\u0E44\u0E21\u0E48\u0E21\u0E35\u0E2A\u0E34\u0E17\u0E18\u0E34\u0E4C\u0E43\u0E19\u0E01\u0E32\u0E23\u0E40\u0E02\u0E49\u0E32\u0E16\u0E36\u0E07\u0E2B\u0E19\u0E49\u0E32\u0E19\u0E35\u0E49 \u0E2B\u0E32\u0E01\u0E04\u0E34\u0E14\u0E27\u0E48\u0E32\u0E04\u0E27\u0E23\u0E40\u0E02\u0E49\u0E32\u0E16\u0E36\u0E07\u0E44\u0E14\u0E49 \u0E01\u0E23\u0E38\u0E13\u0E32\u0E15\u0E34\u0E14\u0E15\u0E48\u0E2D\u0E1C\u0E39\u0E49\u0E14\u0E39\u0E41\u0E25\u0E23\u0E30\u0E1A\u0E1A\u0E02\u0E2D\u0E07\u0E2D\u0E07\u0E04\u0E4C\u0E01\u0E23" })] })] }) }));
1301
+ }
1273
1302
  /**
1274
1303
  * Permission gate for a page subtree. Looks up the current pathname in the
1275
1304
  * permission rules map (live rules from `PermissionRulesProvider`, layered
1276
1305
  * over the optional `fallbackRulesMap`), passes the resolved required roles
1277
- * into the host's `usePermission` probe, and renders one of three states:
1306
+ * into the host's `usePermission` probe, and renders one of:
1278
1307
  *
1279
- * 1. while rules or probe are still loading → centered spinner card
1280
- * 2. probe says no accesscentered "ไม่มีสิทธิ์" card
1281
- * 3. otherwise → children
1282
- *
1283
- * Role codes are intentionally not shown on the deny screen (they are
1284
- * internal identifiers like `fin_controller` and confuse end users).
1308
+ * 1. rules / probe still loading → `null` (let Suspense / loading.tsx show)
1309
+ * 2. no rule covers this path deny (fail closed)
1310
+ * 3. rule is explicit public (`[]`) → children
1311
+ * 4. probe says no access → deny card
1312
+ * 5. otherwise children
1285
1313
  */
1286
1314
  function PermissionProvider({ children, usePermission, fallbackRulesMap, }) {
1287
1315
  const pathname = usePathname();
@@ -1292,31 +1320,27 @@ function PermissionProvider({ children, usePermission, fallbackRulesMap, }) {
1292
1320
  // live rules win over fallback when both define the same key
1293
1321
  return { ...fallbackRulesMap, ...rulesMap };
1294
1322
  }, [rulesMap, fallbackRulesMap]);
1295
- const requiredRoles = useMemo(() => getRequiredChildGroups(pathname, mergedMap), [pathname, mergedMap]);
1296
- // Hook must run unconditionally (rules of hooks); we just ignore the
1297
- // result on the fast path below.
1323
+ const lookup = useMemo(() => resolveUiRoutePermission(pathname, mergedMap), [pathname, mergedMap]);
1324
+ // Hook must run unconditionally (rules of hooks).
1298
1325
  const { hasPermission, isLoading } = usePermission({
1299
- requiredRoles,
1326
+ requiredRoles: lookup.roles,
1300
1327
  });
1301
- // Fast path — no rule covers this pathname, anyone can see it. Skip the
1302
- // probe UI entirely; flashing "checking permission" or "no access" on an
1303
- // unrestricted page is pure noise.
1304
- if (requiredRoles.length === 0) {
1328
+ if (rulesLoading) {
1329
+ return null;
1330
+ }
1331
+ // Unknown route → deny. Only explicit `required_child_groups: []` is public.
1332
+ if (!lookup.matched) {
1333
+ return jsx(DeniedCard, {});
1334
+ }
1335
+ if (lookup.roles.length === 0) {
1305
1336
  return jsx(Fragment$1, { children: children });
1306
1337
  }
1307
- // While the answer hasn't settled, render nothing and let the surrounding
1308
- // Next.js loading.tsx / Suspense fallback show. The old behaviour wrapped
1309
- // a big "กำลังตรวจสอบสิทธิ์..." card over the top, which both duplicated
1310
- // the app's own loader and — worse — caused a "ไม่มีสิทธิ์" flash on
1311
- // fast loads because the probe transiently returned `hasPermission:false,
1312
- // isLoading:false` before its dependencies (org context, role-segments)
1313
- // arrived.
1314
- const isLoadingCombined = rulesLoading || isLoading;
1338
+ const isLoadingCombined = isLoading;
1315
1339
  if (isLoadingCombined) {
1316
1340
  return null;
1317
1341
  }
1318
1342
  if (!hasPermission) {
1319
- return (jsx(Card, { className: "p-6", children: jsxs("div", { className: "flex flex-col items-center justify-center gap-3 text-center", children: [jsx(AlertCircle, { className: "h-8 w-8 text-destructive" }), jsxs("div", { className: "space-y-1", children: [jsx("h3", { className: "text-lg font-semibold text-destructive", children: "\u0E44\u0E21\u0E48\u0E21\u0E35\u0E2A\u0E34\u0E17\u0E18\u0E34\u0E4C\u0E40\u0E02\u0E49\u0E32\u0E16\u0E36\u0E07" }), jsx("p", { className: "text-sm text-muted-foreground", children: "\u0E04\u0E38\u0E13\u0E44\u0E21\u0E48\u0E21\u0E35\u0E2A\u0E34\u0E17\u0E18\u0E34\u0E4C\u0E43\u0E19\u0E01\u0E32\u0E23\u0E40\u0E02\u0E49\u0E32\u0E16\u0E36\u0E07\u0E2B\u0E19\u0E49\u0E32\u0E19\u0E35\u0E49 \u0E2B\u0E32\u0E01\u0E04\u0E34\u0E14\u0E27\u0E48\u0E32\u0E04\u0E27\u0E23\u0E40\u0E02\u0E49\u0E32\u0E16\u0E36\u0E07\u0E44\u0E14\u0E49 \u0E01\u0E23\u0E38\u0E13\u0E32\u0E15\u0E34\u0E14\u0E15\u0E48\u0E2D\u0E1C\u0E39\u0E49\u0E14\u0E39\u0E41\u0E25\u0E23\u0E30\u0E1A\u0E1A\u0E02\u0E2D\u0E07\u0E2D\u0E07\u0E04\u0E4C\u0E01\u0E23" })] })] }) }));
1343
+ return jsx(DeniedCard, {});
1320
1344
  }
1321
1345
  return jsx(Fragment$1, { children: children });
1322
1346
  }
@@ -1813,5 +1837,5 @@ const getAxiosErrorMessage = (error) => {
1813
1837
  return String(error);
1814
1838
  };
1815
1839
 
1816
- export { ApiError, AppBreadcrumb, AppLayoutShell, AppSidebar, FeatureFlagsProvider, HeaderBackground, NavMain, NavTools, NavUser, OrganizationSwitcher, PermissionGateShell, PermissionProvider, PermissionRulesProvider, ReactQueryProvider, SessionBackedProfileCacheSeeder, SessionRefreshTrigger, SidebarContent, SidebarFooter, SidebarHeader, SidebarProvider, SidebarTrigger, TapMenu, ThemeProvider, instance as axiosInstance, buildUrlWithCode, canAccessNavPermissionRoute, clearAccessTokenCache, clearOrganizationId, decodeJWT, filterNavigationModulesByPermission, filterNavigationToolsByPermission, getAccessToken, getActiveModule, getActiveSubModule, getAxiosErrorMessage, getOrganizationId, getQueryClientConfig, getRequiredChildGroups, isLikelyTransientNetworkError, isPathActive, normalizePathnameForPermissionRules, runGlobalRequestPrecheck, runWithTransientRetry, setOrganizationId, toApiError, unwrapApiResponse, updateOrganizationId, useClearOrgOnUserChange, useFeatureFlag, useFeatureFlags, useInvalidateQueriesOnOrgChange, useKeycloakRoles, useOrganizationIdState, usePermissionRules, useSessionRefresh, useSidebar, useSidebarToggle };
1840
+ export { ApiError, AppBreadcrumb, AppLayoutShell, AppSidebar, FeatureFlagsProvider, HeaderBackground, NavMain, NavTools, NavUser, OrganizationSwitcher, PermissionGateShell, PermissionProvider, PermissionRulesProvider, ReactQueryProvider, SessionBackedProfileCacheSeeder, SessionRefreshTrigger, SidebarContent, SidebarFooter, SidebarHeader, SidebarProvider, SidebarTrigger, TapMenu, ThemeProvider, instance as axiosInstance, buildUrlWithCode, canAccessNavPermissionRoute, clearAccessTokenCache, clearOrganizationId, decodeJWT, filterNavigationModulesByPermission, filterNavigationToolsByPermission, getAccessToken, getActiveModule, getActiveSubModule, getAxiosErrorMessage, getOrganizationId, getQueryClientConfig, getRequiredChildGroups, isLikelyTransientNetworkError, isPathActive, normalizePathnameForPermissionRules, resolveUiRoutePermission, runGlobalRequestPrecheck, runWithTransientRetry, setOrganizationId, toApiError, unwrapApiResponse, updateOrganizationId, useClearOrgOnUserChange, useFeatureFlag, useFeatureFlags, useInvalidateQueriesOnOrgChange, useKeycloakRoles, useOrganizationIdState, usePermissionRules, useSessionRefresh, useSidebar, useSidebarToggle };
1817
1841
  //# sourceMappingURL=index.esm.js.map