@eintrek/erp-shell 0.1.10 → 0.1.11

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.
@@ -0,0 +1,22 @@
1
+ import type { ReactNode } from "react";
2
+ export type PermissionGateSkeletonVariant = "kpi" | "simple";
3
+ export interface PermissionGateShellProps {
4
+ /** True while policy / route rules are still resolving */
5
+ policyLoading: boolean;
6
+ /** False when the user does not satisfy required_child_groups */
7
+ hasPolicyAccess: boolean;
8
+ /** Shown in a card when `hasPolicyAccess` is false */
9
+ deniedMessage: string;
10
+ children: ReactNode;
11
+ /** Number of placeholder stat cards */
12
+ skeletonCount?: number;
13
+ /** Grid wrapper classes for the skeleton row */
14
+ skeletonGridClassName?: string;
15
+ /** `kpi` matches dashboard KPI cards; `simple` is a lighter header + value */
16
+ skeletonVariant?: PermissionGateSkeletonVariant;
17
+ }
18
+ /**
19
+ * Shared layout for “กำลังตรวจสอบสิทธิ์” (skeleton) and “ไม่มีสิทธิ์” (message).
20
+ * Renders `children` only when policy is loaded and access is granted.
21
+ */
22
+ export declare function PermissionGateShell({ policyLoading, hasPolicyAccess, deniedMessage, children, skeletonCount, skeletonGridClassName, skeletonVariant, }: PermissionGateShellProps): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,44 @@
1
+ import { type ReactNode } from "react";
2
+ import { type PermissionRulesMap } from "../providers/permission-rules-context";
3
+ /**
4
+ * Probe contract — the host app supplies a hook that resolves "does the
5
+ * current user satisfy these required child-group names?". The shell can't
6
+ * own this directly because the probe pulls from the host's ERP context and
7
+ * organization-roles data client.
8
+ */
9
+ export interface PermissionProbe {
10
+ hasPermission: boolean;
11
+ isLoading: boolean;
12
+ /** All groups the user has (used to render the "your roles" hint). */
13
+ userRoles: string[];
14
+ }
15
+ export type UsePermissionHook = (input: {
16
+ requiredRoles: string[];
17
+ }) => PermissionProbe;
18
+ export interface PermissionProviderProps {
19
+ children: ReactNode;
20
+ /** Host app's probe hook — called on every render to test the current
21
+ * pathname's required roles. */
22
+ usePermission: UsePermissionHook;
23
+ /**
24
+ * Optional static fallback layered under the live rules map. Lets each
25
+ * app ship its checked-in `fallback-ui-route-permissions.json` so first
26
+ * paint isn't blocked on the live fetch.
27
+ */
28
+ fallbackRulesMap?: PermissionRulesMap;
29
+ }
30
+ /**
31
+ * Permission gate for a page subtree. Looks up the current pathname in the
32
+ * permission rules map (live rules from `PermissionRulesProvider`, layered
33
+ * over the optional `fallbackRulesMap`), passes the resolved required roles
34
+ * into the host's `usePermission` probe, and renders one of three states:
35
+ *
36
+ * 1. while rules or probe are still loading → centered spinner card
37
+ * 2. probe says no access → centered "ไม่มีสิทธิ์" card with required vs.
38
+ * user roles for debugging
39
+ * 3. otherwise → children
40
+ *
41
+ * The host wraps `<PermissionProvider>` around its page chrome (typically
42
+ * via `AppLayoutShell`'s `permissionProvider` slot).
43
+ */
44
+ export declare function PermissionProvider({ children, usePermission, fallbackRulesMap, }: PermissionProviderProps): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,22 @@
1
+ import type { PermissionRulesMap } from "../providers/permission-rules-context";
2
+ /**
3
+ * Strip the org `[code]` prefix from a pathname so we can look it up in the
4
+ * permission rules map. `/O12345/setting/foo` → `/setting/foo`. Single-segment
5
+ * paths (e.g. `/select-organization`) keep their leading slash.
6
+ *
7
+ * Also normalizes the user-role subtab aliases — both `purchase-doc` and
8
+ * `sales-doc` are gated by the same `members` rule because the UI shows
9
+ * the same management screen with different filters.
10
+ */
11
+ export declare function normalizePathnameForPermissionRules(pathname: string): string;
12
+ /**
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.
18
+ *
19
+ * `rulesMap` is the merged map — apps that want a fallback should layer it
20
+ * here (`{ ...fallbackMap, ...liveMap }`) before calling.
21
+ */
22
+ export declare function getRequiredChildGroups(pathname: string, rulesMap: PermissionRulesMap): string[];
package/dist/index.d.ts CHANGED
@@ -18,6 +18,10 @@ export { NavMain, type NavMainProps } from "./navigation/nav-main";
18
18
  export { OrganizationSwitcher, type OrganizationSwitcherProps, type OrganizationLike, } from "./navigation/organization-switcher";
