@eintrek/erp-shell 0.1.60 → 0.1.62

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.
@@ -1,3 +1,11 @@
1
1
  import { AxiosInstance } from "axios";
2
+ declare module "axios" {
3
+ interface AxiosRequestConfig {
4
+ /** Send this request without X-Organization-ID. */
5
+ erpWithoutOrganization?: boolean;
6
+ /** Send this request for this organisation, whatever the tab has active. */
7
+ erpOrganizationId?: string;
8
+ }
9
+ }
2
10
  declare const instance: AxiosInstance;
3
11
  export default instance;
@@ -0,0 +1 @@
1
+ export declare function retryWithStoredOrganizations<T>(config: Record<string, unknown> | undefined, status: number | undefined, send: (config: Record<string, unknown>) => Promise<T>): Promise<T | undefined>;
package/dist/index.d.ts CHANGED
@@ -17,13 +17,14 @@ export { NavUser, type NavUserProps, type NavUserSystemLink } from "./navigation
17
17
  export { useMyRoles, myRolesQueryKey } from "./navigation/use-my-roles";
18
18
  export { parseMyRoles, roleSummary, OWNER_CHIP, type MyRole, type MyRoles, } from "./navigation/my-roles";
19
19
  export { buildSystemLinks, type BuildSystemLinksOptions, type ErpAppId, } from "./navigation/app-registry";
20
+ export { accountSettingsHref, platformBaseUrl, type AccountSettingsHrefOptions, } from "./navigation/platform-url";
20
21
  export { PLAN_FEATURES, hasPlanFeature, type PlanFeatureKey, } from "./lib/plan-features";
21
22
  export { NavMain, type NavMainProps } from "./navigation/nav-main";
22
23
  export { OrganizationSwitcher, type OrganizationSwitcherProps, type OrganizationLike, } from "./navigation/organization-switcher";
23
24
  export { SidebarHeader } from "./navigation/sidebar-header";
24
25
  export { AppSidebar, type AppSidebarProps } from "./navigation/app-sidebar";
25
26
  export { AppLayoutShell, type AppLayoutShellProps, type AppLayoutShellOrganization, } from "./navigation/app-layout-shell";
26
- export { resolveUrlOrgGate, findOrganizationForCode, type UrlOrgGate, type UrlOrgGateInput, type OrganizationsStatus, } from "./navigation/url-org-gate";
27
+ export { resolveUrlOrgGate, findOrganizationForCode, resolveStaleOrganization, type StaleOrganizationRecovery, type UrlOrgGate, type UrlOrgGateInput, type OrganizationsStatus, } from "./navigation/url-org-gate";
27
28
  export { type ShellChrome, type WorkspaceHeaderTone, WORKSPACE_HEADER_VARS, WORKSPACE_LIGHT_HEADER_VARS, } from "./navigation/chrome";
28
29
  export { WorkspaceBrand, type WorkspaceBrandProps } from "./navigation/workspace-brand";
29
30
  export { SidebarHelpCard, SidebarHelpLink, type SidebarHelpCardProps, type SidebarHelpLinkProps, } from "./navigation/sidebar-help-card";
@@ -54,6 +55,7 @@ export { unwrapApiResponse } from "./http/unwrap";
54
55
  export { getAccessToken, clearAccessTokenCache, } from "./http/auth-token-manager";
55
56
  export { runGlobalRequestPrecheck } from "./http/request-precheck";
56
57
  export { getOrganizationId, setOrganizationId, clearOrganizationId, } from "./organization/organization-id";
57
- export { getRequestOrganizationId, getTabOrganizationId, setTabOrganizationId, } from "./organization/active-organization";
58
+ export { getRequestOrganizationId, getTabOrganizationId, setTabOrganizationId, setKnownOrganizationIds, isKnownStaleOrganizationId, withoutOrganization, WITHOUT_ORGANIZATION_FLAG, FOR_ORGANIZATION_FIELD, getStoredOrganizationIds, clearStoredOrganization, } from "./organization/active-organization";
59
+ export { retryWithStoredOrganizations } from "./http/organization-fallback";
58
60
  export declare const updateOrganizationId: (organizationId: string | null) => void;
59
61
  export declare const getAxiosErrorMessage: (error: unknown) => string;
