@eintrek/erp-shell 0.1.60 → 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
@@ -324,20 +324,154 @@ function readOrganizationStorage() {
324
324
  return null;
325
325
  }
326
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
+ }
327
403
  /**
328
404
  * The organisation a request from this tab belongs to: the tab's own, then
329
405
  * localStorage, then the cookie. Use it for the X-Organization-ID header —
330
406
  * and read it before any `await` in the interceptor, so the header names the
331
407
  * organisation that was active when the request was made.
332
408
  *
409
+ * Pass the request's config: a request marked withoutOrganization() gets
410
+ * null, so no header.
411
+ *
333
412
  * localStorage before the cookie: setOrganizationId writes localStorage
334
413
  * synchronously and then awaits a server action for the cookie, so during
335
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.
336
462
  */
337
- function getRequestOrganizationId() {
338
- return (getTabOrganizationId() ??
339
- readOrganizationStorage() ??
340
- readOrganizationCookie());
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();
341
475
  }
342
476
 
343
477
  const SELECTED_ORG_STORAGE_KEY = "erp.organizationId";
@@ -1431,6 +1565,44 @@ const runGlobalRequestPrecheck = (config) => {
1431
1565
  return validateGoodsReceiptChecks(config.data);
1432
1566
  };
1433
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
+
1434
1606
  const isBrowser = () => typeof window !== "undefined";
1435
1607
  const isAuthEndpoint = (url) => !!url && url.includes("/api/auth/");
1436
1608
  const isAuthPage = () => isBrowser() && window.location.pathname.startsWith("/auth/");
@@ -1439,7 +1611,7 @@ const redirectToLogout = async () => {
1439
1611
  return;
1440
1612
  localStorage.clear();
1441
1613
  sessionStorage.clear();
1442
- setOrganizationId(null);
1614
+ clearStoredOrganization();
1443
1615
  clearAccessTokenCache();
1444
1616
  await react.signOut({ callbackUrl: "/auth/signin" });
1445
1617
  };