19
19
  export { SidebarHeader } from "./navigation/sidebar-header";
20
20
  export { AppSidebar, type AppSidebarProps } from "./navigation/app-sidebar";
21
+ export { AppLayoutShell, type AppLayoutShellProps, type AppLayoutShellOrganization, } from "./navigation/app-layout-shell";
22
+ export { PermissionGateShell, type PermissionGateShellProps, type PermissionGateSkeletonVariant, } from "./auth/permission-gate-shell";
23
+ export { PermissionProvider, type PermissionProviderProps, type PermissionProbe, type UsePermissionHook, } from "./auth/permission-provider";
24
+ export { getRequiredChildGroups, normalizePathnameForPermissionRules, } from "./auth/permission-utils";
21
25
  export * from "./navigation/navigation-helpers";
22
26
  export type { Module, SubPage, SubModule, Tool, Company, NavigationData, } from "./navigation/navigation-types";
23
27
  export { canAccessNavPermissionRoute, filterNavigationToolsByPermission, filterNavigationModulesByPermission, getActiveModule, getActiveSubModule, buildUrlWithCode, } from "./navigation/navigation-data-helpers";
package/dist/index.esm.js CHANGED
@@ -1,11 +1,11 @@
1
1
  'use client';
2
2
  import * as React from 'react';
3
- import React__default, { useRef, useEffect, useMemo, useState, useCallback, createContext, useContext, Fragment, useTransition } from 'react';
3
+ import React__default, { useRef, useEffect, useMemo, useState, useCallback, createContext, useContext, Fragment, useTransition, Suspense } from 'react';
4
4
  import { useSession, signOut } from 'next-auth/react';
5
5
  import { useQueryClient, MutationCache, QueryCache, QueryClient, QueryClientProvider } from '@tanstack/react-query';
6
6
  import { jsx, jsxs, Fragment as Fragment$1 } from 'react/jsx-runtime';
7
- import { cn, Button, Breadcrumb, BreadcrumbList, BreadcrumbItem, BreadcrumbLink, BreadcrumbSeparator, BreadcrumbPage, Avatar, AvatarImage, AvatarFallback, Collapsible, CollapsibleTrigger, CollapsibleContent, Badge, Skeleton, DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuLabel, DropdownMenuItem, DropdownMenuSeparator } from '@eintrek/erp-theme';
8
- import { PanelLeftClose, Menu, ChevronRight, Building2, Palette, ChevronUp, Sun, Moon, Monitor, Globe, User, Loader2, LogOut, ChevronDown, ChevronsUpDown, Plus } from 'lucide-react';
7
+ import { cn, Button, Breadcrumb, BreadcrumbList, BreadcrumbItem, BreadcrumbLink, BreadcrumbSeparator, BreadcrumbPage, Avatar, AvatarImage, AvatarFallback, Collapsible, CollapsibleTrigger, CollapsibleContent, Badge, Skeleton, DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuLabel, DropdownMenuItem, DropdownMenuSeparator, Card, CardHeader, CardContent } from '@eintrek/erp-theme';
8
+ import { PanelLeftClose, Menu, ChevronRight, Building2, Palette, ChevronUp, Sun, Moon, Monitor, Globe, User, Loader2, LogOut, ChevronDown, ChevronsUpDown, Plus, AlertCircle } from 'lucide-react';
9
9
  import { create } from 'zustand';
10
10
  import { persist, createJSONStorage } from 'zustand/middleware';
11
11
  import { useRouter, usePathname, useParams } from 'next/navigation';
