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:
@@ -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();
|
||||
}
|
||||
|
||||
+16
-4
@@ -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),
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(),
|
||||
|
||||
+165
-118
@@ -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(() => {
|
||||
|
||||
<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" />
|
||||
<div class="background-fixed">
|
||||
<qrcode-stream
|
||||
:constraints="selectedConstraints"
|
||||
:track="trackFunctionSelected.value"
|
||||
:formats="selectedBarcodeFormats"
|
||||
@error="onError"
|
||||
@detect="onDetect"
|
||||
@camera-on="onCameraReady"
|
||||
:constraints="selectedConstraints"
|
||||
:track="trackFunctionSelected.value"
|
||||
:formats="selectedBarcodeFormats"
|
||||
@error="onError"
|
||||
@detect="onDetect"
|
||||
@camera-on="onCameraReady"
|
||||
/>
|
||||
<div>
|
||||
|
||||
<!--<p class="error">{{ error }}</p> -->
|
||||
</div>
|
||||
</div>
|
||||
<div class="custom-content">
|
||||
<!-- Instructions for the user -->
|
||||
<ScannerInstructions
|
||||
:title="$t('login_qr.scan_title')"
|
||||
:subtitle="$t('login_qr.scan_subtitle')"
|
||||
:buttonText="$t('login_qr.login_with_password_instead')"
|
||||
@button-click="onClickRedirectPasswordLogin"
|
||||
:title="$t('login_qr.scan_title')"
|
||||
:subtitle="$t('login_qr.scan_subtitle')"
|
||||
:buttonText="$t('login_qr.login_with_password_instead')"
|
||||
@button-click="onClickRedirectPasswordLogin"
|
||||
/>
|
||||
<!-- Scanner outline object -->
|
||||
<div class="is-align-content-center is-flex is-justify-content-center">
|
||||
<ScannerOutline :loading="false"/>
|
||||
<ScannerOutline :loading="false" />
|
||||
</div>
|
||||
<!-- Reg. 1, Reg. 2, Reg. 3 -->
|
||||
<div class="is-flex is-align-content-center is-justify-content-center is-flex-direction-column">
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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" },
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user