Exchange one-time login grants in QR flow (#235)

## Summary

Updates the QR login view to consume the short-lived, one-time employee
login grants created by approved Pleno Control Plane
Conversations/Suggestions actions.

- reads generated grants from the URL fragment
- scrubs the bearer from the address bar before exchange
- exchanges the grant for a normal session token, then uses the existing
secure session-storage path
- preserves legacy token QR links
- validates exact URL origin and removes raw credential/QR logging
- prevents repeated scanner exchange attempts while one is in progress

## Visual change previews

No layout or styling changes. The visible flow changes only after
opening or scanning a grant:

- Before: one-time grant links were rejected as unknown QR content.
- After: the existing loader appears during exchange; invalid/expired
grants use the existing localized error dialog; successful grants
redirect through the existing login path.

## Verification

- focused Vitest: 2 passed
- focused ESLint: passed
- production Vite build: passed (existing chunk-size warning only)
- `git diff --check`: passed

## Dependency

Pair with copenhagentruckwash/api (one-time limited-backoffice login
grants) and merge after that backend PR. Required by
copenhagentruckwash/pleno-control-plane#1.
This commit is contained in:
Jeppe B
2026-07-29 00:01:22 +02:00
committed by GitHub
parent 1da6fbd1c4
commit 5ffd471a45
8 changed files with 341 additions and 173 deletions
+12 -5
View File
@@ -3,25 +3,32 @@
*/
import { hasStoredSessionToken } from "@/services/sessionStorage.js";
export default function guestMiddleware({ next }) {
export default function guestMiddleware({ next, to }) {
const loginGrant =
to?.name === "loginqr" ? new URLSearchParams(String(to.hash || "").replace(/^#/, "")).get("grant") : null;
if (loginGrant) {
return next();
}
// Check if the user is authenticated
if (hasStoredSessionToken()) {
// Check if a redirect path is set in the query "redirect" parameter
const urlParams = new URLSearchParams(window.location.search);
const redirectPath = urlParams.get('redirect') || undefined;
const redirectPath = urlParams.get("redirect") || undefined;
const redirectUrl = redirectPath ? new URL(redirectPath, window.location.origin) : undefined;
// Redirect to the specified path or default to '/dashboard'
if (redirectUrl && redirectUrl.pathname) {
// console.log('Redirecting to:', redirectUrl.pathname, 'with query:', redirectUrl.search, 'and hash:', redirectUrl.hash, 'from guestMiddleware');
// Clear the redirect query parameter
urlParams.delete('redirect');
urlParams.delete("redirect");
// Update the URL without the redirect query parameter
// window.history.replaceState({}, '', `${redirectUrl.pathname}${urlParams.toString() ? '?' + urlParams.toString() : ''}${redirectUrl.hash}`);
// window.location.reload();
return next(`${redirectUrl.pathname}${urlParams.toString() ? '?' + urlParams.toString() : ''}${redirectUrl.hash}`);
return next(
`${redirectUrl.pathname}${urlParams.toString() ? "?" + urlParams.toString() : ""}${redirectUrl.hash}`
);
} else {
// window.location.href = '/';
return next({ name: 'default' })
return next({ name: "default" });
}
}
return next();
+16 -4
View File
@@ -1,8 +1,9 @@
import { ALLOWED_ORIGINS } from '@/config';
import { ALLOWED_ORIGINS, MIGRATION_ORIGIN } from "@/config";
import {
isStandaloneContext,
resolveGrantMigrationUrl,
resolveOriginMigrationRoute,
} from '@/middleware/originMigration';
} from "@/middleware/originMigration";
function middlewarePipeline(context, middleware, index) {
const nextMiddleware = middleware[index];
@@ -19,10 +20,21 @@ function middlewarePipeline(context, middleware, index) {
export default function applyMiddleware(router) {
router.beforeEach((to, from, next) => {
const browserWindow = typeof window !== 'undefined' ? window : undefined;
const browserWindow = typeof window !== "undefined" ? window : undefined;
const grantMigrationUrl = resolveGrantMigrationUrl({
to,
currentOrigin: browserWindow?.location?.origin || "",
allowedOrigins: ALLOWED_ORIGINS,
migrationOrigin: MIGRATION_ORIGIN,
currentPath: browserWindow?.location?.pathname || "",
});
if (grantMigrationUrl) {
browserWindow.location.replace(grantMigrationUrl);
return undefined;
}
const originMigrationRoute = resolveOriginMigrationRoute({
to,
currentOrigin: browserWindow?.location?.origin || '',
currentOrigin: browserWindow?.location?.origin || "",
allowedOrigins: ALLOWED_ORIGINS,
standalone: isStandaloneContext(browserWindow),
});
+31 -28
View File
@@ -1,11 +1,8 @@
export const OUTDATED_INSTALLATION_ROUTE_NAME = 'outdated-installation';
export const OUTDATED_GATEWAY_ROUTE_NAME = 'outdated-gateway';
export const OUTDATED_INSTALLATION_ROUTE_NAME = "outdated-installation";
export const OUTDATED_GATEWAY_ROUTE_NAME = "outdated-gateway";
const EXEMPT_ROUTE_NAMES = new Set([
OUTDATED_INSTALLATION_ROUTE_NAME,
OUTDATED_GATEWAY_ROUTE_NAME,
]);
const LOOPBACK_HOSTNAMES = new Set(['localhost', '127.0.0.1', '[::1]']);
const EXEMPT_ROUTE_NAMES = new Set([OUTDATED_INSTALLATION_ROUTE_NAME, OUTDATED_GATEWAY_ROUTE_NAME]);
const LOOPBACK_HOSTNAMES = new Set(["localhost", "127.0.0.1", "[::1]"]);
const isLoopbackOrigin = (currentOrigin) => {
if (!currentOrigin) return false;
@@ -28,7 +25,7 @@ const normalizeAllowedOrigins = (allowedOrigins = []) => {
return origin;
}
})
.filter(Boolean),
.filter(Boolean)
);
};
@@ -40,11 +37,11 @@ export const isOriginAllowed = (currentOrigin, allowedOrigins = []) => {
};
export const isStandaloneContext = (win) => {
const resolvedWindow = win || (typeof window !== 'undefined' ? window : undefined);
const resolvedWindow = win || (typeof window !== "undefined" ? window : undefined);
if (!resolvedWindow) return false;
const iosStandalone = resolvedWindow.navigator?.standalone === true;
const mediaStandalone = typeof resolvedWindow.matchMedia === 'function'
&& resolvedWindow.matchMedia('(display-mode: standalone)').matches;
const mediaStandalone =
typeof resolvedWindow.matchMedia === "function" && resolvedWindow.matchMedia("(display-mode: standalone)").matches;
return iosStandalone || mediaStandalone;
};
@@ -52,12 +49,7 @@ export const shouldBypassOriginGate = (route) => {
return EXEMPT_ROUTE_NAMES.has(route?.name);
};
export const resolveOriginMigrationRoute = ({
to,
currentOrigin,
allowedOrigins,
standalone,
}) => {
export const resolveOriginMigrationRoute = ({ to, currentOrigin, allowedOrigins, standalone }) => {
if (shouldBypassOriginGate(to)) {
return null;
}
@@ -73,33 +65,44 @@ export const resolveOriginMigrationRoute = ({
return {
name: OUTDATED_GATEWAY_ROUTE_NAME,
query: {
redirect: to?.fullPath || '/',
redirect: to?.fullPath || "/",
},
};
};
export const resolveGrantMigrationUrl = ({ to, currentOrigin, allowedOrigins, migrationOrigin, currentPath = "" }) => {
if (isOriginAllowed(currentOrigin, allowedOrigins) || to?.name !== "loginqr") {
return null;
}
const fragment = new URLSearchParams(String(to?.hash || "").replace(/^#/, ""));
if (!fragment.get("grant")) return null;
const path = applyReleaseFrontendBase(to?.path || "/login/qr", currentPath);
const query = to?.query && Object.keys(to.query).length > 0 ? `?${new URLSearchParams(to.query).toString()}` : "";
return new URL(`${path}${query}${to.hash}`, migrationOrigin).toString();
};
export const sanitizeRedirectPath = (redirectPath) => {
if (typeof redirectPath !== 'string') return '/';
if (!redirectPath.startsWith('/')) return '/';
if (typeof redirectPath !== "string") return "/";
if (!redirectPath.startsWith("/")) return "/";
return redirectPath;
};
export const releaseFrontendPathBase = (pathname = '') => {
const match = String(pathname || '').match(/^\/[^/]+\/frontend(?:\/|$)/);
return match ? match[0].replace(/\/+$/, '') : '';
export const releaseFrontendPathBase = (pathname = "") => {
const match = String(pathname || "").match(/^\/[^/]+\/frontend(?:\/|$)/);
return match ? match[0].replace(/\/+$/, "") : "";
};
export const applyReleaseFrontendBase = (redirectPath, currentPath = '') => {
export const applyReleaseFrontendBase = (redirectPath, currentPath = "") => {
const safePath = sanitizeRedirectPath(redirectPath);
const releaseBase = releaseFrontendPathBase(currentPath);
if (!releaseBase || safePath === releaseBase || safePath.startsWith(`${releaseBase}/`)) {
return safePath;
}
return safePath === '/' ? `${releaseBase}/` : `${releaseBase}${safePath}`;
return safePath === "/" ? `${releaseBase}/` : `${releaseBase}${safePath}`;
};
export const buildMigrationUrl = (migrationOrigin, redirectPath, currentPath = '') => {
export const buildMigrationUrl = (migrationOrigin, redirectPath, currentPath = "") => {
const safePath = applyReleaseFrontendBase(redirectPath, currentPath);
try {
return new URL(safePath, migrationOrigin).toString();
@@ -120,7 +123,7 @@ export const startRedirectCountdown = ({
let remainingSeconds = seconds;
const intervalId = setIntervalFn(() => {
remainingSeconds = Math.max(remainingSeconds - 1, 0);
if (typeof onTick === 'function') {
if (typeof onTick === "function") {
onTick(remainingSeconds);
}
if (remainingSeconds === 0) {
@@ -129,7 +132,7 @@ export const startRedirectCountdown = ({
}, 1000);
const timeoutId = setTimeoutFn(() => {
if (typeof onRedirect === 'function') {
if (typeof onRedirect === "function") {
onRedirect();
}
}, seconds * 1000);
+26 -2
View File
@@ -262,7 +262,7 @@ export const rewriteReleaseApiUrl = (url = "") => {
};
const SENSITIVE_PAYLOAD_KEY_PATTERN =
/authorization|cookie|password|passwd|secret|token|api[_-]?key|session|credential|card|cpr|ssn|recaptcha/i;
/authorization|cookie|password|passwd|secret|token|grant|api[_-]?key|session|credential|card|cpr|ssn|recaptcha/i;
const CAPTURE_DISABLED_BODY_PLACEHOLDER = "[capture-disabled]";
const truncateReleasePayloadString = (value) =>
@@ -374,6 +374,28 @@ const sanitizeReleaseTimelinePayload = (type, severity, payload) => {
return payload;
};
const sanitizeReleaseTimelineRoute = (value) => {
const route = String(value || "");
if (!route) {
return null;
}
const [withoutFragment] = route.split("#", 1);
const [pathname, rawQuery = ""] = withoutFragment.split("?", 2);
if (!rawQuery) {
return pathname;
}
const query = new URLSearchParams(rawQuery);
for (const key of Array.from(query.keys())) {
if (SENSITIVE_PAYLOAD_KEY_PATTERN.test(key)) {
query.set(key, "[redacted]");
}
}
const serialized = query.toString();
return serialized ? `${pathname}?${serialized}` : pathname;
};
export const recordReleaseTimelineEvent = (type, payload = {}, options = {}) => {
const severity = options.severity || payload?.severity || "info";
if (!shouldSendEvent(type, severity)) {
@@ -384,7 +406,9 @@ export const recordReleaseTimelineEvent = (type, payload = {}, options = {}) =>
type,
severity,
module_key: options.moduleKey || payload?.module_key || null,
route: options.route || payload?.route || (typeof window !== "undefined" ? window.location.pathname : null),
route: sanitizeReleaseTimelineRoute(
options.route || payload?.route || (typeof window !== "undefined" ? window.location.pathname : null)
),
component: options.component || payload?.component || null,
request_id: options.requestId || payload?.request_id || null,
occurred_at: new Date().toISOString(),
+151 -104
View File
@@ -2,25 +2,35 @@
import ScannerOutline from "@/components/viewport/page/templates/scanner/graphics/ScannerOutline.vue";
import ScannerInstructions from "@/components/viewport/page/templates/scanner/graphics/ScannerInstructions.vue";
import GenericButton from "@/components/viewport/page/templates/generic/graphics/GenericButton.vue";
import { setTransparency, setBackgroundColor, setOverflow, backgroundColors } from "@/components/viewport/page/headers/ViewportHeaderSettings.vue";
import {
setTransparency,
setBackgroundColor,
setOverflow,
backgroundColors,
} from "@/components/viewport/page/headers/ViewportHeaderSettings.vue";
import { onMounted, onUnmounted, ref, computed } from "vue";
import { useI18n } from 'vue-i18n';
import { useI18n } from "vue-i18n";
const { t } = useI18n();
import PosDepartmentStepMobileFixedBottomControl
from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileFixedBottomControl.vue";
import { QrcodeStream } from 'vue-qrcode-reader'
import PosDepartmentStepMobileFixedBottomControl from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileFixedBottomControl.vue";
import { QrcodeStream } from "vue-qrcode-reader";
import { sounds } from "@/components/displays/department/pos/steps/mobile/objects/PosDepartmentStepMobileFlow.vue";
import PageLoader from "@/components/global/PageLoader.vue";
import Swal from "sweetalert2";
import { clearEdgeGatewayWorkspaceCache } from "@/services/edgeGateways.js";
import { storeSessionToken } from "@/services/sessionStorage.js";
import axios from "axios";
import { API_URL } from "@/config.js";
import { releaseFrontendPathBase } from "@/middleware/originMigration.js";
const isProcessingQRCode = ref(false); // State to indicate if QR code is being processed
const playCaptureSound = () => {
sounds.play(sounds.list.value.onAfterSuccessfulScan)
sounds.play(sounds.list.value.onAfterSuccessfulScan);
};
const isSubuserSessionType = (type: string | null) => String(type || "").trim().toLowerCase() === "subuser";
const isSubuserSessionType = (type: string | null) =>
String(type || "")
.trim()
.toLowerCase() === "subuser";
const applyToken = (
token: string,
@@ -38,22 +48,61 @@ const applyToken = (
isSubuser: isSubuserSessionType(type),
selectedCustomerNumber: customerNumber,
});
window.location.href = "/";
}
const releaseBase = releaseFrontendPathBase(window.location.pathname);
window.location.href = releaseBase ? `${releaseBase}/` : "/";
};
const applyTokenIfPresent = () => {
// Get the current URL
const urlParams = new URLSearchParams(window.location.search);
const exchangeLoginGrant = async (grant: string) => {
isProcessingQRCode.value = true;
try {
const response = await axios.post(
`${API_URL}/auth/limited-backoffice-login-grants/exchange`,
{ grant },
{ __skipRequestQueue: true }
);
const data = response.data?.data ?? response.data;
if (!data?.token) throw new Error("The login grant exchange did not return a session.");
applyToken(data.token);
} catch (exchangeError: any) {
await Swal.fire({
icon: "error",
title: t("login_qr.unknown_qr_title"),
text:
exchangeError?.response?.data?.message ?? exchangeError?.response?.data?.error ?? t("login_qr.unknown_qr_text"),
});
isProcessingQRCode.value = false;
}
};
const grantFromUrl = (url: URL, allowQuery = false) => {
const fragment = new URLSearchParams(url.hash.replace(/^#/, ""));
return fragment.get("grant") ?? (allowQuery ? url.searchParams.get("grant") : null);
};
const scrubGrantFromAddressBar = () => {
const clean = new URL(window.location.href);
clean.searchParams.delete("grant");
clean.hash = "";
window.history.replaceState(null, "", `${clean.pathname}${clean.search}${clean.hash}`);
};
const applyTokenIfPresent = async () => {
const currentUrl = new URL(window.location.href);
const urlParams = currentUrl.searchParams;
const grant = grantFromUrl(currentUrl);
if (grant) {
scrubGrantFromAddressBar();
await exchangeLoginGrant(grant);
return;
}
const token = urlParams.get("token");
if (token) {
// If a token is present, store it in localStorage
console.log("Token found in URL:", token);
applyToken(token, {
type: urlParams.get("type"),
customerNumber: urlParams.get("customer_number"),
}); // Apply the token (store and redirect)
}
});
}
};
/** Handle image parsing from the camera feed **/
//const parseImage = (imageData: string) => {
@@ -65,27 +114,23 @@ const applyTokenIfPresent = () => {
const onClickRedirectPasswordLogin = () => {
window.location.href = "/";
}
};
/** QR Code Scanner Logic **/
// Checking if the result is a valid url
const isValidUrl = (string: string) => {
try {
new URL(string); // If the URL constructor doesn't throw an error, it's a valid URL
// Check if the url is from the same origin
if (!string.startsWith(window.location.origin)) {
console.log("URL is not from the same origin:", string, window.location.origin);
const url = new URL(string);
if (url.origin !== window.location.origin) {
Swal.fire({
icon: 'error',
title: t('login_qr.unknown_qr_title'),
text: t('login_qr.unknown_qr_text'),
icon: "error",
title: t("login_qr.unknown_qr_title"),
text: t("login_qr.unknown_qr_text"),
});
return false;
}
// Check if the url contains the token parameter
const url = new URL(string);
if (!url.searchParams.get("token")) {
console.log("URL does not contain token parameter:", string);
// Both legacy session links and short-lived one-time grants are supported.
if (!url.searchParams.get("token") && !grantFromUrl(url, true)) {
return false;
}
return true;
@@ -96,114 +141,119 @@ const isValidUrl = (string: string) => {
/*** detection handling ***/
const result = ref('')
const result = ref("");
function onDetect(detectedCodes) {
if (isProcessingQRCode.value) return;
playCaptureSound(); // Play sound on successful detection
console.log(detectedCodes)
result.value = JSON.stringify(detectedCodes.map((code) => code.rawValue))
result.value = JSON.stringify(detectedCodes.map((code) => code.rawValue));
// Validate the detected code (check if it matches an expected format)
const validCode = detectedCodes.find(code => isValidUrl(code.rawValue));
const validCode = detectedCodes.find((code) => isValidUrl(code.rawValue));
if (validCode) {
console.log("Valid QR code detected:", validCode.rawValue);
// Process the valid code (e.g., extract token and apply it)
const url = new URL(validCode.rawValue);
const grant = grantFromUrl(url, true);
if (grant) {
isProcessingQRCode.value = true;
void exchangeLoginGrant(grant);
return;
}
const token = url.searchParams.get("token");
if (token) {
isProcessingQRCode.value = true;
applyToken(token, {
type: url.searchParams.get("type"),
customerNumber: url.searchParams.get("customer_number"),
});
} else {
console.warn("No token found in the URL:", validCode.rawValue);
console.warn("No supported login credential found in scanned URL.");
}
}
}
/*** select camera ***/
const selectedConstraints = ref({ facingMode: 'environment' })
const selectedConstraints = ref({ facingMode: "environment" });
const defaultConstraintOptions = [
{ label: 'rear camera', constraints: { facingMode: 'environment' } },
{ label: 'front camera', constraints: { facingMode: 'user' } }
]
const constraintOptions = ref(defaultConstraintOptions)
{ label: "rear camera", constraints: { facingMode: "environment" } },
{ label: "front camera", constraints: { facingMode: "user" } },
];
const constraintOptions = ref(defaultConstraintOptions);
async function onCameraReady() {
// NOTE: on iOS we can't invoke `enumerateDevices` before the user has given
// camera access permission. `QrcodeStream` internally takes care of
// requesting the permissions. The `camera-on` event should guarantee that this
// has happened.
const devices = await navigator.mediaDevices.enumerateDevices()
const videoDevices = devices.filter(({ kind }) => kind === 'videoinput')
const devices = await navigator.mediaDevices.enumerateDevices();
const videoDevices = devices.filter(({ kind }) => kind === "videoinput");
constraintOptions.value = [
...defaultConstraintOptions,
...videoDevices.map(({ deviceId, label }) => ({
label: `${label} (ID: ${deviceId})`,
constraints: { deviceId }
}))
]
constraints: { deviceId },
})),
];
error.value = ''
error.value = "";
}
/*** track functons ***/
function paintOutline(detectedCodes, ctx) {
for (const detectedCode of detectedCodes) {
const [firstPoint, ...otherPoints] = detectedCode.cornerPoints
const [firstPoint, ...otherPoints] = detectedCode.cornerPoints;
ctx.strokeStyle = 'red'
ctx.strokeStyle = "red";
ctx.beginPath()
ctx.moveTo(firstPoint.x, firstPoint.y)
ctx.beginPath();
ctx.moveTo(firstPoint.x, firstPoint.y);
for (const { x, y } of otherPoints) {
ctx.lineTo(x, y)
ctx.lineTo(x, y);
}
ctx.lineTo(firstPoint.x, firstPoint.y)
ctx.closePath()
ctx.stroke()
ctx.lineTo(firstPoint.x, firstPoint.y);
ctx.closePath();
ctx.stroke();
}
}
function paintBoundingBox(detectedCodes, ctx) {
for (const detectedCode of detectedCodes) {
const {
boundingBox: { x, y, width, height }
} = detectedCode
boundingBox: { x, y, width, height },
} = detectedCode;
ctx.lineWidth = 2
ctx.strokeStyle = '#007bff'
ctx.strokeRect(x, y, width, height)
ctx.lineWidth = 2;
ctx.strokeStyle = "#007bff";
ctx.strokeRect(x, y, width, height);
}
}
function paintCenterText(detectedCodes, ctx) {
for (const detectedCode of detectedCodes) {
const { boundingBox, rawValue } = detectedCode
const { boundingBox, rawValue } = detectedCode;
const centerX = boundingBox.x + boundingBox.width / 2
const centerY = boundingBox.y + boundingBox.height / 2
const centerX = boundingBox.x + boundingBox.width / 2;
const centerY = boundingBox.y + boundingBox.height / 2;
const fontSize = Math.max(12, (50 * boundingBox.width) / ctx.canvas.width)
const fontSize = Math.max(12, (50 * boundingBox.width) / ctx.canvas.width);
ctx.font = `bold ${fontSize}px sans-serif`
ctx.textAlign = 'center'
ctx.font = `bold ${fontSize}px sans-serif`;
ctx.textAlign = "center";
ctx.lineWidth = 3
ctx.strokeStyle = '#35495e'
ctx.strokeText(detectedCode.rawValue, centerX, centerY)
ctx.lineWidth = 3;
ctx.strokeStyle = "#35495e";
ctx.strokeText(detectedCode.rawValue, centerX, centerY);
ctx.fillStyle = '#5cb984'
ctx.fillText(rawValue, centerX, centerY)
ctx.fillStyle = "#5cb984";
ctx.fillText(rawValue, centerX, centerY);
}
}
const trackFunctionOptions = [
{ text: 'nothing (default)', value: undefined },
{ text: 'outline', value: paintOutline },
{ text: 'centered text', value: paintCenterText },
{ text: 'bounding box', value: paintBoundingBox }
]
const trackFunctionSelected = ref(trackFunctionOptions[1])
{ text: "nothing (default)", value: undefined },
{ text: "outline", value: paintOutline },
{ text: "centered text", value: paintCenterText },
{ text: "bounding box", value: paintBoundingBox },
];
const trackFunctionSelected = ref(trackFunctionOptions[1]);
/*** barcode formats ***/
@@ -228,40 +278,39 @@ const barcodeFormats = ref({
upc_a: false,
upc_e: false,
linear_codes: false,
matrix_codes: false
})
matrix_codes: false,
});
const selectedBarcodeFormats = computed(() => {
return Object.keys(barcodeFormats.value).filter((format) => barcodeFormats.value[format])
})
return Object.keys(barcodeFormats.value).filter((format) => barcodeFormats.value[format]);
});
/*** error handling ***/
const error = ref('')
const error = ref("");
function onError(err) {
error.value = `[${err.name}]: `
error.value = `[${err.name}]: `;
if (err.name === 'NotAllowedError') {
error.value += 'you need to grant camera access permission'
} else if (err.name === 'NotFoundError') {
error.value += 'no camera on this device'
} else if (err.name === 'NotSupportedError') {
error.value += 'secure context required (HTTPS, localhost)'
} else if (err.name === 'NotReadableError') {
error.value += 'is the camera already in use?'
} else if (err.name === 'OverconstrainedError') {
error.value += 'installed cameras are not suitable'
} else if (err.name === 'StreamApiNotSupportedError') {
error.value += 'Stream API is not supported in this browser'
} else if (err.name === 'InsecureContextError') {
error.value +=
'Camera access is only permitted in secure context. Use HTTPS or localhost rather than HTTP.'
if (err.name === "NotAllowedError") {
error.value += "you need to grant camera access permission";
} else if (err.name === "NotFoundError") {
error.value += "no camera on this device";
} else if (err.name === "NotSupportedError") {
error.value += "secure context required (HTTPS, localhost)";
} else if (err.name === "NotReadableError") {
error.value += "is the camera already in use?";
} else if (err.name === "OverconstrainedError") {
error.value += "installed cameras are not suitable";
} else if (err.name === "StreamApiNotSupportedError") {
error.value += "Stream API is not supported in this browser";
} else if (err.name === "InsecureContextError") {
error.value += "Camera access is only permitted in secure context. Use HTTPS or localhost rather than HTTP.";
} else {
error.value += err.message
error.value += err.message;
}
Swal.fire({
icon: 'error',
title: t('login_qr.camera_error'),
icon: "error",
title: t("login_qr.camera_error"),
text: error.value,
});
}
@@ -286,7 +335,7 @@ onUnmounted(() => {
<template>
<div class="is-hidden-mobile">
<p data-testid="login-qr-desktop-message">{{ $t('login_qr.mobile_only') }}</p>
<p data-testid="login-qr-desktop-message">{{ $t("login_qr.mobile_only") }}</p>
</div>
<div class="is-hidden-desktop">
<PageLoader v-show="isProcessingQRCode" />
@@ -300,7 +349,6 @@ onUnmounted(() => {
@camera-on="onCameraReady"
/>
<div>
<!--<p class="error">{{ error }}</p> -->
</div>
</div>
@@ -326,7 +374,7 @@ onUnmounted(() => {
<PosDepartmentStepMobileFixedBottomControl>
<div class="is-flex is-justify-content-center">
<GenericButton @click="onClickRedirectPasswordLogin">
{{ $t('login_qr.login_with_password') }}
{{ $t("login_qr.login_with_password") }}
</GenericButton>
</div>
</PosDepartmentStepMobileFixedBottomControl>
@@ -362,5 +410,4 @@ onUnmounted(() => {
color: white; /* Default text color */
padding: 1rem; /* Optional padding for content */
}
</style>
+28
View File
@@ -0,0 +1,28 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
const source = readFileSync(new URL("../../src/views/auth/LoginQR.vue", import.meta.url), "utf8");
describe("LoginQR one-time grants", () => {
it("exchanges a grant before storing the returned session token", () => {
expect(source).toContain("/auth/limited-backoffice-login-grants/exchange");
expect(source).toMatch(/url\.hash\.replace\(\/\^#\//u);
expect(source).toContain("window.history.replaceState");
expect(source).toContain("url.origin !== window.location.origin");
expect(source).not.toContain("string.startsWith(window.location.origin)");
expect(source).toMatch(/applyToken\(data\.token\)/u);
expect(source).toContain("__skipRequestQueue: true");
expect(source).toContain("releaseFrontendPathBase(window.location.pathname)");
expect(source).toMatch(
/const grantFromUrl = \(url: URL, allowQuery = false\)[\s\S]*allowQuery \? url\.searchParams\.get\("grant"\) : null/u
);
});
it("accepts grant QR URLs without logging bearer values", () => {
expect(source).toMatch(/searchParams\.get\("token"\).*grantFromUrl\(url, true\)/su);
expect(source).not.toMatch(/console\.log\([^)]*(?:token|rawValue)/iu);
expect(source).toMatch(/function onDetect\(detectedCodes\) \{\s+if \(isProcessingQRCode\.value\) return;/u);
expect(source).toMatch(/await Swal\.fire\([\s\S]*isProcessingQRCode\.value = false;/u);
});
});
+21
View File
@@ -5,6 +5,7 @@ import {
applyReleaseFrontendBase,
buildMigrationUrl,
isOriginAllowed,
resolveGrantMigrationUrl,
resolveOriginMigrationRoute,
sanitizeRedirectPath,
startRedirectCountdown,
@@ -66,6 +67,26 @@ describe("origin migration route guard", () => {
});
});
it("moves a login grant directly in the fragment without an intermediate redirect query", () => {
const target = resolveGrantMigrationUrl({
to: {
name: "loginqr",
path: "/login/qr",
fullPath: "/login/qr#grant=lbg_secret",
hash: "#grant=lbg_secret",
query: {},
},
currentOrigin: "https://legacy.truckwash.io",
allowedOrigins,
migrationOrigin: "https://truckwash.io",
currentPath: "/canary/frontend/login/qr",
});
expect(target).toBe("https://truckwash.io/canary/frontend/login/qr#grant=lbg_secret");
expect(target).not.toContain("redirect=");
expect(target).not.toContain("?grant=");
});
it("bypasses the gate on dedicated outdated routes", () => {
const target = resolveOriginMigrationRoute({
to: { name: OUTDATED_GATEWAY_ROUTE_NAME, fullPath: "/outdated-gateway" },
+26
View File
@@ -327,6 +327,7 @@ describe("release timeline runtime", () => {
Authorization: "Bearer token",
nested: {
api_key: "secret",
grant: "lbg_sensitive",
safe: "visible",
},
})
@@ -334,6 +335,7 @@ describe("release timeline runtime", () => {
Authorization: "[redacted]",
nested: {
api_key: "[redacted]",
grant: "[redacted]",
safe: "visible",
},
});
@@ -441,6 +443,30 @@ describe("release timeline runtime", () => {
expect(sentBodies[0].events[0].type).toBe("route_change");
});
it("never emits login grants in the top-level timeline route", async () => {
const sentBodies = [];
__setReleaseTimelineTransportForTests(async (body) => {
sentBodies.push(body);
return { accepted: body.events.length };
});
configureReleaseRuntime({
trace_id: "trace-route-redaction",
channel: { slug: "beta" },
capture_policy: { enabled: true, capture_level: "full_redacted" },
});
recordReleaseTimelineEvent(
"route_change",
{ to: "/login/qr#grant=lbg_payload_copy" },
{ route: "/login/qr?grant=lbg_query_copy#grant=lbg_fragment_copy" }
);
await flushReleaseTimelineEvents();
expect(sentBodies[0].events[0].route).toBe("/login/qr?grant=%5Bredacted%5D");
expect(JSON.stringify(sentBodies[0])).not.toContain("lbg_query_copy");
expect(JSON.stringify(sentBodies[0])).not.toContain("lbg_fragment_copy");
});
it("builds device and release context for replay sessions", () => {
configureReleaseRuntime({
trace_id: "trace-context",