@eintrek/erp-shell 0.1.59 → 0.1.61

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/index.js CHANGED
@@ -259,6 +259,221 @@ async function runWithTransientRetry(fn, attempts = 3) {
259
259
  throw last;
260
260
  }
261
261
 
262
+ /**
263
+ * The organisation this browser tab is working in.
264
+ *
265
+ * Every request carries an X-Organization-ID header, and every cached query
266
+ * belongs to one organisation. Both used to come from the `erp.organizationId`
267
+ * cookie (then localStorage), which is the organisation the BROWSER last had
268
+ * active — shared by every tab and, on antniti.com, by every app. A tab
269
+ * showing /O26000021/… therefore sent whatever another tab, or another app,
270
+ * had picked last, and the URL lost.
271
+ *
272
+ * The tab's own value is set by `useOrganizationIdState` — the state that
273
+ * AppLayoutShell drives to the organisation the URL names — and read first by
274
+ * `getRequestOrganizationId()`. Until a tab has one (a cold load before the
275
+ * provider mounts), localStorage and then the cookie answer as before.
276
+ *
277
+ * Module state, so browser-only: on the server one module instance serves
278
+ * every request, and a value written there would leak between users.
279
+ */
280
+ const ORGANIZATION_ID_KEY = "erp.organizationId";
281
+ let tabOrganizationId = null;
282
+ const isBrowser$1 = () => typeof window !== "undefined";
283
+ function normalise(id) {
284
+ if (id === null || id === undefined)
285
+ return null;
286
+ const value = String(id).trim();
287
+ return value === "" ? null : value;
288
+ }
289
+ /** The organisation this tab is working in, or null before it has one. */
290
+ function getTabOrganizationId() {
291
+ return isBrowser$1() ? tabOrganizationId : null;
292
+ }
293
+ /** Record the organisation this tab is working in (browser only). */
294
+ function setTabOrganizationId(id) {
295
+ if (!isBrowser$1())
296
+ return;
297
+ tabOrganizationId = normalise(id);
298
+ }
299
+ function readOrganizationCookie() {
300
+ if (!isBrowser$1())
301
+ return null;
302
+ const prefix = `${ORGANIZATION_ID_KEY}=`;
303
+ const entry = document.cookie
304
+ .split(";")
305
+ .map(part => part.trim())
306
+ .find(part => part.startsWith(prefix));
307
+ if (!entry)
308
+ return null;
309
+ const raw = entry.slice(prefix.length);
310
+ try {
311
+ return normalise(decodeURIComponent(raw));
312
+ }
313
+ catch {
314
+ return normalise(raw);
315
+ }
316
+ }
317
+ function readOrganizationStorage() {
318
+ if (!isBrowser$1())
319
+ return null;
320
+ try {
321
+ return normalise(localStorage.getItem(ORGANIZATION_ID_KEY));
322
+ }
323
+ catch {
324
+ return null;
325
+ }
326
+ }
327
+ /**
328
+ * The organisations the signed-in user belongs to, once their list has
329
+ * loaded (AppLayoutShell reports it); null until then.
330
+ */
331
+ let knownOrganizationIds = null;
332
+ /**
333
+ * Record the user's organisations (browser only). From then on a stored or
334
+ * tab organisation that is not among them loses to one that is: a browser
335
+ * that still remembers a deleted organisation — or one the user has left —
336
+ * must not keep sending it while the cookie names a good one.
337
+ */
338
+ function setKnownOrganizationIds(ids) {
339
+ if (!isBrowser$1())
340
+ return;
341
+ if (ids === null) {
342
+ knownOrganizationIds = null;
343
+ return;
344
+ }
345
+ const set = new Set();
346
+ for (const id of ids) {
347
+ const value = normalise(id);
348
+ if (value)
349
+ set.add(value);
350
+ }
351
+ knownOrganizationIds = set;
352
+ }
353
+ /** Whether the loaded list says the user is NOT a member of `id`. */
354
+ function isKnownStaleOrganizationId(id) {
355
+ const value = normalise(id);
356
+ return (value !== null &&
357
+ knownOrganizationIds !== null &&
358
+ !knownOrganizationIds.has(value));
359
+ }
360
+ /**
361
+ * Marks a request that must not carry X-Organization-ID: the user's own list
362
+ * of organisations is how a stale organisation id is found out, so it cannot
363
+ * depend on one. An app's interceptor honours it by passing the request's
364
+ * config to getRequestOrganizationId(config).
365
+ *
366
+ * axiosInstance.get("/organizations", withoutOrganization({ params }))
367
+ */
368
+ const WITHOUT_ORGANIZATION_FLAG = "erpWithoutOrganization";
369
+ function withoutOrganization(config) {
370
+ return { ...(config ?? {}), [WITHOUT_ORGANIZATION_FLAG]: true };
371
+ }
372
+ /**
373
+ * Marks a request for one particular organisation, whatever this tab has
374
+ * active. Used to retry the organisation list under each stored id when an
375
+ * API refuses it without one (see http/organization-fallback.ts).
376
+ */
377
+ const FOR_ORGANIZATION_FIELD = "erpOrganizationId";
378
+ function isWithoutOrganization(config) {
379
+ return (!!config &&
380
+ typeof config === "object" &&
381
+ config[WITHOUT_ORGANIZATION_FLAG] === true);
382
+ }
383
+ function explicitOrganization(config) {
384
+ if (!config || typeof config !== "object")
385
+ return null;
386
+ const value = config[FOR_ORGANIZATION_FIELD];
387
+ return typeof value === "string" || typeof value === "number"
388
+ ? normalise(value)
389
+ : null;
390
+ }
391
+ /**
392
+ * Every organisation id this browser has stored for the tab — the tab's
393
+ * own, localStorage, the cookie — distinct, in that order.
394
+ */
395
+ function getStoredOrganizationIds() {
396
+ const ids = [
397
+ getTabOrganizationId(),
398
+ readOrganizationStorage(),
399
+ readOrganizationCookie(),
400
+ ].filter((id) => id !== null);
401
+ return [...new Set(ids)];
402
+ }
403
+ /**
404
+ * The organisation a request from this tab belongs to: the tab's own, then
405
+ * localStorage, then the cookie. Use it for the X-Organization-ID header —
406
+ * and read it before any `await` in the interceptor, so the header names the
407
+ * organisation that was active when the request was made.
408
+ *
409
+ * Pass the request's config: a request marked withoutOrganization() gets
410
+ * null, so no header.
411
+ *
412
+ * localStorage before the cookie: setOrganizationId writes localStorage
413
+ * synchronously and then awaits a server action for the cookie, so during
414
+ * that round trip the cookie still names the previous organisation.
415
+ *
416
+ * Once the user's organisations are known (setKnownOrganizationIds), the
417
+ * first of those that is one of them wins: 0.1.60 let a deleted
418
+ * organisation's id left in localStorage beat a valid cookie, and every
419
+ * request — the organisation list included — was refused. If none is a
420
+ * member the first is still sent, as before: the list may simply be behind
421
+ * (an organisation created a moment ago).
422
+ */
423
+ function getRequestOrganizationId(config) {
424
+ const explicit = explicitOrganization(config);
425
+ if (explicit)
426
+ return explicit;
427
+ if (isWithoutOrganization(config))
428
+ return null;
429
+ const candidates = [
430
+ getTabOrganizationId(),
431
+ readOrganizationStorage(),
432
+ readOrganizationCookie(),
433
+ ];
434
+ if (knownOrganizationIds !== null) {
435
+ const member = candidates.find(id => id !== null && knownOrganizationIds.has(id));
436
+ if (member)
437
+ return member;
438
+ }
439
+ return candidates.find(id => id !== null) ?? null;
440
+ }
441
+ function expireOrganizationCookie() {
442
+ // The cookie is set by the host's server action, host-only or on a parent
443
+ // domain (AUTH_COOKIE_DOMAIN, e.g. .antniti.com). The browser does not say
444
+ // which, so expire it on the host and on every parent domain.
445
+ const base = `${ORGANIZATION_ID_KEY}=; Max-Age=0; path=/; SameSite=Lax`;
446
+ try {
447
+ document.cookie = base;
448
+ const labels = window.location.hostname.split(".");
449
+ for (let i = 0; i < labels.length - 1; i++) {
450
+ document.cookie = `${base}; domain=.${labels.slice(i).join(".")}`;
451
+ }
452
+ }
453
+ catch {
454
+ // Cookies unavailable: nothing to clear.
455
+ }
456
+ }
457
+ /**
458
+ * Forget the active organisation everywhere this browser keeps it: the tab,
459
+ * localStorage and the `erp.organizationId` cookie. Call on sign-out, so the
460
+ * next person to sign in on this browser does not start in the previous
461
+ * person's organisation.
462
+ */
463
+ function clearStoredOrganization() {
464
+ if (!isBrowser$1())
465
+ return;
466
+ tabOrganizationId = null;
467
+ knownOrganizationIds = null;
468
+ try {
469
+ localStorage.removeItem(ORGANIZATION_ID_KEY);
470
+ }
471
+ catch {
472
+ // Storage unavailable: nothing to clear.
473
+ }
474
+ expireOrganizationCookie();
475
+ }
476
+
262
477
  const SELECTED_ORG_STORAGE_KEY = "erp.organizationId";