@@ -947,6 +947,261 @@ function AppSidebar({ modules, tools, activeModule, orgCode, approvalCounts, clo
947
947
  return (jsxs("div", { className: cn("bg-sidebar shrink-0 flex flex-col overflow-hidden shadow-2xl h-screen w-[var(--sidebar-width-mobile)]", "!w-76 xl:rounded-b-4xl xl:rounded-tl-4xl xl:m-5 xl:h-[calc(100vh-2.5rem)]", className), children: [jsx(SidebarHeader, { children: header }), jsxs(SidebarContent, { className: "pl-2 pr-2 flex-1 overflow-y-auto", children: [jsx(NavMain, { items: modulesWithActiveState, orgCode: orgCode, approvalCounts: approvalCounts, closeOnNavigation: closeOnNavigation }), jsx(NavTools, { tools: tools, closeOnNavigation: closeOnNavigation })] }), jsx(SidebarFooter, { className: "pl-2 pr-2", children: footer })] }));
948
948
  }
949
949
 
950
+ /**
951
+ * Top-level app chrome: sticky header, sidebar slot, content gutter and the
952
+ * session/org-context plumbing that both Accounting and HR need. Apps wrap
953
+ * it inside their `SidebarProvider` (server-rendered with the cookie-backed
954
+ * `defaultOpen`) and their `ERPProvider`, then pass app-specific bits as
955
+ * props.
956
+ *
957
+ * The padding contract is deliberately owned here so future style tweaks
958
+ * (e.g. the recent left/right/bottom gutter pass) propagate to both apps via
959
+ * an erp-shell bump — domain components must NOT add their own root padding
960
+ * or content gets indented twice.
961
+ */
962
+ function AppLayoutShell({ children, sidebar, loadingComponent, breadcrumb, permissionProvider, organizationId, setOrganizationId, organizations, signInUrl, selectOrganizationUrl, }) {
963
+ const { data: session, status } = useSession();
964
+ const { open, setOpen } = useSidebar();
965
+ const router = useRouter();
966
+ const params = useParams();
967
+ const code = params?.code;
968
+ const extendedSession = session;
969
+ // Find the org from the URL `[code]` so we can detect a context mismatch
970
+ // (the user bookmarked a different org than the cookie remembers).
971
+ const orgFromCode = React.useMemo(() => {
972
+ if (!code || !organizations.length)
973
+ return null;
974
+ return (organizations.find(org => org.code === code || String(org.id) === code) ?? null);
975
+ }, [code, organizations]);
976
+ // Sync state: when the URL code points at a different org than the cookie,
977
+ // trust the URL and update the cookie. Otherwise do nothing — the user
978
+ // wins ties via the in-app org switcher.
979
+ useEffect(() => {
980
+ if (!code)
981
+ return;
982
+ if (!organizations.length)
983
+ return;
984
+ if (!orgFromCode)
985
+ return;
986
+ if (String(organizationId) !== String(orgFromCode.id)) {
987
+ void setOrganizationId(orgFromCode.id);
988
+ }
989
+ }, [
990
+ orgFromCode,
991
+ organizationId,
992
+ setOrganizationId,
993
+ code,
994
+ organizations.length,
995
+ ]);
996
+ // ≤1024px: sidebar renders as a modal overlay (backdrop dims content). >1024px:
997
+ // sidebar is docked and the content gets a left margin to clear it.
998
+ const [isSidebarModalLayout, setIsSidebarModalLayout] = React.useState(false);
999
+ useEffect(() => {
1000
+ const mq = window.matchMedia("(max-width: 1024px)");
1001
+ const sync = () => setIsSidebarModalLayout(mq.matches);
1002
+ sync();
1003
+ mq.addEventListener("change", sync);
1004
+ return () => mq.removeEventListener("change", sync);
1005
+ }, []);
1006
+ // Wait for session to load (initial load only). When refetching/updating we
1007
+ // already have a session — don't flash the full-screen loader.
1008
+ if (status === "loading" && !session) {
1009
+ return jsx(Fragment$1, { children: loadingComponent });
1010
+ }
1011
+ // Session loaded but no accessToken (e.g. token refresh failed) -> bounce
1012
+ // through signin so we don't end up at a "No Keycloak Context" error.
1013
+ if (status === "authenticated" && !extendedSession?.accessToken) {
1014
+ if (typeof window !== "undefined") {
1015
+ const url = new URL(signInUrl, window.location.origin);
1016
+ url.searchParams.set("error", "NoAccessToken");
1017
+ url.searchParams.set("callbackUrl", window.location.pathname);
1018
+ window.location.href = url.toString();
1019
+ }
1020
+ return jsx(Fragment$1, { children: loadingComponent });
1021
+ }
1022
+ if (!extendedSession?.accessToken) {
1023
+ return jsx(Fragment$1, { children: loadingComponent });
1024
+ }
1025
+ if (!code) {
1026
+ router.push(selectOrganizationUrl);
1027
+ return jsx(Fragment$1, { children: loadingComponent });
1028
+ }
1029
+ // When the sidebar is docked-open it consumes 21.5rem on the left; otherwise
1030
+ // the content takes the full width and we add a left gutter inside.
1031
+ const contentMargin = open && !isSidebarModalLayout ? "ml-[21.5rem]" : "";
1032
+ const needsLeftGutter = !(open && !isSidebarModalLayout);
1033
+ const wrap = permissionProvider ?? ((c) => c);
1034
+ return (jsxs("div", { className: "flex min-h-screen w-full bg-background", children: [open && (jsx("div", { className: "fixed left-0 top-0 z-50 h-screen", children: sidebar })), open && isSidebarModalLayout && (jsx("button", { type: "button", "aria-label": "\u0E1B\u0E34\u0E14\u0E40\u0E21\u0E19\u0E39", className: "fixed inset-0 z-40 bg-black/50", onClick: () => setOpen(false) })), jsx("div", { className: cx("relative flex min-w-0 flex-1 flex-col transition-all", contentMargin), children: jsxs("main", { className: "flex flex-1 flex-col min-h-screen gap-6", children: [jsxs("div", { className: "sticky top-0 z-30 shrink-0 w-full", children: [jsx(HeaderBackground, {}), jsxs("header", { className: "relative shrink-0 gap-2 py-4 h-auto min-h-[100px] sm:h-[144px] w-full z-10", children: [jsxs("div", { className: "flex w-full items-center gap-2 px-3 sm:px-4 lg:px-6", children: [jsx(SidebarTrigger, { className: "shrink-0 text-white hover:bg-white/10" }), jsx("div", { className: "flex-1 min-w-0", children: breadcrumb })] }), jsx("div", { id: "header-title" })] })] }), jsx(Suspense, { fallback: jsx("div", { className: "relative z-10 min-h-[calc(100vh-8rem)]", children: jsx("div", { className: "flex h-full items-center justify-center", children: jsxs("div", { className: "space-y-4 text-center", children: [jsx("div", { className: "mx-auto h-8 w-8 animate-spin rounded-full border-b-2 border-primary" }), jsx("p", { className: "text-sm text-muted-foreground", children: "\u0E01\u0E33\u0E25\u0E31\u0E07\u0E42\u0E2B\u0E25\u0E14..." })] }) }) }), children: jsx("div", { className: cx("relative z-10 pr-6 pb-12", needsLeftGutter && "pl-6"), children: wrap(children) }, `org-${organizationId ?? "no-org"}`) })] }) })] }));
1035
+ }
1036
+ /** Local tiny classnames helper — avoids pulling clsx/tailwind-merge into
1037
+ * the shell just for the layout file. Falsy values are skipped. */
1038
+ function cx(...parts) {
1039
+ return parts.filter(Boolean).join(" ");
1040
+ }
1041
+
1042
+ const shellClassName = "flex min-w-0 flex-1 flex-col gap-6 overflow-x-hidden";
1043
+ /**
1044
+ * Shared layout for “กำลังตรวจสอบสิทธิ์” (skeleton) and “ไม่มีสิทธิ์” (message).
1045
+ * Renders `children` only when policy is loaded and access is granted.
1046
+ */
1047
+ function PermissionGateShell({ policyLoading, hasPolicyAccess, deniedMessage, children, skeletonCount = 6, skeletonGridClassName = "grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3", skeletonVariant = "kpi", }) {
1048
+ if (policyLoading) {
1049
+ return (jsx("div", { className: shellClassName, children: jsx("div", { className: skeletonGridClassName, children: Array.from({ length: skeletonCount }).map((_, i) => (jsx(Card, { children: skeletonVariant === "kpi" ? (jsxs(Fragment$1, { children: [jsxs(CardHeader, { className: "flex flex-row items-center justify-between pb-2", children: [jsx(Skeleton, { className: "h-4 w-24" }), jsx(Skeleton, { className: "h-4 w-4 rounded" })] }), jsxs(CardContent, { children: [jsx(Skeleton, { className: "h-8 w-32 mb-2" }), jsx(Skeleton, { className: "h-3 w-20" })] })] })) : (jsxs(Fragment$1, { children: [jsx(CardHeader, { className: "pb-2", children: jsx(Skeleton, { className: "h-4 w-24" }) }), jsx(CardContent, { children: jsx(Skeleton, { className: "h-8 w-16" }) })] })) }, i))) }) }));
1050
+ }
1051
+ if (!hasPolicyAccess) {
1052
+ return (jsx("div", { className: shellClassName, children: jsx(Card, { children: jsx(CardContent, { className: "text-sm text-muted-foreground", children: deniedMessage }) }) }));
1053
+ }
1054
+ return jsx(Fragment$1, { children: children });
1055
+ }
1056
+
1057
+ const PermissionRulesContext = createContext(undefined);
1058
+ /**
1059
+ * Generic provider — the consumer app passes in `data` (live fetch) and
1060
+ * `fallbackPolicies` (static JSON). All apps share the same mapping logic
1061
+ * (build a flat map of route_key/policy_key → required child groups).
1062
+ */
1063
+ function PermissionRulesProvider({ children, data, isLoading = false, isError = false, fallbackPolicies = [], }) {
1064
+ const rulesMap = useMemo(() => {
1065
+ const m = {};
1066
+ if (!data?.routes)
1067
+ return m;
1068
+ for (const row of data.routes) {
1069
+ m[row.route_key] = row.required_child_groups ?? [];
1070
+ }
1071
+ return m;
1072
+ }, [data]);
1073
+ const policiesMap = useMemo(() => {
1074
+ const m = {};
1075
+ for (const row of fallbackPolicies) {
1076
+ m[row.policy_key] = row.required_child_groups ?? [];
1077
+ }
1078
+ if (data?.policies?.length) {
1079
+ for (const row of data.policies) {
1080
+ m[row.policy_key] = row.required_child_groups ?? [];
1081
+ }
1082
+ }
1083
+ return m;
1084
+ }, [data, fallbackPolicies]);
1085
+ const value = useMemo(() => ({
1086
+ rulesMap,
1087
+ policiesMap,
1088
+ isLoading,
1089
+ isError,
1090
+ updatedAt: data?.updated_at ?? null,
1091
+ }), [rulesMap, policiesMap, isLoading, isError, data?.updated_at]);
1092
+ return (jsx(PermissionRulesContext.Provider, { value: value, children: children }));
1093
+ }
1094
+ /**
1095
+ * Read the permission rules. Returns empty maps when used outside the
1096
+ * provider (lets pages render without a hard-fail in tests).
1097
+ */
1098
+ function usePermissionRules() {
1099
+ const ctx = useContext(PermissionRulesContext);
1100
+ if (!ctx) {
1101
+ return {
1102
+ rulesMap: {},
1103
+ policiesMap: {},
1104
+ isLoading: false,
1105
+ isError: false,
1106
+ updatedAt: null,
1107
+ };
1108
+ }
1109
+ return ctx;
1110
+ }
1111
+
1112
+ /**
1113
+ * Strip the org `[code]` prefix from a pathname so we can look it up in the
1114
+ * permission rules map. `/O12345/setting/foo` → `/setting/foo`. Single-segment
1115
+ * paths (e.g. `/select-organization`) keep their leading slash.
1116
+ *
1117
+ * Also normalizes the user-role subtab aliases — both `purchase-doc` and
1118
+ * `sales-doc` are gated by the same `members` rule because the UI shows
1119
+ * the same management screen with different filters.
1120
+ */
1121
+ function normalizePathnameForPermissionRules(pathname) {
1122
+ const parts = pathname.split("/").filter(Boolean);
1123
+ const path = parts.length <= 1 ? "/" : "/" + parts.slice(1).join("/");
1124
+ return path.replace(/\/user-role\/(purchase-doc|sales-doc)$/, "/user-role/members");
1125
+ }
1126
+ /**
1127
+ * Returns the required child-group segments for a page pathname (the user's
1128
+ * JWT must contain at least one matching group). An exact match on the
1129
+ * normalized path wins; otherwise the function falls back to a segment-by-
1130
+ * segment pattern match where `[param]` placeholders accept any non-empty
1131
+ * value. Returns an empty array when no rule covers the path.
1132
+ *
1133
+ * `rulesMap` is the merged map — apps that want a fallback should layer it
1134
+ * here (`{ ...fallbackMap, ...liveMap }`) before calling.
1135
+ */
1136
+ function getRequiredChildGroups(pathname, rulesMap) {
1137
+ const normalized = normalizePathnameForPermissionRules(pathname);
1138
+ if (rulesMap[normalized]) {
1139
+ return rulesMap[normalized];
1140
+ }
1141
+ const pathSegments = normalized.split("/").filter(Boolean);
1142
+ for (const [pattern, roles] of Object.entries(rulesMap)) {
1143
+ const patternSegments = pattern.split("/").filter(Boolean);
1144
+ if (patternSegments.length !== pathSegments.length)
1145
+ continue;
1146
+ let matches = true;
1147
+ for (let i = 0; i < patternSegments.length; i++) {
1148
+ const patternSegment = patternSegments[i];
1149
+ const pathSegment = pathSegments[i];
1150
+ if (patternSegment.startsWith("[") && patternSegment.endsWith("]")) {
1151
+ if (!pathSegment) {
1152
+ matches = false;
1153
+ break;
1154
+ }
1155
+ }
1156
+ else if (patternSegment !== pathSegment) {
1157
+ matches = false;
1158
+ break;
1159
+ }
1160
+ }
1161
+ if (matches) {
1162
+ return roles;
1163
+ }
1164
+ }
1165
+ return [];
1166
+ }
1167
+
1168
+ /**
1169
+ * Permission gate for a page subtree. Looks up the current pathname in the
1170
+ * permission rules map (live rules from `PermissionRulesProvider`, layered
1171
+ * over the optional `fallbackRulesMap`), passes the resolved required roles
1172
+ * into the host's `usePermission` probe, and renders one of three states:
1173
+ *
1174
+ * 1. while rules or probe are still loading → centered spinner card
1175
+ * 2. probe says no access → centered "ไม่มีสิทธิ์" card with required vs.
1176
+ * user roles for debugging
1177
+ * 3. otherwise → children
1178
+ *
1179
+ * The host wraps `<PermissionProvider>` around its page chrome (typically
1180
+ * via `AppLayoutShell`'s `permissionProvider` slot).
1181
+ */
1182
+ function PermissionProvider({ children, usePermission, fallbackRulesMap, }) {
1183
+ const pathname = usePathname();
1184
+ const { rulesMap, isLoading: rulesLoading } = usePermissionRules();
1185
+ const mergedMap = useMemo(() => {
1186
+ if (!fallbackRulesMap)
1187
+ return rulesMap;
1188
+ // live rules win over fallback when both define the same key
1189
+ return { ...fallbackRulesMap, ...rulesMap };
1190
+ }, [rulesMap, fallbackRulesMap]);
1191
+ const requiredRoles = useMemo(() => getRequiredChildGroups(pathname, mergedMap), [pathname, mergedMap]);
1192
+ const { hasPermission, isLoading, userRoles } = usePermission({
1193
+ requiredRoles,
1194
+ });
1195
+ const isLoadingCombined = rulesLoading || isLoading;
1196
+ if (isLoadingCombined) {
1197
+ return (jsx(Card, { className: "p-6", children: jsxs("div", { className: "flex items-center justify-center gap-3", children: [jsx(Loader2, { className: "h-5 w-5 animate-spin text-muted-foreground" }), jsx("span", { className: "text-sm text-muted-foreground", children: "\u0E01\u0E33\u0E25\u0E31\u0E07\u0E15\u0E23\u0E27\u0E08\u0E2A\u0E2D\u0E1A\u0E2A\u0E34\u0E17\u0E18\u0E34\u0E4C\u0E01\u0E32\u0E23\u0E40\u0E02\u0E49\u0E32\u0E16\u0E36\u0E07..." })] }) }));
1198
+ }
1199
+ if (!hasPermission) {
1200
+ 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" }), requiredRoles.length > 0 && (jsxs("div", { className: "mt-3 space-y-1", children: [jsx("p", { className: "text-xs text-muted-foreground", children: "\u0E2A\u0E34\u0E17\u0E18\u0E34\u0E4C\u0E17\u0E35\u0E48\u0E15\u0E49\u0E2D\u0E07\u0E01\u0E32\u0E23:" }), jsx("div", { className: "flex flex-wrap gap-1 justify-center", children: requiredRoles.map((role, idx) => (jsx("span", { className: "rounded bg-muted px-2 py-0.5 text-xs font-mono", children: role }, idx))) })] })), userRoles.length > 0 && (jsxs("div", { className: "mt-3 space-y-1", children: [jsx("p", { className: "text-xs text-muted-foreground", children: "\u0E2A\u0E34\u0E17\u0E18\u0E34\u0E4C\u0E02\u0E2D\u0E07\u0E04\u0E38\u0E13:" }), jsx("div", { className: "flex flex-wrap gap-1 justify-center", children: userRoles.map((role, idx) => (jsx("span", { className: "rounded bg-primary/10 px-2 py-0.5 text-xs font-mono text-primary", children: role }, idx))) })] }))] })] }) }));
1201
+ }
1202
+ return jsx(Fragment$1, { children: children });
1203
+ }
1204
+
950
1205
  function ThemeProvider({ children, ...props }) {
951
1206
  return jsx(ThemeProvider$1, { ...props, children: children });
952
1207
  }
@@ -1048,61 +1303,6 @@ function ReactQueryProvider({ children }) {
1048
1303
  return (jsxs(QueryClientProvider, { client: queryClient, children: [children, jsx(ReactQueryDevtools, { client: queryClient, initialIsOpen: false })] }));
1049
1304
  }
1050
1305
 
1051
- const PermissionRulesContext = createContext(undefined);
1052
- /**
1053
- * Generic provider — the consumer app passes in `data` (live fetch) and
1054
- * `fallbackPolicies` (static JSON). All apps share the same mapping logic
1055
- * (build a flat map of route_key/policy_key → required child groups).
1056
- */
1057
- function PermissionRulesProvider({ children, data, isLoading = false, isError = false, fallbackPolicies = [], }) {
1058
- const rulesMap = useMemo(() => {
1059
- const m = {};
1060
- if (!data?.routes)
1061
- return m;
1062
- for (const row of data.routes) {
1063
- m[row.route_key] = row.required_child_groups ?? [];
1064
- }
1065
- return m;
1066
- }, [data]);
1067
- const policiesMap = useMemo(() => {
1068
- const m = {};
1069
- for (const row of fallbackPolicies) {
1070
- m[row.policy_key] = row.required_child_groups ?? [];
1071
- }
1072
- if (data?.policies?.length) {
1073
- for (const row of data.policies) {
1074
- m[row.policy_key] = row.required_child_groups ?? [];
1075
- }
1076
- }
1077
- return m;
1078
- }, [data, fallbackPolicies]);
1079
- const value = useMemo(() => ({
1080
- rulesMap,
1081
- policiesMap,
1082
- isLoading,
1083
- isError,
1084
- updatedAt: data?.updated_at ?? null,
1085
- }), [rulesMap, policiesMap, isLoading, isError, data?.updated_at]);
1086
- return (jsx(PermissionRulesContext.Provider, { value: value, children: children }));
1087
- }
1088
- /**
1089
- * Read the permission rules. Returns empty maps when used outside the
1090
- * provider (lets pages render without a hard-fail in tests).
1091
- */
1092
- function usePermissionRules() {
1093
- const ctx = useContext(PermissionRulesContext);
1094
- if (!ctx) {
1095
- return {
1096
- rulesMap: {},
1097
- policiesMap: {},
1098
- isLoading: false,
1099
- isError: false,
1100
- updatedAt: null,
1101
- };
1102
- }
1103
- return ctx;
1104
- }
1105
-
1106
1306
  /**
1107
1307
  * Renders nothing. Runs during render (after SessionProvider, inside
1108
1308
  * QueryClientProvider) to seed the cached profile slot from the JWT-backed
@@ -1411,5 +1611,5 @@ const getAxiosErrorMessage = (error) => {
1411
1611
  return String(error);
1412
1612
  };
1413
1613
 
1414
- export { ApiError, AppBreadcrumb, AppSidebar, FeatureFlagsProvider, HeaderBackground, NavMain, NavTools, NavUser, OrganizationSwitcher, 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, isLikelyTransientNetworkError, isPathActive, runGlobalRequestPrecheck, runWithTransientRetry, setOrganizationId, toApiError, unwrapApiResponse, updateOrganizationId, useClearOrgOnUserChange, useFeatureFlag, useFeatureFlags, useInvalidateQueriesOnOrgChange, useKeycloakRoles, useOrganizationIdState, usePermissionRules, useSessionRefresh, useSidebar, useSidebarToggle };
1614
+ 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 };
1415
1615
  //# sourceMappingURL=index.esm.js.map