Fix subuser QR session bootstrap

This commit is contained in:
Jeppe B
2026-07-13 20:33:48 +02:00
parent 60b6e82c88
commit 218ead9a31
6 changed files with 344 additions and 40 deletions
+54 -34
View File
@@ -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<void>}
@@ -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.",
+73 -2
View File
@@ -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;
};
+25 -4
View File
@@ -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);
}
+60
View File
@@ -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([]);
});
});
+95
View File
@@ -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());
+37
View File
@@ -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();
});
});