263
478
  const SYSTEM_ROUTE_FIRST_SEGMENTS = [
264
479
  "auth",
@@ -276,10 +491,15 @@ const SYSTEM_ROUTE_FIRST_SEGMENTS = [
276
491
  *
277
492
  * On set, writes through to localStorage + cookie before flipping React state
278
493
  * so all readers see a consistent value across navigations.
494
+ *
495
+ * The tab's own organisation (organization/active-organization.ts) follows
496
+ * this state, and it is written synchronously — in the initialiser and at the
497
+ * start of every set — because requests read it: an effect would run after
498
+ * the children's effects, which is where their queries start.
279
499
  */
280
500
  function useOrganizationIdState(initialOrganizationId, actions) {
281
501
  const previousOrgIdRef = React.useRef(initialOrganizationId || null);
282
- const [organizationIdState, setOrganizationIdState] = React.useState(() => {
502
+ function readInitialOrganizationId() {
283
503
  if (initialOrganizationId) {
284
504
  if (typeof window !== "undefined") {
285
505
  setOrganizationId(initialOrganizationId);
@@ -311,8 +531,16 @@ function useOrganizationIdState(initialOrganizationId, actions) {
311
531
  console.error("[useOrganizationIdState] Error reading localStorage:", error);
312
532
  }
313
533
  return null;
534
+ }
535
+ const [organizationIdState, setOrganizationIdState] = React.useState(() => {
536
+ const initial = readInitialOrganizationId();
537
+ setTabOrganizationId(initial);
538
+ return initial;
314
539
  });
315
540
  const setOrganizationId$1 = React.useCallback(async (id) => {
541
+ // Before the cookie round trip: from here on this tab's requests are
542
+ // the new organisation's, whatever the shared cookie still says.
543
+ setTabOrganizationId(id);
316
544
  try {
317
545
  if (id === null) {
318
546
  setOrganizationId(null);
@@ -335,6 +563,7 @@ function useOrganizationIdState(initialOrganizationId, actions) {
335
563
  React.useEffect(() => {
336
564
  if (initialOrganizationId &&
337
565
  initialOrganizationId !== String(organizationIdState)) {
566
+ setTabOrganizationId(initialOrganizationId);
338
567
  setOrganizationIdState(initialOrganizationId);
339
568
  previousOrgIdRef.current = initialOrganizationId;
340
569
  }
@@ -606,6 +835,46 @@ const TapMenu = ({ tabDatas, starterTab, currentTabIndex, variant = "main", }) =
606
835
  }) }), tabDatas[currentTab]?.page] }));
607
836
  };
608
837
 
838
+ /**
839
+ * How a sidebar row looks, apart from its layout.
840
+ *
841
+ * Exactly one row is lit — the page's own (resolveSidebarNav). Until 0.1.60 a
842
+ * row under the pointer took the lit row's fill and colour, and an open group
843
+ * (or a one-line row once clicked, which was a collapsible too) took its
844
+ * colour, so the menu showed several "current pages" at once.
845
+ */
846
+ /** A row under the pointer: a faint neutral tint, never the lit row's look. */
847
+ const NAV_ROW_HOVER = "hover:bg-sidebar-foreground/5 hover:text-sidebar-foreground";
848
+ /** The lit row: the accent fill and text. The pointer changes nothing. */
849
+ const NAV_ROW_LIT = "bg-sidebar-accent text-sidebar-accent-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground";
850
+ /** Fill and colour for a row, lit or not. */
851
+ function navRowTone(lit) {
852
+ return lit ? NAV_ROW_LIT : NAV_ROW_HOVER;
853
+ }
854
+ /**
855
+ * A group opens and shuts; any other row is a plain link with no open state.
856
+ */
857
+ function isNavGroup(item) {
858
+ return (item.subModules?.length ?? 0) > 0;
859
+ }
860
+ /**
861
+ * The open groups after `id` is opened or shut. An id that is not a group's
862
+ * is ignored — a one-line row has nothing to open.
863
+ */
864
+ function setNavGroupOpen(openIds, items, id, isOpen) {
865
+ if (!id || !items.some(item => item.id === id && isNavGroup(item))) {
866
+ return openIds;
867
+ }
868
+ if (openIds.has(id) === isOpen)
869
+ return openIds;
870
+ const next = new Set(openIds);
871
+ if (isOpen)
872
+ next.add(id);
873
+ else
874
+ next.delete(id);
875
+ return next;
876
+ }
877
+
609
878
  /**
610
879
  * Lightweight sidebar primitives — plain styled wrappers, no `useSidebar()`
611
880
  * context dependency. The full `@eintrek/erp-theme` sidebar suite ships its
@@ -629,21 +898,25 @@ const SidebarMenuButton = React__namespace.forwardRef(({ className, asChild = fa
629
898
  default: "h-8 text-sm",
630
899
  lg: "h-12 text-sm",
631
900
  };
632
- const baseClassName = erpTheme.cn("flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left outline-none transition-colors hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 focus-visible:ring-sidebar-ring disabled:pointer-events-none disabled:opacity-50", sizeClasses[size], isActive &&
633
- "bg-sidebar-accent font-medium text-sidebar-accent-foreground", className);
901
+ const baseClassName = erpTheme.cn("flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left outline-none transition-colors focus-visible:ring-2 focus-visible:ring-sidebar-ring disabled:pointer-events-none disabled:opacity-50", sizeClasses[size], navRowTone(isActive), isActive && "font-medium", className);
902
+ let row;
634
903
  if (asChild) {
635
904
  if (!children || !React__namespace.isValidElement(children)) {
636
905
  console.error("SidebarMenuButton: Invalid children for asChild prop", children);
637
906
  return null;
638
907
  }
639
- return React__namespace.cloneElement(children, {
908
+ row = React__namespace.cloneElement(children, {
640
909
  className: erpTheme.cn(baseClassName, children.props
641
910
  ?.className),
642
- title: tooltip,
643
911
  ...props,
644
912
  });
645
913
  }
646
- return (jsxRuntime.jsx("button", { ref: ref, className: baseClassName, title: tooltip, ...props, children: children }));
914
+ else {
915
+ row = (jsxRuntime.jsx("button", { ref: ref, className: baseClassName, ...props, children: children }));
916
+ }
917
+ if (!tooltip)
918
+ return row;
919
+ return (jsxRuntime.jsxs(erpTheme.Tooltip, { children: [jsxRuntime.jsx(erpTheme.TooltipTrigger, { asChild: true, children: row }), jsxRuntime.jsx(erpTheme.TooltipContent, { side: "right", sideOffset: 10, children: tooltip })] }));
647
920
  });
648
921
  SidebarMenuButton.displayName = "SidebarMenuButton";
649
922
  const SidebarMenuSub = React__namespace.forwardRef(({ className, ...props }, ref) => (jsxRuntime.jsx("ul", { ref: ref, className: erpTheme.cn("mx-3.5 flex min-w-0 bg-sidebar translate-x-px flex-col gap-1 px-2.5 py-0.5", className), ...props })));
@@ -651,7 +924,7 @@ SidebarMenuSub.displayName = "SidebarMenuSub";
651
924
  const SidebarMenuSubItem = React__namespace.forwardRef(({ ...props }, ref) => jsxRuntime.jsx("li", { ref: ref, ...props }));
652
925
  SidebarMenuSubItem.displayName = "SidebarMenuSubItem";
653
926
  const SidebarMenuSubButton = React__namespace.forwardRef(({ asChild = false, size = "md", isActive, className, children, ...props }, ref) => {
654
- const baseClassName = erpTheme.cn("flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground outline-none transition-colors hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 focus-visible:ring-sidebar-ring disabled:pointer-events-none disabled:opacity-50", isActive && "bg-sidebar-accent text-sidebar-accent-foreground", size === "sm" && "text-xs", size === "md" && "text-sm", className);
927
+ const baseClassName = erpTheme.cn("flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground outline-none transition-colors focus-visible:ring-2 focus-visible:ring-sidebar-ring disabled:pointer-events-none disabled:opacity-50", navRowTone(!!isActive), size === "sm" && "text-xs", size === "md" && "text-sm", className);
655
928
  if (asChild) {
656
929
  return React__namespace.cloneElement(children, {
657
930
  className: erpTheme.cn(baseClassName, children.props
@@ -966,7 +1239,7 @@ function NavTools({ tools, closeOnNavigation = false }) {
966
1239
  // /reports/approval doesn't light up the /reports tool.
967
1240
  const isToolActive = pathname === toolUrl;
968
1241
  const Icon = tool.icon;
969
- return (jsxRuntime.jsx(SidebarMenuItem, { children: jsxRuntime.jsx(SidebarMenuButton, { asChild: true, isActive: isToolActive, className: "font-thai data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground", children: jsxRuntime.jsxs(Link, { href: toolUrl, className: "flex items-center gap-2 w-full", onClick: handleNavLinkClick, children: [jsxRuntime.jsx(Icon, { className: "sidebar-module-icon" }), jsxRuntime.jsx("span", { children: tool.titleTh })] }) }) }, tool.id));
1242
+ return (jsxRuntime.jsx(SidebarMenuItem, { children: jsxRuntime.jsx(SidebarMenuButton, { asChild: true, isActive: isToolActive, className: "font-thai data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground", children: jsxRuntime.jsxs(Link, { href: toolUrl, className: "flex items-center gap-2 w-full", onClick: handleNavLinkClick, "aria-current": isToolActive ? "page" : undefined, children: [jsxRuntime.jsx(Icon, { className: "sidebar-module-icon" }), jsxRuntime.jsx("span", { children: tool.titleTh })] }) }) }, tool.id));
970
1243
  }) }) }));
971
1244
  }
972
1245
 
@@ -1292,25 +1565,53 @@ const runGlobalRequestPrecheck = (config) => {
1292
1565
  return validateGoodsReceiptChecks(config.data);
1293
1566
  };
1294
1567
 
1568
+ /**
1569
+ * The organisation list is asked for without an organisation (see
1570
+ * withoutOrganization), but some APIs still refuse it without one: Peoplx's
1571
+ * GET /organizations sits behind its module.hr plan gate, which answers 400
1572
+ * "organization context required" to no header and 403 to an organisation
1573
+ * that does not exist. Until those routes stop needing one, a refused list is
1574
+ * asked again under each organisation this browser has stored — the tab's,
1575
+ * localStorage's, the cookie's — so one good id among stale ones still gets
1576
+ * the user their list, and with it the way back to a working organisation.
1577
+ *
1578
+ * `send` re-sends the request (an axios instance call). Returns the first
1579
+ * answer that succeeds, or undefined when none did or the request is not a
1580
+ * refused organisation-less one. Each retry is marked, so it never retries
1581
+ * itself.
1582
+ */
1583
+ const RETRIED = "_erpOrganizationFallback";
1584
+ async function retryWithStoredOrganizations(config, status, send) {
1585
+ if (!config ||
1586
+ config[WITHOUT_ORGANIZATION_FLAG] !== true ||
1587
+ config[RETRIED] === true ||
1588
+ (status !== 400 && status !== 403)) {
1589
+ return undefined;
1590
+ }
1591
+ for (const id of getStoredOrganizationIds()) {
1592
+ try {
1593
+ return await send({
1594
+ ...config,
1595
+ [RETRIED]: true,
1596
+ [FOR_ORGANIZATION_FIELD]: id,
1597
+ });
1598
+ }
1599
+ catch {
1600
+ // That one was refused too; try the next.
1601
+ }
1602
+ }
1603
+ return undefined;
1604
+ }
1605
+
1295
1606
  const isBrowser = () => typeof window !== "undefined";
1296
1607
  const isAuthEndpoint = (url) => !!url && url.includes("/api/auth/");
1297
1608
  const isAuthPage = () => isBrowser() && window.location.pathname.startsWith("/auth/");
1298
- const getOrgFromCookie = () => {
1299
- if (!isBrowser())
1300
- return null;
1301
- const cookies = document.cookie.split(";").map(cookie => cookie.trim());
1302
- const orgCookie = cookies.find(cookie => cookie.startsWith("erp.organizationId="));
1303
- if (!orgCookie)
1304
- return null;
1305
- const raw = orgCookie.substring("erp.organizationId=".length);
1306
- return raw ? decodeURIComponent(raw) : null;
1307
- };
1308
1609
  const redirectToLogout = async () => {
1309
1610
  if (!isBrowser() || isAuthPage())
1310
1611
  return;
1311
1612
  localStorage.clear();
1312
1613
  sessionStorage.clear();
1313
- setOrganizationId(null);
1614
+ clearStoredOrganization();
1314
1615
  clearAccessTokenCache();
1315
1616
  await react.signOut({ callbackUrl: "/auth/signin" });
1316
1617
  };
@@ -1339,23 +1640,16 @@ instance.interceptors.request.use(async (config) => {
1339
1640
  }
1340
1641
  if (!isBrowser())
1341
1642
  return config;
1643
+ // Read before the token await: the header must name the organisation that
1644
+ // was active when the request was made (and under which its query is
1645
+ // cached), not whatever is active once the token resolves.
1646
+ const organizationId = getRequestOrganizationId(config);
1342
1647
  const token = await getAccessToken(false);
1343
1648
  if (token) {
1344
1649
  config.headers.Authorization = `Bearer ${token}`;
1345
1650
  }
1346
- // localStorage first, cookie second.
1347
- //
1348
- // Both are written by the same state machine, but not at the same moment:
1349
- // setOrganizationId writes localStorage synchronously and then awaits a
1350
- // server action for the cookie. Preferring the cookie meant every request
1351
- // fired during that round trip carried the previous organisation — while
1352
- // the query params, built from React state, already carried the new one.
1353
- //
1354
- // The backend scopes by this header and ignores organization_id in the
1355
- // query, so those requests asked for organisation A and were handed B, and
1356
- // the answer landed in the cache under A's key. No amount of cache clearing
1357
- // reaches that: the entry is wrong, not stale.
1358
- const organizationId = getOrganizationId() || getOrgFromCookie();
1651
+ // The tab's own organisation, then localStorage, then the cookie — see
1652
+ // getRequestOrganizationId.
1359
1653
  if (organizationId) {
1360
1654
  config.headers["X-Organization-ID"] = organizationId;
1361
1655
  }
@@ -1409,6 +1703,9 @@ instance.interceptors.response.use(response => {
1409
1703
  window.dispatchEvent(new CustomEvent("erp:employee-record-required"));
1410
1704
  }
1411
1705
  }
1706
+ const listed = await retryWithStoredOrganizations(originalRequest, error.response?.status, config => instance(config));
1707
+ if (listed !== undefined)
1708
+ return listed;
1412
1709
  return Promise.reject(toApiError(error));
1413
1710
  });
1414
1711
 
@@ -1435,7 +1732,7 @@ function platformApiBase(app) {
1435
1732
  * This package's version, attached to help messages so the back office can
1436
1733
  * tell which shell a report came from. Keep in step with package.json.
1437
1734
  */
1438
- const SHELL_VERSION = "0.1.59";
1735
+ const SHELL_VERSION = "0.1.61";
1439
1736
 
1440
1737
  const HELP_APP_LABEL = {
1441
1738
  platform: "แพลตฟอร์ม",
@@ -1758,6 +2055,9 @@ function NavUser({ organization, avatarSrc, profileHref, selectOrganizationHref
1758
2055
  };
1759
2056
  const handleSignOut = async () => {
1760
2057
  setIsSigningOut(true);
2058
+ // The next person to sign in on this browser must not start in this
2059
+ // person's organisation: forget it in the tab, localStorage and cookie.
2060
+ clearStoredOrganization();
1761
2061
  try {
1762
2062
  await react.signOut({ callbackUrl: signInRedirectHref, redirect: true });
1763
2063
  }
@@ -2040,30 +2340,18 @@ function NavMain({ items, orgCode, closeOnNavigation = false, approvalCounts, ch
2040
2340
  // A group opens when the page moves into it and otherwise stays as the
2041
2341
  // user left it. defaultOpen alone decided once, on the first render — and
2042
2342
  // on a direct load the sub-items arrive after permissions do, so the group
2043
- // holding the current page stayed shut.
2044
- const openId = open ? (items[open.item]?.id ?? "") : "";
2343
+ // holding the current page stayed shut. Only groups have an open state: a
2344
+ // one-line row is a plain link (0.1.60 — it was a collapsible too, and a
2345
+ // click left it "open", drawn in the accent colour for the session).
2346
+ const openItem = open ? items[open.item] : undefined;
2347
+ const openId = openItem && isNavGroup(openItem) ? openItem.id : "";
2045
2348
  const [openIds, setOpenIds] = React__namespace.useState(() => new Set(openId ? [openId] : []));
2046
2349
  React__namespace.useEffect(() => {
2047
2350
  if (!openId)
2048
2351
  return;
2049
- setOpenIds(prev => {
2050
- if (prev.has(openId))
2051
- return prev;
2052
- const next = new Set(prev);
2053
- next.add(openId);
2054
- return next;
2055
- });
2056
- }, [openId]);
2057
- const setItemOpen = (id, isOpen) => setOpenIds(prev => {
2058
- if (prev.has(id) === isOpen)
2059
- return prev;
2060
- const next = new Set(prev);
2061
- if (isOpen)
2062
- next.add(id);
2063
- else
2064
- next.delete(id);
2065
- return next;
2066
- });
2352
+ setOpenIds(prev => setNavGroupOpen(prev, items, openId, true));
2353
+ }, [openId, items]);
2354
+ const setItemOpen = (id, isOpen) => setOpenIds(prev => setNavGroupOpen(prev, items, id, isOpen));
2067
2355
  return (jsxRuntime.jsx(SidebarGroup, { "data-sidebar": true, className: workspace ? "p-0" : undefined, children: jsxRuntime.jsx(SidebarMenu, { className: workspace ? "gap-[3px]" : undefined, children: items.map((item, index) => {
2068
2356
  const groupHeading = workspace &&
2069
2357
  item.group &&
@@ -2079,9 +2367,15 @@ function NavMain({ items, orgCode, closeOnNavigation = false, approvalCounts, ch
2079
2367
  : "";
2080
2368
  const icon = workspace && IconComp ? (jsxRuntime.jsx(IconComp, { className: "size-[18px] shrink-0" })) : null;
2081
2369
  const badge = workspace && item.badge !== undefined && item.badge !== "" ? (jsxRuntime.jsx("span", { className: "ml-auto min-w-5 rounded-[5px] bg-primary/15 px-1 text-center text-xs leading-5 font-bold tabular-nums text-primary", children: item.badge })) : null;
2082
- return (jsxRuntime.jsxs(React__namespace.Fragment, { children: [groupHeading, jsxRuntime.jsx(erpTheme.Collapsible, { asChild: true, open: openIds.has(item.id), onOpenChange: isOpen => setItemOpen(item.id, isOpen), className: "group/collapsible", children: jsxRuntime.jsxs(SidebarMenuItem, { children: [jsxRuntime.jsx(erpTheme.CollapsibleTrigger, { asChild: true, children: jsxRuntime.jsx(SidebarMenuButton, { asChild: !item.subModules || item.subModules.length === 0, isActive: isItemActive, className: "data-[state=open]:text-sidebar-accent-foreground " +
2083
- workspaceButton, children: item.subModules && item.subModules.length > 0 ? (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [icon, jsxRuntime.jsx("span", { id: item.id, children: item.titleTh }), jsxRuntime.jsx(lucideReact.ChevronRight, { className: "ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" })] })) : (jsxRuntime.jsxs(Link, { href: itemUrl, prefetch: false, className: "flex items-center w-full min-w-0 flex-1 " +
2084
- (workspace ? "gap-3" : "gap-2"), onClick: handleNavLinkClick, "aria-current": isItemActive ? "page" : undefined, children: [icon, jsxRuntime.jsx("span", { id: item.id, className: "flex-1 truncate", title: item.titleTh, children: item.titleTh }), badge] })) }) }), item.subModules && item.subModules.length > 0 && (jsxRuntime.jsx(erpTheme.CollapsibleContent, { children: jsxRuntime.jsx(SidebarMenuSub, { className: workspace
2370
+ if (!isNavGroup(item)) {
2371
+ // A one-line row: a link, lit on its own page only. No open
2372
+ // state, no title tooltip.
2373
+ return (jsxRuntime.jsxs(React__namespace.Fragment, { children: [groupHeading, jsxRuntime.jsx(SidebarMenuItem, { children: jsxRuntime.jsx(SidebarMenuButton, { asChild: true, isActive: isItemActive, className: workspaceButton, children: jsxRuntime.jsxs(Link, { href: itemUrl, prefetch: false, className: "flex items-center w-full min-w-0 flex-1 " +
2374
+ (workspace ? "gap-3" : "gap-2"), onClick: handleNavLinkClick, "aria-current": isItemActive ? "page" : undefined, children: [icon, jsxRuntime.jsx("span", { id: item.id, className: "flex-1 truncate", children: item.titleTh }), badge] }) }) })] }, item.id));
2375
+ }
2376
+ // A group: its header opens and shuts it (the chevron says which).
2377
+ // An open group is not the current page — only a row in it is.
2378
+ return (jsxRuntime.jsxs(React__namespace.Fragment, { children: [groupHeading, jsxRuntime.jsx(erpTheme.Collapsible, { asChild: true, open: openIds.has(item.id), onOpenChange: isOpen => setItemOpen(item.id, isOpen), className: "group/collapsible", children: jsxRuntime.jsxs(SidebarMenuItem, { children: [jsxRuntime.jsx(erpTheme.CollapsibleTrigger, { asChild: true, children: jsxRuntime.jsxs(SidebarMenuButton, { isActive: isItemActive, className: workspaceButton, children: [icon, jsxRuntime.jsx("span", { id: item.id, className: "flex-1 truncate text-left", children: item.titleTh }), jsxRuntime.jsx(lucideReact.ChevronRight, { className: "ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" })] }) }), item.subModules && (jsxRuntime.jsx(erpTheme.CollapsibleContent, { children: jsxRuntime.jsx(SidebarMenuSub, { className: workspace
2085
2379
  ? "mx-0 mt-1 mb-3 ml-[21px] gap-0.5 border-l border-sidebar-border pl-2.5 [&_a]:h-auto [&_a]:gap-[11px] [&_a]:px-3 [&_a]:py-2 [&_a]:text-xs [&_svg]:!size-[17px]"
2086
2380
  : undefined, children: item.subModules.map((subItem, subIndex) => {
2087
2381
  const subItemUrl = buildUrlWithCode(code, subItem.url);
@@ -2093,20 +2387,24 @@ function NavMain({ items, orgCode, closeOnNavigation = false, approvalCounts, ch
2093
2387
  if (subItem.isCollapsible &&
2094
2388
  subItem.subPages &&
2095
2389
  subItem.subPages.length > 0) {
2096
- return (jsxRuntime.jsx(erpTheme.Collapsible, { asChild: true, defaultOpen: isSubItemActive || openSub === subIndex, className: "group/sub-collapsible", children: jsxRuntime.jsxs(SidebarMenuSubItem, { children: [jsxRuntime.jsx(erpTheme.CollapsibleTrigger, { asChild: true, children: jsxRuntime.jsxs(SidebarMenuSubButton, { className: "w-full", isActive: isSubItemActive, children: [SubIcon && (jsxRuntime.jsx(SubIcon, { className: "sidebar-submodule-icon flex-shrink-0" })), jsxRuntime.jsx("span", { className: "flex-1 truncate", title: subItem.titleTh, children: subItem.titleTh }), jsxRuntime.jsx(lucideReact.ChevronDown, { className: "ml-auto size-4 transition-transform duration-200 group-data-[state=open]/sub-collapsible:rotate-180" })] }) }), jsxRuntime.jsx(erpTheme.CollapsibleContent, { children: jsxRuntime.jsx(SidebarMenuSub, { className: "ml-4", children: subItem.subPages.map((subPage, subPageIndex) => {
2390
+ return (jsxRuntime.jsx(erpTheme.Collapsible, { asChild: true, defaultOpen: isSubItemActive || openSub === subIndex, className: "group/sub-collapsible", children: jsxRuntime.jsxs(SidebarMenuSubItem, { children: [jsxRuntime.jsx(erpTheme.CollapsibleTrigger, { asChild: true, children: jsxRuntime.jsxs(SidebarMenuSubButton, { className: "w-full", isActive: isSubItemActive &&
2391
+ activeSubPage === null, children: [SubIcon && (jsxRuntime.jsx(SubIcon, { className: "sidebar-submodule-icon flex-shrink-0" })), jsxRuntime.jsx("span", { className: "flex-1 truncate", children: subItem.titleTh }), jsxRuntime.jsx(lucideReact.ChevronDown, { className: "ml-auto size-4 transition-transform duration-200 group-data-[state=open]/sub-collapsible:rotate-180" })] }) }), jsxRuntime.jsx(erpTheme.CollapsibleContent, { children: jsxRuntime.jsx(SidebarMenuSub, { className: "ml-4", children: subItem.subPages.map((subPage, subPageIndex) => {
2097
2392
  const subPageUrl = buildUrlWithCode(code, subPage.url);
2098
2393
  const isSubPageActive = isSubItemActive &&
2099
2394
  activeSubPage === subPageIndex;
2100
2395
  const SubPageIcon = subPage.icon;
2101
- return (jsxRuntime.jsx(SidebarMenuSubItem, { children: jsxRuntime.jsx(SidebarMenuSubButton, { asChild: true, className: `font-thai w-full text-sm ${isSubPageActive
2102
- ? "text-sidebar-accent-foreground font-medium"
2103
- : "text-sidebar-foreground"}`, isActive: false, children: jsxRuntime.jsxs(Link, { href: subPageUrl, prefetch: false, className: "w-full flex items-center", onClick: handleNavLinkClick, children: [SubPageIcon && (jsxRuntime.jsx(SubPageIcon, { className: "sidebar-subpage-icon flex-shrink-0" })), jsxRuntime.jsx("span", { className: "flex-1 truncate", title: subPage.titleTh, children: subPage.titleTh })] }) }) }, subPage.id));
2396
+ return (jsxRuntime.jsx(SidebarMenuSubItem, { children: jsxRuntime.jsx(SidebarMenuSubButton, { asChild: true, className: "font-thai w-full text-sm" +
2397
+ (isSubPageActive
2398
+ ? " font-medium"
2399
+ : ""), isActive: isSubPageActive, children: jsxRuntime.jsxs(Link, { href: subPageUrl, prefetch: false, className: "w-full flex items-center", onClick: handleNavLinkClick, "aria-current": isSubPageActive
2400
+ ? "page"
2401
+ : undefined, children: [SubPageIcon && (jsxRuntime.jsx(SubPageIcon, { className: "sidebar-subpage-icon flex-shrink-0" })), jsxRuntime.jsx("span", { className: "flex-1 truncate", children: subPage.titleTh })] }) }) }, subPage.id));
2104
2402
  }) }) })] }) }, subItem.id));
2105
2403
  }
2106
2404
  return (jsxRuntime.jsx(SidebarMenuSubItem, { children: jsxRuntime.jsx(SidebarMenuSubButton, { asChild: true, className: "w-full" +
2107
2405
  (workspace && isSubItemActive
2108
2406
  ? " font-semibold"
2109
- : ""), isActive: isSubItemActive, children: jsxRuntime.jsxs(Link, { href: subItemUrl, prefetch: false, className: "w-full flex items-center", onClick: handleNavLinkClick, children: [SubIcon && (jsxRuntime.jsx(SubIcon, { className: "sidebar-submodule-icon flex-shrink-0" })), jsxRuntime.jsx("span", { className: "flex-1 truncate", title: subItem.titleTh, children: subItem.titleTh }), approvalCount > 0 && (jsxRuntime.jsx(erpTheme.Badge, { variant: "destructive", className: "ml-auto h-5 min-w-5 px-1.5 text-xs font-medium tabular-nums", children: approvalCount > 99
2407
+ : ""), isActive: isSubItemActive, children: jsxRuntime.jsxs(Link, { href: subItemUrl, prefetch: false, className: "w-full flex items-center", onClick: handleNavLinkClick, "aria-current": isSubItemActive ? "page" : undefined, children: [SubIcon && (jsxRuntime.jsx(SubIcon, { className: "sidebar-submodule-icon flex-shrink-0" })), jsxRuntime.jsx("span", { className: "flex-1 truncate", children: subItem.titleTh }), approvalCount > 0 && (jsxRuntime.jsx(erpTheme.Badge, { variant: "destructive", className: "ml-auto h-5 min-w-5 px-1.5 text-xs font-medium tabular-nums", children: approvalCount > 99
2110
2408
  ? "99+"
2111
2409
  : approvalCount }))] }) }) }, subItem.id));
2112
2410
  }) }) }))] }) })] }, item.id));
@@ -3074,6 +3372,111 @@ function ThreadBubble({ mine, label, at, body, images, }) {
3074
3372
  (mine ? "border border-primary/30 bg-primary/5" : "bg-muted"), children: [jsxRuntime.jsxs("p", { className: "text-xs text-muted-foreground", children: [label, " \u00B7 ", when(at)] }), jsxRuntime.jsx("p", { className: "mt-1 whitespace-pre-wrap break-words text-sm", children: body }), jsxRuntime.jsx(HelpImageStrip, { images: images })] }) }));
3075
3373
  }
3076
3374
 
3375
+ /**
3376
+ * Central "no access" screen shared by every policy check (route-level
3377
+ * PermissionProvider and page-level PermissionGateShell). One look everywhere:
3378
+ * an illustration, a 403 headline, the page-specific reason, and recovery
3379
+ * actions.
3380
+ */
3381
+ function AccessDenied({ title = "ขออภัย, คุณไม่มีสิทธิ์เข้าถึงหน้านี้ (403)", message = "คุณไม่มีสิทธิ์เข้าถึงหน้านี้ ขออภัยในความไม่สะดวก", homeHref, homeLabel = "กลับไปหน้าหลัก", supportHref, supportLabel = "ติดต่อฝ่ายสนับสนุน", fullScreen = true, className, }) {
3382
+ // "/" is not the home page inside an app — it bounces to organization
3383
+ // selection, so the button read "กลับไปหน้าหลัก" and dropped the user into a
3384
+ // picker for the org they were already in. Send them to that org's home.
3385
+ const params = navigation.useParams();
3386
+ const code = typeof params?.code === "string" ? params.code : "";
3387
+ const homeTarget = homeHref ?? (code ? `/${code}` : "/");
3388
+ const go = (href) => {
3389
+ if (typeof window !== "undefined")
3390
+ window.location.href = href;
3391
+ };
3392
+ const wrapperClass = [
3393
+ "flex min-w-0 flex-1 items-center justify-center p-4",
3394
+ fullScreen ? "min-h-[70vh]" : "",
3395
+ className ?? "",
3396
+ ]
3397
+ .filter(Boolean)
3398
+ .join(" ");
3399
+ return (jsxRuntime.jsx("div", { className: wrapperClass, children: jsxRuntime.jsx("div", { className: "relative w-full max-w-3xl overflow-hidden rounded-2xl border border-border/60 bg-card/60 p-8 shadow-sm backdrop-blur sm:p-10", children: jsxRuntime.jsxs("div", { className: "flex flex-col items-center gap-8 text-center sm:flex-row sm:text-left", children: [jsxRuntime.jsxs("div", { className: "relative shrink-0", children: [jsxRuntime.jsx("div", { className: "absolute inset-0 rounded-full bg-destructive/20 blur-2xl" }), jsxRuntime.jsx("div", { className: "relative flex h-28 w-28 items-center justify-center rounded-3xl border border-border/60 bg-gradient-to-br from-muted/40 to-background shadow-inner", children: jsxRuntime.jsx("div", { className: "flex h-16 w-16 items-center justify-center rounded-full border border-destructive/40 bg-destructive/10", children: jsxRuntime.jsx(lucideReact.Ban, { className: "h-8 w-8 text-destructive", strokeWidth: 2.5 }) }) })] }), jsxRuntime.jsxs("div", { className: "min-w-0 flex-1", children: [jsxRuntime.jsx("h1", { className: "text-xl font-bold text-foreground sm:text-2xl", children: title }), message ? (jsxRuntime.jsx("p", { className: "mt-3 whitespace-pre-line text-sm leading-relaxed text-muted-foreground sm:text-base", children: message })) : null, jsxRuntime.jsxs("div", { className: "mt-6 flex flex-col gap-3 sm:flex-row", children: [jsxRuntime.jsxs(erpTheme.Button, { type: "button", onClick: () => go(homeTarget), className: "w-full sm:w-auto", children: [jsxRuntime.jsx(lucideReact.Home, { className: "mr-2 h-4 w-4" }), homeLabel] }), supportHref ? (jsxRuntime.jsxs(erpTheme.Button, { type: "button", variant: "outline", onClick: () => go(supportHref), className: "w-full sm:w-auto", children: [jsxRuntime.jsx(lucideReact.Headset, { className: "mr-2 h-4 w-4" }), supportLabel] })) : null] })] })] }) }) }));
3400
+ }
3401
+
3402
+ /**
3403
+ * What AppLayoutShell does with the page while the organisation in the URL and
3404
+ * the tab's active organisation are being reconciled. Pure, so it is tested
3405
+ * without React (url-org-gate.test.ts).
3406
+ *
3407
+ * The URL's `[code]` is authoritative. The page — and with it every request
3408
+ * the page makes — mounts only once the active organisation IS the one the
3409
+ * URL names:
3410
+ *
3411
+ * ready the active organisation is the URL's: render the page.
3412
+ * resolving the caller's organisations are still loading, so the code
3413
+ * cannot be resolved yet: hold the page.
3414
+ * switching the code is one of the caller's organisations but not the
3415
+ * active one: switch to it (`targetId`), hold the page meanwhile.
3416
+ * not-member the list loaded and the code is not in it: refuse.
3417
+ * unverified the list could not be loaded: render as before the gate
3418
+ * existed (the API still scopes every request), rather than
3419
+ * leave the page on a loader for good.
3420
+ */
3421
+ /** The organisation a `[code]` segment names, among the caller's. */
3422
+ function findOrganizationForCode(code, organizations) {
3423
+ if (!code)
3424
+ return null;
3425
+ return (organizations.find(org => org.code === code || String(org.id) === code) ??
3426
+ null);
3427
+ }
3428
+ function resolveUrlOrgGate({ code, organizations, organizationsStatus, organizationId, }) {
3429
+ // No code in the path: the shell sends the user to pick an organisation.
3430
+ if (!code)
3431
+ return { state: "ready", targetId: null };
3432
+ const target = findOrganizationForCode(code, organizations);
3433
+ if (target) {
3434
+ const targetId = String(target.id);
3435
+ const activeId = organizationId === null || organizationId === undefined
3436
+ ? ""
3437
+ : String(organizationId).trim();
3438
+ return activeId === targetId
3439
+ ? { state: "ready", targetId }
3440
+ : { state: "switching", targetId };
3441
+ }
3442
+ const status = organizationsStatus ?? (organizations.length > 0 ? "success" : undefined);
3443
+ switch (status) {
3444
+ case "success":
3445
+ return { state: "not-member" };
3446
+ case "pending":
3447
+ return { state: "resolving" };
3448
+ default:
3449
+ // "error", or a host that does not report the list's status and has
3450
+ // an empty one.
3451
+ return { state: "unverified" };
3452
+ }
3453
+ }
3454
+ function resolveStaleOrganization({ code, organizations, organizationsStatus, organizationId, }) {
3455
+ const status = organizationsStatus ?? (organizations.length > 0 ? "success" : undefined);
3456
+ if (status !== "success")
3457
+ return { action: "none" };
3458
+ const activeId = organizationId === null || organizationId === undefined
3459
+ ? ""
3460
+ : String(organizationId).trim();
3461
+ if (activeId === "")
3462
+ return { action: "none" };
3463
+ if (organizations.some(org => String(org.id) === activeId)) {
3464
+ return { action: "none" };
3465
+ }
3466
+ const urlOrg = findOrganizationForCode(code, organizations);
3467
+ if (urlOrg) {
3468
+ return { action: "use", targetId: String(urlOrg.id), fromUrl: true };
3469
+ }
3470
+ if (organizations.length === 1) {
3471
+ return {
3472
+ action: "use",
3473
+ targetId: String(organizations[0].id),
3474
+ fromUrl: false,
3475
+ };
3476
+ }
3477
+ return { action: "forget" };
3478
+ }
3479
+
3077
3480
  /**
3078
3481
  * Top-level app chrome: sticky header, sidebar slot, content gutter and the
3079
3482
  * session/org-context plumbing that both Accounting and HR need. Apps wrap
@@ -3086,7 +3489,7 @@ function ThreadBubble({ mine, label, at, body, images, }) {
3086
3489
  * an erp-shell bump — domain components must NOT add their own root padding
3087
3490
  * or content gets indented twice.
3088
3491
  */
3089
- function AppLayoutShell({ children, sidebar, loadingComponent, breadcrumb, permissionProvider, organizationId, setOrganizationId, organizations, signInUrl, selectOrganizationUrl, chrome = "brand", workspaceHeader = "brand", headerEnd, mobileBrand, showHelp = true, helpApp, }) {
3492
+ function AppLayoutShell({ children, sidebar, loadingComponent, breadcrumb, permissionProvider, organizationId, setOrganizationId, organizations, organizationsStatus, notMemberComponent, signInUrl, selectOrganizationUrl, chrome = "brand", workspaceHeader = "brand", headerEnd, mobileBrand, showHelp = true, helpApp, }) {
3090
3493
  const { data: session, status } = react.useSession();
3091
3494
  const { open, setOpen } = useSidebar();
3092
3495
  const router = navigation.useRouter();
@@ -3103,33 +3506,75 @@ function AppLayoutShell({ children, sidebar, loadingComponent, breadcrumb, permi
3103
3506
  if (extendedSession?.accessToken)
3104
3507
  hasSeenAccessTokenRef.current = true;
3105
3508
  }, [extendedSession?.accessToken]);
3106
- // Find the org from the URL `[code]` so we can detect a context mismatch
3107
- // (the user bookmarked a different org than the cookie remembers).
3108
- const orgFromCode = React__namespace.useMemo(() => {
3109
- if (!code || !organizations.length)
3110
- return null;
3111
- return (organizations.find(org => org.code === code || String(org.id) === code) ?? null);
3112
- }, [code, organizations]);
3113
- // Sync state: when the URL code points at a different org than the cookie,
3114
- // trust the URL and update the cookie. Otherwise do nothing — the user
3115
- // wins ties via the in-app org switcher.
3509
+ // The URL's `[code]` is authoritative: the page mounts only once the active
3510
+ // organisation is the one it names (see url-org-gate.ts).
3511
+ //
3512
+ // The gate used to start only when the code had resolved AND an active id
3513
+ // existed, which left two holes. While the organisation list loaded, the
3514
+ // page mounted at once and its first requests carried the previous
3515
+ // organisation — /O26000021/procurement/purchase-requisition/<id> fetched
3516
+ // the document from O26000019 before the switch. And a code naming none of
3517
+ // the user's organisations never resolved, so the page rendered the
3518
+ // previous organisation's data under it.
3519
+ const urlOrgGate = resolveUrlOrgGate({
3520
+ code,
3521
+ organizations,
3522
+ organizationsStatus,
3523
+ organizationId,
3524
+ });
3525
+ const switchTargetId = urlOrgGate.state === "switching" ? urlOrgGate.targetId : null;
3526
+ // Switch the active organisation to the URL's. Once per target: the setter
3527
+ // writes the cookie through a server action, and re-firing it on every
3528
+ // render while that round trip is in flight only queues duplicates.
3529
+ const requestedSwitchRef = React.useRef(null);
3116
3530
  React.useEffect(() => {
3117
- if (!code)
3118
- return;
3119
- if (!organizations.length)
3531
+ if (!switchTargetId) {
3532
+ requestedSwitchRef.current = null;
3120
3533
  return;
3121
- if (!orgFromCode)
3534
+ }
3535
+ if (requestedSwitchRef.current === switchTargetId)
3122
3536
  return;
3123
- if (String(organizationId) !== String(orgFromCode.id)) {
3124
- void setOrganizationId(orgFromCode.id);
3537
+ requestedSwitchRef.current = switchTargetId;
3538
+ void setOrganizationId(switchTargetId);
3539
+ }, [switchTargetId, setOrganizationId]);
3540
+ // Once the user's organisations are known, requests prefer a stored
3541
+ // organisation that is one of them (getRequestOrganizationId).
3542
+ const organizationsLoaded = (organizationsStatus ??
3543
+ (organizations.length > 0 ? "success" : undefined)) === "success";
3544
+ React.useEffect(() => {
3545
+ if (organizationsLoaded) {
3546
+ setKnownOrganizationIds(organizations.map(org => org.id));
3125
3547
  }
3126
- }, [
3127
- orgFromCode,
3128
- organizationId,
3129
- setOrganizationId,
3548
+ }, [organizationsLoaded, organizations]);
3549
+ // An active organisation that is not one of the user's — deleted, or left —
3550
+ // is replaced, so it stops being sent with every request. The URL's
3551
+ // organisation, when the user is a member, is the gate's switch above;
3552
+ // otherwise the user's only organisation, or none (they pick one: the
3553
+ // refusal below offers it, and a path without a code goes to the picker).
3554
+ // Once per stale id and target, so a setter that does not take cannot loop.
3555
+ const staleRecovery = resolveStaleOrganization({
3130
3556
  code,
3131
- organizations.length,
3132
- ]);
3557
+ organizations,
3558
+ organizationsStatus,
3559
+ organizationId,
3560
+ });
3561
+ const staleRecoveryTarget = staleRecovery.action === "forget"
3562
+ ? null
3563
+ : staleRecovery.action === "use" && !staleRecovery.fromUrl
3564
+ ? staleRecovery.targetId
3565
+ : undefined;
3566
+ const staleRecoveryKey = staleRecoveryTarget === undefined
3567
+ ? null
3568
+ : `${String(organizationId)}>${staleRecoveryTarget ?? ""}`;
3569
+ const handledStaleRecoveryRef = React.useRef(null);
3570
+ React.useEffect(() => {
3571
+ if (staleRecoveryKey === null || staleRecoveryTarget === undefined)
3572
+ return;
3573
+ if (handledStaleRecoveryRef.current === staleRecoveryKey)
3574
+ return;
3575
+ handledStaleRecoveryRef.current = staleRecoveryKey;
3576
+ void setOrganizationId(staleRecoveryTarget);
3577
+ }, [staleRecoveryKey, staleRecoveryTarget, setOrganizationId]);
3133
3578
  // Key for the content subtree. It exists so switching organizations throws
3134
3579
  // away the previous org's component state — but it must react ONLY to a real
3135
3580
  // switch. organizationId legitimately passes through null while the context
@@ -3151,21 +3596,20 @@ function AppLayoutShell({ children, sidebar, loadingComponent, breadcrumb, permi
3151
3596
  const contentOrgKey = lastOrgKeyRef.current;
3152
3597
  // Hold the page back until the active organisation is the one the URL names.
3153
3598
  //
3154
- // The sync effect above writes the cookie through a server action, which is
3155
- // a round trip. Until it lands, getOrgFromCookie() still answers with the
3156
- // previous organisation and every request the page fires on mount carries
3157
- // it — so /O26000021/work-record asked about O26000033 and was correctly
3158
- // told there was nothing there. An empty table is the mild version: the
3159
- // create form posts to the same header, so a record could be filed against
3160
- // a company the user was not looking at.
3599
+ // The switch writes the cookie through a server action, which is a round
3600
+ // trip. Until it lands, every request the page fires on mount would carry
3601
+ // the previous organisation — so /O26000021/work-record asked about
3602
+ // O26000033 and was correctly told there was nothing there. An empty table
3603
+ // is the mild version: the create form posts to the same header, so a
3604
+ // record could be filed against a company the user was not looking at.
3161
3605
  //
3162
- // Only while the two genuinely disagree. A code naming no organisation of
3163
- // the caller's is left to the app to refuse, and a page with no code in its
3164
- // path is unaffected.
3165
- const orgContextIsStale = !!orgFromCode &&
3166
- organizationId !== null &&
3167
- organizationId !== undefined &&
3168
- String(organizationId) !== String(orgFromCode.id);
3606
+ // The whole layout waits, not just the content: the chrome reads the URL
3607
+ // too. The breadcrumb looks up the document in the path to print its
3608
+ // number, so with only the content held it fetched
3609
+ // /purchase-requests/<id> under the previous organisation and printed that
3610
+ // organisation's document number under the new one's URL.
3611
+ const holdLayout = urlOrgGate.state === "resolving" || urlOrgGate.state === "switching";
3612
+ const refusePage = urlOrgGate.state === "not-member";
3169
3613
  // ≤1024px: sidebar renders as a modal overlay (backdrop dims content). >1024px:
3170
3614
  // sidebar is docked and the content gets a left margin to clear it.
3171
3615
  const [isSidebarModalLayout, setIsSidebarModalLayout] = React__namespace.useState(false);
@@ -3214,6 +3658,9 @@ function AppLayoutShell({ children, sidebar, loadingComponent, breadcrumb, permi
3214
3658
  router.push(selectOrganizationUrl);
3215
3659
  return jsxRuntime.jsx(jsxRuntime.Fragment, { children: loadingComponent });
3216
3660
  }
3661
+ if (holdLayout) {
3662
+ return jsxRuntime.jsx(jsxRuntime.Fragment, { children: loadingComponent });
3663
+ }
3217
3664
  // When the sidebar is docked-open it consumes 21.5rem on the left; otherwise
3218
3665
  // the content takes the full width and we add a left gutter inside.
3219
3666
  const workspace = chrome === "workspace";
@@ -3224,6 +3671,9 @@ function AppLayoutShell({ children, sidebar, loadingComponent, breadcrumb, permi
3224
3671
  : "";
3225
3672
  const needsLeftGutter = workspace || !(open && !isSidebarModalLayout);
3226
3673
  const wrap = permissionProvider ?? ((c) => c);
3674
+ // No trail over a refusal: it would look up the path's document under the
3675
+ // active organisation, which is not the URL's.
3676
+ const trail = refusePage ? null : breadcrumb;
3227
3677
  return (jsxRuntime.jsxs("div", { className: "flex min-h-screen w-full bg-background", children: [open && (jsxRuntime.jsx("div", { className: "fixed left-0 top-0 z-50 h-screen", children: sidebar })), open && isSidebarModalLayout && (jsxRuntime.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) })), jsxRuntime.jsx("div", { className: cx("relative flex min-w-0 flex-1 flex-col transition-all", contentMargin), children: jsxRuntime.jsxs("main", { className: cx("flex flex-1 flex-col min-h-screen", workspace ? "gap-0" : "gap-4"), children: [workspace ? (jsxRuntime.jsxs("div", { className: cx("sticky top-0 z-30 shrink-0 w-full", workspaceHeader === "light" &&
3228
3678
  "border-b border-border bg-card/85 backdrop-blur-sm"), style: workspaceHeader === "light"
3229
3679
  ? WORKSPACE_LIGHT_HEADER_VARS
@@ -3233,9 +3683,11 @@ function AppLayoutShell({ children, sidebar, loadingComponent, breadcrumb, permi
3233
3683
  // As in the design: the mark, then a menu button, and the
3234
3684
  // organisation on the right — no trail, no title, so the
3235
3685
  // organisation's name has the room it needs on a phone.
3236
- jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [mobileBrand ? (jsxRuntime.jsx("div", { className: "shrink-0", children: mobileBrand })) : null, jsxRuntime.jsx("button", { type: "button", onClick: () => setOpen(!open), "aria-label": open ? "ปิดเมนู" : "เปิดเมนู", className: "grid size-[34px] shrink-0 place-items-center rounded-lg text-[var(--header-fg-muted)] transition-colors hover:bg-[var(--header-hover)] hover:text-[var(--header-fg)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--header-fg-muted)]", children: jsxRuntime.jsx(lucideReact.LayoutGrid, { className: "size-5", strokeWidth: 1.65 }) })] })) : (jsxRuntime.jsx(SidebarTrigger, { className: "shrink-0 text-[var(--header-fg-muted)] hover:bg-[var(--header-hover)] hover:text-[var(--header-fg)] [&_svg]:!text-current" })), jsxRuntime.jsxs("div", { className: "flex min-w-0 flex-1 flex-col justify-center gap-0.5", children: [jsxRuntime.jsx("div", { className: "hidden min-w-0 sm:block", children: breadcrumb }), jsxRuntime.jsx("div", { id: "header-title", className: "min-w-0 max-sm:hidden" })] }), headerEnd || showHelp ? (jsxRuntime.jsxs("div", { className: "flex shrink-0 items-center gap-3 sm:gap-5", children: [headerEnd, showHelp ? jsxRuntime.jsx(HelpButton, {}) : null] })) : null] })] })) : (jsxRuntime.jsxs("div", { className: "sticky top-0 z-30 shrink-0 w-full", children: [jsxRuntime.jsx(HeaderBackground, {}), jsxRuntime.jsxs("header", { className: "relative shrink-0 gap-2 py-4 h-auto min-h-[123.5px] w-full z-10", children: [jsxRuntime.jsxs("div", { className: "flex w-full items-center gap-2 px-3 sm:px-4 lg:px-6", children: [jsxRuntime.jsx(SidebarTrigger, { className: "shrink-0 text-white hover:bg-white/10" }), jsxRuntime.jsx("div", { className: "flex-1 min-w-0", children: breadcrumb }), headerEnd || showHelp ? (jsxRuntime.jsxs("div", { className: "flex shrink-0 items-center gap-3", children: [headerEnd, showHelp ? jsxRuntime.jsx(HelpButton, {}) : null] })) : null] }), jsxRuntime.jsx("div", { id: "header-title" })] })] })), jsxRuntime.jsx(React.Suspense, { fallback: jsxRuntime.jsx("div", { className: "relative z-10 min-h-[calc(100vh-8rem)]", children: jsxRuntime.jsx("div", { className: "flex h-full items-center justify-center", children: jsxRuntime.jsxs("div", { className: "space-y-4 text-center", children: [jsxRuntime.jsx("div", { className: "mx-auto h-8 w-8 animate-spin rounded-full border-b-2 border-primary" }), jsxRuntime.jsx("p", { className: "text-sm text-muted-foreground", children: "\u0E01\u0E33\u0E25\u0E31\u0E07\u0E42\u0E2B\u0E25\u0E14..." })] }) }) }), children: jsxRuntime.jsx("div", { className: workspace
3686
+ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [mobileBrand ? (jsxRuntime.jsx("div", { className: "shrink-0", children: mobileBrand })) : null, jsxRuntime.jsx("button", { type: "button", onClick: () => setOpen(!open), "aria-label": open ? "ปิดเมนู" : "เปิดเมนู", className: "grid size-[34px] shrink-0 place-items-center rounded-lg text-[var(--header-fg-muted)] transition-colors hover:bg-[var(--header-hover)] hover:text-[var(--header-fg)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--header-fg-muted)]", children: jsxRuntime.jsx(lucideReact.LayoutGrid, { className: "size-5", strokeWidth: 1.65 }) })] })) : (jsxRuntime.jsx(SidebarTrigger, { className: "shrink-0 text-[var(--header-fg-muted)] hover:bg-[var(--header-hover)] hover:text-[var(--header-fg)] [&_svg]:!text-current" })), jsxRuntime.jsxs("div", { className: "flex min-w-0 flex-1 flex-col justify-center gap-0.5", children: [jsxRuntime.jsx("div", { className: "hidden min-w-0 sm:block", children: trail }), jsxRuntime.jsx("div", { id: "header-title", className: "min-w-0 max-sm:hidden" })] }), headerEnd || showHelp ? (jsxRuntime.jsxs("div", { className: "flex shrink-0 items-center gap-3 sm:gap-5", children: [headerEnd, showHelp ? jsxRuntime.jsx(HelpButton, {}) : null] })) : null] })] })) : (jsxRuntime.jsxs("div", { className: "sticky top-0 z-30 shrink-0 w-full", children: [jsxRuntime.jsx(HeaderBackground, {}), jsxRuntime.jsxs("header", { className: "relative shrink-0 gap-2 py-4 h-auto min-h-[123.5px] w-full z-10", children: [jsxRuntime.jsxs("div", { className: "flex w-full items-center gap-2 px-3 sm:px-4 lg:px-6", children: [jsxRuntime.jsx(SidebarTrigger, { className: "shrink-0 text-white hover:bg-white/10" }), jsxRuntime.jsx("div", { className: "flex-1 min-w-0", children: trail }), headerEnd || showHelp ? (jsxRuntime.jsxs("div", { className: "flex shrink-0 items-center gap-3", children: [headerEnd, showHelp ? jsxRuntime.jsx(HelpButton, {}) : null] })) : null] }), jsxRuntime.jsx("div", { id: "header-title" })] })] })), jsxRuntime.jsx(React.Suspense, { fallback: jsxRuntime.jsx("div", { className: "relative z-10 min-h-[calc(100vh-8rem)]", children: jsxRuntime.jsx("div", { className: "flex h-full items-center justify-center", children: jsxRuntime.jsxs("div", { className: "space-y-4 text-center", children: [jsxRuntime.jsx("div", { className: "mx-auto h-8 w-8 animate-spin rounded-full border-b-2 border-primary" }), jsxRuntime.jsx("p", { className: "text-sm text-muted-foreground", children: "\u0E01\u0E33\u0E25\u0E31\u0E07\u0E42\u0E2B\u0E25\u0E14..." })] }) }) }), children: jsxRuntime.jsx("div", { className: workspace
3237
3687
  ? "relative z-10 px-[18px] pt-[26px] pb-4 sm:px-6 lg:px-10 lg:pt-[38px]"
3238
- : cx("relative z-10 pr-6", needsLeftGutter && "pl-6"), children: orgContextIsStale ? loadingComponent : wrap(children) }, `org-${contentOrgKey}`) })] }) }), showHelp ? jsxRuntime.jsx(HelpDialogHost, { app: helpApp, orgCode: code }) : null] }));
3688
+ : cx("relative z-10 pr-6", needsLeftGutter && "pl-6"), children: refusePage
3689
+ ? (notMemberComponent ?? (jsxRuntime.jsx(AccessDenied, { title: "\u0E44\u0E21\u0E48\u0E21\u0E35\u0E2A\u0E34\u0E17\u0E18\u0E34\u0E4C\u0E40\u0E02\u0E49\u0E32\u0E16\u0E36\u0E07\u0E2D\u0E07\u0E04\u0E4C\u0E01\u0E23\u0E19\u0E35\u0E49", message: `บัญชีนี้ไม่ได้เป็นสมาชิกขององค์กร ${code}`, homeHref: selectOrganizationUrl, homeLabel: "\u0E40\u0E25\u0E37\u0E2D\u0E01\u0E2D\u0E07\u0E04\u0E4C\u0E01\u0E23" })))
3690
+ : wrap(children) }, `org-${contentOrgKey}`) })] }) }), showHelp ? jsxRuntime.jsx(HelpDialogHost, { app: helpApp, orgCode: code }) : null] }));
3239
3691
  }
3240
3692
  /** Local tiny classnames helper — avoids pulling clsx/tailwind-merge into
3241
3693
  * the shell just for the layout file. Falsy values are skipped. */
@@ -3274,33 +3726,6 @@ function SidebarHelpLink({ label, href, icon, className }) {
3274
3726
  return external ? (jsxRuntime.jsx("a", { href: href, target: "_blank", rel: "noopener noreferrer", className: cls, children: body })) : (jsxRuntime.jsx(Link, { href: href, className: cls, children: body }));
3275
3727
  }
3276
3728
 
3277
- /**
3278
- * Central "no access" screen shared by every policy check (route-level
3279
- * PermissionProvider and page-level PermissionGateShell). One look everywhere:
3280
- * an illustration, a 403 headline, the page-specific reason, and recovery
3281
- * actions.
3282
- */
3283
- function AccessDenied({ title = "ขออภัย, คุณไม่มีสิทธิ์เข้าถึงหน้านี้ (403)", message = "คุณไม่มีสิทธิ์เข้าถึงหน้านี้ ขออภัยในความไม่สะดวก", homeHref, homeLabel = "กลับไปหน้าหลัก", supportHref, supportLabel = "ติดต่อฝ่ายสนับสนุน", fullScreen = true, className, }) {
3284
- // "/" is not the home page inside an app — it bounces to organization
3285
- // selection, so the button read "กลับไปหน้าหลัก" and dropped the user into a
3286
- // picker for the org they were already in. Send them to that org's home.
3287
- const params = navigation.useParams();
3288
- const code = typeof params?.code === "string" ? params.code : "";
3289
- const homeTarget = homeHref ?? (code ? `/${code}` : "/");
3290
- const go = (href) => {
3291
- if (typeof window !== "undefined")
3292
- window.location.href = href;
3293
- };
3294
- const wrapperClass = [
3295
- "flex min-w-0 flex-1 items-center justify-center p-4",
3296
- fullScreen ? "min-h-[70vh]" : "",
3297
- className ?? "",
3298
- ]
3299
- .filter(Boolean)
3300
- .join(" ");
3301
- return (jsxRuntime.jsx("div", { className: wrapperClass, children: jsxRuntime.jsx("div", { className: "relative w-full max-w-3xl overflow-hidden rounded-2xl border border-border/60 bg-card/60 p-8 shadow-sm backdrop-blur sm:p-10", children: jsxRuntime.jsxs("div", { className: "flex flex-col items-center gap-8 text-center sm:flex-row sm:text-left", children: [jsxRuntime.jsxs("div", { className: "relative shrink-0", children: [jsxRuntime.jsx("div", { className: "absolute inset-0 rounded-full bg-destructive/20 blur-2xl" }), jsxRuntime.jsx("div", { className: "relative flex h-28 w-28 items-center justify-center rounded-3xl border border-border/60 bg-gradient-to-br from-muted/40 to-background shadow-inner", children: jsxRuntime.jsx("div", { className: "flex h-16 w-16 items-center justify-center rounded-full border border-destructive/40 bg-destructive/10", children: jsxRuntime.jsx(lucideReact.Ban, { className: "h-8 w-8 text-destructive", strokeWidth: 2.5 }) }) })] }), jsxRuntime.jsxs("div", { className: "min-w-0 flex-1", children: [jsxRuntime.jsx("h1", { className: "text-xl font-bold text-foreground sm:text-2xl", children: title }), message ? (jsxRuntime.jsx("p", { className: "mt-3 whitespace-pre-line text-sm leading-relaxed text-muted-foreground sm:text-base", children: message })) : null, jsxRuntime.jsxs("div", { className: "mt-6 flex flex-col gap-3 sm:flex-row", children: [jsxRuntime.jsxs(erpTheme.Button, { type: "button", onClick: () => go(homeTarget), className: "w-full sm:w-auto", children: [jsxRuntime.jsx(lucideReact.Home, { className: "mr-2 h-4 w-4" }), homeLabel] }), supportHref ? (jsxRuntime.jsxs(erpTheme.Button, { type: "button", variant: "outline", onClick: () => go(supportHref), className: "w-full sm:w-auto", children: [jsxRuntime.jsx(lucideReact.Headset, { className: "mr-2 h-4 w-4" }), supportLabel] })) : null] })] })] }) }) }));
3302
- }
3303
-
3304
3729
  const EMPLOYEE_RECORD_REQUIRED = "EMPLOYEE_RECORD_REQUIRED";
3305
3730
  const EMPLOYEE_RECORD_REQUIRED_EVENT = "erp:employee-record-required";
3306
3731
  const EMPLOYEE_RECORD_OK_EVENT = "erp:employee-record-ok";
@@ -3693,6 +4118,51 @@ function useFeatureFlag(flag) {
3693
4118
  return flags[flag];
3694
4119
  }
3695
4120
 
4121
+ // No relative imports: node --test loads this file as is (query-scope.test.ts).
4122
+ /**
4123
+ * Every query is cached per organisation.
4124
+ *
4125
+ * Requests are scoped by the X-Organization-ID header, so almost no query key
4126
+ * names the organisation — ["purchase-requests", "detail", id] is the same key
4127
+ * in every company. A key that is the same in two organisations is one cache
4128
+ * entry: what was fetched in one was a fresh cache hit in the other, and a
4129
+ * page opened under another organisation's URL rendered the first one's
4130
+ * document until something refetched it.
4131
+ *
4132
+ * The hash — the identity of a cache entry — therefore carries the
4133
+ * organisation requests are sent with right now (getQueryClientConfig passes
4134
+ * getRequestOrganizationId()). The key itself is untouched,
4135
+ * so partial-key invalidation and every existing key keep working; a switch
4136
+ * simply lands on a different set of entries.
4137
+ */
4138
+ function organizationScopedQueryHash(queryKey, organizationId) {
4139
+ return `${reactQuery.hashKey(queryKey)}@org:${organizationId ?? ""}`;
4140
+ }
4141
+ function errorStatus(error) {
4142
+ if (!error || typeof error !== "object")
4143
+ return undefined;
4144
+ const e = error;
4145
+ const status = typeof e.status === "number" ? e.status : e.response?.status;
4146
+ return typeof status === "number" ? status : undefined;
4147
+ }
4148
+ /**
4149
+ * Retry a failed query at most twice — unless the API answered with a 4xx.
4150
+ * A 404 for a document that is not in this organisation will not appear on a
4151
+ * second ask; retrying held the page on its skeleton for three seconds before
4152
+ * it could say "not found". 408 and 429 are transient and still retried.
4153
+ */
4154
+ function shouldRetryQuery(failureCount, error) {
4155
+ const status = errorStatus(error);
4156
+ if (status !== undefined &&
4157
+ status >= 400 &&
4158
+ status < 500 &&
4159
+ status !== 408 &&
4160
+ status !== 429) {
4161
+ return false;
4162
+ }
4163
+ return failureCount < 2;
4164
+ }
4165
+
3696
4166
  const getAxiosErrorMessage$1 = (error) => {
3697
4167
  if (error.response?.data?.message) {
3698
4168
  return error.response.data.message;
@@ -3716,7 +4186,9 @@ const formatError = (handlerName, error) => {
3716
4186
  };
3717
4187
  /**
3718
4188
  * Default React Query config used across ERP apps:
3719
- * - 2 retries on queries / 1 on mutations, exponential backoff
4189
+ * - cache entries scoped by organisation (organizationScopedQueryHash)
4190
+ * - 2 retries on queries except 4xx answers / none on mutations,
4191
+ * exponential backoff
3720
4192
  * - 5 min staleTime, refetchOnWindowFocus
3721
4193
  * - swallows "no org context" errors to silence noise on the
3722
4194
  * select-organization page
@@ -3727,7 +4199,8 @@ const formatError = (handlerName, error) => {
3727
4199
  const getQueryClientConfig = () => ({
3728
4200
  defaultOptions: {
3729
4201
  queries: {
3730
- retry: 2,
4202
+ queryKeyHashFn: queryKey => organizationScopedQueryHash(queryKey, getRequestOrganizationId()),
4203
+ retry: shouldRetryQuery,
3731
4204
  retryDelay: attemptIndex => Math.min(1000 * 2 ** attemptIndex, 30000),
3732
4205
  staleTime: 5 * 60 * 1000,
3733
4206
  refetchOnWindowFocus: true,
@@ -3825,6 +4298,7 @@ exports.EMPLOYEE_RECORD_OK_EVENT = EMPLOYEE_RECORD_OK_EVENT;
3825
4298
  exports.EMPLOYEE_RECORD_REQUIRED = EMPLOYEE_RECORD_REQUIRED;
3826
4299
  exports.EMPLOYEE_RECORD_REQUIRED_EVENT = EMPLOYEE_RECORD_REQUIRED_EVENT;
3827
4300
  exports.EmployeeRequiredGate = EmployeeRequiredGate;
4301
+ exports.FOR_ORGANIZATION_FIELD = FOR_ORGANIZATION_FIELD;
3828
4302
  exports.FeatureFlagsProvider = FeatureFlagsProvider;
3829
4303
  exports.HeaderBackground = HeaderBackground;
3830
4304
  exports.HelpButton = HelpButton;
@@ -3851,6 +4325,7 @@ exports.SidebarProvider = SidebarProvider;
3851
4325
  exports.SidebarTrigger = SidebarTrigger;
3852
4326
  exports.TapMenu = TapMenu;
3853
4327
  exports.ThemeProvider = ThemeProvider;
4328
+ exports.WITHOUT_ORGANIZATION_FLAG = WITHOUT_ORGANIZATION_FLAG;
3854
4329
  exports.WORKSPACE_HEADER_VARS = WORKSPACE_HEADER_VARS;
3855
4330
  exports.WORKSPACE_LIGHT_HEADER_VARS = WORKSPACE_LIGHT_HEADER_VARS;
3856
4331
  exports.WorkspaceBrand = WorkspaceBrand;
@@ -3860,6 +4335,7 @@ exports.buildUrlWithCode = buildUrlWithCode;
3860
4335
  exports.canAccessNavPermissionRoute = canAccessNavPermissionRoute;
3861
4336
  exports.clearAccessTokenCache = clearAccessTokenCache;
3862
4337
  exports.clearOrganizationId = clearOrganizationId;
4338
+ exports.clearStoredOrganization = clearStoredOrganization;
3863
4339
  exports.closeHelp = closeHelp;
3864
4340
  exports.decodeJWT = decodeJWT;
3865
4341
  exports.describeAttached = describeAttached;
@@ -3867,27 +4343,39 @@ exports.describeBrowser = describeBrowser;
3867
4343
  exports.detectHelpApp = detectHelpApp;
3868
4344
  exports.filterNavigationModulesByPermission = filterNavigationModulesByPermission;
3869
4345
  exports.filterNavigationToolsByPermission = filterNavigationToolsByPermission;
4346
+ exports.findOrganizationForCode = findOrganizationForCode;
3870
4347
  exports.getAccessToken = getAccessToken;
3871
4348
  exports.getActiveModule = getActiveModule;
3872
4349
  exports.getActiveSubModule = getActiveSubModule;
3873
4350
  exports.getAxiosErrorMessage = getAxiosErrorMessage;
3874
4351
  exports.getOrganizationId = getOrganizationId;
3875
4352
  exports.getQueryClientConfig = getQueryClientConfig;
4353
+ exports.getRequestOrganizationId = getRequestOrganizationId;
3876
4354
  exports.getRequiredChildGroups = getRequiredChildGroups;
4355
+ exports.getStoredOrganizationIds = getStoredOrganizationIds;
4356
+ exports.getTabOrganizationId = getTabOrganizationId;
3877
4357
  exports.hasPlanFeature = hasPlanFeature;
4358
+ exports.isKnownStaleOrganizationId = isKnownStaleOrganizationId;
3878
4359
  exports.isLikelyTransientNetworkError = isLikelyTransientNetworkError;
3879
4360
  exports.isPathActive = isPathActive;
3880
4361
  exports.myRolesQueryKey = myRolesQueryKey;
3881
4362
  exports.normalizePathnameForPermissionRules = normalizePathnameForPermissionRules;
3882
4363
  exports.openHelp = openHelp;
4364
+ exports.organizationScopedQueryHash = organizationScopedQueryHash;
3883
4365
  exports.parseMyRoles = parseMyRoles;
3884
4366
  exports.resolveActiveNav = resolveActiveNav;
3885
4367
  exports.resolveSidebarNav = resolveSidebarNav;
4368
+ exports.resolveStaleOrganization = resolveStaleOrganization;
3886
4369
  exports.resolveUiRoutePermission = resolveUiRoutePermission;
4370
+ exports.resolveUrlOrgGate = resolveUrlOrgGate;
4371
+ exports.retryWithStoredOrganizations = retryWithStoredOrganizations;
3887
4372
  exports.roleSummary = roleSummary;
3888
4373
  exports.runGlobalRequestPrecheck = runGlobalRequestPrecheck;
3889
4374
  exports.runWithTransientRetry = runWithTransientRetry;
4375
+ exports.setKnownOrganizationIds = setKnownOrganizationIds;
3890
4376
  exports.setOrganizationId = setOrganizationId;
4377
+ exports.setTabOrganizationId = setTabOrganizationId;
4378
+ exports.shouldRetryQuery = shouldRetryQuery;
3891
4379
  exports.toApiError = toApiError;
3892
4380
  exports.unwrapApiResponse = unwrapApiResponse;
3893
4381
  exports.updateOrganizationId = updateOrganizationId;
@@ -3902,4 +4390,5 @@ exports.usePermissionRules = usePermissionRules;
3902
4390
  exports.useSessionRefresh = useSessionRefresh;
3903
4391
  exports.useSidebar = useSidebar;
3904
4392
  exports.useSidebarToggle = useSidebarToggle;
4393
+ exports.withoutOrganization = withoutOrganization;
3905
4394
  //# sourceMappingURL=index.js.map