Introduce origin migration handling with outdated route guards, redirect logic, and migration-related error views.

- Add `originMigration.js` for handling allowed origins, standalone contexts, and redirect routes.
- Create `OutdatedGateway.vue` and `OutdatedInstallation.vue` error views with timer-based redirection and user instructions.
- Update router to include `/outdated-installation` and `/outdated-gateway` paths.
- Implement unit tests for migration utility methods and route guards.
- Remove debug `console.log` statements across multiple components.
This commit is contained in:
Jeppe Bundgaard
2026-03-19 10:44:10 +01:00
parent 7e9489573a
commit efed5ac557
24 changed files with 566 additions and 27 deletions
@@ -52,7 +52,6 @@ const loadPendingBookings = () => {
}
).then(response => {
pendingBookings.value = response.data.data;
console.log("Pending bookings:", pendingBookings.value);
}).catch(error => {
console.error("Error:", error);
});
@@ -742,4 +741,4 @@ const isDropdownContentLoaded = (scan) => {
.input.has-placeholder-italic::placeholder {
font-style: italic;
}
</style>
</style>
@@ -121,12 +121,10 @@ const handleLPRResult = () => {
const parseImage = async (image: string) => {
// Check if the time since the last successful parse is enough
if (!camera.hasDelayAfterSuccessPassed()) {
console.log("Skipping parsing due to delay after success.");
return;
}
if (lastParsedImage.value === image) {
// If the image is the same as the last parsed one, skip parsing
console.log("Skipping parsing for the same image.");
return;
}
lastParsedImage.value = image; // Update the last parsed image
@@ -11,7 +11,6 @@ const { coords, locatedAt, error, resume, pause } = useGeolocation({
timeout: 27000,
});
const onUpdate = (newCoords: { latitude: number | null; longitude: number | null }) => {
console.log("Geolocation updated:", newCoords);
if (newCoords.latitude && newCoords.longitude) {
locations.set({
coords: {
@@ -48,4 +47,4 @@ watch(coords, (newCoords) => {
<style scoped>
</style>
</style>
@@ -1,7 +1,8 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n';
import {defineProps, onMounted, computed, ref} from "vue";
import { reset_all_values, customer_name, nextStep, searchAndSelectCustomer, isCustomerSelected, order_id, customer_id, step, reg_1, reg_2, reg_3, reference, order_notes, loadCustomerAttributes, hasAttribute, uploadAttachment, isCreatingOrder } from "@/components/shop/POSDepartmentProcess.vue";
import { reset_all_values, customer_name, nextStep, searchAndSelectCustomer, isCustomerSelected, order_id, customer_id, step, reg_1, reg_2, reg_3, reference, order_notes, loadCustomerAttributes, hasAttribute, uploadAttachment } from "@/components/shop/POSDepartmentProcess.vue";
import * as POSDepartmentProcess from "@/components/shop/POSDepartmentProcess.vue";
import { errors } from "@/components/request/HandleGlobalError.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import GenericButton from "@/components/viewport/page/templates/generic/graphics/GenericButton.vue";
@@ -13,6 +14,7 @@ import {views} from "@/components/displays/department/pos/steps/mobile/objects/P
const { t } = useI18n();
const selectCustomerString = `${SessionUser.objects.global.language.select} ${SessionUser.objects.global.language.customer.toLowerCase()}`;
const isCreatingOrder = computed(() => POSDepartmentProcess.isCreatingOrder?.value ?? false);
const props = defineProps({
// If true, the button will have a white background. Default is true.
@@ -213,12 +213,10 @@ const getPopup = () => {
}
// Function to set the popup
const setPopup = (newPopup: PosPopup | null) => {
console.log('Setting popup:', newPopup);
popup.value = newPopup;
}
// Function to clear the popup
const clearPopup = () => {
console.log('Clearing popup');
popup.value = null;
}
// Default list of popups
@@ -231,7 +229,6 @@ const addPopup = (popup: PosPopup) => {
return;
}
popupsList.value[popup.id] = {value: popup};
console.log(`Added popup with id: ${popup.id}`);
}
// Function to add default popups to the list
const addDefaultPopups = () => {
@@ -2068,4 +2065,4 @@ export {
getCameraImageCaptureDelay as getCameraCaptureDelay,
};
</script>
</script>
@@ -26,7 +26,6 @@ const loadPendingBookings = () => {
}
).then(response => {
pendingBookings.value = response.data.data;
console.log("Pending bookings:", pendingBookings.value);
}).catch(error => {
console.error("Error:", error);
});
@@ -558,4 +557,4 @@ const getCurrentIconColor = () => {
<style scoped>
</style>
</style>
@@ -16,7 +16,6 @@ const props = defineProps({
});
const slots = useSlots();
console.log(slots);
</script>
<template>
@@ -47,4 +46,4 @@ console.log(slots);
<style scoped>
</style>
</style>
@@ -12,7 +12,6 @@ export const getDepartments = async () => {
const request = await authenticatedRequest('/departments','get');
departments.value = request.data.data;
isLoading.value = false;
console.log(departments.value);
};
// Check if a department exists by ID
@@ -53,7 +52,6 @@ export const getDepartmentsGuest = async (queryParams = {}) => {
const request = await unauthenticatedRequest('/guest/departments' + (queryString ? `?${queryString}` : ''),'get');
departments.value = request.data.data;
isLoading.value = false;
console.log(departments.value);
return departments.value;
};
</script>
@@ -952,7 +952,6 @@ export const loadPendingBookings = () => {
}
).then(response => {
pendingBookings.value = response.data.data;
console.log("Pending bookings:", pendingBookings.value);
}).catch(error => {
console.error("Error:", error);
});
+1 -4
View File
@@ -13,8 +13,6 @@ import { isHidden } from "@/components/viewport/page/headers/ViewportHeaderSetti
import ConnectivityIssue from "@/views/errors/ConnectivityIssue.vue";
const router = useRouter();
console.log("Viewport component loaded");
const headerHeight = ref(60); // Default header height in pixels
const headerElement = ref<HTMLElement | null>(null);
const footerHeight = ref(0); // Default footer height in pixels
@@ -23,7 +21,6 @@ const footerElement = ref<HTMLElement | null>(null);
const updateHeaderHeight = () => {
if (headerElement.value) {
headerHeight.value = headerElement.value.offsetHeight;
console.log("Header height updated:", headerHeight.value);
}
};
@@ -162,4 +159,4 @@ const isFooterInContent = computed(() => {
.transform-color-black {
filter: invert(100%) grayscale(100%) brightness(0) contrast(100%);
}
</style>
</style>
@@ -128,7 +128,6 @@ onMounted(() => {
// Emit a picture every 10 seconds
if (!isCameraMounted.value) {
isCameraMounted.value = true;
console.log("Camera mounted")
setInterval(() => {
if (isCameraActive.value) {
getFrame();
@@ -140,7 +139,6 @@ onMounted(() => {
onUnmounted(() => {
stopCamera();
console.log('Camera unmounted');
});
defineExpose({
+23
View File
@@ -3929,6 +3929,29 @@
"retry": "Prøv igen",
"footer": "Hvis problemet fortsætter, kontakt venligst support."
},
"outdated": {
"installation": {
"title": "Forældet installation",
"subtitle": "Denne installerede app er forældet og skal migreres.",
"description": "Truck Wash er flyttet til en ny installation på truckwash.io. Åbn den nye side og installer appen igen på din enhed.",
"android_title": "Android-migrering",
"android_step_1": "Åbn truckwash.io i Chrome.",
"android_step_2": "Åbn browsermenuen og vælg \"Installer app\".",
"android_step_3": "Brug den nye installerede app og fjern denne forældede app.",
"ios_title": "iPhone-migrering",
"ios_step_1": "Åbn truckwash.io i Safari.",
"ios_step_2": "Tryk på Del, og vælg \"Føj til hjemmeskærm\".",
"ios_step_3": "Brug den nye hjemmeskærmsapp og fjern denne forældede app.",
"open_now": "Åbn truckwash.io nu"
},
"gateway": {
"title": "Forældet gateway",
"subtitle": "Dette link er ved at blive udfaset.",
"description": "Du bliver omdirigeret til den samme sti på truckwash.io.",
"redirecting_in": "Omdirigerer om {seconds} sekunder...",
"redirect_now": "Gå nu"
}
},
"global_search": {
"title": "Avanceret systemsøgning",
"launcher": {
+23
View File
@@ -4034,6 +4034,29 @@
"retry": "Wiederholen",
"footer": "Wenn das Problem weiterhin besteht, kontaktieren Sie bitte den Support."
},
"outdated": {
"installation": {
"title": "Veraltete Installation",
"subtitle": "Diese installierte App ist veraltet und muss migriert werden.",
"description": "Truck Wash wurde auf eine neue Installation unter truckwash.io umgestellt. Öffnen Sie die neue Seite und installieren Sie die App erneut auf Ihrem Gerät.",
"android_title": "Android-Migration",
"android_step_1": "Öffnen Sie truckwash.io in Chrome.",
"android_step_2": "Öffnen Sie das Browsermenü und wählen Sie \"App installieren\".",
"android_step_3": "Verwenden Sie die neu installierte App und entfernen Sie diese veraltete App.",
"ios_title": "iPhone-Migration",
"ios_step_1": "Öffnen Sie truckwash.io in Safari.",
"ios_step_2": "Tippen Sie auf \"Teilen\" und dann auf \"Zum Home-Bildschirm\".",
"ios_step_3": "Verwenden Sie die neue Home-Bildschirm-App und entfernen Sie diese veraltete App.",
"open_now": "truckwash.io jetzt öffnen"
},
"gateway": {
"title": "Veraltetes Gateway",
"subtitle": "Dieser Link wird schrittweise eingestellt.",
"description": "Sie werden auf denselben Pfad bei truckwash.io weitergeleitet.",
"redirecting_in": "Weiterleitung in {seconds} Sekunden...",
"redirect_now": "Jetzt weiter"
}
},
"passkeys": {
"title": "Passkeys",
"description": "Verwenden Sie Passkeys für eine sichere Anmeldung mit Biometrie wie Face ID, Touch ID oder der PIN Ihres Geräts.",
+23
View File
@@ -4250,5 +4250,28 @@
"retry_now": "Trying to reconnect...",
"retry": "Retry",
"footer": "If the issue persists, please contact support."
},
"outdated": {
"installation": {
"title": "Outdated installation",
"subtitle": "This installed app is outdated and must be migrated.",
"description": "Truck Wash has moved to a new installation at truckwash.io. Please open the new site and install it again on your device.",
"android_title": "Android migration",
"android_step_1": "Open truckwash.io in Chrome.",
"android_step_2": "Open the browser menu and choose \"Install app\".",
"android_step_3": "Use the newly installed app and remove this outdated one.",
"ios_title": "iPhone migration",
"ios_step_1": "Open truckwash.io in Safari.",
"ios_step_2": "Tap Share and choose \"Add to Home Screen\".",
"ios_step_3": "Use the new home-screen app and remove this outdated one.",
"open_now": "Open truckwash.io now"
},
"gateway": {
"title": "Outdated gateway",
"subtitle": "This link is being phased out.",
"description": "You will be redirected to the same path on truckwash.io.",
"redirecting_in": "Redirecting in {seconds} seconds...",
"redirect_now": "Go now"
}
}
}
+23
View File
@@ -4014,6 +4014,29 @@
"retry": "Prøv på nytt",
"footer": "Hvis problemet vedvarer, kontakt support."
},
"outdated": {
"installation": {
"title": "Utdatert installasjon",
"subtitle": "Denne installerte appen er utdatert og må migreres.",
"description": "Truck Wash er flyttet til en ny installasjon på truckwash.io. Åpne den nye siden og installer appen på nytt på enheten din.",
"android_title": "Android-migrering",
"android_step_1": "Åpne truckwash.io i Chrome.",
"android_step_2": "Åpne nettlesermenyen og velg \"Installer app\".",
"android_step_3": "Bruk den nye installerte appen og fjern denne utdaterte appen.",
"ios_title": "iPhone-migrering",
"ios_step_1": "Åpne truckwash.io i Safari.",
"ios_step_2": "Trykk Del og velg \"Legg til på hjemskjermen\".",
"ios_step_3": "Bruk den nye hjemskjerm-appen og fjern denne utdaterte appen.",
"open_now": "Åpne truckwash.io nå"
},
"gateway": {
"title": "Utdatert gateway",
"subtitle": "Denne lenken fases ut.",
"description": "Du blir omdirigert til samme sti på truckwash.io.",
"redirecting_in": "Omdirigerer om {seconds} sekunder...",
"redirect_now": "Gå nå"
}
},
"2fa": {
"title": "Tofaktorautentisering",
"description": "Legg til et ekstra sikkerhetslag på kontoen din ved å kreve en verifiseringskode ved innlogging.",
+23
View File
@@ -4014,6 +4014,29 @@
"retry": "Försök igen",
"footer": "Om problemet kvarstår, kontakta supporten."
},
"outdated": {
"installation": {
"title": "Föråldrad installation",
"subtitle": "Den här installerade appen är föråldrad och måste migreras.",
"description": "Truck Wash har flyttats till en ny installation på truckwash.io. Öppna den nya sidan och installera appen igen på din enhet.",
"android_title": "Android-migrering",
"android_step_1": "Öppna truckwash.io i Chrome.",
"android_step_2": "Öppna webbläsarmenyn och välj \"Installera app\".",
"android_step_3": "Använd den nyinstallerade appen och ta bort den här föråldrade appen.",
"ios_title": "iPhone-migrering",
"ios_step_1": "Öppna truckwash.io i Safari.",
"ios_step_2": "Tryck på Dela och välj \"Lägg till på hemskärmen\".",
"ios_step_3": "Använd den nya hemskärmsappen och ta bort den här föråldrade appen.",
"open_now": "Öppna truckwash.io nu"
},
"gateway": {
"title": "Föråldrad gateway",
"subtitle": "Den här länken håller på att fasas ut.",
"description": "Du omdirigeras till samma sökväg på truckwash.io.",
"redirecting_in": "Omdirigerar om {seconds} sekunder...",
"redirect_now": "Gå nu"
}
},
"2fa": {
"title": "Tvåfaktorsautentisering",
"description": "Lägg till ett extra säkerhetslager på ditt konto genom att kräva en verifieringskod vid inloggning.",
+59
View File
@@ -18,6 +18,65 @@ import { initializeAutoTableExports } from '@/services/AutoTableExportService.js
import { API_URL, IS_DEV, POS_STEP_1_VERSION } from './config';
// export { API_URL, IS_DEV, POS_STEP_1_VERSION };
const VITE_BUILD_DATE = import.meta.env.VITE_BUILD_DATE || '';
const VITE_COMMIT_HASH = import.meta.env.VITE_COMMIT_HASH || '';
const LAST_VERSION_CHECK_STORAGE_KEY = 'lastVersionCheck';
const TRUCK_WASH_ASCII = [
' _____ _ _ _ _ \n' +
'|_ _| | | | | | | | | \n' +
' | |_ __ _ _ ___| | __ | | | | __ _ ___| |__ \n' +
' | | \'__| | | |/ __| |/ / | |/\\| |/ _` / __| \'_ \\ \n' +
' | | | | |_| | (__| < \\ /\\ / (_| \\__ \\ | | |\n' +
' \\_/_| \\__,_|\\___|_|\\_\\ \\/ \\/ \\__,_|___/_| |_|'
].join('\n');
const pad = (value) => String(value).padStart(2, '0');
const formatDateTime = (value) => {
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return 'Unknown';
}
const day = pad(date.getDate());
const month = pad(date.getMonth() + 1);
const year = date.getFullYear();
const hours = pad(date.getHours());
const minutes = pad(date.getMinutes());
return `${day}-${month}-${year} ${hours}:${minutes}`;
};
const formatTimeAgo = (timestamp) => {
const elapsed = Date.now() - timestamp;
if (elapsed < 60_000) return 'just now';
if (elapsed < 3_600_000) return `${Math.floor(elapsed / 60_000)}m ago`;
if (elapsed < 86_400_000) return `${Math.floor(elapsed / 3_600_000)}h ago`;
return `${Math.floor(elapsed / 86_400_000)}d ago`;
};
const formatCommit = (hash) => {
if (!hash) return '#unknown';
return `#${hash.substring(0, 7)}`;
};
const logBuildBanner = () => {
const lastVersionCheckRaw = Number.parseInt(localStorage.getItem(LAST_VERSION_CHECK_STORAGE_KEY) || '', 10);
const hasLastVersionCheck = Number.isFinite(lastVersionCheckRaw) && lastVersionCheckRaw > 0;
const lastVersionCheckDisplay = hasLastVersionCheck
? `${formatTimeAgo(lastVersionCheckRaw)} @ ${formatDateTime(lastVersionCheckRaw)}`
: 'never @ Unknown';
console.log([
TRUCK_WASH_ASCII,
'',
`Build: ${formatCommit(VITE_COMMIT_HASH)} @ ${formatDateTime(VITE_BUILD_DATE)} (${IS_DEV ? 'development' : 'production'})`,
`Last version check: ${lastVersionCheckDisplay}`,
`Remote API: ${API_URL}`,
].join('\n'));
};
logBuildBanner();
// Service worker update handling
if ('serviceWorker' in navigator) {
navigator.serviceWorker.addEventListener('controllerchange', () => {
+19 -1
View File
@@ -1,3 +1,9 @@
import { ALLOWED_ORIGINS } from '@/config';
import {
isStandaloneContext,
resolveOriginMigrationRoute,
} from '@/middleware/originMigration';
function middlewarePipeline(context, middleware, index) {
const nextMiddleware = middleware[index];
@@ -13,6 +19,18 @@ function middlewarePipeline(context, middleware, index) {
export default function applyMiddleware(router) {
router.beforeEach((to, from, next) => {
const browserWindow = typeof window !== 'undefined' ? window : undefined;
const originMigrationRoute = resolveOriginMigrationRoute({
to,
currentOrigin: browserWindow?.location?.origin || '',
allowedOrigins: ALLOWED_ORIGINS,
standalone: isStandaloneContext(browserWindow),
});
if (originMigrationRoute) {
return next(originMigrationRoute);
}
if (!to.meta.middleware) {
return next();
}
@@ -30,4 +48,4 @@ export default function applyMiddleware(router) {
return middleware[0]({ ...context, next: nextMiddleware });
});
}
}
+113
View File
@@ -0,0 +1,113 @@
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 normalizeAllowedOrigins = (allowedOrigins = []) => {
return new Set(
allowedOrigins
.map((origin) => {
try {
return new URL(origin).origin;
} catch (_error) {
return origin;
}
})
.filter(Boolean),
);
};
export const isOriginAllowed = (currentOrigin, allowedOrigins = []) => {
if (!currentOrigin) return true;
const allowedOriginSet = normalizeAllowedOrigins(allowedOrigins);
return allowedOriginSet.has(currentOrigin);
};
export const isStandaloneContext = (win) => {
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;
return iosStandalone || mediaStandalone;
};
export const shouldBypassOriginGate = (route) => {
return EXEMPT_ROUTE_NAMES.has(route?.name);
};
export const resolveOriginMigrationRoute = ({
to,
currentOrigin,
allowedOrigins,
standalone,
}) => {
if (shouldBypassOriginGate(to)) {
return null;
}
if (isOriginAllowed(currentOrigin, allowedOrigins)) {
return null;
}
if (standalone) {
return { name: OUTDATED_INSTALLATION_ROUTE_NAME };
}
return {
name: OUTDATED_GATEWAY_ROUTE_NAME,
query: {
redirect: to?.fullPath || '/',
},
};
};
export const sanitizeRedirectPath = (redirectPath) => {
if (typeof redirectPath !== 'string') return '/';
if (!redirectPath.startsWith('/')) return '/';
return redirectPath;
};
export const buildMigrationUrl = (migrationOrigin, redirectPath) => {
const safePath = sanitizeRedirectPath(redirectPath);
try {
return new URL(safePath, migrationOrigin).toString();
} catch (_error) {
return migrationOrigin;
}
};
export const startRedirectCountdown = ({
seconds = 5,
onTick,
onRedirect,
setIntervalFn = setInterval,
clearIntervalFn = clearInterval,
setTimeoutFn = setTimeout,
clearTimeoutFn = clearTimeout,
}) => {
let remainingSeconds = seconds;
const intervalId = setIntervalFn(() => {
remainingSeconds = Math.max(remainingSeconds - 1, 0);
if (typeof onTick === 'function') {
onTick(remainingSeconds);
}
if (remainingSeconds === 0) {
clearIntervalFn(intervalId);
}
}, 1000);
const timeoutId = setTimeoutFn(() => {
if (typeof onRedirect === 'function') {
onRedirect();
}
}, seconds * 1000);
return () => {
clearIntervalFn(intervalId);
clearTimeoutFn(timeoutId);
};
};
+14
View File
@@ -49,6 +49,8 @@ import MyMaterials from "@/views/dashboards/userDashboard/materials/MyMaterials.
/** Errors */
import NotFound from './views/errors/NotFound.vue';
import ConnectivityIssue from './views/errors/ConnectivityIssue.vue';
import OutdatedInstallation from "@/views/errors/OutdatedInstallation.vue";
import OutdatedGateway from "@/views/errors/OutdatedGateway.vue";
import MyOrders from "@/views/dashboards/userDashboard/orders/MyOrders.vue";
import NewVehicle from "@/views/dashboards/userDashboard/vehicles/NewVehicle.vue";
import MyBookings from "@/views/dashboards/userDashboard/bookings/MyBookings.vue";
@@ -945,6 +947,18 @@ export const router = createRouter({
path: '/guest/book/wash',
component: GuestBookExteriorWash,
},
{
name: 'outdated-installation',
path: '/outdated-installation',
component: OutdatedInstallation,
meta: { template: 'clear-main', titleKey: 'outdated.installation.title' }
},
{
name: 'outdated-gateway',
path: '/outdated-gateway',
component: OutdatedGateway,
meta: { template: 'clear-main', titleKey: 'outdated.gateway.title' }
},
{
name: 'connectivity-issue',
path: '/connectivity-issue',
+1 -2
View File
@@ -11,9 +11,8 @@ const check_connection = () => {
reset_timer();
try {
SessionUser.request('/ping')
.then((response: any) => {
.then(() => {
connectionStatus.value = 'ok';
console.warn('Connection OK', response);
})
.catch((error: any) => {
// Keep the user on the error page if the connection check fails
+82
View File
@@ -0,0 +1,82 @@
<script setup>
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import { useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { MIGRATION_ORIGIN } from '@/config';
import {
buildMigrationUrl,
sanitizeRedirectPath,
startRedirectCountdown,
} from '@/middleware/originMigration';
const { t } = useI18n();
const route = useRoute();
const secondsRemaining = ref(5);
let stopRedirectCountdown = null;
const redirectPath = computed(() => {
const redirectQuery = route.query?.redirect;
const candidate = Array.isArray(redirectQuery) ? redirectQuery[0] : redirectQuery;
return sanitizeRedirectPath(candidate);
});
const destinationUrl = computed(() => {
return buildMigrationUrl(MIGRATION_ORIGIN, redirectPath.value);
});
const clearTimers = () => {
if (typeof stopRedirectCountdown === 'function') {
stopRedirectCountdown();
stopRedirectCountdown = null;
}
};
const redirectNow = () => {
window.location.replace(destinationUrl.value);
};
onMounted(() => {
stopRedirectCountdown = startRedirectCountdown({
seconds: 5,
onTick: (remaining) => {
secondsRemaining.value = remaining;
},
onRedirect: redirectNow,
});
});
onBeforeUnmount(() => {
clearTimers();
});
</script>
<template>
<section class="hero is-light is-fullheight">
<div class="hero-body">
<div class="container">
<div class="columns is-centered">
<div class="column is-11-mobile is-9-tablet is-7-desktop">
<div class="box migration-card">
<h1 class="title">{{ t('outdated.gateway.title') }}</h1>
<p class="subtitle">{{ t('outdated.gateway.subtitle') }}</p>
<p class="mb-3">{{ t('outdated.gateway.description') }}</p>
<p class="mb-2">{{ t('outdated.gateway.redirecting_in', { seconds: secondsRemaining }) }}</p>
<p class="is-size-7 has-text-grey mb-5">{{ destinationUrl }}</p>
<button class="button is-primary is-medium" @click="redirectNow">
{{ t('outdated.gateway.redirect_now') }}
</button>
</div>
</div>
</div>
</div>
</div>
</section>
</template>
<style scoped>
.migration-card {
border-top: 5px solid #13324c;
}
</style>
+53
View File
@@ -0,0 +1,53 @@
<script setup>
import { useI18n } from 'vue-i18n';
import { MIGRATION_ORIGIN } from '@/config';
const { t } = useI18n();
</script>
<template>
<section class="hero is-light is-fullheight">
<div class="hero-body">
<div class="container">
<div class="columns is-centered">
<div class="column is-11-mobile is-9-tablet is-7-desktop">
<div class="box migration-card">
<h1 class="title">{{ t('outdated.installation.title') }}</h1>
<p class="subtitle">{{ t('outdated.installation.subtitle') }}</p>
<p class="mb-4">{{ t('outdated.installation.description') }}</p>
<h2 class="is-size-5 has-text-weight-semibold mb-2">{{ t('outdated.installation.android_title') }}</h2>
<ol class="mb-5">
<li>{{ t('outdated.installation.android_step_1') }}</li>
<li>{{ t('outdated.installation.android_step_2') }}</li>
<li>{{ t('outdated.installation.android_step_3') }}</li>
</ol>
<h2 class="is-size-5 has-text-weight-semibold mb-2">{{ t('outdated.installation.ios_title') }}</h2>
<ol class="mb-5">
<li>{{ t('outdated.installation.ios_step_1') }}</li>
<li>{{ t('outdated.installation.ios_step_2') }}</li>
<li>{{ t('outdated.installation.ios_step_3') }}</li>
</ol>
<a class="button is-primary is-medium" :href="MIGRATION_ORIGIN">
{{ t('outdated.installation.open_now') }}
</a>
</div>
</div>
</div>
</div>
</div>
</section>
</template>
<style scoped>
.migration-card {
border-top: 5px solid #13324c;
}
ol {
margin-left: 1.25rem;
}
</style>
+101
View File
@@ -0,0 +1,101 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
OUTDATED_GATEWAY_ROUTE_NAME,
OUTDATED_INSTALLATION_ROUTE_NAME,
buildMigrationUrl,
resolveOriginMigrationRoute,
sanitizeRedirectPath,
startRedirectCountdown,
} from "@/middleware/originMigration";
describe("origin migration route guard", () => {
const allowedOrigins = ["https://truckwash.io", "https://www.truckwash.io"];
const route = { name: "landing", fullPath: "/user/bookings?tab=open#latest" };
it("allows navigation on allowed origins", () => {
const target = resolveOriginMigrationRoute({
to: route,
currentOrigin: "https://truckwash.io",
allowedOrigins,
standalone: false,
});
expect(target).toBeNull();
});
it("routes to outdated installation for disallowed standalone context", () => {
const target = resolveOriginMigrationRoute({
to: route,
currentOrigin: "https://legacy.truckwash.io",
allowedOrigins,
standalone: true,
});
expect(target).toEqual({ name: OUTDATED_INSTALLATION_ROUTE_NAME });
});
it("routes to outdated gateway for disallowed browser context", () => {
const target = resolveOriginMigrationRoute({
to: route,
currentOrigin: "https://legacy.truckwash.io",
allowedOrigins,
standalone: false,
});
expect(target).toEqual({
name: OUTDATED_GATEWAY_ROUTE_NAME,
query: { redirect: "/user/bookings?tab=open#latest" },
});
});
it("bypasses the gate on dedicated outdated routes", () => {
const target = resolveOriginMigrationRoute({
to: { name: OUTDATED_GATEWAY_ROUTE_NAME, fullPath: "/outdated-gateway" },
currentOrigin: "https://legacy.truckwash.io",
allowedOrigins,
standalone: false,
});
expect(target).toBeNull();
});
});
describe("origin migration redirect url", () => {
it("preserves path, query, and hash on migration origin", () => {
const redirectUrl = buildMigrationUrl(
"https://truckwash.io",
"/user/bookings?tab=open#latest",
);
expect(redirectUrl).toBe("https://truckwash.io/user/bookings?tab=open#latest");
});
it("sanitizes invalid redirect paths to root", () => {
expect(sanitizeRedirectPath("https://malicious.example")).toBe("/");
});
});
describe("gateway redirect countdown", () => {
afterEach(() => {
vi.useRealTimers();
});
it("ticks every second and redirects after five seconds", () => {
vi.useFakeTimers();
const onRedirect = vi.fn();
const tickValues = [];
const stop = startRedirectCountdown({
seconds: 5,
onTick: (seconds) => tickValues.push(seconds),
onRedirect,
});
vi.advanceTimersByTime(5000);
expect(tickValues).toEqual([4, 3, 2, 1, 0]);
expect(onRedirect).toHaveBeenCalledTimes(1);
stop();
});
});