@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.
- package/dist/auth/permission-gate-shell.d.ts +22 -0
- package/dist/auth/permission-provider.d.ts +44 -0
- package/dist/auth/permission-utils.d.ts +22 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.esm.js +259 -59
- package/dist/index.esm.js.map +1 -1
- package/dist/index.js +260 -55
- package/dist/index.js.map +1 -1
- package/dist/navigation/app-layout-shell.d.ts +59 -0
- package/package.json +2 -2
|
@@ -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
|