From 218ead9a3133552cc363c1290108f18ea1a9bd95 Mon Sep 17 00:00:00 2001 From: Jeppe B <2jepp9350@gmail.com> Date: Mon, 13 Jul 2026 20:33:48 +0200 Subject: [PATCH] Fix subuser QR session bootstrap --- src/components/session/token/SessionUser.vue | 88 +++++++++++------- src/services/sessionStorage.js | 75 +++++++++++++++- src/views/auth/LoginQR.vue | 29 +++++- tests/e2e/auth.smoke.spec.js | 60 +++++++++++++ tests/unit/session-bootstrap.spec.js | 95 ++++++++++++++++++++ tests/unit/session-storage.spec.js | 37 ++++++++ 6 files changed, 344 insertions(+), 40 deletions(-) create mode 100644 tests/unit/session-storage.spec.js diff --git a/src/components/session/token/SessionUser.vue b/src/components/session/token/SessionUser.vue index 4ea1dde2..f3277f9e 100644 --- a/src/components/session/token/SessionUser.vue +++ b/src/components/session/token/SessionUser.vue @@ -64,6 +64,7 @@ import { clearPeriodCache, clearSelfWashCountsCache, } from "@/views/dashboards/superUserDashboard/InvoicingBillingPeriod/imports/InvoicingBillingPeriodImportPaging.js"; +import { markStoredSessionAsSubuser } from "@/services/sessionStorage.js"; const normalizePositiveInteger = (value) => { const parsedValue = Number.parseInt(String(value ?? ""), 10); @@ -403,6 +404,12 @@ export const isInvalidSessionError = (error) => { ); }; +const isUserEndpointSubuserTokenMismatch = (error) => { + const status = Number.parseInt(String(error?.response?.status ?? ""), 10); + const message = extractErrorMessage(error).toLowerCase(); + return [400, 401].includes(status) && message.includes("user not found"); +}; + const redirectToConnectivityIssue = () => { if (typeof window === "undefined" || window.location.pathname === "/connectivity-issue") { return false; @@ -412,9 +419,10 @@ const redirectToConnectivityIssue = () => { return true; }; -export const handleSessionBootstrapFailure = async (error, { title, text } = {}) => { +export const handleSessionBootstrapFailure = async (error, { title, text, subuserSession = false } = {}) => { if (isInvalidSessionError(error)) { - const wasSubuserSession = SessionUser.isSubuser.value || getStoredSessionSnapshot()?.isSubuser === true; + const wasSubuserSession = + Boolean(subuserSession) || SessionUser.isSubuser.value || getStoredSessionSnapshot()?.isSubuser === true; const pingResult = await pingApiServer(); if (!pingResult.ok) { console.warn("Preserving stored session because API ping failed during invalid-session handling.", pingResult); @@ -573,6 +581,44 @@ export const authenticateSubuser = async (credentials) => { }); }; +const applySubuserSessionPayload = async (payload) => { + const data = payload || {}; + SessionUser.isSubuser.value = true; + markStoredSessionAsSubuser(); + SessionUser.subuser.id.value = data.id; + SessionUser.subuser.username.value = data.username; + SessionUser.subuser.name.value = data.name; + SessionUser.subuser.email.value = data.email; + SessionUser.subuser.email_verified.value = Boolean(data.email_verified); + SessionUser.subuser.email_verified_at.value = data.email_verified_at || null; + SessionUser.subuser.phone.country_code.value = data.phone_country_code; + SessionUser.subuser.phone.number.value = data.phone; + SessionUser.subuser.phone.verified.value = Boolean(data.phone_verified); + SessionUser.subuser.phone.verified_at.value = data.phone_verified_at || null; + SessionUser.subuser.verification_state.value = data.verification_state || data.verification?.state || null; + SessionUser.subuser.verification.value = data.verification || null; + SessionUser.subuser.grants.value = data.grants || []; + reconcileSelectedSubuserGrant(SessionUser.subuser.grants.value); + SessionUser.subuser.created_at.value = data.created_at; + SessionUser.subuser.updated_at.value = data.updated_at; + SessionUser.subuser.suspended_at.value = data.suspended_at; + SessionUser.subuser.cached_at.value = new Date(); + setReleaseChannelSwitchNoticePrincipal(`subuser:${data.id || data.username || data.email || "unknown"}`); + + // Build permissions from grants + const allPermissions = []; + if (data.grants && Array.isArray(data.grants)) { + data.grants.forEach((grant) => { + if (grant.permissions && Array.isArray(grant.permissions)) { + allPermissions.push(...grant.permissions); + } + }); + } + SessionUser.permissions.value = [...new Set(allPermissions)]; + await refreshReleaseRuntime(); + SessionUser.initiated.value = true; +}; + /** * Get the subuser's session data from /subusers/me * @returns {Promise} @@ -581,38 +627,7 @@ export const getSubuserSessionData = async () => { return await authenticatedRequest("/subusers/me", "GET") .then(async (response) => { const data = response.data.data || response.data; - SessionUser.subuser.id.value = data.id; - SessionUser.subuser.username.value = data.username; - SessionUser.subuser.name.value = data.name; - SessionUser.subuser.email.value = data.email; - SessionUser.subuser.email_verified.value = Boolean(data.email_verified); - SessionUser.subuser.email_verified_at.value = data.email_verified_at || null; - SessionUser.subuser.phone.country_code.value = data.phone_country_code; - SessionUser.subuser.phone.number.value = data.phone; - SessionUser.subuser.phone.verified.value = Boolean(data.phone_verified); - SessionUser.subuser.phone.verified_at.value = data.phone_verified_at || null; - SessionUser.subuser.verification_state.value = data.verification_state || data.verification?.state || null; - SessionUser.subuser.verification.value = data.verification || null; - SessionUser.subuser.grants.value = data.grants || []; - reconcileSelectedSubuserGrant(SessionUser.subuser.grants.value); - SessionUser.subuser.created_at.value = data.created_at; - SessionUser.subuser.updated_at.value = data.updated_at; - SessionUser.subuser.suspended_at.value = data.suspended_at; - SessionUser.subuser.cached_at.value = new Date(); - setReleaseChannelSwitchNoticePrincipal(`subuser:${data.id || data.username || data.email || "unknown"}`); - - // Build permissions from grants - const allPermissions = []; - if (data.grants && Array.isArray(data.grants)) { - data.grants.forEach((grant) => { - if (grant.permissions && Array.isArray(grant.permissions)) { - allPermissions.push(...grant.permissions); - } - }); - } - SessionUser.permissions.value = [...new Set(allPermissions)]; - await refreshReleaseRuntime(); - SessionUser.initiated.value = true; + await applySubuserSessionPayload(data); }) .catch(async (error) => { if (isReleaseChannelApiAvailabilityError(error)) { @@ -624,6 +639,7 @@ export const getSubuserSessionData = async () => { return await handleSessionBootstrapFailure(error, { title: "Fejl ved hentning af subbrugerdata", text: "Der opstod en fejl ved hentning af dine brugerdata. Prøv at logge ind igen.", + subuserSession: true, }); }); }; @@ -692,6 +708,10 @@ export const getSessionData = async () => { return; } + if (isUserEndpointSubuserTokenMismatch(error)) { + return await getSubuserSessionData(); + } + return await handleSessionBootstrapFailure(error, { title: "Fejl ved hentning af brugerdata", text: "Der opstod en fejl ved hentning af dine brugerdata. Prøv at logge ind igen.", diff --git a/src/services/sessionStorage.js b/src/services/sessionStorage.js index 608ae646..9bf5a429 100644 --- a/src/services/sessionStorage.js +++ b/src/services/sessionStorage.js @@ -1,16 +1,87 @@ -export const getStoredSessionToken = () => { +const browserLocalStorage = () => { if (typeof window === 'undefined') { return null; } try { - return window.localStorage.getItem('token'); + return window.localStorage; } catch { return null; } }; +export const normalizeStoredCustomerNumber = (value) => { + if (value === null || value === undefined) { + return null; + } + + const normalized = String(value).trim(); + if (!/^[1-9][0-9]*$/.test(normalized)) { + return null; + } + + const parsed = Number.parseInt(normalized, 10); + return Number.isSafeInteger(parsed) ? parsed : null; +}; + +export const getStoredSessionToken = () => { + return browserLocalStorage()?.getItem('token') ?? null; +}; + export const hasStoredSessionToken = () => { const token = getStoredSessionToken(); return typeof token === 'string' && token.length > 0; }; + +export const clearStoredSubuserSessionContext = () => { + const storage = browserLocalStorage(); + if (!storage) { + return false; + } + + storage.removeItem('is_subuser'); + storage.removeItem('selected_customer_number'); + return true; +}; + +export const markStoredSessionAsSubuser = ({ selectedCustomerNumber } = {}) => { + const storage = browserLocalStorage(); + if (!storage) { + return false; + } + + storage.setItem('is_subuser', 'true'); + + if (selectedCustomerNumber !== undefined) { + const normalizedCustomerNumber = normalizeStoredCustomerNumber(selectedCustomerNumber); + if (normalizedCustomerNumber === null) { + storage.removeItem('selected_customer_number'); + } else { + storage.setItem('selected_customer_number', normalizedCustomerNumber.toString()); + } + } + + return true; +}; + +export const storeSessionToken = (token, { isSubuser = false, selectedCustomerNumber = null } = {}) => { + const storage = browserLocalStorage(); + if (!storage) { + return false; + } + + const normalizedToken = typeof token === 'string' ? token.trim() : String(token ?? '').trim(); + if (normalizedToken.length === 0) { + return false; + } + + storage.setItem('token', normalizedToken); + + if (isSubuser) { + markStoredSessionAsSubuser({ selectedCustomerNumber }); + } else { + clearStoredSubuserSessionContext(); + } + + return true; +}; diff --git a/src/views/auth/LoginQR.vue b/src/views/auth/LoginQR.vue index 83a9002e..d178d7d4 100644 --- a/src/views/auth/LoginQR.vue +++ b/src/views/auth/LoginQR.vue @@ -13,16 +13,31 @@ import { sounds } from "@/components/displays/department/pos/steps/mobile/object import PageLoader from "@/components/global/PageLoader.vue"; import Swal from "sweetalert2"; import { clearEdgeGatewayWorkspaceCache } from "@/services/edgeGateways.js"; +import { storeSessionToken } from "@/services/sessionStorage.js"; const isProcessingQRCode = ref(false); // State to indicate if QR code is being processed const playCaptureSound = () => { sounds.play(sounds.list.value.onAfterSuccessfulScan) }; -const applyToken = (token: string) => { +const isSubuserSessionType = (type: string | null) => String(type || "").trim().toLowerCase() === "subuser"; + +const applyToken = ( + token: string, + { + type = null, + customerNumber = null, + }: { + type?: string | null; + customerNumber?: string | null; + } = {} +) => { isProcessingQRCode.value = true; // Start processing clearEdgeGatewayWorkspaceCache(); - localStorage.setItem("token", token); + storeSessionToken(token, { + isSubuser: isSubuserSessionType(type), + selectedCustomerNumber: customerNumber, + }); window.location.href = "/"; } @@ -33,7 +48,10 @@ const applyTokenIfPresent = () => { if (token) { // If a token is present, store it in localStorage console.log("Token found in URL:", token); - applyToken(token); // Apply the token (store and redirect) + applyToken(token, { + type: urlParams.get("type"), + customerNumber: urlParams.get("customer_number"), + }); // Apply the token (store and redirect) } } @@ -92,7 +110,10 @@ function onDetect(detectedCodes) { const url = new URL(validCode.rawValue); const token = url.searchParams.get("token"); if (token) { - applyToken(token); + applyToken(token, { + type: url.searchParams.get("type"), + customerNumber: url.searchParams.get("customer_number"), + }); } else { console.warn("No token found in the URL:", validCode.rawValue); } diff --git a/tests/e2e/auth.smoke.spec.js b/tests/e2e/auth.smoke.spec.js index 3f8bcb5c..e0a2b77c 100644 --- a/tests/e2e/auth.smoke.spec.js +++ b/tests/e2e/auth.smoke.spec.js @@ -43,4 +43,64 @@ test.describe("Auth entry smoke", () => { expect(bootstrapErrors).not.toContain("/node_modules/qrcode/lib/browser.js"); expect(bootstrapErrors).not.toContain("at useQRCode.mjs"); }); + + test("@smoke subuser qr login bootstraps through /subusers/me instead of /auth/session", async ({ page }) => { + const authSessionRequests = []; + + page.on("request", (request) => { + if (request.method() === "GET" && request.url().includes("/auth/session")) { + authSessionRequests.push(request.url()); + } + }); + + await mockApi(page, { authenticated: false }); + await page.route("**/release/runtime**", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ data: {} }), + }); + }); + await page.route("**/subusers/me", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + data: { + id: 77, + username: "driver-77", + name: "Driver 77", + email: "driver@example.test", + email_verified: true, + email_verified_at: "2026-07-13 10:00:00", + phone_country_code: 45, + phone: 12345678, + phone_verified: true, + phone_verified_at: "2026-07-13 10:00:00", + verification_state: "verified", + verification: { state: "verified" }, + grants: [ + { + grant_id: 5, + billing_customer_number: 12345678, + permissions: ["BOOKINGS_LIST"], + }, + ], + created_at: "2026-07-13 09:00:00", + updated_at: "2026-07-13 09:30:00", + suspended_at: null, + }, + }), + }); + }); + + await page.goto("/login/qr?token=subuser-qr-token&type=subuser&customer_number=12345678", { + waitUntil: "domcontentloaded", + }); + + await expect.poll(() => page.evaluate(() => window.localStorage.getItem("is_subuser"))).toBe("true"); + await expect.poll(() => page.evaluate(() => window.localStorage.getItem("selected_customer_number"))).toBe("12345678"); + await expect(page).toHaveURL(/\/user$/, { timeout: AUTH_ENTRY_TIMEOUT }); + expect(authSessionRequests).toEqual([]); + }); }); diff --git a/tests/unit/session-bootstrap.spec.js b/tests/unit/session-bootstrap.spec.js index 2cd95582..5ccff1b6 100644 --- a/tests/unit/session-bootstrap.spec.js +++ b/tests/unit/session-bootstrap.spec.js @@ -6,6 +6,7 @@ const mocks = vi.hoisted(() => ({ authenticatedRequest: vi.fn(), parseError: vi.fn(), pingApiServer: vi.fn(), + fetchReleaseRuntime: vi.fn(() => Promise.resolve({})), swalFire: vi.fn(() => Promise.resolve({ isConfirmed: true })), loginRedirect: vi.fn(), subuserLoginRedirect: vi.fn(), @@ -23,6 +24,10 @@ vi.mock("@/services/apiHealth.js", () => ({ pingApiServer: mocks.pingApiServer, })); +vi.mock("@/services/releaseBootstrap.js", () => ({ + fetchReleaseRuntime: mocks.fetchReleaseRuntime, +})); + vi.mock("sweetalert2", () => ({ default: { fire: mocks.swalFire, @@ -58,6 +63,46 @@ const invalidSessionError = () => ({ }, }); +const userNotFoundSessionError = () => ({ + response: { + status: 400, + data: { + data: { + message: "User not found", + }, + }, + }, +}); + +const subuserSessionPayload = () => ({ + data: { + data: { + id: 77, + username: "driver-77", + name: "Driver 77", + email: "driver@example.test", + email_verified: true, + email_verified_at: "2026-07-13 10:00:00", + phone_country_code: 45, + phone: 12345678, + phone_verified: true, + phone_verified_at: "2026-07-13 10:00:00", + verification_state: "verified", + verification: { state: "verified" }, + grants: [ + { + grant_id: 5, + billing_customer_number: 12345678, + permissions: ["BOOKINGS_LIST", "VEHICLES_LIST"], + }, + ], + created_at: "2026-07-13 09:00:00", + updated_at: "2026-07-13 09:30:00", + suspended_at: null, + }, + }, +}); + describe("session bootstrap failure handling", () => { beforeEach(() => { SessionUser.auth.forceClearSession(); @@ -66,6 +111,8 @@ describe("session bootstrap failure handling", () => { mocks.authenticatedRequest.mockReset(); mocks.parseError.mockReset(); mocks.pingApiServer.mockReset(); + mocks.fetchReleaseRuntime.mockReset(); + mocks.fetchReleaseRuntime.mockResolvedValue({}); mocks.swalFire.mockClear(); mocks.loginRedirect.mockReset(); mocks.subuserLoginRedirect.mockReset(); @@ -246,6 +293,54 @@ describe("session bootstrap failure handling", () => { }); }); + it("recovers a stored subuser token that was missing the subuser localStorage marker", async () => { + seedStoredSession("subuser-token-without-marker"); + mocks.authenticatedRequest.mockRejectedValueOnce(userNotFoundSessionError()); + mocks.authenticatedRequest.mockResolvedValueOnce(subuserSessionPayload()); + + const result = await getSessionData(); + + expect(mocks.authenticatedRequest).toHaveBeenCalledTimes(2); + expect(mocks.authenticatedRequest.mock.calls[0][0]).toBe("/auth/session"); + expect(mocks.authenticatedRequest.mock.calls[1][0]).toBe("/subusers/me"); + expect(result).toBeUndefined(); + expect(localStorage.getItem("token")).toBe("subuser-token-without-marker"); + expect(localStorage.getItem("is_subuser")).toBe("true"); + expect(localStorage.getItem("selected_customer_number")).toBe("12345678"); + expect(SessionUser.isSubuser.value).toBe(true); + expect(SessionUser.initiated.value).toBe(true); + expect(SessionUser.subuser.id.value).toBe(77); + expect(SessionUser.permissions.value).toEqual(["BOOKINGS_LIST", "VEHICLES_LIST"]); + expect(mocks.loginRedirect).not.toHaveBeenCalled(); + expect(mocks.subuserLoginRedirect).not.toHaveBeenCalled(); + }); + + it("redirects to driver login when subuser-token recovery also proves invalid", async () => { + seedStoredSession("stale-subuser-token-without-marker"); + mocks.authenticatedRequest.mockRejectedValueOnce(userNotFoundSessionError()); + mocks.authenticatedRequest.mockRejectedValueOnce(invalidSessionError()); + mocks.pingApiServer.mockResolvedValueOnce({ + ok: true, + status: 200, + url: "/api/ping", + error: null, + }); + + const result = await getSessionData(); + + expect(mocks.authenticatedRequest).toHaveBeenCalledTimes(2); + expect(mocks.authenticatedRequest.mock.calls[1][0]).toBe("/subusers/me"); + expect(localStorage.getItem("token")).toBeNull(); + expect(localStorage.getItem("is_subuser")).toBeNull(); + expect(mocks.loginRedirect).not.toHaveBeenCalled(); + expect(mocks.subuserLoginRedirect).toHaveBeenCalledTimes(1); + expect(result).toMatchObject({ + cleared: true, + apiReachable: true, + redirectedTo: "/login/driver", + }); + }); + it("preserves an invalid stored session and routes to connectivity when ping fails", async () => { seedStoredSession("invalid-token"); mocks.authenticatedRequest.mockRejectedValueOnce(invalidSessionError()); diff --git a/tests/unit/session-storage.spec.js b/tests/unit/session-storage.spec.js new file mode 100644 index 00000000..8bf23288 --- /dev/null +++ b/tests/unit/session-storage.spec.js @@ -0,0 +1,37 @@ +// @vitest-environment jsdom + +import { describe, expect, it } from "vitest"; +import { normalizeStoredCustomerNumber, storeSessionToken } from "@/services/sessionStorage.js"; + +describe("session storage helpers", () => { + it("stores subuser QR session context with the selected customer number", () => { + const stored = storeSessionToken("subuser-qr-token", { + isSubuser: true, + selectedCustomerNumber: "12345678", + }); + + expect(stored).toBe(true); + expect(localStorage.getItem("token")).toBe("subuser-qr-token"); + expect(localStorage.getItem("is_subuser")).toBe("true"); + expect(localStorage.getItem("selected_customer_number")).toBe("12345678"); + }); + + it("clears stale subuser context for normal QR session tokens", () => { + localStorage.setItem("is_subuser", "true"); + localStorage.setItem("selected_customer_number", "12345678"); + + const stored = storeSessionToken("customer-qr-token"); + + expect(stored).toBe(true); + expect(localStorage.getItem("token")).toBe("customer-qr-token"); + expect(localStorage.getItem("is_subuser")).toBeNull(); + expect(localStorage.getItem("selected_customer_number")).toBeNull(); + }); + + it("rejects non-positive or mixed customer number values", () => { + expect(normalizeStoredCustomerNumber("12345678")).toBe(12345678); + expect(normalizeStoredCustomerNumber("0")).toBeNull(); + expect(normalizeStoredCustomerNumber("123abc")).toBeNull(); + expect(normalizeStoredCustomerNumber("")).toBeNull(); + }); +});