package/dist/index.esm.js CHANGED
@@ -5,7 +5,7 @@ import { useSession, signOut } from 'next-auth/react';
5
5
  import { useQueryClient, useQuery, QueryClientContext, QueryClient, hashKey, MutationCache, QueryCache, QueryClientProvider } from '@tanstack/react-query';
6
6
  import { jsx, jsxs, Fragment as Fragment$1 } from 'react/jsx-runtime';
7
7
  import { cn, Button, Tooltip, TooltipTrigger, TooltipContent, Breadcrumb, BreadcrumbList, BreadcrumbItem, BreadcrumbLink, BreadcrumbSeparator, BreadcrumbPage, Avatar, AvatarImage, AvatarFallback, Collapsible, CollapsibleTrigger, CollapsibleContent, Badge, DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuLabel, DropdownMenuItem, DropdownMenuSeparator, Skeleton, Dialog, DialogContent, DialogHeader, DialogTitle, Label, Textarea, DialogFooter, Card, CardHeader, CardContent } from '@eintrek/erp-theme';
8
- import { PanelLeftClose, Menu, ChevronRight, Building2, Palette, ChevronUp, Sun, Moon, Monitor, Globe, User, LayoutGrid, ExternalLink, CircleHelp, Loader2, LogOut, Calculator, Warehouse, FolderKanban, Users, Store, Headset, ChevronDown, Plus, ChevronsUpDown, ImagePlus, X, CircleCheck, ArrowLeft, Ban, Home, ArrowRight, Compass, ArrowUpRight, ContactRound, UserPlus } from 'lucide-react';
8
+ import { PanelLeftClose, Menu, ChevronRight, Building2, Palette, ChevronUp, Sun, Moon, Monitor, Globe, UserCog, LayoutGrid, ExternalLink, CircleHelp, Loader2, LogOut, Calculator, Warehouse, FolderKanban, Users, Store, Headset, ChevronDown, Plus, ChevronsUpDown, ImagePlus, X, CircleCheck, ArrowLeft, Ban, Home, ArrowRight, Compass, ArrowUpRight, ContactRound, UserPlus } 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';
@@ -305,20 +305,154 @@ function readOrganizationStorage() {
305
305
  return null;
306
306
  }
307
307
  }
