diff --git a/src/components/session/token/SessionUser.vue b/src/components/session/token/SessionUser.vue index b68f80d8..452b0017 100644 --- a/src/components/session/token/SessionUser.vue +++ b/src/components/session/token/SessionUser.vue @@ -59,6 +59,7 @@ import { setReleaseChannelSwitchNoticePrincipal, } from "@/services/releaseChannelAvailability.js"; import { clearCachedXlvaskUsageAmount } from "@/components/displays/department/pos/sync/xlvaskUsageAmountCache.js"; +import { clearEdgeGatewayWorkspaceCache } from "@/services/edgeGateways.js"; import { clearPeriodCache, clearSelfWashCountsCache, @@ -107,6 +108,7 @@ const clearPrivilegedSessionCaches = () => { clearPeriodCache(); clearSelfWashCountsCache(); clearCachedXlvaskUsageAmount(); + clearEdgeGatewayWorkspaceCache(); }; const clearStoredSession = () => { diff --git a/src/features/edgeGateways/EdgeGatewayManager.vue b/src/features/edgeGateways/EdgeGatewayManager.vue index b8be8408..21e02180 100644 --- a/src/features/edgeGateways/EdgeGatewayManager.vue +++ b/src/features/edgeGateways/EdgeGatewayManager.vue @@ -2,6 +2,7 @@ import { computed, onMounted, onUnmounted, ref, watch } from "vue"; import { cancelEdgeGatewayOperation, + clearEdgeGatewayWorkspaceCache, createEdgeGatewayInstallToken, createEdgeGatewayOperation, deleteEdgeGateway, @@ -12,10 +13,12 @@ import { getEdgeGatewayStatistics, getEdgeGatewayTasks, listEdgeGatewayDepartments, + isEdgeGatewayAuthorizationError, listEdgeGateways, peekCachedEdgeGateway, peekCachedEdgeGatewayDepartments, peekCachedEdgeGatewayList, + removeEdgeGatewayCache, rotateEdgeGatewayCredentials, saveEdgeGatewayBindings, setDepartmentGatewayCutover, @@ -1215,6 +1218,15 @@ const refreshSelected = async (gatewayId = activeGatewayId.value, { forceRefresh } return gateway; } catch (error) { + if (isEdgeGatewayAuthorizationError(error)) { + removeEdgeGatewayCache(gatewayId); + unavailableGatewayId.value = String(gatewayId); + selectedGateway.value = null; + resetGatewayViewSnapshots(); + fail(error); + return null; + } + const fallbackGateway = findLocalGatewaySnapshot(gatewayId); if (fallbackGateway) { setSelectedGatewaySnapshot(fallbackGateway); @@ -1561,6 +1573,16 @@ const load = async () => { departments.value = unwrap(departmentsResponse, []); await syncSelection(); } catch (error) { + if (isEdgeGatewayAuthorizationError(error)) { + clearEdgeGatewayWorkspaceCache(); + gateways.value = []; + fleetUsage.value = buildFleetUsageFromRows([]); + if (activeGatewayId.value) { + unavailableGatewayId.value = String(activeGatewayId.value); + } + selectedGateway.value = null; + resetGatewayViewSnapshots(); + } fail(error); } finally { loading.value.init = false; diff --git a/src/services/edgeGateways.js b/src/services/edgeGateways.js index 6d16890e..cbec224d 100644 --- a/src/services/edgeGateways.js +++ b/src/services/edgeGateways.js @@ -9,6 +9,7 @@ const listRequestsInFlight = new Map(); const detailRequestsInFlight = new Map(); let departmentsRequestInFlight = null; let edgeGatewayWorkspaceCache = null; +let edgeGatewayWorkspaceCacheStorageKey = null; const cloneJson = (value) => (value === null || value === undefined ? value : JSON.parse(JSON.stringify(value))); const normalizeDepartmentId = (departmentId) => @@ -105,27 +106,55 @@ const storage = () => { return window.localStorage; }; +const scopedWorkspaceCacheKey = () => { + const localStorageHandle = storage(); + if (!localStorageHandle) { + return EDGE_GATEWAY_WORKSPACE_CACHE_KEY; + } + + const sessionParts = [ + localStorageHandle.getItem("token") || "anonymous", + localStorageHandle.getItem("is_subuser") === "true" ? "subuser" : "primary", + localStorageHandle.getItem("selected_customer_number") || "none", + ]; + const principal = sessionParts.join("|"); + let hash = 0; + for (let index = 0; index < principal.length; index += 1) { + hash = (hash * 31 + principal.charCodeAt(index)) >>> 0; + } + + return `${EDGE_GATEWAY_WORKSPACE_CACHE_KEY}.${hash.toString(36)}`; +}; + +const resetWorkspaceCacheMemory = (storageKey = scopedWorkspaceCacheKey()) => { + edgeGatewayWorkspaceCache = createEmptyWorkspaceCache(); + edgeGatewayWorkspaceCacheStorageKey = storageKey; + return edgeGatewayWorkspaceCache; +}; + const loadWorkspaceCache = () => { - if (edgeGatewayWorkspaceCache !== null) { + const storageKey = scopedWorkspaceCacheKey(); + if (edgeGatewayWorkspaceCache !== null && edgeGatewayWorkspaceCacheStorageKey === storageKey) { return edgeGatewayWorkspaceCache; } const localStorageHandle = storage(); if (!localStorageHandle) { - edgeGatewayWorkspaceCache = createEmptyWorkspaceCache(); - return edgeGatewayWorkspaceCache; + return resetWorkspaceCacheMemory(storageKey); } try { - const decoded = JSON.parse(localStorageHandle.getItem(EDGE_GATEWAY_WORKSPACE_CACHE_KEY) || "null"); + localStorageHandle.removeItem(EDGE_GATEWAY_WORKSPACE_CACHE_KEY); + const decoded = JSON.parse(localStorageHandle.getItem(storageKey) || "null"); edgeGatewayWorkspaceCache = { ...createEmptyWorkspaceCache(), ...(decoded && typeof decoded === "object" ? decoded : {}), lists: decoded?.lists && typeof decoded.lists === "object" ? decoded.lists : {}, details: decoded?.details && typeof decoded.details === "object" ? decoded.details : {}, }; + edgeGatewayWorkspaceCacheStorageKey = storageKey; } catch (_error) { - edgeGatewayWorkspaceCache = createEmptyWorkspaceCache(); + resetWorkspaceCacheMemory(storageKey); } return edgeGatewayWorkspaceCache; @@ -137,7 +166,9 @@ const persistWorkspaceCache = () => { return; } - localStorageHandle.setItem(EDGE_GATEWAY_WORKSPACE_CACHE_KEY, JSON.stringify(edgeGatewayWorkspaceCache)); + const storageKey = edgeGatewayWorkspaceCacheStorageKey || scopedWorkspaceCacheKey(); + localStorageHandle.removeItem(EDGE_GATEWAY_WORKSPACE_CACHE_KEY); + localStorageHandle.setItem(storageKey, JSON.stringify(edgeGatewayWorkspaceCache)); }; const mergeGatewaySnapshots = (currentGateway, nextGateway) => { @@ -295,6 +326,27 @@ const buildOperationPayload = (typeOrPayload, request = {}) => { export const unwrapEdgeGatewayResponse = (response, fallback = null) => response?.data?.data ?? fallback; export const unwrapEdgeGatewayMeta = (response) => response?.data?.meta ?? {}; +export const isEdgeGatewayAuthorizationError = (error) => { + const status = Number(error?.response?.status || error?.status || error?.response?.data?.status || 0); + return status === 401 || status === 403; +}; + +export const clearEdgeGatewayWorkspaceCache = () => { + const localStorageHandle = storage(); + const storageKey = scopedWorkspaceCacheKey(); + resetWorkspaceCacheMemory(storageKey); + if (!localStorageHandle) { + return; + } + + Object.keys(localStorageHandle).forEach((key) => { + if (key === EDGE_GATEWAY_WORKSPACE_CACHE_KEY || key.startsWith(`${EDGE_GATEWAY_WORKSPACE_CACHE_KEY}.`)) { + localStorageHandle.removeItem(key); + } + }); + localStorageHandle.removeItem(storageKey); +}; + export const peekCachedEdgeGatewayDepartments = () => { const departments = loadWorkspaceCache().departments?.data; return Array.isArray(departments) ? cloneJson(departments) : null; diff --git a/tests/unit/edge-gateway-service.spec.js b/tests/unit/edge-gateway-service.spec.js index 9b8fff55..bc96d96f 100644 --- a/tests/unit/edge-gateway-service.spec.js +++ b/tests/unit/edge-gateway-service.spec.js @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import { readFileSync } from "node:fs"; import { join } from "node:path"; import { beforeEach, describe, expect, it, vi } from "vitest"; @@ -10,14 +11,19 @@ vi.mock("@/components/session/authenticatedRequest.vue", () => ({ import { EDGE_GATEWAY_WORKSPACE_CACHE_KEY, + clearEdgeGatewayWorkspaceCache, + getEdgeGateway, getEdgeGatewayInstallTokenStatus, getEdgeGatewayModuleConfig, + peekCachedEdgeGateway, setEdgeGatewayModuleConfig, } from "@/services/edgeGateways.js"; describe("edge gateway service", () => { beforeEach(() => { authenticatedRequestMock.mockReset(); + window.localStorage.clear(); + clearEdgeGatewayWorkspaceCache(); }); it("loads module config entries and derives a keyed config object", async () => { @@ -76,6 +82,22 @@ describe("edge gateway service", () => { expect(authenticatedRequestMock).toHaveBeenCalledWith("/edge-gateways/install-token/9001/status", "GET", {}); }); + it("scopes workspace snapshots to the active session principal", async () => { + window.localStorage.setItem("token", "user-one-token"); + authenticatedRequestMock.mockResolvedValueOnce({ + data: { + data: { id: 101, label: "User One Gateway" }, + }, + }); + + await getEdgeGateway(101); + expect(peekCachedEdgeGateway(101)?.label).toBe("User One Gateway"); + + window.localStorage.setItem("token", "user-two-token"); + expect(peekCachedEdgeGateway(101)).toBeNull(); + expect(window.localStorage.getItem(EDGE_GATEWAY_WORKSPACE_CACHE_KEY)).toBeNull(); + }); + it("keeps a stable browser cache namespace for workspace snapshots", () => { const source = readFileSync(join(process.cwd(), "src/services/edgeGateways.js"), "utf8");