@@ -1471,7 +1643,7 @@ instance.interceptors.request.use(async (config) => {
1471
1643
  // Read before the token await: the header must name the organisation that
1472
1644
  // was active when the request was made (and under which its query is
1473
1645
  // cached), not whatever is active once the token resolves.
1474
- const organizationId = getRequestOrganizationId();
1646
+ const organizationId = getRequestOrganizationId(config);
1475
1647
  const token = await getAccessToken(false);
1476
1648
  if (token) {
1477
1649
  config.headers.Authorization = `Bearer ${token}`;
@@ -1531,6 +1703,9 @@ instance.interceptors.response.use(response => {
1531
1703
  window.dispatchEvent(new CustomEvent("erp:employee-record-required"));
1532
1704
  }
1533
1705
  }
1706
+ const listed = await retryWithStoredOrganizations(originalRequest, error.response?.status, config => instance(config));
1707
+ if (listed !== undefined)
1708
+ return listed;
1534
1709
  return Promise.reject(toApiError(error));
1535
1710
  });
1536
1711
 
@@ -1557,7 +1732,7 @@ function platformApiBase(app) {
1557
1732
  * This package's version, attached to help messages so the back office can
1558
1733
  * tell which shell a report came from. Keep in step with package.json.
1559
1734
  */
1560
- const SHELL_VERSION = "0.1.60";
1735
+ const SHELL_VERSION = "0.1.61";
1561
1736
 
1562
1737
  const HELP_APP_LABEL = {
1563
1738
  platform: "แพลตฟอร์ม",
@@ -1880,6 +2055,9 @@ function NavUser({ organization, avatarSrc, profileHref, selectOrganizationHref
1880
2055
  };
1881
2056
  const handleSignOut = async () => {
1882
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();
1883
2061
  try {
1884
2062
  await react.signOut({ callbackUrl: signInRedirectHref, redirect: true });
1885
2063
  }
@@ -3273,6 +3451,31 @@ function resolveUrlOrgGate({ code, organizations, organizationsStatus, organizat
3273
3451
  return { state: "unverified" };
3274
3452
  }
3275
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
+ }
3276
3479
 
3277
3480
  /**
3278
3481
  * Top-level app chrome: sticky header, sidebar slot, content gutter and the
@@ -3334,6 +3537,44 @@ function AppLayoutShell({ children, sidebar, loadingComponent, breadcrumb, permi
3334
3537
  requestedSwitchRef.current = switchTargetId;
3335
3538
  void setOrganizationId(switchTargetId);
3336
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));
3547
+ }
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({
3556
+ code,
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]);
3337
3578
  // Key for the content subtree. It exists so switching organizations throws
3338
3579
  // away the previous org's component state — but it must react ONLY to a real
3339
3580
  // switch. organizationId legitimately passes through null while the context
@@ -4057,6 +4298,7 @@ exports.EMPLOYEE_RECORD_OK_EVENT = EMPLOYEE_RECORD_OK_EVENT;
4057
4298
  exports.EMPLOYEE_RECORD_REQUIRED = EMPLOYEE_RECORD_REQUIRED;
4058
4299
  exports.EMPLOYEE_RECORD_REQUIRED_EVENT = EMPLOYEE_RECORD_REQUIRED_EVENT;
4059
4300
  exports.EmployeeRequiredGate = EmployeeRequiredGate;
4301
+ exports.FOR_ORGANIZATION_FIELD = FOR_ORGANIZATION_FIELD;
4060
4302
  exports.FeatureFlagsProvider = FeatureFlagsProvider;
4061
4303
  exports.HeaderBackground = HeaderBackground;
4062
4304
  exports.HelpButton = HelpButton;
@@ -4083,6 +4325,7 @@ exports.SidebarProvider = SidebarProvider;
4083
4325
  exports.SidebarTrigger = SidebarTrigger;
4084
4326
  exports.TapMenu = TapMenu;
4085
4327
  exports.ThemeProvider = ThemeProvider;
4328
+ exports.WITHOUT_ORGANIZATION_FLAG = WITHOUT_ORGANIZATION_FLAG;
4086
4329
  exports.WORKSPACE_HEADER_VARS = WORKSPACE_HEADER_VARS;
4087
4330
  exports.WORKSPACE_LIGHT_HEADER_VARS = WORKSPACE_LIGHT_HEADER_VARS;
4088
4331
  exports.WorkspaceBrand = WorkspaceBrand;
@@ -4092,6 +4335,7 @@ exports.buildUrlWithCode = buildUrlWithCode;
4092
4335
  exports.canAccessNavPermissionRoute = canAccessNavPermissionRoute;
4093
4336
  exports.clearAccessTokenCache = clearAccessTokenCache;
4094
4337
  exports.clearOrganizationId = clearOrganizationId;
4338
+ exports.clearStoredOrganization = clearStoredOrganization;
4095
4339
  exports.closeHelp = closeHelp;
4096
4340
  exports.decodeJWT = decodeJWT;
4097
4341
  exports.describeAttached = describeAttached;
@@ -4108,8 +4352,10 @@ exports.getOrganizationId = getOrganizationId;
4108
4352
  exports.getQueryClientConfig = getQueryClientConfig;
4109
4353
  exports.getRequestOrganizationId = getRequestOrganizationId;
4110
4354
  exports.getRequiredChildGroups = getRequiredChildGroups;
4355
+ exports.getStoredOrganizationIds = getStoredOrganizationIds;
4111
4356
  exports.getTabOrganizationId = getTabOrganizationId;
4112
4357
  exports.hasPlanFeature = hasPlanFeature;
4358
+ exports.isKnownStaleOrganizationId = isKnownStaleOrganizationId;
4113
4359
  exports.isLikelyTransientNetworkError = isLikelyTransientNetworkError;
4114
4360
  exports.isPathActive = isPathActive;
4115
4361
  exports.myRolesQueryKey = myRolesQueryKey;
@@ -4119,11 +4365,14 @@ exports.organizationScopedQueryHash = organizationScopedQueryHash;
4119
4365
  exports.parseMyRoles = parseMyRoles;
4120
4366
  exports.resolveActiveNav = resolveActiveNav;
4121
4367
  exports.resolveSidebarNav = resolveSidebarNav;
4368
+ exports.resolveStaleOrganization = resolveStaleOrganization;
4122
4369
  exports.resolveUiRoutePermission = resolveUiRoutePermission;
4123
4370
  exports.resolveUrlOrgGate = resolveUrlOrgGate;
4371
+ exports.retryWithStoredOrganizations = retryWithStoredOrganizations;
4124
4372
  exports.roleSummary = roleSummary;
4125
4373
  exports.runGlobalRequestPrecheck = runGlobalRequestPrecheck;
4126
4374
  exports.runWithTransientRetry = runWithTransientRetry;
4375
+ exports.setKnownOrganizationIds = setKnownOrganizationIds;
4127
4376
  exports.setOrganizationId = setOrganizationId;
4128
4377
  exports.setTabOrganizationId = setTabOrganizationId;
4129
4378
  exports.shouldRetryQuery = shouldRetryQuery;
@@ -4141,4 +4390,5 @@ exports.usePermissionRules = usePermissionRules;
4141
4390
  exports.useSessionRefresh = useSessionRefresh;
4142
4391
  exports.useSidebar = useSidebar;
4143
4392
  exports.useSidebarToggle = useSidebarToggle;
4393
+ exports.withoutOrganization = withoutOrganization;
4144
4394
  //# sourceMappingURL=index.js.map