308
+ /**
309
+ * The organisations the signed-in user belongs to, once their list has
310
+ * loaded (AppLayoutShell reports it); null until then.
311
+ */
312
+ let knownOrganizationIds = null;
313
+ /**
314
+ * Record the user's organisations (browser only). From then on a stored or
315
+ * tab organisation that is not among them loses to one that is: a browser
316
+ * that still remembers a deleted organisation — or one the user has left —
317
+ * must not keep sending it while the cookie names a good one.
318
+ */
319
+ function setKnownOrganizationIds(ids) {
320
+ if (!isBrowser$1())
321
+ return;
322
+ if (ids === null) {
323
+ knownOrganizationIds = null;
324
+ return;
325
+ }
326
+ const set = new Set();
327
+ for (const id of ids) {
328
+ const value = normalise(id);
329
+ if (value)
330
+ set.add(value);
331
+ }
332
+ knownOrganizationIds = set;
333
+ }
334
+ /** Whether the loaded list says the user is NOT a member of `id`. */
335
+ function isKnownStaleOrganizationId(id) {
336
+ const value = normalise(id);
337
+ return (value !== null &&
338
+ knownOrganizationIds !== null &&
339
+ !knownOrganizationIds.has(value));
340
+ }
341
+ /**
342
+ * Marks a request that must not carry X-Organization-ID: the user's own list
343
+ * of organisations is how a stale organisation id is found out, so it cannot
344
+ * depend on one. An app's interceptor honours it by passing the request's
345
+ * config to getRequestOrganizationId(config).
346
+ *
347
+ * axiosInstance.get("/organizations", withoutOrganization({ params }))
348
+ */
349
+ const WITHOUT_ORGANIZATION_FLAG = "erpWithoutOrganization";
350
+ function withoutOrganization(config) {
351
+ return { ...(config ?? {}), [WITHOUT_ORGANIZATION_FLAG]: true };
352
+ }
353
+ /**
354
+ * Marks a request for one particular organisation, whatever this tab has
355
+ * active. Used to retry the organisation list under each stored id when an
356
+ * API refuses it without one (see http/organization-fallback.ts).
357
+ */
358
+ const FOR_ORGANIZATION_FIELD = "erpOrganizationId";
359
+ function isWithoutOrganization(config) {
360
+ return (!!config &&
361
+ typeof config === "object" &&
362
+ config[WITHOUT_ORGANIZATION_FLAG] === true);
363
+ }
364
+ function explicitOrganization(config) {
365
+ if (!config || typeof config !== "object")
366
+ return null;
367
+ const value = config[FOR_ORGANIZATION_FIELD];
368
+ return typeof value === "string" || typeof value === "number"
369
+ ? normalise(value)
370
+ : null;
371
+ }
372
+ /**
373
+ * Every organisation id this browser has stored for the tab — the tab's
374
+ * own, localStorage, the cookie — distinct, in that order.
375
+ */
376
+ function getStoredOrganizationIds() {
377
+ const ids = [
378
+ getTabOrganizationId(),
379
+ readOrganizationStorage(),
380
+ readOrganizationCookie(),
381
+ ].filter((id) => id !== null);
382
+ return [...new Set(ids)];
383
+ }
308
384
  /**
309
385
  * The organisation a request from this tab belongs to: the tab's own, then
310
386
  * localStorage, then the cookie. Use it for the X-Organization-ID header —
311
387
  * and read it before any `await` in the interceptor, so the header names the
312
388
  * organisation that was active when the request was made.
313
389
  *
390
+ * Pass the request's config: a request marked withoutOrganization() gets
391
+ * null, so no header.
392
+ *
314
393
  * localStorage before the cookie: setOrganizationId writes localStorage
315
394
  * synchronously and then awaits a server action for the cookie, so during
316
395
  * that round trip the cookie still names the previous organisation.
396
+ *
397
+ * Once the user's organisations are known (setKnownOrganizationIds), the
398
+ * first of those that is one of them wins: 0.1.60 let a deleted
399
+ * organisation's id left in localStorage beat a valid cookie, and every
400
+ * request — the organisation list included — was refused. If none is a
401
+ * member the first is still sent, as before: the list may simply be behind
402
+ * (an organisation created a moment ago).
403
+ */
404
+ function getRequestOrganizationId(config) {
405
+ const explicit = explicitOrganization(config);
406
+ if (explicit)
407
+ return explicit;
408
+ if (isWithoutOrganization(config))
409
+ return null;
410
+ const candidates = [
411
+ getTabOrganizationId(),
412
+ readOrganizationStorage(),
413
+ readOrganizationCookie(),
414
+ ];
415
+ if (knownOrganizationIds !== null) {
416
+ const member = candidates.find(id => id !== null && knownOrganizationIds.has(id));
417
+ if (member)
418
+ return member;
419
+ }
420
+ return candidates.find(id => id !== null) ?? null;
421
+ }
422
+ function expireOrganizationCookie() {
423
+ // The cookie is set by the host's server action, host-only or on a parent
424
+ // domain (AUTH_COOKIE_DOMAIN, e.g. .antniti.com). The browser does not say
425
+ // which, so expire it on the host and on every parent domain.
426
+ const base = `${ORGANIZATION_ID_KEY}=; Max-Age=0; path=/; SameSite=Lax`;
427
+ try {
428
+ document.cookie = base;
429
+ const labels = window.location.hostname.split(".");
430
+ for (let i = 0; i < labels.length - 1; i++) {
431
+ document.cookie = `${base}; domain=.${labels.slice(i).join(".")}`;
432
+ }
433
+ }
434
+ catch {
435
+ // Cookies unavailable: nothing to clear.
436
+ }
437
+ }
438
+ /**
439
+ * Forget the active organisation everywhere this browser keeps it: the tab,
440
+ * localStorage and the `erp.organizationId` cookie. Call on sign-out, so the
441
+ * next person to sign in on this browser does not start in the previous
442
+ * person's organisation.
317
443
  */
