Fix edge gateway cache authorization fallback

This commit is contained in:
Jeppe B
2026-06-01 23:48:17 +02:00
parent f613d58727
commit 326eca656f
4 changed files with 104 additions and 6 deletions
@@ -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 = () => {
@@ -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;
+58 -6
View File
@@ -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;
+22
View File
@@ -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");