From 5ffd471a45f1e48274c0616ee2c1bef224957071 Mon Sep 17 00:00:00 2001 From: Jeppe B <2jepp9350@gmail.com> Date: Wed, 29 Jul 2026 00:01:22 +0200 Subject: [PATCH] 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. --- src/middleware/guestMiddleware.js | 49 ++--- src/middleware/index.js | 20 +- src/middleware/originMigration.js | 59 +++--- src/services/releaseTimeline.js | 28 ++- src/views/auth/LoginQR.vue | 283 ++++++++++++++++------------ tests/unit/login-qr-grant.spec.js | 28 +++ tests/unit/origin-migration.spec.js | 21 +++ tests/unit/release-timeline.spec.js | 26 +++ 8 files changed, 341 insertions(+), 173 deletions(-) create mode 100644 tests/unit/login-qr-grant.spec.js diff --git a/src/middleware/guestMiddleware.js b/src/middleware/guestMiddleware.js index 3819958e..559b24c3 100644 --- a/src/middleware/guestMiddleware.js +++ b/src/middleware/guestMiddleware.js @@ -3,26 +3,33 @@ */ import { hasStoredSessionToken } from "@/services/sessionStorage.js"; -export default function guestMiddleware({ 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 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'); - // 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}`); - } else { - // window.location.href = '/'; - return next({ name: 'default' }) - } +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 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"); + // 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}` + ); + } else { + // window.location.href = '/'; + return next({ name: "default" }); } - return next(); + } + return next(); } diff --git a/src/middleware/index.js b/src/middleware/index.js index acd74119..519dc6d8 100644 --- a/src/middleware/index.js +++ b/src/middleware/index.js @@ -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), }); diff --git a/src/middleware/originMigration.js b/src/middleware/originMigration.js index 7d59c2fa..e511a0c2 100644 --- a/src/middleware/originMigration.js +++ b/src/middleware/originMigration.js @@ -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); diff --git a/src/services/releaseTimeline.js b/src/services/releaseTimeline.js index 15217426..2d55aae6 100644 --- a/src/services/releaseTimeline.js +++ b/src/services/releaseTimeline.js @@ -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(), diff --git a/src/views/auth/LoginQR.vue b/src/views/auth/LoginQR.vue index bdd43560..4d6470c2 100644 --- a/src/views/auth/LoginQR.vue +++ b/src/views/auth/LoginQR.vue @@ -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,54 +48,89 @@ 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) => { // console.log("Image data received:", imageData); - // Here you would typically send the image data to a backend service for processing - // For demonstration, we'll just log it to the console - // Example: sendImageToBackend(imageData); +// Here you would typically send the image data to a backend service for processing +// For demonstration, we'll just log it to the console +// Example: sendImageToBackend(imageData); //}; 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,35 +335,34 @@ onUnmounted(() => {