318
- function getRequestOrganizationId() {
319
- return (getTabOrganizationId() ??
320
- readOrganizationStorage() ??
321
- readOrganizationCookie());
444
+ function clearStoredOrganization() {
445
+ if (!isBrowser$1())
446
+ return;
447
+ tabOrganizationId = null;
448
+ knownOrganizationIds = null;
449
+ try {
450
+ localStorage.removeItem(ORGANIZATION_ID_KEY);
451
+ }
452
+ catch {
453
+ // Storage unavailable: nothing to clear.
454
+ }
455
+ expireOrganizationCookie();
322
456
  }
323
457
 
324
458
  const SELECTED_ORG_STORAGE_KEY = "erp.organizationId";
@@ -1412,6 +1546,44 @@ const runGlobalRequestPrecheck = (config) => {
1412
1546
  return validateGoodsReceiptChecks(config.data);
1413
1547
  };
1414
1548
 
1549
+ /**
1550
+ * The organisation list is asked for without an organisation (see
1551
+ * withoutOrganization), but some APIs still refuse it without one: Peoplx's
1552
+ * GET /organizations sits behind its module.hr plan gate, which answers 400
1553
+ * "organization context required" to no header and 403 to an organisation
1554
+ * that does not exist. Until those routes stop needing one, a refused list is
1555
+ * asked again under each organisation this browser has stored — the tab's,
1556
+ * localStorage's, the cookie's — so one good id among stale ones still gets
1557
+ * the user their list, and with it the way back to a working organisation.
1558
+ *
1559
+ * `send` re-sends the request (an axios instance call). Returns the first
1560
+ * answer that succeeds, or undefined when none did or the request is not a
1561
+ * refused organisation-less one. Each retry is marked, so it never retries
1562
+ * itself.
1563
+ */
1564
+ const RETRIED = "_erpOrganizationFallback";
1565
+ async function retryWithStoredOrganizations(config, status, send) {
1566
+ if (!config ||
1567
+ config[WITHOUT_ORGANIZATION_FLAG] !== true ||
1568
+ config[RETRIED] === true ||
1569
+ (status !== 400 && status !== 403)) {
1570
+ return undefined;
1571
+ }
1572
+ for (const id of getStoredOrganizationIds()) {
1573
+ try {
1574
+ return await send({
1575
+ ...config,
1576
+ [RETRIED]: true,
1577
+ [FOR_ORGANIZATION_FIELD]: id,
1578
+ });
1579
+ }
1580
+ catch {
1581
+ // That one was refused too; try the next.
1582
+ }
1583
+ }
1584
+ return undefined;
1585
+ }
1586
+
1415
1587
  const isBrowser = () => typeof window !== "undefined";
1416
1588
  const isAuthEndpoint = (url) => !!url && url.includes("/api/auth/");
1417
1589
  const isAuthPage = () => isBrowser() && window.location.pathname.startsWith("/auth/");
@@ -1420,7 +1592,7 @@ const redirectToLogout = async () => {
1420
1592
  return;
1421
1593
  localStorage.clear();
1422
1594
  sessionStorage.clear();
1423
- setOrganizationId(null);
1595
+ clearStoredOrganization();
1424
1596
  clearAccessTokenCache();
1425
1597
  await signOut({ callbackUrl: "/auth/signin" });
1426
1598
  };
@@ -1452,7 +1624,7 @@ instance.interceptors.request.use(async (config) => {
1452
1624
  // Read before the token await: the header must name the organisation that
1453
1625
  // was active when the request was made (and under which its query is
1454
1626
  // cached), not whatever is active once the token resolves.
1455
- const organizationId = getRequestOrganizationId();
1627
+ const organizationId = getRequestOrganizationId(config);
1456
1628
  const token = await getAccessToken(false);
1457
1629
  if (token) {
1458
1630
  config.headers.Authorization = `Bearer ${token}`;
@@ -1512,6 +1684,9 @@ instance.interceptors.response.use(response => {
1512
1684
  window.dispatchEvent(new CustomEvent("erp:employee-record-required"));
1513
1685
  }
1514
1686
  }
1687
+ const listed = await retryWithStoredOrganizations(originalRequest, error.response?.status, config => instance(config));
1688
+ if (listed !== undefined)
1689
+ return listed;
1515
1690
  return Promise.reject(toApiError(error));
1516
1691
  });
1517
1692
 
@@ -1538,7 +1713,7 @@ function platformApiBase(app) {
1538
1713
  * This package's version, attached to help messages so the back office can
1539
1714
  * tell which shell a report came from. Keep in step with package.json.
1540
1715
  */
1541
- const SHELL_VERSION = "0.1.60";
1716
+ const SHELL_VERSION = "0.1.62";
1542
1717
 
1543
1718
  const HELP_APP_LABEL = {
1544
1719
  platform: "แพลตฟอร์ม",
@@ -1861,6 +2036,9 @@ function NavUser({ organization, avatarSrc, profileHref, selectOrganizationHref
1861
2036
  };
1862
2037
  const handleSignOut = async () => {
1863
2038
  setIsSigningOut(true);
2039
+ // The next person to sign in on this browser must not start in this
2040
+ // person's organisation: forget it in the tab, localStorage and cookie.
2041
+ clearStoredOrganization();
1864
2042
  try {
1865
2043
  await signOut({ callbackUrl: signInRedirectHref, redirect: true });
1866
2044
  }
@@ -1878,8 +2056,15 @@ function NavUser({ organization, avatarSrc, profileHref, selectOrganizationHref
1878
2056
  };
1879
2057
  const handleProfile = () => {
1880
2058
  setIsOpen(false);
1881
- if (profileHref)
1882
- router.push(profileHref);
2059
+ if (!profileHref)
2060
+ return;
2061
+ // Account settings live on Platform, so from any other app this is a
2062
+ // full page load, the same as the apps menu.
2063
+ if (isExternalHref(profileHref)) {
2064
+ window.location.assign(profileHref);
2065
+ return;
2066
+ }
2067
+ router.push(profileHref);
1883
2068
  };
1884
2069
  const handleSystemLink = (href) => {
1885
2070
  setIsOpen(false);
@@ -1946,7 +2131,7 @@ function NavUser({ organization, avatarSrc, profileHref, selectOrganizationHref
1946
2131
  { code: "en", label: "English" },
1947
2132
  { code: "zh", label: "中文" },
1948
2133
  { code: "ja", label: "日本語" },
1949
- ].map(({ code, label }) => (jsxs("button", { onClick: () => handleLanguageChange(code), className: "flex w-full items-center gap-3 px-3 py-2 text-sm text-sidebar-foreground hover:bg-sidebar-accent rounded-md transition-colors", children: [jsx("span", { className: "flex-1 text-left", children: label }), organization?.language === code && (jsx("div", { className: "h-2 w-2 rounded-full bg-primary" }))] }, code))) }))] })), profileHref && (jsxs("button", { onClick: handleProfile, className: "flex w-full items-center gap-3 px-3 py-2.5 text-sm text-sidebar-foreground hover:bg-sidebar-accent rounded-md transition-colors", children: [jsx(User, { className: "h-4 w-4" }), jsx("span", { className: "flex-1 text-left", children: "\u0E02\u0E49\u0E2D\u0E21\u0E39\u0E25\u0E2A\u0E48\u0E27\u0E19\u0E15\u0E31\u0E27" })] })), showSelectOrganization && (jsxs("button", { onClick: handleSelectOrganization, className: "flex w-full items-center gap-3 px-3 py-2.5 text-sm text-sidebar-foreground hover:bg-sidebar-accent rounded-md transition-colors", children: [jsx(Building2, { className: "h-4 w-4" }), jsx("span", { className: "flex-1 text-left", children: "\u0E40\u0E25\u0E37\u0E2D\u0E01\u0E18\u0E38\u0E23\u0E01\u0E34\u0E08" })] })), visibleSystemLinks.length > 0 && (jsxs("div", { className: "relative", children: [jsxs("button", { onClick: () => {
2134
+ ].map(({ code, label }) => (jsxs("button", { onClick: () => handleLanguageChange(code), className: "flex w-full items-center gap-3 px-3 py-2 text-sm text-sidebar-foreground hover:bg-sidebar-accent rounded-md transition-colors", children: [jsx("span", { className: "flex-1 text-left", children: label }), organization?.language === code && (jsx("div", { className: "h-2 w-2 rounded-full bg-primary" }))] }, code))) }))] })), profileHref && (jsxs("button", { onClick: handleProfile, className: "flex w-full items-center gap-3 px-3 py-2.5 text-sm text-sidebar-foreground hover:bg-sidebar-accent rounded-md transition-colors", children: [jsx(UserCog, { className: "h-4 w-4" }), jsx("span", { className: "flex-1 text-left", children: "\u0E15\u0E31\u0E49\u0E07\u0E04\u0E48\u0E32\u0E1A\u0E31\u0E0D\u0E0A\u0E35" })] })), showSelectOrganization && (jsxs("button", { onClick: handleSelectOrganization, className: "flex w-full items-center gap-3 px-3 py-2.5 text-sm text-sidebar-foreground hover:bg-sidebar-accent rounded-md transition-colors", children: [jsx(Building2, { className: "h-4 w-4" }), jsx("span", { className: "flex-1 text-left", children: "\u0E40\u0E25\u0E37\u0E2D\u0E01\u0E18\u0E38\u0E23\u0E01\u0E34\u0E08" })] })), visibleSystemLinks.length > 0 && (jsxs("div", { className: "relative", children: [jsxs("button", { onClick: () => {
1950
2135
  setShowAppsMenu(!showAppsMenu);
1951
2136
  setShowThemeMenu(false);
1952
2137
  setShowLanguageMenu(false);
@@ -2026,6 +2211,32 @@ function hasPlanFeature(features, feature) {
2026
2211
  return false;
2027
2212
  }
2028
2213
 
2214
+ /**
2215
+ * Where Platform lives, and the pages every app sends people to on it.
2216
+ *
2217
+ * Personal settings — profile, password, signature, LINE — exist once, on
2218
+ * Platform. Every other app links there instead of keeping its own copy.
2219
+ *
2220
+ * Plain .ts (no JSX) so node --test can import it; app-registry.tsx reads the
2221
+ * base from here too, so the app switcher and the account link never disagree.
2222
+ */
2223
+ const DEFAULT_PLATFORM_URL = "https://platform.antniti.com";
2224
+ /** Read per call: Next inlines NEXT_PUBLIC_* at the consuming app's build. */
2225
+ function platformBaseUrl() {
2226
+ return (process.env.NEXT_PUBLIC_PLATFORM_URL || DEFAULT_PLATFORM_URL).replace(/\/+$/, "");
2227
+ }
2228
+ /**
2229
+ * ตั้งค่าบัญชี for an organisation: `/{code}/account/settings` on Platform.
2230
+ * Undefined without an organisation code — there is no page to open then.
2231
+ */
2232
+ function accountSettingsHref(orgCode, { activeApp } = {}) {
2233
+ const code = orgCode?.trim();
2234
+ if (!code)
2235
+ return undefined;
2236
+ const path = `/${encodeURIComponent(code)}/account/settings`;
2237
+ return activeApp === "platform" ? path : `${platformBaseUrl()}${path}`;
2238
+ }
2239
+
2029
2240
  function normaliseBase(value, fallback) {
2030
2241
  return (value ?? fallback).replace(/\/$/, "");
2031
2242
  }
@@ -2042,7 +2253,7 @@ const APPS = [
2042
2253
  label: "แพลตฟอร์ม",
2043
2254
  icon: jsx(Building2, { className: "h-4 w-4 shrink-0" }),
2044
2255
  features: null,
2045
- baseUrl: () => normaliseBase(process.env.NEXT_PUBLIC_PLATFORM_URL, "https://platform.antniti.com"),
2256
+ baseUrl: platformBaseUrl,
2046
2257
  },
2047
2258
  {
2048
2259
  id: "accounx",
@@ -3254,6 +3465,31 @@ function resolveUrlOrgGate({ code, organizations, organizationsStatus, organizat
3254
3465
  return { state: "unverified" };
3255
3466
  }
3256
3467
  }
3468
+ function resolveStaleOrganization({ code, organizations, organizationsStatus, organizationId, }) {
3469
+ const status = organizationsStatus ?? (organizations.length > 0 ? "success" : undefined);
3470
+ if (status !== "success")
3471
+ return { action: "none" };
3472
+ const activeId = organizationId === null || organizationId === undefined
3473
+ ? ""
3474
+ : String(organizationId).trim();
3475
+ if (activeId === "")
3476
+ return { action: "none" };
3477
+ if (organizations.some(org => String(org.id) === activeId)) {
3478
+ return { action: "none" };
3479
+ }
3480
+ const urlOrg = findOrganizationForCode(code, organizations);
3481
+ if (urlOrg) {
3482
+ return { action: "use", targetId: String(urlOrg.id), fromUrl: true };
3483
+ }
3484
+ if (organizations.length === 1) {
3485
+ return {
3486
+ action: "use",
3487
+ targetId: String(organizations[0].id),
3488
+ fromUrl: false,
3489
+ };
3490
+ }
3491
+ return { action: "forget" };
3492
+ }
3257
3493
 
3258
3494
  /**
3259
3495
  * Top-level app chrome: sticky header, sidebar slot, content gutter and the
@@ -3315,6 +3551,44 @@ function AppLayoutShell({ children, sidebar, loadingComponent, breadcrumb, permi
3315
3551
  requestedSwitchRef.current = switchTargetId;
3316
3552
  void setOrganizationId(switchTargetId);
3317
3553
  }, [switchTargetId, setOrganizationId]);
3554
+ // Once the user's organisations are known, requests prefer a stored
3555
+ // organisation that is one of them (getRequestOrganizationId).
3556
+ const organizationsLoaded = (organizationsStatus ??
3557
+ (organizations.length > 0 ? "success" : undefined)) === "success";
3558
+ useEffect(() => {
3559
+ if (organizationsLoaded) {
3560
+ setKnownOrganizationIds(organizations.map(org => org.id));
3561
+ }
3562
+ }, [organizationsLoaded, organizations]);
3563
+ // An active organisation that is not one of the user's — deleted, or left —
3564
+ // is replaced, so it stops being sent with every request. The URL's
3565
+ // organisation, when the user is a member, is the gate's switch above;
3566
+ // otherwise the user's only organisation, or none (they pick one: the
3567
+ // refusal below offers it, and a path without a code goes to the picker).
3568
+ // Once per stale id and target, so a setter that does not take cannot loop.
3569
+ const staleRecovery = resolveStaleOrganization({
3570
+ code,
3571
+ organizations,
3572
+ organizationsStatus,
3573
+ organizationId,
3574
+ });
3575
+ const staleRecoveryTarget = staleRecovery.action === "forget"
3576
+ ? null
3577
+ : staleRecovery.action === "use" && !staleRecovery.fromUrl
3578
+ ? staleRecovery.targetId
3579
+ : undefined;
3580
+ const staleRecoveryKey = staleRecoveryTarget === undefined
3581
+ ? null
3582
+ : `${String(organizationId)}>${staleRecoveryTarget ?? ""}`;
3583
+ const handledStaleRecoveryRef = useRef(null);
3584
+ useEffect(() => {
3585
+ if (staleRecoveryKey === null || staleRecoveryTarget === undefined)
3586
+ return;
3587
+ if (handledStaleRecoveryRef.current === staleRecoveryKey)
3588
+ return;
3589
+ handledStaleRecoveryRef.current = staleRecoveryKey;
3590
+ void setOrganizationId(staleRecoveryTarget);
3591
+ }, [staleRecoveryKey, staleRecoveryTarget, setOrganizationId]);
3318
3592
  // Key for the content subtree. It exists so switching organizations throws
3319
3593
  // away the previous org's component state — but it must react ONLY to a real
3320
3594
  // switch. organizationId legitimately passes through null while the context
@@ -4025,5 +4299,5 @@ const getAxiosErrorMessage = (error) => {
4025
4299
  return String(error);
4026
4300
  };
4027
4301
 
4028
- export { AccessDenied, ApiError, AppBreadcrumb, AppLayoutShell, AppSidebar, EMPLOYEE_RECORD_OK_EVENT, EMPLOYEE_RECORD_REQUIRED, EMPLOYEE_RECORD_REQUIRED_EVENT, EmployeeRequiredGate, FeatureFlagsProvider, HeaderBackground, HelpButton, HelpDialogHost, NavMain, NavTools, NavUser, OWNER_CHIP, OrganizationSwitcher, PLAN_FEATURES, PermissionGateShell, PermissionProvider, PermissionRulesProvider, ReactQueryProvider, SHELL_VERSION, SessionBackedProfileCacheSeeder, SessionRefreshTrigger, SidebarContent, SidebarFooter, SidebarHeader, SidebarHelpCard, SidebarHelpLink, SidebarProvider, SidebarTrigger, TapMenu, ThemeProvider, WORKSPACE_HEADER_VARS, WORKSPACE_LIGHT_HEADER_VARS, WorkspaceBrand, instance as axiosInstance, buildSystemLinks, buildUrlWithCode, canAccessNavPermissionRoute, clearAccessTokenCache, clearOrganizationId, closeHelp, decodeJWT, describeAttached, describeBrowser, detectHelpApp, filterNavigationModulesByPermission, filterNavigationToolsByPermission, findOrganizationForCode, getAccessToken, getActiveModule, getActiveSubModule, getAxiosErrorMessage, getOrganizationId, getQueryClientConfig, getRequestOrganizationId, getRequiredChildGroups, getTabOrganizationId, hasPlanFeature, isLikelyTransientNetworkError, isPathActive, myRolesQueryKey, normalizePathnameForPermissionRules, openHelp, organizationScopedQueryHash, parseMyRoles, resolveActiveNav, resolveSidebarNav, resolveUiRoutePermission, resolveUrlOrgGate, roleSummary, runGlobalRequestPrecheck, runWithTransientRetry, setOrganizationId, setTabOrganizationId, shouldRetryQuery, toApiError, unwrapApiResponse, updateOrganizationId, useClearOrgOnUserChange, useFeatureFlag, useFeatureFlags, useInvalidateQueriesOnOrgChange, useKeycloakRoles, useMyRoles, useOrganizationIdState, usePermissionRules, useSessionRefresh, useSidebar, useSidebarToggle };
4302
+ export { AccessDenied, ApiError, AppBreadcrumb, AppLayoutShell, AppSidebar, EMPLOYEE_RECORD_OK_EVENT, EMPLOYEE_RECORD_REQUIRED, EMPLOYEE_RECORD_REQUIRED_EVENT, EmployeeRequiredGate, FOR_ORGANIZATION_FIELD, FeatureFlagsProvider, HeaderBackground, HelpButton, HelpDialogHost, NavMain, NavTools, NavUser, OWNER_CHIP, OrganizationSwitcher, PLAN_FEATURES, PermissionGateShell, PermissionProvider, PermissionRulesProvider, ReactQueryProvider, SHELL_VERSION, SessionBackedProfileCacheSeeder, SessionRefreshTrigger, SidebarContent, SidebarFooter, SidebarHeader, SidebarHelpCard, SidebarHelpLink, SidebarProvider, SidebarTrigger, TapMenu, ThemeProvider, WITHOUT_ORGANIZATION_FLAG, WORKSPACE_HEADER_VARS, WORKSPACE_LIGHT_HEADER_VARS, WorkspaceBrand, accountSettingsHref, instance as axiosInstance, buildSystemLinks, buildUrlWithCode, canAccessNavPermissionRoute, clearAccessTokenCache, clearOrganizationId, clearStoredOrganization, closeHelp, decodeJWT, describeAttached, describeBrowser, detectHelpApp, filterNavigationModulesByPermission, filterNavigationToolsByPermission, findOrganizationForCode, getAccessToken, getActiveModule, getActiveSubModule, getAxiosErrorMessage, getOrganizationId, getQueryClientConfig, getRequestOrganizationId, getRequiredChildGroups, getStoredOrganizationIds, getTabOrganizationId, hasPlanFeature, isKnownStaleOrganizationId, isLikelyTransientNetworkError, isPathActive, myRolesQueryKey, normalizePathnameForPermissionRules, openHelp, organizationScopedQueryHash, parseMyRoles, platformBaseUrl, resolveActiveNav, resolveSidebarNav, resolveStaleOrganization, resolveUiRoutePermission, resolveUrlOrgGate, retryWithStoredOrganizations, roleSummary, runGlobalRequestPrecheck, runWithTransientRetry, setKnownOrganizationIds, setOrganizationId, setTabOrganizationId, shouldRetryQuery, toApiError, unwrapApiResponse, updateOrganizationId, useClearOrgOnUserChange, useFeatureFlag, useFeatureFlags, useInvalidateQueriesOnOrgChange, useKeycloakRoles, useMyRoles, useOrganizationIdState, usePermissionRules, useSessionRefresh, useSidebar, useSidebarToggle, withoutOrganization };
4029
4303
  //# sourceMappingURL=index.esm.js.map