Compare commits
191
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
51b996d8cb | ||
|
|
880680d7f8 | ||
|
|
7d5ed772c2 | ||
|
|
83fbcdeaa8 | ||
|
|
55a761d3a5 | ||
|
|
abe8e7750f | ||
|
|
aeef75b831 | ||
|
|
d6356d0244 | ||
|
|
bbf2aeb95f | ||
|
|
3a7d78ae05 | ||
|
|
2a2f752692 | ||
|
|
8883bf8a07 | ||
|
|
6ec80e2796 | ||
|
|
849a7b77cf | ||
|
|
36ccd0c381 | ||
|
|
58e7c8d6c9 | ||
|
|
d04fc8cc0f | ||
|
|
339a1b1452 | ||
|
|
2d2d63d09e | ||
|
|
8241f7fd2f | ||
|
|
985571af87 | ||
|
|
82e1e4a466 | ||
|
|
9a7f7bbb78 | ||
|
|
359f29b75e | ||
|
|
ceced7cbaa | ||
|
|
6b5cd00b10 | ||
|
|
02eae47d34 | ||
|
|
3f01d38676 | ||
|
|
390a29ef55 | ||
|
|
b7f5786747 | ||
|
|
814c535757 | ||
|
|
ed0e27e4fd | ||
|
|
659df480d7 | ||
|
|
d0c45f6180 | ||
|
|
5a20461bd4 | ||
|
|
849f52fb8e | ||
|
|
da0c699b88 | ||
|
|
af5514c982 | ||
|
|
cc689e80a9 | ||
|
|
3b51374678 | ||
|
|
f07569db98 | ||
|
|
c5fcb90683 | ||
|
|
270f87dee1 | ||
|
|
765b8f3b35 | ||
|
|
3ff695899c | ||
|
|
be35baa838 | ||
|
|
470d4d7314 | ||
|
|
0976608a25 | ||
|
|
e7f4dfa07e | ||
|
|
2c68f335c6 | ||
|
|
cf29589e29 | ||
|
|
05cabd8edc | ||
|
|
2f4ecba51a | ||
|
|
74a7c9f23f | ||
|
|
b075a717f7 | ||
|
|
a3dcb888d3 | ||
|
|
02706af508 | ||
|
|
1f2c007776 | ||
|
|
42dfdfef13 | ||
|
|
b40bf0b9e1 | ||
|
|
cc15a9c468 | ||
|
|
5d8b1eecb4 | ||
|
|
093d82d762 | ||
|
|
686577c209 | ||
|
|
c2b333a78b | ||
|
|
cef8b8bf0f | ||
|
|
62a46710ba | ||
|
|
a3e6feb07e | ||
|
|
2ad093d4f1 | ||
|
|
6017d576fc | ||
|
|
08ac38dc87 | ||
|
|
3960daa556 | ||
|
|
395cbd87dd | ||
|
|
23429c047f | ||
|
|
af68748c16 | ||
|
|
18ab007f87 | ||
|
|
63942045be | ||
|
|
a4cdc6e4f3 | ||
|
|
07c611bdbe | ||
|
|
5170139a32 | ||
|
|
90952a68b7 | ||
|
|
1cc14d7b41 | ||
|
|
aa4d6576f7 | ||
|
|
2a46984f9c | ||
|
|
4a9c721c55 | ||
|
|
7be9995c87 | ||
|
|
85529de3f8 | ||
|
|
114ca9d956 | ||
|
|
29f858685a | ||
|
|
4112faf682 | ||
|
|
a13b73c414 | ||
|
|
667e93af8f | ||
|
|
54a888026e | ||
|
|
a0fb02baa1 | ||
|
|
c445b932db | ||
|
|
ffccb1683b | ||
|
|
0d590fc9d6 | ||
|
|
1d4910f947 | ||
|
|
7e457ed961 | ||
|
|
34722c6985 | ||
|
|
c5a13b50ab | ||
|
|
387bd50281 | ||
|
|
81016fd898 | ||
|
|
39fc1612ac | ||
|
|
7ab12a3fff | ||
|
|
b4d869c469 | ||
|
|
799b667828 | ||
|
|
ae3513edf3 | ||
|
|
6db1861b78 | ||
|
|
a8e839ba18 | ||
|
|
b224cf67d1 | ||
|
|
222237ab34 | ||
|
|
433cfe521e | ||
|
|
1e4a776fa5 | ||
|
|
e88a424a7d | ||
|
|
9450c7902d | ||
|
|
75b5883afe | ||
|
|
c26f0184d9 | ||
|
|
cb74b4bbe7 | ||
|
|
326eca656f | ||
|
|
f0a5058b68 | ||
|
|
d39cd36995 | ||
|
|
0548018b5c | ||
|
|
699e10856e | ||
|
|
36b399cb2f | ||
|
|
f613d58727 | ||
|
|
feccfada77 | ||
|
|
4127e66114 | ||
|
|
b2a9b59399 | ||
|
|
6ffed9c2f2 | ||
|
|
53e193d8d1 | ||
|
|
7835691bc9 | ||
|
|
91715541ec | ||
|
|
e3f9e99129 | ||
|
|
4634cbad89 | ||
|
|
5db2a36da0 | ||
|
|
cd7b273da4 | ||
|
|
cbbad1a6e2 | ||
|
|
9661d4efa7 | ||
|
|
31762c23c5 | ||
|
|
c71a6b4d14 | ||
|
|
e8ddb33f21 | ||
|
|
ea368d19bb | ||
|
|
7ab50b6290 | ||
|
|
fd7cfbd3c3 | ||
|
|
90725af10a | ||
|
|
d63a62a921 | ||
|
|
4bc5481941 | ||
|
|
9feede3b0d | ||
|
|
5aad9eda8d | ||
|
|
91b15faba6 | ||
|
|
98c1c61cbd | ||
|
|
4463a5326a | ||
|
|
bdb23513a4 | ||
|
|
ee40f16232 | ||
|
|
a76944b00c | ||
|
|
cf074ff893 | ||
|
|
712eb19db5 | ||
|
|
de9bc9b170 | ||
|
|
95e2404307 | ||
|
|
2c01feb3be | ||
|
|
f416e0d462 | ||
|
|
7f1308c8ce | ||
|
|
fd8d45e437 | ||
|
|
20390342d5 | ||
|
|
c47bf229f0 | ||
|
|
3616e197e1 | ||
|
|
3d8544de21 | ||
|
|
504abb7a15 | ||
|
|
37021533b9 | ||
|
|
59eab84f03 | ||
|
|
e67033711c | ||
|
|
85fd00f020 | ||
|
|
4f064a1625 | ||
|
|
3bf7ada226 | ||
|
|
3b90e17a1e | ||
|
|
c1fb5b72ee | ||
|
|
29da26cc08 | ||
|
|
807fc09b1e | ||
|
|
528e4994db | ||
|
|
c339122f4f | ||
|
|
feeef46354 | ||
|
|
066bd14554 | ||
|
|
bffaa2ab88 | ||
|
|
31495ea67b | ||
|
|
8f2533f1b2 | ||
|
|
13b0742deb | ||
|
|
e2b2d50e5e | ||
|
|
55d61e37f3 | ||
|
|
b995031255 | ||
|
|
d096444ca0 |
@@ -9,7 +9,7 @@ on:
|
||||
|
||||
jobs:
|
||||
upload-qodana-config:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: [self-hosted, Linux, X64, default]
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
|
||||
@@ -9,7 +9,7 @@ on:
|
||||
|
||||
jobs:
|
||||
upload-qodana-config:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: [self-hosted, Linux, X64, default]
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
|
||||
@@ -19,10 +19,12 @@ jobs:
|
||||
runs-on: [self-hosted, Linux, X64, default]
|
||||
env:
|
||||
RELEASE_BASE_URL: https://api-v2.truckwash.io/master/frontend
|
||||
PLAYWRIGHT_BASE_URL: https://api-v2.truckwash.io/master/frontend
|
||||
PLAYWRIGHT_BASE_URL: https://dev.truckwash.io
|
||||
PLAYWRIGHT_RELEASE_STATIC_BASE_URL: https://api-v2.truckwash.io/master/frontend
|
||||
PLAYWRIGHT_RELEASE_API_BASE_URL: https://api-v2.truckwash.io
|
||||
PLAYWRIGHT_RELEASE_API_PING_PATHS: /ping,/master/api/ping,/canary/api/ping,/stable/api/ping
|
||||
PLAYWRIGHT_RELEASE_API_PING_PATHS: /master/api/ping
|
||||
RELEASE_BUILD_ID: ${{ github.run_id }}-${{ github.run_attempt }}
|
||||
RELEASE_EXPECTED_BUILD_ID: ${{ github.run_id }}-${{ github.run_attempt }}
|
||||
RELEASE_EXPECTED_COMMIT: ${{ github.sha }}
|
||||
RELEASE_WAIT_INITIAL_SECONDS: 45
|
||||
RELEASE_WAIT_TIMEOUT_SECONDS: 600
|
||||
@@ -98,7 +100,7 @@ jobs:
|
||||
-X POST "$RELEASE_MANAGER_GATE_URL" \
|
||||
-H "Authorization: Bearer $RELEASE_MANAGER_GATE_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
--data "{\"environment_url\":\"$RELEASE_BASE_URL\",\"channel_slug\":\"stable\",\"app\":\"frontend\",\"repository\":\"$RELEASE_REPOSITORY\",\"branch\":\"$RELEASE_BRANCH\",\"expected_commit\":\"$RELEASE_EXPECTED_COMMIT\",\"workflow_url\":\"$RELEASE_WORKFLOW_URL\",\"auto_sync\":true,\"wait_timeout_seconds\":300,\"poll_interval_seconds\":10,\"required_checks\":[\"static_artifact\",\"api_gateway\"]}"
|
||||
--data "{\"environment_url\":\"$RELEASE_BASE_URL\",\"channel_slug\":\"stable\",\"app\":\"frontend\",\"repository\":\"$RELEASE_REPOSITORY\",\"branch\":\"$RELEASE_BRANCH\",\"expected_commit\":\"$RELEASE_EXPECTED_COMMIT\",\"build_id\":\"$RELEASE_EXPECTED_BUILD_ID\",\"workflow_url\":\"$RELEASE_WORKFLOW_URL\",\"auto_sync\":true,\"wait_timeout_seconds\":300,\"poll_interval_seconds\":10,\"required_checks\":[\"static_artifact\",\"api_gateway\"]}"
|
||||
env:
|
||||
RELEASE_MANAGER_GATE_URL: ${{ secrets.RELEASE_MANAGER_GATE_URL || 'https://api.truckwash.io/release/gate/test-runs' }}
|
||||
RELEASE_MANAGER_GATE_TOKEN: ${{ secrets.RELEASE_MANAGER_GATE_TOKEN }}
|
||||
|
||||
@@ -16,7 +16,7 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
format-tests:
|
||||
# Match the labels exposed by the Coolify-managed GitHub runner.
|
||||
# CI runs on the repository's self-hosted runner pool.
|
||||
runs-on: [self-hosted, Linux, X64, default]
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"version": 1,
|
||||
"setupCompletedAt": "2026-06-07T11:37:04.444Z"
|
||||
}
|
||||
@@ -38,6 +38,14 @@ To use another remote API route:
|
||||
$env:VITE_API_PROXY_BASE_PATH="/canary/api"; npm run dev
|
||||
```
|
||||
|
||||
TLS certificate validation is enabled for proxied HTTPS APIs by default. If you
|
||||
are using a trusted local HTTPS API with a self-signed certificate, you can opt
|
||||
out explicitly:
|
||||
|
||||
```powershell
|
||||
$env:VITE_API_PROXY_TARGET="https://local-api.test"; $env:VITE_API_PROXY_SECURE="false"; npm run dev
|
||||
```
|
||||
|
||||
For compatible local gateways that expect the `/api` prefix to be preserved:
|
||||
|
||||
```powershell
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
# User wash start production-readiness QA matrix
|
||||
|
||||
This note documents deterministic coverage for the self-serve user wash start flow. The `@dynamic-image` Playwright case remains separated because it validates rendered image behavior in addition to deterministic state and API transitions.
|
||||
|
||||
## Unit matrix
|
||||
|
||||
| Area | Required scenario | Coverage |
|
||||
| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `useWashFlowState` | Step transitions for vehicle, questions, lane, tasks, in-progress, completed | `tests/unit/use-wash-flow-state-production.spec.js` validates step clickability, next-button gating, questions-to-lane updates, lane-to-start target selection, task completion transition, in-progress navigation, and completed-step non-clickability contract. |
|
||||
| `useWashSessionActions` | START success and failure | `tests/unit/use-wash-session-actions-production.spec.js` covers successful manual START state mutation and failed START retryability without active-wash mutation. |
|
||||
| `useWashSessionActions` | Relay enable success and relay enable failure with STOP rollback | `tests/unit/use-wash-session-actions-production.spec.js` covers machine relay success and relay failure rollback via STOP. |
|
||||
| `useWashSessionActions` | STOP failure and already-stopped STOP recovery | `tests/unit/use-wash-session-actions-production.spec.js` covers failed STOP preserving active state and already-not-occupied STOP clearing local state. |
|
||||
| `useSelfServeLogic` | Preview/summary merge | `tests/unit/use-self-serve-logic-production.spec.js` covers preview questions merging with summary questions, answer maps, visible question order, tasks, and allowed services. |
|
||||
| `useSelfServeLogic` | Request race handling | `tests/unit/use-self-serve-logic-production.spec.js` covers stale preview/summary responses being ignored when a newer request wins. |
|
||||
| `useSelfServeLogic` | Allowed services updates | `tests/unit/use-self-serve-logic-production.spec.js` covers lane allowed-service endpoint updates and first-failure fallback behavior. |
|
||||
| `useSelfServeLogic` | Answer sync failures | `tests/unit/use-self-serve-logic-production.spec.js` covers error reporting while preserving the caller's optimistic local answer. |
|
||||
| `MyWashStart.vue` | Local progress restore, server active-wash restore, recent-completion suppression, unmount cleanup, duplicate-fetch prevention | `tests/unit/my-wash-start-production.spec.js` locks the component contracts for restore ordering, authenticated active-wash application, recent-completion suppression across restore/polling, unmount cleanup, and duplicate-fetch/sync guards. |
|
||||
|
||||
## E2E mocked matrix
|
||||
|
||||
| Required scenario | Coverage |
|
||||
| -------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Full manual flow | `tests/e2e/self-serve-wash.spec.js` covers direct manual wash start, guided completion, STOP, exit-gate open, completed state, and close/reset. |
|
||||
| Full machine flow | `tests/e2e/self-serve-wash.spec.js` covers machine task rendering, dynamic image progress, required task completion, machine start path, and reload persistence. |
|
||||
| Reload/resume active wash | `tests/e2e/self-serve-wash.spec.js` covers local progress reload and authenticated server active-wash resume from another device. |
|
||||
| Backend says wash completed during polling | `tests/e2e/self-serve-wash.spec.js` covers server polling of active wash and local transition to completed when the backend no longer reports the matching in-progress wash. |
|
||||
| Network failures for preview, answer sync, START, relay enable, STOP | `tests/e2e/self-serve-wash.spec.js` covers retryable START failure, STOP failure preservation, allowed-services gateway timeout/retry, answer sync background resilience, and unit-level relay rollback. Add mocked network route overrides when expanding browser-level failure assertions. |
|
||||
|
||||
## Optional live smoke
|
||||
|
||||
| Optional scenario | Coverage |
|
||||
| ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Dynamic image smoke | The `@dynamic-image` Playwright test in `tests/e2e/self-serve-wash.spec.js` is tagged separately from deterministic CI coverage so it can be included or excluded explicitly with Playwright grep controls. |
|
||||
+13
-4
@@ -2,10 +2,19 @@
|
||||
<html lang="" class="theme-light" data-theme="light">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" type="image/png" href="/assets/favicons/favicon-96x96.png" sizes="96x96" />
|
||||
<link rel="icon" type="image/svg+xml" href="/assets/favicons/favicon.svg" />
|
||||
<link rel="shortcut icon" href="/assets/favicons/favicon.ico" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/assets/favicons/apple-touch-icon.png" />
|
||||
<script>
|
||||
(function () {
|
||||
var match = window.location.pathname.match(/^\/[^/]+\/frontend(?:\/|$)/);
|
||||
var href = match ? match[0].replace(/\/+$/, '') + '/' : '/';
|
||||
var base = document.createElement('base');
|
||||
base.href = href;
|
||||
document.currentScript.after(base);
|
||||
})();
|
||||
</script>
|
||||
<link rel="icon" type="image/png" href="%BASE_URL%assets/favicons/favicon-96x96.png" sizes="96x96" />
|
||||
<link rel="icon" type="image/svg+xml" href="%BASE_URL%assets/favicons/favicon.svg" />
|
||||
<link rel="shortcut icon" href="%BASE_URL%assets/favicons/favicon.ico" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="%BASE_URL%assets/favicons/apple-touch-icon.png" />
|
||||
<meta name="apple-mobile-web-app-title" content="Truck Wash Kundeportal" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
|
||||
@@ -5,22 +5,31 @@ server {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
add_header Content-Security-Policy "frame-ancestors 'self'" always;
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
|
||||
location ~ ^/(release-entry|release-manifest)\.json$ {
|
||||
add_header Cache-Control "no-store";
|
||||
add_header Content-Security-Policy "frame-ancestors 'self'" always;
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location ~ ^/(master|beta|canary|internal)/frontend/(release-entry|release-manifest)\.json$ {
|
||||
add_header Cache-Control "no-store";
|
||||
add_header Content-Security-Policy "frame-ancestors 'self'" always;
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
try_files /$2.json =404;
|
||||
}
|
||||
|
||||
location ~ ^/(?:.+/)?(?<static_asset_path>(?:assets|resources|favicons|icons|img|sounds|\.well-known)/.+)$ {
|
||||
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||
add_header Content-Security-Policy "frame-ancestors 'self'" always;
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
try_files /$static_asset_path =404;
|
||||
}
|
||||
|
||||
location ~ ^/(?:.+/)?(?<static_file_path>manifest\.json|manifest\.webmanifest|favicon\.ico|favicon_default\.ico|pleno-favicon\.ico|release-entry\.json|release-manifest\.json|registerSW\.js|sw\.js|workbox-[^/]+\.js)$ {
|
||||
location ~ ^/(?:.+/)?(?<static_file_path>index\.html|manifest\.json|manifest\.webmanifest|favicon\.ico|favicon_default\.ico|pleno-favicon\.ico|release-entry\.json|release-manifest\.json|registerSW\.js|sw\.js|workbox-[^/]+\.js)$ {
|
||||
try_files /$static_file_path =404;
|
||||
}
|
||||
|
||||
@@ -30,11 +39,15 @@ server {
|
||||
|
||||
location /assets/ {
|
||||
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||
add_header Content-Security-Policy "frame-ancestors 'self'" always;
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location ~ ^/(master|beta|canary|internal)/frontend/assets/ {
|
||||
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||
add_header Content-Security-Policy "frame-ancestors 'self'" always;
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
rewrite ^/(master|beta|canary|internal)/frontend/(.*)$ /$2 break;
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
+113
-19
@@ -4624,7 +4624,7 @@ paths:
|
||||
tags:
|
||||
- Self-Serve
|
||||
summary: Add vehicle condition
|
||||
description: Add a new vehicle condition (answer to a question). Customers can only add conditions for their own vehicles.
|
||||
description: Add a new vehicle condition (answer to a question). Customers can only add conditions for their own vehicles. This answer mutation does not activate machines or synchronize live relay state; hardware changes are handled only by the explicit wash start flow.
|
||||
operationId: addSelfserveVehicleCondition
|
||||
requestBody:
|
||||
required: true
|
||||
@@ -4659,14 +4659,6 @@ paths:
|
||||
type: integer
|
||||
nullable: true
|
||||
description: Alias for vehicle_type.
|
||||
activate_machine:
|
||||
type: boolean
|
||||
default: true
|
||||
description: Whether the session synchronization may enable the machine relay. User wash-start saves answers with false.
|
||||
sync_relay_state:
|
||||
type: boolean
|
||||
default: true
|
||||
description: Whether the answer mutation should synchronize live relay state.
|
||||
responses:
|
||||
'200':
|
||||
description: Successfully added vehicle condition
|
||||
@@ -4683,7 +4675,7 @@ paths:
|
||||
tags:
|
||||
- Self-Serve
|
||||
summary: Update vehicle condition
|
||||
description: Update an existing vehicle condition. Customers can only update conditions for their own vehicles.
|
||||
description: Update an existing vehicle condition. Customers can only update conditions for their own vehicles. This answer mutation does not activate machines or synchronize live relay state; hardware changes are handled only by the explicit wash start flow.
|
||||
operationId: updateSelfserveVehicleCondition
|
||||
parameters:
|
||||
- name: id
|
||||
@@ -4719,14 +4711,6 @@ paths:
|
||||
type: integer
|
||||
nullable: true
|
||||
description: Alias for vehicle_type.
|
||||
activate_machine:
|
||||
type: boolean
|
||||
default: true
|
||||
description: Whether the session synchronization may enable the machine relay.
|
||||
sync_relay_state:
|
||||
type: boolean
|
||||
default: true
|
||||
description: Whether the mutation should synchronize live relay state.
|
||||
responses:
|
||||
'200':
|
||||
description: Successfully updated vehicle condition
|
||||
@@ -5402,7 +5386,7 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [department, gateway_id, action]
|
||||
required: [department, gateway_id, action, confirm]
|
||||
properties:
|
||||
department: { type: integer }
|
||||
gateway_id: { type: integer }
|
||||
@@ -8704,6 +8688,116 @@ paths:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SelfServeLaneStatus'
|
||||
|
||||
/modules/self-serve/lane/wash/my-active-wash:
|
||||
get:
|
||||
tags:
|
||||
- Modules
|
||||
summary: Get the authenticated customer's active self-serve wash
|
||||
description: |
|
||||
Returns the current authenticated customer's open self-serve wash session,
|
||||
if one exists. Regular customers must only receive their own active wash
|
||||
details from this endpoint.
|
||||
operationId: getMyActiveSelfServeWash
|
||||
responses:
|
||||
'200':
|
||||
description: Authenticated customer's active wash details resolved
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
lane_id:
|
||||
type: integer
|
||||
nullable: true
|
||||
in_progress:
|
||||
type: boolean
|
||||
session:
|
||||
type: object
|
||||
nullable: true
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
lane_id:
|
||||
type: integer
|
||||
nullable: true
|
||||
department_id:
|
||||
type: integer
|
||||
nullable: true
|
||||
status:
|
||||
type: string
|
||||
reg:
|
||||
type: string
|
||||
customer_number:
|
||||
type: integer
|
||||
nullable: true
|
||||
vehicle_id:
|
||||
type: integer
|
||||
nullable: true
|
||||
vehicle_type_id:
|
||||
type: integer
|
||||
nullable: true
|
||||
included_minutes:
|
||||
type: integer
|
||||
nullable: true
|
||||
machine_type_id:
|
||||
type: integer
|
||||
nullable: true
|
||||
machine_relay_enabled:
|
||||
type: boolean
|
||||
machine_relay_enabled_at:
|
||||
type: string
|
||||
nullable: true
|
||||
machine_start_triggered:
|
||||
type: boolean
|
||||
machine_start_triggered_at:
|
||||
type: string
|
||||
nullable: true
|
||||
wash_started_at:
|
||||
type: string
|
||||
nullable: true
|
||||
created_at:
|
||||
type: string
|
||||
updated_at:
|
||||
type: string
|
||||
nullable: true
|
||||
customer:
|
||||
type: object
|
||||
nullable: true
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
nullable: true
|
||||
customer_number:
|
||||
type: integer
|
||||
nullable: true
|
||||
display_name:
|
||||
type: string
|
||||
nullable: true
|
||||
email:
|
||||
type: string
|
||||
nullable: true
|
||||
phone_country_code:
|
||||
type: integer
|
||||
nullable: true
|
||||
phone:
|
||||
type: string
|
||||
nullable: true
|
||||
vehicle:
|
||||
type: object
|
||||
nullable: true
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
customer_id:
|
||||
type: integer
|
||||
type:
|
||||
type: integer
|
||||
reg:
|
||||
type: string
|
||||
reference:
|
||||
type: string
|
||||
nullable: true
|
||||
|
||||
/modules/self-serve/lane/wash/in-progress:
|
||||
get:
|
||||
tags:
|
||||
|
||||
Generated
-1
@@ -7,7 +7,6 @@
|
||||
"": {
|
||||
"name": "truckwashdashboardsfrontend",
|
||||
"version": "0.0.0",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@azure/msal-browser": "^4.12.0",
|
||||
"@bubblewrap/cli": "^1.23.0",
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
"format:tests:commit": "node scripts/pre-commit-format-tests.mjs",
|
||||
"format:tests:check": "prettier --check \"tests/**/*.{js,ts}\"",
|
||||
"prepare": "node scripts/prepare-husky.mjs",
|
||||
"postinstall": "node scripts/postinstall-sync-playwright-root-links.mjs",
|
||||
"preview": "vite preview",
|
||||
"preview:prod": "npm run build && npm run preview -- --host 127.0.0.1 --port 4173",
|
||||
"text:fix-encoding": "node scripts/text-encoding.mjs fix",
|
||||
|
||||
+4
-4
@@ -23,17 +23,17 @@
|
||||
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
RewriteCond %{REQUEST_FILENAME} !-d
|
||||
RewriteRule ^.+/((?:manifest\.json|manifest\.webmanifest|favicon\.ico|favicon_default\.ico|pleno-favicon\.ico|release-entry\.json|release-manifest\.json|registerSW\.js|sw\.js|workbox-[^/]+\.js))$ $1 [L]
|
||||
RewriteRule ^.+/((?:index\.html|manifest\.json|manifest\.webmanifest|favicon\.ico|favicon_default\.ico|pleno-favicon\.ico|release-entry\.json|release-manifest\.json|registerSW\.js|sw\.js|workbox-[^/]+\.js))$ $1 [L]
|
||||
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
RewriteCond %{REQUEST_FILENAME} !-d
|
||||
RewriteCond %{DOCUMENT_ROOT}/public/$1 -f
|
||||
RewriteRule ^(?:.*?/)?((?:manifest\.json|manifest\.webmanifest|favicon\.ico|favicon_default\.ico|pleno-favicon\.ico|release-entry\.json|release-manifest\.json|registerSW\.js|sw\.js|workbox-[^/]+\.js))$ public/$1 [L]
|
||||
RewriteRule ^(?:.*?/)?((?:index\.html|manifest\.json|manifest\.webmanifest|favicon\.ico|favicon_default\.ico|pleno-favicon\.ico|release-entry\.json|release-manifest\.json|registerSW\.js|sw\.js|workbox-[^/]+\.js))$ public/$1 [L]
|
||||
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
RewriteCond %{REQUEST_FILENAME} !-d
|
||||
RewriteCond %{DOCUMENT_ROOT}/dist/$1 -f
|
||||
RewriteRule ^(?:.*?/)?((?:manifest\.json|manifest\.webmanifest|favicon\.ico|favicon_default\.ico|pleno-favicon\.ico|release-entry\.json|release-manifest\.json|registerSW\.js|sw\.js|workbox-[^/]+\.js))$ dist/$1 [L]
|
||||
RewriteRule ^(?:.*?/)?((?:index\.html|manifest\.json|manifest\.webmanifest|favicon\.ico|favicon_default\.ico|pleno-favicon\.ico|release-entry\.json|release-manifest\.json|registerSW\.js|sw\.js|workbox-[^/]+\.js))$ dist/$1 [L]
|
||||
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
RewriteCond %{REQUEST_FILENAME} !-d
|
||||
@@ -41,7 +41,7 @@
|
||||
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
RewriteCond %{REQUEST_FILENAME} !-d
|
||||
RewriteRule ^(?:.*?/)?(?:manifest\.json|manifest\.webmanifest|favicon\.ico|favicon_default\.ico|pleno-favicon\.ico|release-entry\.json|release-manifest\.json|registerSW\.js|sw\.js|workbox-[^/]+\.js)$ - [R=404,L]
|
||||
RewriteRule ^(?:.*?/)?(?:index\.html|manifest\.json|manifest\.webmanifest|favicon\.ico|favicon_default\.ico|pleno-favicon\.ico|release-entry\.json|release-manifest\.json|registerSW\.js|sw\.js|workbox-[^/]+\.js)$ - [R=404,L]
|
||||
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
RewriteCond %{REQUEST_FILENAME} !-d
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
|
||||
const helperScriptPath = path.resolve(process.cwd(), "..", "scripts", "sync-playwright-root-links.mjs");
|
||||
|
||||
if (!fs.existsSync(helperScriptPath)) {
|
||||
console.log(
|
||||
`Skipping root Playwright link sync: helper script not found at ${helperScriptPath}.`
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const result = spawnSync(process.execPath, [helperScriptPath], {
|
||||
stdio: "inherit",
|
||||
});
|
||||
|
||||
if (typeof result.status === "number") {
|
||||
process.exit(result.status);
|
||||
}
|
||||
|
||||
process.exit(1);
|
||||
@@ -23,20 +23,52 @@ if [[ -z "$DEPLOY_URL" ]]; then
|
||||
echo "Set RELEASE_DEPLOY_URL or RELEASE_DEPLOY_HOST, RELEASE_DEPLOY_USER, and RELEASE_DEPLOY_PASSWORD." >&2
|
||||
exit 1
|
||||
fi
|
||||
DEPLOY_URL="ftp://${USER_NAME}:${PASSWORD}@${HOST}"
|
||||
DEPLOY_URL="ftp://${HOST}"
|
||||
elif [[ -n "$USER_NAME" || -n "$PASSWORD" ]]; then
|
||||
if [[ -z "$USER_NAME" || -z "$PASSWORD" ]]; then
|
||||
echo "Set both RELEASE_DEPLOY_USER and RELEASE_DEPLOY_PASSWORD when providing deploy credentials separately." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
lftp_quote() {
|
||||
local value="${1//\'/\'\\\'\'}"
|
||||
printf "'%s'" "$value"
|
||||
}
|
||||
|
||||
run_lftp() {
|
||||
local transfer_command="$1"
|
||||
|
||||
{
|
||||
printf 'set ftp:ssl-allow true\n'
|
||||
printf 'set ftp:ssl-force true\n'
|
||||
printf 'set ftp:ssl-protect-data true\n'
|
||||
printf 'set net:max-retries 3\n'
|
||||
printf 'set net:timeout 20\n'
|
||||
|
||||
if [[ -n "$USER_NAME" && -n "$PASSWORD" ]]; then
|
||||
printf 'open -u %s,%s %s\n' "$(lftp_quote "$USER_NAME")" "$(lftp_quote "$PASSWORD")" "$(lftp_quote "$DEPLOY_URL")"
|
||||
else
|
||||
printf 'open %s\n' "$(lftp_quote "$DEPLOY_URL")"
|
||||
fi
|
||||
|
||||
printf 'cd %s\n' "$(lftp_quote "$REMOTE_ROOT")"
|
||||
printf '%s\n' "$transfer_command"
|
||||
printf 'bye\n'
|
||||
} | lftp -f /dev/stdin
|
||||
}
|
||||
|
||||
upload_file() {
|
||||
local source_file="$1"
|
||||
local remote_file="$2"
|
||||
if [[ -f "$source_file" ]]; then
|
||||
lftp "$DEPLOY_URL" -e "set ftp:ssl-allow true; set net:max-retries 3; set net:timeout 20; cd \"$REMOTE_ROOT\"; put -O \"$(dirname "$remote_file")\" \"$source_file\" -o \"$(basename "$remote_file")\"; bye"
|
||||
run_lftp "put -O $(lftp_quote "$(dirname "$remote_file")") $(lftp_quote "$source_file") -o $(lftp_quote "$(basename "$remote_file")")"
|
||||
fi
|
||||
}
|
||||
|
||||
for directory in assets resources favicons icons img sounds .well-known; do
|
||||
if [[ -d "$DIST_DIR/$directory" ]]; then
|
||||
lftp "$DEPLOY_URL" -e "set ftp:ssl-allow true; set net:max-retries 3; set net:timeout 20; cd \"$REMOTE_ROOT\"; mirror -R --only-newer --parallel=4 \"$DIST_DIR/$directory\" \"$directory\"; bye"
|
||||
run_lftp "mirror -R --only-newer --parallel=4 $(lftp_quote "$DIST_DIR/$directory") $(lftp_quote "$directory")"
|
||||
fi
|
||||
done
|
||||
|
||||
|
||||
@@ -120,7 +120,7 @@ async function verifyShell(baseUrl, shellPath) {
|
||||
|
||||
async function verifyRelease(baseUrl) {
|
||||
const expectedCommit = process.env.RELEASE_EXPECTED_COMMIT || process.env.GITHUB_SHA || "";
|
||||
const expectedBuildId = process.env.RELEASE_EXPECTED_BUILD_ID || "";
|
||||
const expectedBuildId = process.env.RELEASE_EXPECTED_BUILD_ID || process.env.RELEASE_BUILD_ID || "";
|
||||
const manifest = await fetchJson(baseUrl, "release-manifest.json");
|
||||
const releaseEntry = await fetchJson(baseUrl, "release-entry.json");
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ export const ownedFilesByRole = {
|
||||
"userHome.spec.ts",
|
||||
"userInvoices.spec.ts",
|
||||
"userMyWashStart.spec.ts",
|
||||
"userMyWashStartFlow.spec.ts",
|
||||
"userProfileInvoicing.spec.ts",
|
||||
"userProfileNotifications.spec.ts",
|
||||
"userProfileSecurity.spec.ts",
|
||||
|
||||
@@ -97,6 +97,10 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
allowBookingDeletion: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
department_lane_id: {
|
||||
type: Number,
|
||||
default: null,
|
||||
@@ -1752,6 +1756,11 @@ const acceptSelfServeWashDraft = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const canDeleteOrderBooking = computed(() =>
|
||||
!props.order_id &&
|
||||
(props.allowBookingDeletion || SessionUser.canAccessAdmin() || SessionUser.canAccessSuperUser())
|
||||
);
|
||||
|
||||
const flatBuiltInMenuSections = computed(() => {
|
||||
const sections = [];
|
||||
|
||||
@@ -1806,7 +1815,7 @@ const flatBuiltInMenuSections = computed(() => {
|
||||
),
|
||||
})
|
||||
: null,
|
||||
!props.order_id
|
||||
canDeleteOrderBooking.value
|
||||
? buildMenuAction("booking-delete", {
|
||||
icon: "fas fa-trash-alt",
|
||||
label: t("admin.pos.settings_wheel.delete_booking"),
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
selectPreferredStripeTerminalReaderId,
|
||||
STRIPE_TERMINAL_STATUS,
|
||||
} from "@/components/displays/department/pos/displays/stripeTerminalReaders.js";
|
||||
import { normalizeStripeInvoice } from "@/components/displays/department/pos/displays/stripeEmailInvoice.js";
|
||||
|
||||
const POLLING_INTERVAL_MS = 5000;
|
||||
const STRIPE_TERMINAL_SETUP_REQUIRED_CODE = 'stripe_terminal_setup_required';
|
||||
@@ -87,29 +88,6 @@ const selectedTaxRate = ref(1);
|
||||
const paymentIntent = computed(() => StripeModule.paymentIntents.paymentIntent.value);
|
||||
const isTerminalPaymentCaptured = computed(() => StripeModule.paymentIntents.isPaymentIntentAmountReceived(paymentIntent.value));
|
||||
|
||||
const normalizeStripeInvoice = (value) => {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const invoiceId = value.invoice_id || value.id || null;
|
||||
if (!invoiceId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: value.id ?? props.order_id,
|
||||
invoice_id: invoiceId,
|
||||
customer_id: value.customer_id ?? null,
|
||||
url: value.url || value.hosted_invoice_url || null,
|
||||
created_at: value.created_at || null,
|
||||
paid: Boolean(value.paid),
|
||||
status: value.status || 'unknown',
|
||||
amount_due: Number(value.amount_due ?? 0),
|
||||
amount_paid: Number(value.amount_paid ?? 0),
|
||||
};
|
||||
};
|
||||
|
||||
const hasStripeEmailInvoice = computed(() => stripeInvoice.value !== null);
|
||||
const isStripeEmailInvoicePaid = computed(() => stripeInvoice.value?.paid === true);
|
||||
const isStripeEmailInvoiceTerminalState = computed(() => {
|
||||
@@ -202,7 +180,7 @@ const loadStripeInvoiceState = async () => {
|
||||
|
||||
try {
|
||||
const response = await getOrder(props.order_id, true);
|
||||
const nextInvoice = normalizeStripeInvoice(response?.data?.includes?.stripeModuleOrders);
|
||||
const nextInvoice = normalizeStripeInvoice(response?.data?.includes?.stripeModuleOrders, props.order_id);
|
||||
stripeInvoice.value = nextInvoice;
|
||||
if (nextInvoice) {
|
||||
emailPanelState.value = 'tracking';
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
const STRIPE_INVOICE_PAID_STATUS = 'paid';
|
||||
|
||||
export const parseStripeInvoicePaidFlag = (value) => {
|
||||
if (typeof value === 'boolean') {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (typeof value === 'number') {
|
||||
return value === 1;
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
const normalizedValue = value.trim().toLowerCase();
|
||||
if (['true', '1'].includes(normalizedValue)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (['false', '0', ''].includes(normalizedValue)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const toFiniteNumber = (value, fallback = 0) => {
|
||||
const parsedValue = Number(value ?? fallback);
|
||||
return Number.isFinite(parsedValue) ? parsedValue : fallback;
|
||||
};
|
||||
|
||||
export const normalizeStripeInvoice = (value, fallbackOrderId = null) => {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const invoiceId = value.invoice_id || value.id || null;
|
||||
if (!invoiceId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const status = String(value.status || 'unknown').toLowerCase();
|
||||
const amountDue = toFiniteNumber(value.amount_due);
|
||||
const amountPaid = toFiniteNumber(value.amount_paid);
|
||||
const hasCoveredAmountDue = amountDue <= 0 || amountPaid >= amountDue;
|
||||
const isPaid = status === STRIPE_INVOICE_PAID_STATUS
|
||||
&& parseStripeInvoicePaidFlag(value.paid)
|
||||
&& hasCoveredAmountDue;
|
||||
|
||||
return {
|
||||
id: value.id ?? fallbackOrderId,
|
||||
invoice_id: invoiceId,
|
||||
customer_id: value.customer_id ?? null,
|
||||
url: value.url || value.hosted_invoice_url || null,
|
||||
created_at: value.created_at || null,
|
||||
paid: isPaid,
|
||||
status,
|
||||
amount_due: amountDue,
|
||||
amount_paid: amountPaid,
|
||||
};
|
||||
};
|
||||
@@ -10,6 +10,16 @@ import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
const acceptedOrderAttachmentFileTypes = "image/*,application/pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx";
|
||||
const imageExtensions = [".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".svg"];
|
||||
const officeExtensions = [".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx"];
|
||||
const safePreviewBlobTypesByKind = {
|
||||
image: {
|
||||
fallback: "image/png",
|
||||
allowed: new Set(["image/png", "image/jpeg", "image/gif", "image/webp", "image/bmp", "image/svg+xml"]),
|
||||
},
|
||||
document: {
|
||||
fallback: "application/pdf",
|
||||
allowed: new Set(["application/pdf"]),
|
||||
},
|
||||
};
|
||||
|
||||
const props = defineProps({
|
||||
order: {
|
||||
@@ -324,7 +334,21 @@ const hasCachedPreviewSource = (attachmentId) => {
|
||||
return Object.prototype.hasOwnProperty.call(previewSourcesById.value, attachmentId);
|
||||
};
|
||||
|
||||
const createEmbeddablePreviewUrl = async (downloadLink) => {
|
||||
const createSafePreviewBlob = (fileBlob, previewKind) => {
|
||||
const previewBlobTypes = safePreviewBlobTypesByKind[previewKind];
|
||||
if (!previewBlobTypes) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedBlobType = String(fileBlob.type || "").toLowerCase();
|
||||
const safeBlobType = previewBlobTypes.allowed.has(normalizedBlobType)
|
||||
? normalizedBlobType
|
||||
: previewBlobTypes.fallback;
|
||||
|
||||
return new Blob([fileBlob], { type: safeBlobType });
|
||||
};
|
||||
|
||||
const createEmbeddablePreviewUrl = async (downloadLink, previewKind) => {
|
||||
if (!downloadLink) {
|
||||
return null;
|
||||
}
|
||||
@@ -340,7 +364,12 @@ const createEmbeddablePreviewUrl = async (downloadLink) => {
|
||||
return null;
|
||||
}
|
||||
|
||||
const objectUrl = URL.createObjectURL(fileBlob);
|
||||
const safePreviewBlob = createSafePreviewBlob(fileBlob, previewKind);
|
||||
if (!safePreviewBlob) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const objectUrl = URL.createObjectURL(safePreviewBlob);
|
||||
generatedObjectUrls.add(objectUrl);
|
||||
return objectUrl;
|
||||
} catch (error) {
|
||||
@@ -372,7 +401,7 @@ const ensurePreviewSource = async (attachment) => {
|
||||
attachment.id,
|
||||
false
|
||||
);
|
||||
const previewSource = await createEmbeddablePreviewUrl(downloadLink);
|
||||
const previewSource = await createEmbeddablePreviewUrl(downloadLink, previewKind);
|
||||
previewSourcesById.value = {
|
||||
...previewSourcesById.value,
|
||||
[attachment.id]: previewSource,
|
||||
@@ -517,6 +546,7 @@ const toggleDropdown = async () => {
|
||||
:src="activePreviewSource"
|
||||
class="order-attachments-preview-panel__document"
|
||||
title="Attachment preview"
|
||||
sandbox
|
||||
></iframe>
|
||||
<a
|
||||
v-else-if="activePreviewKind === 'link'"
|
||||
|
||||
@@ -92,6 +92,7 @@ const duplicateDetailsExpanded = ref(false);
|
||||
const pendingNextResolution = ref(false);
|
||||
const isDesktopLastWashCopying = ref(false);
|
||||
let desktopStep1CoordinationPromise = Promise.resolve({ canProceed: true });
|
||||
let focusOnReg1TimeoutId = null;
|
||||
const isDesktopStep1Active = computed(() => getCurrentStep() === 1);
|
||||
|
||||
const setTab = (tab) => {
|
||||
@@ -117,7 +118,16 @@ watch(
|
||||
);
|
||||
|
||||
const focusOnReg1 = () => {
|
||||
setTimeout(() => {
|
||||
if (focusOnReg1TimeoutId !== null) {
|
||||
clearTimeout(focusOnReg1TimeoutId);
|
||||
}
|
||||
|
||||
focusOnReg1TimeoutId = setTimeout(() => {
|
||||
focusOnReg1TimeoutId = null;
|
||||
if (typeof document === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
const reg1Input = document.getElementById("reg_1");
|
||||
if (reg1Input) {
|
||||
reg1Input.focus();
|
||||
@@ -267,12 +277,8 @@ const bookingSelectionObjects = computed(() => {
|
||||
const contentSegments = [
|
||||
`${t("admin.pos.order_booking_selector.customer_label")}: ${booking?.customer_name || t("admin.pos.not_found")}`,
|
||||
`${t("admin.pos.order_booking_selector.plates_label")}: ${plateText || t("admin.pos.not_found")}`,
|
||||
`${t("common.reference")}: ${
|
||||
getOrderBookingReferenceValue(booking) || t("admin.pos.not_found")
|
||||
}`,
|
||||
`${t("common.services")}: ${
|
||||
getOrderBookingServiceText(booking) || t("admin.pos.not_found")
|
||||
}`,
|
||||
`${t("common.reference")}: ${getOrderBookingReferenceValue(booking) || t("admin.pos.not_found")}`,
|
||||
`${t("common.services")}: ${getOrderBookingServiceText(booking) || t("admin.pos.not_found")}`,
|
||||
];
|
||||
|
||||
return {
|
||||
@@ -681,6 +687,10 @@ onMounted(() => {
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (focusOnReg1TimeoutId !== null) {
|
||||
clearTimeout(focusOnReg1TimeoutId);
|
||||
focusOnReg1TimeoutId = null;
|
||||
}
|
||||
clearDesktopStep1PreflightHandler(handleDesktopStep1Preflight);
|
||||
});
|
||||
|
||||
@@ -752,11 +762,7 @@ watch(
|
||||
<ButtonsBox class="pos-actions pos-actions--stacked">
|
||||
<Cancel tabindex="2" class="is-fullwidth" />
|
||||
</ButtonsBox>
|
||||
<div
|
||||
v-if="shouldShowActionRailControls"
|
||||
class="pos-shell-actions__rail"
|
||||
data-testid="pos-step-1-action-rail"
|
||||
>
|
||||
<div v-if="shouldShowActionRailControls" class="pos-shell-actions__rail" data-testid="pos-step-1-action-rail">
|
||||
<PosDesktopDuplicateWarning
|
||||
v-if="shouldShowDuplicateWarningInActionRail"
|
||||
:title="t('admin.pos.warning')"
|
||||
|
||||
+2
-9
@@ -299,14 +299,12 @@ const getSelectedBookingId = () => {
|
||||
|
||||
const completeStep2Order = async ({
|
||||
bookingSafetySeal = null,
|
||||
markOrderCompleted = false,
|
||||
}: {
|
||||
bookingSafetySeal?: string | null;
|
||||
markOrderCompleted?: boolean;
|
||||
} = {}) => {
|
||||
await finalizeCurrentMobileOrder({
|
||||
bookingSafetySeal,
|
||||
markOrderCompleted,
|
||||
markOrderCompleted: true,
|
||||
});
|
||||
popups.select("completed_transaction", {
|
||||
message: `Order #${order_id.value} successfully created.`,
|
||||
@@ -327,14 +325,12 @@ const step2 = async () => {
|
||||
const resolvedSafetySeal = getResolvedMobileSafetySeal();
|
||||
const hasResolvedSafetySeal = isNonEmptyString(resolvedSafetySeal);
|
||||
const requiresBookingCompletionPopup = Boolean(selectedBookingId && hasWashCertificateInBasket);
|
||||
const shouldMarkOrderAsCompleted = !selectedBookingId && hasWashCertificateInBasket;
|
||||
|
||||
try {
|
||||
if (requiresBookingCompletionPopup && hasResolvedSafetySeal) {
|
||||
syncMobileSafetySealState(resolvedSafetySeal);
|
||||
await completeStep2Order({
|
||||
bookingSafetySeal: resolvedSafetySeal,
|
||||
markOrderCompleted: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -344,7 +340,6 @@ const step2 = async () => {
|
||||
syncMobileSafetySealState(safetySeal);
|
||||
await completeStep2Order({
|
||||
bookingSafetySeal: String(safetySeal ?? ""),
|
||||
markOrderCompleted: false,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -358,9 +353,7 @@ const step2 = async () => {
|
||||
return;
|
||||
}
|
||||
|
||||
await completeStep2Order({
|
||||
markOrderCompleted: shouldMarkOrderAsCompleted,
|
||||
});
|
||||
await completeStep2Order();
|
||||
} catch (error: any) {
|
||||
errors.value.push(error);
|
||||
console.warn("An error occurred while completing the mobile order:", error);
|
||||
|
||||
@@ -3,6 +3,45 @@ export const XLVASK_USAGE_AMOUNT_CACHE_TTL_MS = 10 * 60 * 1000;
|
||||
const CACHE_PREFIX = "xlvask-usage-amount:";
|
||||
const memoryCache = new Map();
|
||||
|
||||
const getStorageValue = (key) => {
|
||||
try {
|
||||
if (typeof window === "undefined" || !window.localStorage) {
|
||||
return "";
|
||||
}
|
||||
return window.localStorage.getItem(key) || "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
const hashCacheScopePart = (value) => {
|
||||
let hash = 5381;
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
hash = ((hash << 5) + hash) ^ value.charCodeAt(index);
|
||||
}
|
||||
return (hash >>> 0).toString(36);
|
||||
};
|
||||
|
||||
const getAuthenticatedCacheScope = () => {
|
||||
const token = getStorageValue("token");
|
||||
if (!token) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return [
|
||||
hashCacheScopePart(token),
|
||||
getStorageValue("is_subuser") === "true" ? "subuser" : "user",
|
||||
getStorageValue("selected_customer_number"),
|
||||
]
|
||||
.map((part) => encodeURIComponent(String(part ?? "")))
|
||||
.join("|");
|
||||
};
|
||||
|
||||
const getScopedCacheKey = (cacheKey) => {
|
||||
const scope = getAuthenticatedCacheScope();
|
||||
return scope ? `${scope}:${cacheKey}` : "";
|
||||
};
|
||||
|
||||
const safeSessionStorage = () => {
|
||||
try {
|
||||
if (typeof window === "undefined" || !window.sessionStorage) {
|
||||
@@ -15,9 +54,7 @@ const safeSessionStorage = () => {
|
||||
};
|
||||
|
||||
const normalizeUsageLogId = (objectOrId) => {
|
||||
const value = typeof objectOrId === "object"
|
||||
? objectOrId?.usage_log_id ?? objectOrId?.id
|
||||
: objectOrId;
|
||||
const value = typeof objectOrId === "object" ? objectOrId?.usage_log_id ?? objectOrId?.id : objectOrId;
|
||||
const parsed = Number.parseInt(String(value ?? ""), 10);
|
||||
return Number.isInteger(parsed) && parsed > 0 ? parsed : 0;
|
||||
};
|
||||
@@ -34,17 +71,18 @@ export const buildXlvaskUsageAmountCacheKey = (objectOrId) => {
|
||||
|
||||
export const getCachedXlvaskUsageAmount = (objectOrId) => {
|
||||
const cacheKey = buildXlvaskUsageAmountCacheKey(objectOrId);
|
||||
if (!cacheKey) {
|
||||
const scopedCacheKey = cacheKey ? getScopedCacheKey(cacheKey) : "";
|
||||
if (!scopedCacheKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const memoryEntry = memoryCache.get(cacheKey);
|
||||
const memoryEntry = memoryCache.get(scopedCacheKey);
|
||||
if (isFreshEntry(memoryEntry)) {
|
||||
return memoryEntry.data;
|
||||
}
|
||||
|
||||
if (memoryEntry) {
|
||||
memoryCache.delete(cacheKey);
|
||||
memoryCache.delete(scopedCacheKey);
|
||||
}
|
||||
|
||||
const storage = safeSessionStorage();
|
||||
@@ -52,14 +90,14 @@ export const getCachedXlvaskUsageAmount = (objectOrId) => {
|
||||
return null;
|
||||
}
|
||||
|
||||
const storageKey = `${CACHE_PREFIX}${cacheKey}`;
|
||||
const storageKey = `${CACHE_PREFIX}${scopedCacheKey}`;
|
||||
try {
|
||||
const parsed = JSON.parse(storage.getItem(storageKey) || "null");
|
||||
if (!isFreshEntry(parsed)) {
|
||||
storage.removeItem(storageKey);
|
||||
return null;
|
||||
}
|
||||
memoryCache.set(cacheKey, parsed);
|
||||
memoryCache.set(scopedCacheKey, parsed);
|
||||
return parsed.data;
|
||||
} catch {
|
||||
storage.removeItem(storageKey);
|
||||
@@ -69,7 +107,8 @@ export const getCachedXlvaskUsageAmount = (objectOrId) => {
|
||||
|
||||
export const setCachedXlvaskUsageAmount = (objectOrId, data) => {
|
||||
const cacheKey = buildXlvaskUsageAmountCacheKey(objectOrId);
|
||||
if (!cacheKey) {
|
||||
const scopedCacheKey = cacheKey ? getScopedCacheKey(cacheKey) : "";
|
||||
if (!scopedCacheKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -77,7 +116,7 @@ export const setCachedXlvaskUsageAmount = (objectOrId, data) => {
|
||||
storedAt: Date.now(),
|
||||
data,
|
||||
};
|
||||
memoryCache.set(cacheKey, entry);
|
||||
memoryCache.set(scopedCacheKey, entry);
|
||||
|
||||
const storage = safeSessionStorage();
|
||||
if (!storage) {
|
||||
@@ -85,7 +124,7 @@ export const setCachedXlvaskUsageAmount = (objectOrId, data) => {
|
||||
}
|
||||
|
||||
try {
|
||||
storage.setItem(`${CACHE_PREFIX}${cacheKey}`, JSON.stringify(entry));
|
||||
storage.setItem(`${CACHE_PREFIX}${scopedCacheKey}`, JSON.stringify(entry));
|
||||
} catch {
|
||||
// Best-effort cache. Quota errors should not block XL Vask usage rows.
|
||||
}
|
||||
@@ -94,11 +133,12 @@ export const setCachedXlvaskUsageAmount = (objectOrId, data) => {
|
||||
export const clearCachedXlvaskUsageAmount = (objectOrId = null) => {
|
||||
const storage = safeSessionStorage();
|
||||
const cacheKey = objectOrId === null ? "" : buildXlvaskUsageAmountCacheKey(objectOrId);
|
||||
const scopedCacheKey = cacheKey ? getScopedCacheKey(cacheKey) : "";
|
||||
|
||||
if (cacheKey) {
|
||||
memoryCache.delete(cacheKey);
|
||||
if (scopedCacheKey) {
|
||||
memoryCache.delete(scopedCacheKey);
|
||||
try {
|
||||
storage?.removeItem(`${CACHE_PREFIX}${cacheKey}`);
|
||||
storage?.removeItem(`${CACHE_PREFIX}${scopedCacheKey}`);
|
||||
} catch {
|
||||
// Best-effort cache cleanup.
|
||||
}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
<!--
|
||||
ErrorBanner.vue – reusable self-serve error notification bar.
|
||||
|
||||
Props:
|
||||
message – error text to display (blank ⇒ hidden)
|
||||
type – Bulma tint: is-danger | is-warning (default is-danger)
|
||||
iconLeft – Font Awesome icon name
|
||||
:loading – whether retry button is in loading state
|
||||
:showRetry – whether to show the retry button
|
||||
Emit:
|
||||
retry – fired when retry button is clicked
|
||||
-->
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
message: string | null;
|
||||
type?: "is-danger" | "is-warning";
|
||||
iconLeft?: string;
|
||||
loading?: boolean;
|
||||
showRetry?: boolean;
|
||||
}
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
message: string | null;
|
||||
type?: "is-danger" | "is-warning";
|
||||
iconLeft?: string;
|
||||
loading?: boolean;
|
||||
showRetry?: boolean;
|
||||
}>(),
|
||||
{
|
||||
type: "is-danger",
|
||||
iconLeft: "sync-alt",
|
||||
loading: false,
|
||||
showRetry: true,
|
||||
}
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
retry: [];
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<b-message
|
||||
v-if="message"
|
||||
:type="type"
|
||||
has-icon
|
||||
:closable="false"
|
||||
class="self-serve-error-banner"
|
||||
>
|
||||
<div class="is-flex is-align-items-center is-justify-content-space-between is-flex-wrap-wrap">
|
||||
<span class="mr-3">{{ message }}</span>
|
||||
<b-button
|
||||
v-if="showRetry"
|
||||
size="is-small"
|
||||
:type="`${type} is-light`"
|
||||
icon-pack="fas"
|
||||
:icon-left="iconLeft"
|
||||
:loading="loading"
|
||||
@click="emit('retry')"
|
||||
>
|
||||
{{ $t("common.try_again") }}
|
||||
</b-button>
|
||||
</div>
|
||||
</b-message>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.self-serve-error-banner {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,59 @@
|
||||
<!--
|
||||
LaneSelectionSection.vue – bane-valg radio buttons and lane status display.
|
||||
|
||||
Props:
|
||||
lanes – array of lane objects from the department
|
||||
selectedLaneId – currently selected lane id
|
||||
washType – "Manual" | "Machine"
|
||||
departmentName – human-readable department name
|
||||
isLaneAvailableFn – function(lane) => boolean
|
||||
isMachineAvailableFn – function(laneId) => boolean
|
||||
|
||||
Emit:
|
||||
update:selectedLaneId – new lane id selected
|
||||
update:washType – "Manual" | "Machine"
|
||||
-->
|
||||
<script setup lang="ts">
|
||||
import SelfServeLaneStep from "@/components/displays/selfServe/SelfServeLaneStep.vue";
|
||||
|
||||
interface Props {
|
||||
lanes: any[];
|
||||
selectedLaneId: number | string | null;
|
||||
washType: string;
|
||||
departmentName: string | null;
|
||||
isLaneAvailableFn: (lane: any) => boolean;
|
||||
isMachineAvailableFn: (laneId: number | string | null) => boolean;
|
||||
}
|
||||
|
||||
defineProps<Props>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
"update:selectedLaneId": [id: number];
|
||||
"update:washType": [type: string];
|
||||
}>();
|
||||
|
||||
function handleLaneUpdate(id: number) {
|
||||
emit("update:selectedLaneId", id);
|
||||
}
|
||||
|
||||
function handleWashTypeUpdate(type: string) {
|
||||
emit("update:washType", type);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelfServeLaneStep
|
||||
:lanes="lanes"
|
||||
:selected-lane-id="selectedLaneId"
|
||||
:wash-type="washType"
|
||||
:department-name="departmentName"
|
||||
:is-lane-available="isLaneAvailableFn"
|
||||
:is-machine-available="isMachineAvailableFn"
|
||||
@update:selected-lane-id="handleLaneUpdate"
|
||||
@update:wash-type="handleWashTypeUpdate"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Lane selection styling is inherited from SelfServeLaneStep */
|
||||
</style>
|
||||
@@ -118,10 +118,9 @@ const emit = defineEmits<{
|
||||
</div>
|
||||
</div>
|
||||
<b-message
|
||||
v-if="selectedLaneId && !isMachineAvailable(selectedLaneId)"
|
||||
v-if="!isMachineAvailable(selectedLaneId)"
|
||||
type="is-warning"
|
||||
has-icon
|
||||
:closable="false"
|
||||
aria-close-label="Luk besked"
|
||||
data-testid="self-serve-machine-unavailable-guidance"
|
||||
>
|
||||
{{ $t("self_wash.machine_unavailable_for_lane") }}
|
||||
|
||||
@@ -21,16 +21,6 @@ const isImageAttachment = (attachment: any) => (
|
||||
&& attachment.content.other.match(/\.(jpg|jpeg|png|gif|webp|svg)$/i)
|
||||
);
|
||||
|
||||
const hasTaskDescription = (task: any) => {
|
||||
const description = task?.description;
|
||||
if (typeof description !== "string") {
|
||||
return !!description;
|
||||
}
|
||||
|
||||
const trimmedDescription = description.trim();
|
||||
return trimmedDescription.length > 0 && trimmedDescription !== "-";
|
||||
};
|
||||
|
||||
const getImageAttachments = (task: any) => {
|
||||
if (!Array.isArray(task?.attachments)) {
|
||||
return [];
|
||||
@@ -83,7 +73,7 @@ const formatTaskTitle = (task: any) => {
|
||||
const title = String(task?.task || "");
|
||||
const programWheelSelection = getProgramWheelSelection(task);
|
||||
|
||||
if (programWheelSelection === null) {
|
||||
if (programWheelSelection === null || /\bprogram\s*#\d+\b/i.test(title)) {
|
||||
return title;
|
||||
}
|
||||
|
||||
@@ -127,13 +117,11 @@ const formatTaskTitle = (task: any) => {
|
||||
type="is-success"
|
||||
:data-testid="`self-serve-task-${task.id}-toggle`"
|
||||
@update:model-value="emit('toggle-task', task.id, $event)"
|
||||
@input="emit('toggle-task', task.id, $event)"
|
||||
/>
|
||||
</b-field>
|
||||
</div>
|
||||
<div class="self-serve-task-image-overlay-text">
|
||||
<p class="self-serve-task-title"><strong>{{ formatTaskTitle(task) }}</strong></p>
|
||||
<p v-if="hasTaskDescription(task)" class="self-serve-task-description">{{ task.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -156,22 +144,21 @@ const formatTaskTitle = (task: any) => {
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="columns is-mobile is-vcentered">
|
||||
<div v-if="props.showCheckboxes" class="column is-narrow">
|
||||
<div v-else class="self-serve-task-row">
|
||||
<div v-if="props.showCheckboxes" class="self-serve-task-checkbox">
|
||||
<b-field>
|
||||
<b-checkbox
|
||||
class="mr-0 pr-0"
|
||||
size="is-large"
|
||||
:model-value="completedTasks[task.id] === true"
|
||||
type="is-success"
|
||||
:data-testid="`self-serve-task-${task.id}-toggle`"
|
||||
@update:model-value="emit('toggle-task', task.id, $event)"
|
||||
@input="emit('toggle-task', task.id, $event)"
|
||||
/>
|
||||
</b-field>
|
||||
</div>
|
||||
<div class="column">
|
||||
<div class="self-serve-task-content">
|
||||
<p><strong>{{ formatTaskTitle(task) }}</strong></p>
|
||||
<p v-if="hasTaskDescription(task)" class="is-size-7">{{ task.description }}</p>
|
||||
<div v-if="getVisibleServices(task).length > 0" class="tags mt-2">
|
||||
<span v-for="service in getVisibleServices(task)" :key="service" class="tag is-success">{{ service }}</span>
|
||||
</div>
|
||||
@@ -239,7 +226,7 @@ const formatTaskTitle = (task: any) => {
|
||||
padding: 0.65rem 0.75rem;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.6rem;
|
||||
gap: 0.35rem;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
@@ -247,12 +234,30 @@ const formatTaskTitle = (task: any) => {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.self-serve-task-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.self-serve-task-checkbox {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.self-serve-task-checkbox :deep(.field) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.self-serve-task-content {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.self-serve-task-content p {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.self-serve-task-title {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.self-serve-task-description {
|
||||
margin: 0.25rem 0 0;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from "vue";
|
||||
import { BIcon, BSkeleton } from "buefy";
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { BIcon } from "buefy";
|
||||
import SelfServeTaskList from "@/components/displays/selfServe/SelfServeTaskList.vue";
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -19,9 +19,16 @@ const emit = defineEmits<{
|
||||
}>();
|
||||
|
||||
const isDynamicImageLoading = ref(false);
|
||||
const failedDynamicImageUrl = ref<string | null>(null);
|
||||
const showDynamicImageFrame = computed(
|
||||
() => !!props.dynamicImageUrl
|
||||
&& !isDynamicImageLoading.value
|
||||
&& failedDynamicImageUrl.value !== props.dynamicImageUrl
|
||||
);
|
||||
|
||||
watch(() => props.dynamicImageUrl, (dynamicImageUrl) => {
|
||||
isDynamicImageLoading.value = !!dynamicImageUrl;
|
||||
failedDynamicImageUrl.value = null;
|
||||
}, { immediate: true });
|
||||
|
||||
const emitToggleTask = (taskId: number, value: boolean) => {
|
||||
@@ -38,6 +45,7 @@ const onDynamicImageLoad = () => {
|
||||
|
||||
const onDynamicImageError = () => {
|
||||
isDynamicImageLoading.value = false;
|
||||
failedDynamicImageUrl.value = props.dynamicImageUrl;
|
||||
emit("clear-dynamic-image");
|
||||
};
|
||||
</script>
|
||||
@@ -58,26 +66,28 @@ const onDynamicImageError = () => {
|
||||
<span class="ml-2">{{ $t("self_wash.loading_data") }}</span>
|
||||
</div>
|
||||
|
||||
<img
|
||||
v-if="dynamicImageUrl && isDynamicImageLoading"
|
||||
:key="`${dynamicImageUrl}:preload`"
|
||||
:src="dynamicImageUrl"
|
||||
alt=""
|
||||
data-testid="self-serve-dynamic-image-preload"
|
||||
class="self-serve-dynamic-image-preload"
|
||||
@load="onDynamicImageLoad"
|
||||
@error="onDynamicImageError"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="dynamicImageUrl"
|
||||
v-if="showDynamicImageFrame"
|
||||
class="self-serve-dynamic-image-frame mb-4"
|
||||
:class="{ 'is-loading': isDynamicImageLoading }"
|
||||
data-testid="self-serve-dynamic-image-frame"
|
||||
>
|
||||
<b-skeleton
|
||||
v-if="isDynamicImageLoading"
|
||||
class="self-serve-dynamic-image-skeleton"
|
||||
width="100%"
|
||||
height="100%"
|
||||
data-testid="self-serve-dynamic-image-skeleton"
|
||||
/>
|
||||
<img
|
||||
:key="dynamicImageUrl"
|
||||
:src="dynamicImageUrl"
|
||||
alt="Machine status"
|
||||
data-testid="self-serve-dynamic-image"
|
||||
class="self-serve-dynamic-image"
|
||||
:class="{ 'is-loading': isDynamicImageLoading }"
|
||||
@load="onDynamicImageLoad"
|
||||
@error="onDynamicImageError"
|
||||
/>
|
||||
@@ -107,19 +117,20 @@ const onDynamicImageError = () => {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.self-serve-dynamic-image-preload {
|
||||
position: absolute;
|
||||
width: 0;
|
||||
height: 0;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 769px) {
|
||||
.self-serve-dynamic-image-frame {
|
||||
max-width: 640px;
|
||||
}
|
||||
}
|
||||
|
||||
.self-serve-dynamic-image-skeleton {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.self-serve-dynamic-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
@@ -128,8 +139,4 @@ const onDynamicImageError = () => {
|
||||
object-fit: contain;
|
||||
transition: opacity 120ms ease;
|
||||
}
|
||||
|
||||
.self-serve-dynamic-image.is-loading {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from "vue";
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { BAutocomplete, BField, BInput, BMessage } from "buefy";
|
||||
import SelfServeVehicleTypeSelector from "@/components/displays/selfServe/SelfServeVehicleTypeSelector.vue";
|
||||
|
||||
@@ -21,8 +21,11 @@ const props = defineProps<{
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "update:customerNumber", value: string): void;
|
||||
(e: "update:customer-number", value: string): void;
|
||||
(e: "update:registrationNumber", value: string): void;
|
||||
(e: "update:registration-number", value: string): void;
|
||||
(e: "select-vehicle-type", selection: any): void;
|
||||
(e: "selected", selection: any): void;
|
||||
}>();
|
||||
|
||||
const extractRegistrationValue = (value: unknown): string => {
|
||||
@@ -49,6 +52,19 @@ const normalizeRegistration = (value: unknown) => extractRegistrationValue(value
|
||||
|
||||
const typedRegistration = ref(normalizeRegistration(props.registrationNumber));
|
||||
|
||||
const filteredRegistrationOptions = computed(() => {
|
||||
const query = normalizeRegistration(typedRegistration.value);
|
||||
const options = props.registrationOptions || [];
|
||||
if (!query) {
|
||||
return options;
|
||||
}
|
||||
|
||||
return options.filter((option) => {
|
||||
const normalizedOption = normalizeRegistration(option);
|
||||
return normalizedOption !== query && normalizedOption.includes(query);
|
||||
});
|
||||
});
|
||||
|
||||
watch(() => props.registrationNumber, (newValue) => {
|
||||
typedRegistration.value = normalizeRegistration(newValue);
|
||||
});
|
||||
@@ -57,6 +73,18 @@ const emitRegistration = (value: unknown) => {
|
||||
const normalized = normalizeRegistration(value);
|
||||
typedRegistration.value = normalized;
|
||||
emit("update:registrationNumber", normalized);
|
||||
emit("update:registration-number", normalized);
|
||||
};
|
||||
|
||||
const emitCustomerNumber = (value: unknown) => {
|
||||
const normalized = String(value || "");
|
||||
emit("update:customerNumber", normalized);
|
||||
emit("update:customer-number", normalized);
|
||||
};
|
||||
|
||||
const emitVehicleTypeSelection = (selection: any) => {
|
||||
emit("select-vehicle-type", selection);
|
||||
emit("selected", selection);
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -68,38 +96,41 @@ const emitRegistration = (value: unknown) => {
|
||||
:model-value="customerNumber || ''"
|
||||
data-testid="self-serve-customer-number"
|
||||
:placeholder="$t('self_wash.enter_customer_number')"
|
||||
@input="emit('update:customerNumber', String($event || ''))"
|
||||
@input="emitCustomerNumber($event)"
|
||||
@update:modelValue="emitCustomerNumber($event)"
|
||||
/>
|
||||
</b-field>
|
||||
<b-field :label="$t('self_wash.registration_number')">
|
||||
<b-autocomplete
|
||||
:model-value="typedRegistration"
|
||||
data-testid="self-serve-registration"
|
||||
:data="registrationOptions"
|
||||
:placeholder="$t('self_wash.enter_registration_number')"
|
||||
:debounce="300"
|
||||
:min-length="1"
|
||||
:filter="(option, query) => option.toLowerCase().includes(query.toLowerCase())"
|
||||
icon-pack="fas"
|
||||
icon="car-side"
|
||||
clearable
|
||||
:loading="registrationOptions.length === 0 && isCustomerVehiclesLoading"
|
||||
:selectable-header="true"
|
||||
@select="emitRegistration($event)"
|
||||
@typing="emitRegistration($event)"
|
||||
@update:modelValue="emitRegistration($event)"
|
||||
@select-header="emitRegistration(typedRegistration)"
|
||||
>
|
||||
<template #header>
|
||||
<b-field :label="$t('self_wash.registration_number')" class="self-serve-registration-field">
|
||||
<div class="self-serve-registration-control">
|
||||
<b-autocomplete
|
||||
:model-value="typedRegistration"
|
||||
data-testid="self-serve-registration"
|
||||
:data="filteredRegistrationOptions"
|
||||
:placeholder="$t('self_wash.enter_registration_number')"
|
||||
:debounce="300"
|
||||
:min-length="1"
|
||||
icon-pack="fas"
|
||||
icon="car-side"
|
||||
clearable
|
||||
:loading="filteredRegistrationOptions.length === 0 && isCustomerVehiclesLoading"
|
||||
@select="emitRegistration($event)"
|
||||
@typing="emitRegistration($event)"
|
||||
@update:modelValue="emitRegistration($event)"
|
||||
>
|
||||
</b-autocomplete>
|
||||
<p
|
||||
v-if="typedRegistration"
|
||||
class="help self-serve-registration-guidance"
|
||||
data-testid="self-serve-registration-guidance"
|
||||
>
|
||||
<template v-if="hasMatchingVehicle">
|
||||
{{ $t("self_wash.select_from_vehicles", { plate: typedRegistration }) }}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ $t("self_wash.add_as_new_vehicle", { plate: typedRegistration }) }}
|
||||
</template>
|
||||
</template>
|
||||
<template #empty>{{ $t("self_wash.no_vehicles_found") }}</template>
|
||||
</b-autocomplete>
|
||||
</p>
|
||||
</div>
|
||||
</b-field>
|
||||
<b-message
|
||||
v-if="props.vehicleStepError"
|
||||
@@ -124,7 +155,7 @@ const emitRegistration = (value: unknown) => {
|
||||
<SelfServeVehicleTypeSelector
|
||||
:selectedVehicleTypeId="selectedVehicleTypeId"
|
||||
:restrictToProductIds="availableProductIds"
|
||||
@selected="emit('select-vehicle-type', $event)"
|
||||
@selected="emitVehicleTypeSelection"
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="vehicleTypes.length === 0">
|
||||
@@ -140,3 +171,34 @@ const emitRegistration = (value: unknown) => {
|
||||
</b-field>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.self-serve-registration-control {
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.self-serve-registration-guidance {
|
||||
display: block;
|
||||
margin-top: 0.5rem;
|
||||
line-height: 1.35;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.self-serve-registration-field :deep(.dropdown),
|
||||
.self-serve-registration-field :deep(.autocomplete) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.self-serve-registration-field :deep(.dropdown-menu) {
|
||||
left: 0;
|
||||
right: 0;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.self-serve-registration-field :deep(.dropdown-item) {
|
||||
white-space: normal;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
<!--
|
||||
VehicleInputSection.vue – customer number, license plate, and vehicle type inputs.
|
||||
|
||||
Props:
|
||||
showCustomerNumberInput – whether to render the customer number field
|
||||
customerNumber – current customer number string
|
||||
registrationNumber – current license plate string
|
||||
registrationOptions – autocomplete options for existing vehicles
|
||||
selectedVehicleTypeId – currently selected vehicle type id
|
||||
selectedVehicleName – human-readable name of selected vehicle type
|
||||
availableProductIds – allowed product ids for the department
|
||||
vehicleTypes – list of vehicle type options
|
||||
|
||||
Emit:
|
||||
update:customerNumber – new customer number string
|
||||
update:registrationNumber – new license plate string
|
||||
selectVehicleType – selected VehicleTypeTemplate object
|
||||
-->
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import SelfServeVehicleStep from "@/components/displays/selfServe/SelfServeVehicleStep.vue";
|
||||
|
||||
interface Props {
|
||||
showCustomerNumberInput: boolean;
|
||||
customerNumber: string | null;
|
||||
registrationNumber: string | null;
|
||||
registrationOptions: string[];
|
||||
selectedVehicleTypeId: number | null;
|
||||
selectedVehicleName: string | null;
|
||||
availableProductIds: (number | string)[];
|
||||
vehicleTypes: any[];
|
||||
}
|
||||
|
||||
defineProps<Props>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
"update:customerNumber": [value: string];
|
||||
"update:registrationNumber": [value: string];
|
||||
selectVehicleType: [type: { id: number }];
|
||||
}>();
|
||||
|
||||
function handleCustomerNumberUpdate(value: string) {
|
||||
emit("update:customerNumber", value);
|
||||
}
|
||||
|
||||
function handleRegistrationNumberUpdate(value: string) {
|
||||
emit("update:registrationNumber", value);
|
||||
}
|
||||
|
||||
function handleSelectVehicleType(selection: any) {
|
||||
emit("selectVehicleType", selection);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelfServeVehicleStep
|
||||
:customer-number="customerNumber"
|
||||
:show-customer-number-input="showCustomerNumberInput"
|
||||
:registration-number="registrationNumber"
|
||||
:registration-options="registrationOptions"
|
||||
:is-customer-vehicles-loading="false"
|
||||
:has-matching-vehicle="false"
|
||||
:selected-vehicle-type-id="selectedVehicleTypeId"
|
||||
:selected-vehicle-name="selectedVehicleName"
|
||||
:selected-vehicle-description="null"
|
||||
:available-product-ids="availableProductIds"
|
||||
:vehicle-types="vehicleTypes"
|
||||
:vehicle-step-error="null"
|
||||
:vehicle-step-guidance="null"
|
||||
@update:customer-number="handleCustomerNumberUpdate"
|
||||
@update:customerNumber="handleCustomerNumberUpdate"
|
||||
@update:registration-number="handleRegistrationNumberUpdate"
|
||||
@update:registrationNumber="handleRegistrationNumberUpdate"
|
||||
@select-vehicle-type="handleSelectVehicleType"
|
||||
@selected="handleSelectVehicleType"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Vehicle input section styling is inherited from SelfServeVehicleStep */
|
||||
</style>
|
||||
@@ -0,0 +1,255 @@
|
||||
<!--
|
||||
WashProgressCard.vue – timer, guided instructions, and bottom actions during active wash.
|
||||
|
||||
Renders when a wash session is in progress (WASH_IN_PROGRESS step).
|
||||
|
||||
Props:
|
||||
currentGuidedWashStep – index into guidedWashFlowSteps
|
||||
guidedWashFlowSteps – array of instruction step objects
|
||||
formattedElapsed – human-readable elapsed time string
|
||||
isCompletingWash – whether the wash is finishing right now
|
||||
openingPropertyAccessGate – loading state for access gate button
|
||||
openingPropertyExitGate – loading state for exit gate button
|
||||
Emit:
|
||||
update:currentGuidedWashStep – new step index
|
||||
goPreviousGuidedWashStep – navigate to previous instruction
|
||||
goNextGuidedWashStep – navigate to next instruction
|
||||
completeWash – finish the wash session
|
||||
openPropertyAccessGate – open the property access gate (laneId)
|
||||
openPropertyExitGate – open the property exit gate (laneId)
|
||||
requestAssistance – call for assistance
|
||||
-->
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { BButton } from "buefy";
|
||||
|
||||
interface Props {
|
||||
currentGuidedWashStep: number;
|
||||
guidedWashFlowSteps: any[];
|
||||
formattedElapsed: string;
|
||||
isCompletingWash: boolean;
|
||||
openingPropertyAccessGate: boolean;
|
||||
openingPropertyExitGate: boolean;
|
||||
}
|
||||
|
||||
defineProps<Props>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
"update:currentGuidedWashStep": [step: number];
|
||||
goPreviousGuidedWashStep: [];
|
||||
goNextGuidedWashStep: [];
|
||||
completeWash: [];
|
||||
openPropertyAccessGate: [laneId: number | null];
|
||||
openPropertyExitGate: [laneId: number | null];
|
||||
requestAssistance: [];
|
||||
}>();
|
||||
|
||||
const isLastGuidedWashStep = computed(
|
||||
() =>
|
||||
typeof guidedWashFlowSteps !== "undefined" &&
|
||||
guidedWashFlowSteps.length > 0 &&
|
||||
currentGuidedWashStep >= guidedWashFlowSteps.length - 1
|
||||
);
|
||||
|
||||
function handleGoPrevious() {
|
||||
emit("goPreviousGuidedWashStep");
|
||||
}
|
||||
|
||||
function handleGoNext() {
|
||||
emit("goNextGuidedWashStep");
|
||||
}
|
||||
|
||||
function handleComplete() {
|
||||
emit("completeWash");
|
||||
}
|
||||
|
||||
function handleOpenAccessGate(laneId: number | null) {
|
||||
emit("openPropertyAccessGate", laneId);
|
||||
}
|
||||
|
||||
function handleOpenExitGate(laneId: number | null) {
|
||||
emit("openPropertyExitGate", laneId);
|
||||
}
|
||||
|
||||
function handleRequestAssistance() {
|
||||
emit("requestAssistance");
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div data-testid="self-serve-wash-progress">
|
||||
<!-- Finishing screen -->
|
||||
<div
|
||||
v-if="isCompletingWash"
|
||||
class="notification is-info is-light self-serve-finishing-screen"
|
||||
data-testid="self-serve-finishing-wash"
|
||||
>
|
||||
<p class="title is-5 mb-0">{{ $t("self_wash.finishing_wash_exit_opening") }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Guided wash instructions (stepped walkthrough) -->
|
||||
<template v-if="!isCompletingWash">
|
||||
<!--
|
||||
Accept a callback slot for the guided-instructions component so the
|
||||
parent can plug in SelfServeGuidedInstructions or its own implementation.
|
||||
-->
|
||||
<slot
|
||||
name="guided-instructions"
|
||||
:current-step="currentGuidedWashStep"
|
||||
:steps="guidedWashFlowSteps"
|
||||
:is-last-step="isLastGuidedWashStep"
|
||||
@update:current-step="emit('update:currentGuidedWashStep', $event)"
|
||||
>
|
||||
<!-- Default: render nothing (parent provides via slot) -->
|
||||
</slot>
|
||||
|
||||
<!-- Bottom action bar while wash is in progress -->
|
||||
<div
|
||||
class="self-serve-bottom-actions"
|
||||
data-testid="self-serve-bottom-actions"
|
||||
>
|
||||
<!-- Help / assistance -->
|
||||
<div class="self-serve-bottom-actions__row" data-testid="self-serve-session-bottom-actions">
|
||||
<b-button
|
||||
class="self-serve-bottom-actions__button"
|
||||
type="is-link is-light"
|
||||
icon-pack="fas"
|
||||
icon-right="info-circle"
|
||||
data-testid="self-serve-nav-help"
|
||||
@click.prevent="handleRequestAssistance"
|
||||
>
|
||||
{{ $t("self_wash.assistance") }}
|
||||
</b-button>
|
||||
</div>
|
||||
|
||||
<!-- Property gate controls -->
|
||||
<div class="self-serve-bottom-actions__row" data-testid="self-serve-property-gate-actions">
|
||||
<b-button
|
||||
class="self-serve-bottom-actions__button"
|
||||
type="is-link is-light"
|
||||
icon-pack="fas"
|
||||
icon-left="sign-in-alt"
|
||||
data-testid="self-serve-nav-open-property-access-gate"
|
||||
:loading="openingPropertyAccessGate"
|
||||
:disabled="openingPropertyAccessGate"
|
||||
@click.prevent="$emit('openPropertyAccessGate', $props)"
|
||||
>
|
||||
{{ $t("self_wash.open_property_access_gate") }}
|
||||
</b-button>
|
||||
|
||||
<b-button
|
||||
class="self-serve-bottom-actions__button"
|
||||
type="is-link is-light"
|
||||
icon-pack="fas"
|
||||
icon-left="sign-out-alt"
|
||||
data-testid="self-serve-nav-open-property-exit-gate"
|
||||
:loading="openingPropertyExitGate"
|
||||
:disabled="openingPropertyExitGate"
|
||||
@click.prevent="$emit('openPropertyExitGate', $props)"
|
||||
>
|
||||
{{ $t("self_wash.open_property_exit_gate") }}
|
||||
</b-button>
|
||||
</div>
|
||||
|
||||
<!-- Guided step navigation -->
|
||||
<div class="self-serve-bottom-actions__row" data-testid="self-serve-guided-bottom-actions">
|
||||
<b-button
|
||||
class="self-serve-bottom-actions__button"
|
||||
type="is-link is-light"
|
||||
icon-pack="fas"
|
||||
icon-left="arrow-left"
|
||||
data-testid="self-serve-guided-prev"
|
||||
:disabled="currentGuidedWashStep === 0"
|
||||
@click.prevent="handleGoPrevious"
|
||||
>
|
||||
{{ $t("common.previous") }}
|
||||
</b-button>
|
||||
|
||||
<b-button
|
||||
v-if="!isLastGuidedWashStep"
|
||||
class="self-serve-bottom-actions__button"
|
||||
type="is-link"
|
||||
icon-pack="fas"
|
||||
icon-right="arrow-right"
|
||||
data-testid="self-serve-guided-next"
|
||||
@click.prevent="handleGoNext"
|
||||
>
|
||||
{{ $t("common.next") }}
|
||||
</b-button>
|
||||
|
||||
<b-button
|
||||
v-else
|
||||
class="self-serve-bottom-actions__button"
|
||||
type="is-link"
|
||||
icon-pack="fas"
|
||||
icon-right="check"
|
||||
data-testid="self-serve-nav-complete"
|
||||
:loading="isCompletingWash"
|
||||
:disabled="isCompletingWash"
|
||||
@click.prevent="handleComplete"
|
||||
>
|
||||
{{ $t("common.done") }}
|
||||
</b-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.self-serve-finishing-screen {
|
||||
margin: 0 auto;
|
||||
max-width: 32rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.self-serve-bottom-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
justify-content: center;
|
||||
margin: 0.75rem auto 0;
|
||||
max-width: 30rem;
|
||||
}
|
||||
|
||||
.self-serve-bottom-actions__row {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.self-serve-bottom-actions__button {
|
||||
flex: 1 1 0;
|
||||
font-weight: 700;
|
||||
line-height: 1.15;
|
||||
max-width: 14rem;
|
||||
min-height: 2.75rem;
|
||||
min-width: 0;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
.self-serve-bottom-actions {
|
||||
background: #ffffff;
|
||||
border-top: 1px solid #dfe5f0;
|
||||
bottom: calc(4.75rem + env(safe-area-inset-bottom, 0px));
|
||||
box-shadow: 0 -0.25rem 0.75rem rgba(17, 47, 95, 0.08);
|
||||
left: 0;
|
||||
margin: 0;
|
||||
max-width: none;
|
||||
padding: 0.5rem 0.75rem;
|
||||
position: fixed;
|
||||
right: 0;
|
||||
z-index: 41;
|
||||
}
|
||||
|
||||
.self-serve-bottom-actions__button {
|
||||
font-size: 0.88rem;
|
||||
max-width: none;
|
||||
min-height: 2.65rem;
|
||||
padding-left: 0.55rem;
|
||||
padding-right: 0.55rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,170 @@
|
||||
<!--
|
||||
WashTypeSelector.vue – manual vs machine wash toggle.
|
||||
|
||||
Renders two radio-style cards: "Manual" (always available) and
|
||||
"Machine" (available when the lane supports it).
|
||||
|
||||
Props:
|
||||
modelValue – current wash type ("Manual" | "Machine")
|
||||
isMachineAvailable – whether machine wash is available for this lane
|
||||
|
||||
Emit:
|
||||
update:modelValue – new wash type string
|
||||
-->
|
||||
<script setup lang="ts">
|
||||
import { BRadioButton } from "buefy";
|
||||
|
||||
interface Props {
|
||||
modelValue: string;
|
||||
isMachineAvailable: boolean;
|
||||
}
|
||||
|
||||
defineProps<Props>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
"update:modelValue": [type: string];
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="self-serve-choice-group" data-testid="self-serve-wash-type-group">
|
||||
<h2 class="self-serve-choice-label">{{ $t("self_wash.wash_type") }}</h2>
|
||||
<div
|
||||
class="self-serve-choice-grid self-serve-choice-grid--centered"
|
||||
data-testid="self-serve-wash-type-options"
|
||||
>
|
||||
<!-- Manual option (always available) -->
|
||||
<div class="self-serve-choice-grid__item">
|
||||
<b-radio-button
|
||||
class="self-serve-choice-card"
|
||||
:model-value="modelValue"
|
||||
native-value="Manual"
|
||||
type="is-link"
|
||||
data-testid="self-serve-wash-type-manual"
|
||||
@update:model-value="emit('update:modelValue', 'Manual')"
|
||||
@input="emit('update:modelValue', 'Manual')"
|
||||
>
|
||||
<span>
|
||||
<span>{{ $t("self_wash.manual") }}<br /></span>
|
||||
<small>
|
||||
<b-icon icon="check-circle" type="is-success" pack="fas" class="mr-1" />
|
||||
{{ $t("self_wash.available") }}
|
||||
</small>
|
||||
</span>
|
||||
</b-radio-button>
|
||||
</div>
|
||||
|
||||
<!-- Machine option (conditioned) -->
|
||||
<div class="self-serve-choice-grid__item">
|
||||
<b-radio-button
|
||||
class="self-serve-choice-card"
|
||||
:model-value="modelValue"
|
||||
native-value="Machine"
|
||||
type="is-link"
|
||||
:disabled="!isMachineAvailable"
|
||||
:title="!isMachineAvailable ? $t('self_wash.machine_unavailable_for_lane') : null"
|
||||
:aria-label="!isMachineAvailable ? $t('self_wash.machine_unavailable_for_lane') : null"
|
||||
data-testid="self-serve-wash-type-machine"
|
||||
@update:model-value="emit('update:modelValue', 'Machine')"
|
||||
@input="emit('update:modelValue', 'Machine')"
|
||||
>
|
||||
<span>
|
||||
<span>{{ $t("self_wash.machine") }}<br /></span>
|
||||
<span v-if="!isMachineAvailable">
|
||||
<small>
|
||||
<b-icon icon="times-circle" type="is-danger" pack="fas" class="mr-1" />
|
||||
{{ $t("self_wash.unavailable") }}
|
||||
</small>
|
||||
</span>
|
||||
<span v-else>
|
||||
<small>
|
||||
<b-icon icon="check-circle" type="is-success" pack="fas" class="mr-1" />
|
||||
{{ $t("self_wash.available") }}
|
||||
</small>
|
||||
</span>
|
||||
</span>
|
||||
</b-radio-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<b-message
|
||||
v-if="!isMachineAvailable"
|
||||
type="is-warning"
|
||||
aria-close-label="Luk besked"
|
||||
data-testid="self-serve-machine-unavailable-guidance"
|
||||
>
|
||||
{{ $t("self_wash.machine_unavailable_for_lane") }}
|
||||
</b-message>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.self-serve-choice-group {
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.self-serve-choice-label {
|
||||
color: #303440;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
margin: 0 0 0.75rem;
|
||||
}
|
||||
|
||||
.self-serve-choice-grid {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
margin-bottom: 0.75rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.self-serve-choice-grid--centered {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.self-serve-choice-grid__item {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.self-serve-choice-card {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
height: 100%;
|
||||
justify-content: center;
|
||||
min-height: 5rem;
|
||||
padding: 0.75rem 0.5rem;
|
||||
white-space: normal;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.self-serve-choice-card :deep(.button) {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
height: 100%;
|
||||
justify-content: center;
|
||||
min-height: 5rem;
|
||||
padding: 0.75rem 0.5rem;
|
||||
white-space: normal;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.self-serve-choice-card :deep(.button),
|
||||
.self-serve-choice-card span,
|
||||
.self-serve-choice-card :deep(.button span) {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.self-serve-choice-card small,
|
||||
.self-serve-choice-card :deep(.button small) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 769px) {
|
||||
.self-serve-choice-grid {
|
||||
grid-template-columns: repeat(auto-fit, minmax(11rem, 15rem));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -2,6 +2,8 @@
|
||||
import Swal from "sweetalert2";
|
||||
import { usePaginatedListInstance } from "@/components/pagination/paginatedList.vue";
|
||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
import ActionSettingsWheelButton from "@/components/displays/buttons/ActionSettingsWheelButton.vue";
|
||||
import ActionSettingsWheelItem from "@/components/displays/buttons/ActionSettingsWheelItem.vue";
|
||||
|
||||
const props = defineProps({
|
||||
objects: {
|
||||
@@ -23,6 +25,8 @@ const canDisableAccess = () =>
|
||||
const canResendInvite = (subuser) =>
|
||||
(props.showCustomer ? SessionUser.canAccessSuperUser() : SessionUser.canAccessUser() || SessionUser.hasPermission("SUBUSERS_EDIT"))
|
||||
&& Boolean(subuser?.can_resend_invite ?? subuser?.setup_required);
|
||||
const hasRowActions = (subuser) =>
|
||||
(canEditPermissions() && subuser?.grant_id) || canResendInvite(subuser) || (canDisableAccess() && subuser?.grant_id);
|
||||
|
||||
const formatDateTime = (dateString) => {
|
||||
if (!dateString) {
|
||||
@@ -241,14 +245,6 @@ const onResendInvite = async (subuser) => {
|
||||
|
||||
<td>
|
||||
<div>{{ subuser.grant_note || "-" }}</div>
|
||||
<button
|
||||
v-if="canEditPermissions() && subuser.grant_id"
|
||||
class="button is-text is-small px-0 mt-1"
|
||||
type="button"
|
||||
@click="onEditNote(subuser)"
|
||||
>
|
||||
Redigér note
|
||||
</button>
|
||||
</td>
|
||||
|
||||
<td>{{ formatDateTime(subuser.created_at) }}</td>
|
||||
@@ -256,36 +252,42 @@ const onResendInvite = async (subuser) => {
|
||||
|
||||
<td>
|
||||
<div class="buttons is-justify-content-flex-end action-buttons">
|
||||
<button
|
||||
v-if="canEditPermissions() && subuser.grant_id"
|
||||
class="button is-small"
|
||||
type="button"
|
||||
:data-testid="`subuser-permissions-${subuser.id}`"
|
||||
@click="onEditPermissions(subuser)"
|
||||
>
|
||||
Tilladelser
|
||||
</button>
|
||||
<ActionSettingsWheelButton v-if="hasRowActions(subuser)">
|
||||
<template #actions>
|
||||
<ActionSettingsWheelItem
|
||||
v-if="canEditPermissions() && subuser.grant_id"
|
||||
icon="fas fa-pen"
|
||||
label="Redigér note"
|
||||
:click-action="() => onEditNote(subuser)"
|
||||
:test-id="`subuser-note-${subuser.id}`"
|
||||
/>
|
||||
|
||||
<button
|
||||
v-if="canResendInvite(subuser)"
|
||||
class="button is-small"
|
||||
type="button"
|
||||
:data-testid="`subuser-resend-${subuser.id}`"
|
||||
@click="onResendInvite(subuser)"
|
||||
>
|
||||
Gensend
|
||||
</button>
|
||||
<ActionSettingsWheelItem
|
||||
v-if="canEditPermissions() && subuser.grant_id"
|
||||
icon="fas fa-user-shield"
|
||||
label="Tilladelser"
|
||||
:click-action="() => onEditPermissions(subuser)"
|
||||
:test-id="`subuser-permissions-${subuser.id}`"
|
||||
/>
|
||||
|
||||
<button
|
||||
v-if="canDisableAccess() && subuser.grant_id"
|
||||
class="button is-small"
|
||||
:class="subuser.grant_enabled ? 'is-danger is-light' : 'is-success is-light'"
|
||||
type="button"
|
||||
:data-testid="`subuser-toggle-${subuser.id}`"
|
||||
@click="onToggleEnabled(subuser, !subuser.grant_enabled)"
|
||||
>
|
||||
{{ subuser.grant_enabled ? "Deaktivér" : "Aktivér" }}
|
||||
</button>
|
||||
<ActionSettingsWheelItem
|
||||
v-if="canResendInvite(subuser)"
|
||||
icon="fas fa-paper-plane"
|
||||
label="Gensend"
|
||||
:click-action="() => onResendInvite(subuser)"
|
||||
:test-id="`subuser-resend-${subuser.id}`"
|
||||
/>
|
||||
|
||||
<ActionSettingsWheelItem
|
||||
v-if="canDisableAccess() && subuser.grant_id"
|
||||
:icon="subuser.grant_enabled ? 'fas fa-ban' : 'fas fa-check'"
|
||||
:label="subuser.grant_enabled ? 'Deaktivér' : 'Aktivér'"
|
||||
:template="subuser.grant_enabled ? 'danger' : 'success'"
|
||||
:click-action="() => onToggleEnabled(subuser, !subuser.grant_enabled)"
|
||||
:test-id="`subuser-toggle-${subuser.id}`"
|
||||
/>
|
||||
</template>
|
||||
</ActionSettingsWheelButton>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -10,6 +10,7 @@ import LoadButtonWhileAwait from "@/components/request/LoadButtonWhileAwait.vue"
|
||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
import TwoFactorVerify from "@/components/forms/auth/TwoFactorVerify.vue";
|
||||
import { isPasskeySupported, authenticateWithPasskey } from "@/services/PasskeyAuthService.js";
|
||||
import { clearEdgeGatewayWorkspaceCache } from "@/services/edgeGateways.js";
|
||||
|
||||
const employees = ref([]);
|
||||
const { t } = useI18n();
|
||||
@@ -52,6 +53,7 @@ const login = async () => {
|
||||
}
|
||||
|
||||
// Save the token in the local storage
|
||||
clearEdgeGatewayWorkspaceCache();
|
||||
localStorage.setItem('token', data.token);
|
||||
// Redirect to the dashboard
|
||||
window.location.href = '/admin';
|
||||
@@ -72,6 +74,7 @@ const loginWithPasskey = async () => {
|
||||
const result = await authenticateWithPasskey('employee', null, recaptchaToken);
|
||||
|
||||
if (result.token) {
|
||||
clearEdgeGatewayWorkspaceCache();
|
||||
localStorage.setItem('token', result.token);
|
||||
window.location.href = '/admin';
|
||||
return;
|
||||
|
||||
@@ -9,6 +9,7 @@ import { parseError, clearErrors, addError, getError } from "@/components/reques
|
||||
import LoadButtonWhileAwait from "@/components/request/LoadButtonWhileAwait.vue";
|
||||
import { isPasskeySupported, authenticateWithPasskey } from "@/services/PasskeyAuthService.js";
|
||||
import TwoFactorVerify from "@/components/forms/auth/TwoFactorVerify.vue";
|
||||
import { clearEdgeGatewayWorkspaceCache } from "@/services/edgeGateways.js";
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
@@ -68,6 +69,7 @@ const login = async () => {
|
||||
}
|
||||
|
||||
// Save the token in the local storage
|
||||
clearEdgeGatewayWorkspaceCache();
|
||||
localStorage.setItem('token', data.token);
|
||||
// Set the success message
|
||||
successMessage.value = "Du er nu logget ind!"
|
||||
@@ -103,6 +105,7 @@ const loginWithPasskey = async () => {
|
||||
const result = await authenticateWithPasskey('user', customerNum, recaptchaToken);
|
||||
console.log('Passkey authentication result:', result);
|
||||
if (result.token) {
|
||||
clearEdgeGatewayWorkspaceCache();
|
||||
localStorage.setItem('token', result.token);
|
||||
successMessage.value = "Du er nu logget ind!";
|
||||
setTimeout(() => {
|
||||
|
||||
@@ -9,6 +9,7 @@ import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
import { isPasskeySupported, authenticateWithPasskey } from "@/services/PasskeyAuthService.js";
|
||||
import { getSubuserPasswordPolicyError } from "@/services/subuserPasswordPolicy.js";
|
||||
import TwoFactorVerify from "@/components/forms/auth/TwoFactorVerify.vue";
|
||||
import { clearEdgeGatewayWorkspaceCache } from "@/services/edgeGateways.js";
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
@@ -69,6 +70,7 @@ const login = async () => {
|
||||
|
||||
// Save the session token
|
||||
if (data.session) {
|
||||
clearEdgeGatewayWorkspaceCache();
|
||||
localStorage.setItem('token', data.session);
|
||||
localStorage.setItem('is_subuser', 'true');
|
||||
window.location.reload();
|
||||
@@ -97,6 +99,7 @@ const loginWithPasskey = async () => {
|
||||
// Subuser login returns 'session' token, user login returns 'token'
|
||||
const sessionToken = result.session || result.token;
|
||||
if (sessionToken) {
|
||||
clearEdgeGatewayWorkspaceCache();
|
||||
localStorage.setItem('token', sessionToken);
|
||||
localStorage.setItem('is_subuser', 'true');
|
||||
// Reload the page to update the UI
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useI18n } from 'vue-i18n';
|
||||
import { verify2FA } from "@/services/TwoFactorAuthService.js";
|
||||
import { parseError, getError, clearErrors } from "@/components/request/HandleGlobalError.vue";
|
||||
import LoadButtonWhileAwait from "@/components/request/LoadButtonWhileAwait.vue";
|
||||
import { clearEdgeGatewayWorkspaceCache } from "@/services/edgeGateways.js";
|
||||
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
@@ -43,9 +44,11 @@ const verify = async () => {
|
||||
|
||||
// Handle the response based on user type
|
||||
if (props.userType === 'subuser' && result.session) {
|
||||
clearEdgeGatewayWorkspaceCache();
|
||||
localStorage.setItem('token', result.session);
|
||||
localStorage.setItem('is_subuser', 'true');
|
||||
} else if (result.token) {
|
||||
clearEdgeGatewayWorkspaceCache();
|
||||
localStorage.setItem('token', result.token);
|
||||
localStorage.removeItem('is_subuser');
|
||||
}
|
||||
|
||||
@@ -52,6 +52,7 @@ const customer_suggestions = ref([
|
||||
const isSettingCustomer = ref(false);
|
||||
const isSettingCustomerToInteger = ref(0);
|
||||
const isSettingCustomerStartTime = ref(null);
|
||||
let customerSuggestionsRequestId = 0;
|
||||
|
||||
const isSettingCustomerTo = (customer_number) => {
|
||||
return isSettingCustomerToInteger.value === parseInt(customer_number);
|
||||
@@ -88,10 +89,17 @@ const getCustomerSuggestions = () => {
|
||||
if (!props.reg_1) {
|
||||
return;
|
||||
}
|
||||
const requestedReg1 = props.reg_1;
|
||||
const requestId = ++customerSuggestionsRequestId;
|
||||
|
||||
SessionUser.request("/department/vehicle/customer-suggestions", "GET", {
|
||||
reg_1: props.reg_1,
|
||||
reg_1: requestedReg1,
|
||||
})
|
||||
.then((response) => {
|
||||
if (requestId !== customerSuggestionsRequestId || requestedReg1 !== props.reg_1) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Assuming the response contains an array of customer suggestions
|
||||
console.log("Customer suggestions:", response.data.data);
|
||||
let suggestions = [];
|
||||
@@ -126,6 +134,7 @@ watch(
|
||||
if (newValue) {
|
||||
getCustomerSuggestions();
|
||||
} else {
|
||||
customerSuggestionsRequestId += 1;
|
||||
customer_suggestions.value = [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,6 +118,7 @@ const impersonatedUserRoleId = computed(() => {
|
||||
const canGrantMissingPermissions = computed(() =>
|
||||
hasSuperuserToken.value && !SessionUser.isSubuser.value && impersonatedUserRoleId.value !== null
|
||||
);
|
||||
const canInspectReleaseRuntime = computed(() => hasSuperuserToken.value || SessionUser.canAccessAdmin?.() === true);
|
||||
|
||||
const userDetailRows = computed(() => {
|
||||
if (SessionUser.isSubuser.value) {
|
||||
@@ -284,7 +285,12 @@ const resolvePingUrl = () => {
|
||||
};
|
||||
|
||||
const activeApiUrl = computed(() => getReleaseRuntimeApiBaseUrl());
|
||||
const releaseSessionSummary = computed(() => buildReleaseSessionSummary());
|
||||
const activeApiUrlLabel = computed(() =>
|
||||
canInspectReleaseRuntime.value ? activeApiUrl.value : "Restricted to release operators"
|
||||
);
|
||||
const releaseSessionSummary = computed(() =>
|
||||
buildReleaseSessionSummary(undefined, { includeInfrastructureDetails: canInspectReleaseRuntime.value })
|
||||
);
|
||||
|
||||
const measurePingLatency = async () => {
|
||||
if (typeof fetch !== "function") {
|
||||
@@ -646,9 +652,10 @@ onBeforeUnmount(() => {
|
||||
</aside>
|
||||
|
||||
<aside class="request-queue-progress__side request-queue-progress__side--runtime" data-testid="request-queue-runtime-box">
|
||||
<div class="request-queue-progress__section-title">Session release</div>
|
||||
<div class="request-queue-progress__section-content request-queue-progress__section-content--release">
|
||||
<ul class="request-queue-progress__meta-list">
|
||||
<template v-if="canInspectReleaseRuntime">
|
||||
<div class="request-queue-progress__section-title">Session release</div>
|
||||
<div class="request-queue-progress__section-content request-queue-progress__section-content--release">
|
||||
<ul class="request-queue-progress__meta-list">
|
||||
<li class="request-queue-progress__meta-item">
|
||||
<span class="request-queue-progress__meta-label">Channel</span>
|
||||
<span
|
||||
@@ -771,11 +778,15 @@ onBeforeUnmount(() => {
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="request-queue-progress__subsection-title">Runtime details</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="request-queue-progress__section-title">Runtime details</div>
|
||||
<div class="request-queue-progress__section-content request-queue-progress__section-content--release">
|
||||
<ul class="request-queue-progress__meta-list">
|
||||
<li class="request-queue-progress__meta-item">
|
||||
<span class="request-queue-progress__meta-label">API URL</span>
|
||||
<span class="request-queue-progress__meta-value" :title="activeApiUrl">{{ activeApiUrl }}</span>
|
||||
<span class="request-queue-progress__meta-value" :title="activeApiUrlLabel">{{ activeApiUrlLabel }}</span>
|
||||
</li>
|
||||
<li class="request-queue-progress__meta-item">
|
||||
<span class="request-queue-progress__meta-label">Current host</span>
|
||||
|
||||
@@ -48,10 +48,13 @@ export const getDepartmentDescription = (id) => {
|
||||
|
||||
export const getDepartmentsGuest = async (queryParams = {}) => {
|
||||
isLoading.value = true;
|
||||
const queryString = new URLSearchParams(queryParams).toString();
|
||||
const request = await unauthenticatedRequest("/guest/departments" + (queryString ? `?${queryString}` : ""), "get");
|
||||
departments.value = sortByDepartmentPriorityOrder(request.data.data || []);
|
||||
isLoading.value = false;
|
||||
return departments.value;
|
||||
try {
|
||||
const queryString = new URLSearchParams(queryParams).toString();
|
||||
const request = await unauthenticatedRequest("/guest/departments" + (queryString ? `?${queryString}` : ""), "get");
|
||||
departments.value = sortByDepartmentPriorityOrder(request.data.data || []);
|
||||
return departments.value;
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { ref } from 'vue'
|
||||
import axios from 'axios'
|
||||
import {API_URL} from "@/config.js";
|
||||
import { clearEdgeGatewayWorkspaceCache } from "@/services/edgeGateways.js";
|
||||
|
||||
export const currentDepartment = ref(null);
|
||||
export const selectDepartment = (department) => {
|
||||
@@ -31,6 +32,7 @@ export const exitSession = () => {
|
||||
}
|
||||
});
|
||||
|
||||
clearEdgeGatewayWorkspaceCache();
|
||||
localStorage.removeItem('token');
|
||||
return result;
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import axios from 'axios'
|
||||
import { enqueueRequest } from "@/services/requestQueue.js";
|
||||
import { buildCurrentReleaseHeaders, resolveReleaseApiUrl } from "@/services/releaseTimeline.js";
|
||||
import { isTrustedReleaseUrl } from "@/services/releaseTrust.js";
|
||||
|
||||
/**
|
||||
* Get the selected customer number for X-Customer-Number header (used by subusers)
|
||||
@@ -12,28 +13,82 @@ const getSelectedCustomerNumber = () => {
|
||||
return stored ? parseInt(stored) : null;
|
||||
};
|
||||
|
||||
const MY_ACTIVE_WASH_ENDPOINT = '/modules/self-serve/lane/wash/my-active-wash';
|
||||
const ACTIVE_WASH_STARTED_STATUSES = new Set(['MACHINE_RELAY_ENABLED', 'MACHINE_STARTED']);
|
||||
|
||||
const normalizeStatus = (status) => String(status || '').trim().toUpperCase();
|
||||
|
||||
const parseDateTimeMs = (value) => {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const timestamp = Date.parse(String(value));
|
||||
return Number.isFinite(timestamp) ? timestamp : null;
|
||||
};
|
||||
|
||||
const hasActiveWashStartEvidence = (details) => {
|
||||
const session = details?.session || {};
|
||||
const status = normalizeStatus(session?.status ?? details?.status);
|
||||
|
||||
return (
|
||||
ACTIVE_WASH_STARTED_STATUSES.has(status) ||
|
||||
session?.machine_relay_enabled === true ||
|
||||
session?.machine_start_triggered === true ||
|
||||
details?.machine_relay_enabled === true ||
|
||||
details?.machine_start_triggered === true ||
|
||||
parseDateTimeMs(session?.wash_started_at) !== null ||
|
||||
parseDateTimeMs(session?.machine_start_triggered_at) !== null ||
|
||||
parseDateTimeMs(session?.machine_relay_enabled_at) !== null
|
||||
);
|
||||
};
|
||||
|
||||
const normalizeActiveWashResponse = (url, method, response) => {
|
||||
if (String(method || '').toUpperCase() !== 'GET' || !String(url || '').includes(MY_ACTIVE_WASH_ENDPOINT)) {
|
||||
return response;
|
||||
}
|
||||
|
||||
const details = response?.data?.data;
|
||||
if (!details?.in_progress || hasActiveWashStartEvidence(details)) {
|
||||
return response;
|
||||
}
|
||||
|
||||
return {
|
||||
...response,
|
||||
data: {
|
||||
...response.data,
|
||||
data: {
|
||||
...details,
|
||||
in_progress: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const authenticatedRequest = (url, method, data, catchCallable = null, thenCallable = null) => {
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) {
|
||||
//throw new Error('No token was found, unable to make authenticated request');
|
||||
}
|
||||
|
||||
const requestUrl = resolveReleaseApiUrl(url);
|
||||
const canSendCredentials = isTrustedReleaseUrl(requestUrl);
|
||||
|
||||
// Build headers
|
||||
const headers = {
|
||||
...buildCurrentReleaseHeaders(),
|
||||
};
|
||||
if (token && token.length > 0) {
|
||||
if (canSendCredentials && token && token.length > 0) {
|
||||
headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
// Add X-Customer-Number header if subuser has selected a grant
|
||||
const isSubuser = localStorage.getItem('is_subuser') === 'true';
|
||||
const selectedCustomerNumber = getSelectedCustomerNumber();
|
||||
if (isSubuser && selectedCustomerNumber) {
|
||||
if (canSendCredentials && isSubuser && selectedCustomerNumber) {
|
||||
headers['X-Customer-Number'] = selectedCustomerNumber;
|
||||
}
|
||||
|
||||
const requestUrl = resolveReleaseApiUrl(url);
|
||||
return enqueueRequest(
|
||||
() => axios({
|
||||
method,
|
||||
@@ -60,6 +115,7 @@ export const authenticatedRequest = (url, method, data, catchCallable = null, th
|
||||
throw error;
|
||||
})
|
||||
.then((response) => {
|
||||
response = normalizeActiveWashResponse(url, method, response);
|
||||
if (thenCallable) {
|
||||
// Call the then callable
|
||||
thenCallable(response);
|
||||
|
||||
@@ -58,6 +58,12 @@ import {
|
||||
releaseChannelRuntimeRequestParams,
|
||||
setReleaseChannelSwitchNoticePrincipal,
|
||||
} from "@/services/releaseChannelAvailability.js";
|
||||
import { clearCachedXlvaskUsageAmount } from "@/components/displays/department/pos/sync/xlvaskUsageAmountCache.js";
|
||||
import { clearEdgeGatewayWorkspaceCache } from "@/services/edgeGateways.js";
|
||||
import {
|
||||
clearPeriodCache,
|
||||
clearSelfWashCountsCache,
|
||||
} from "@/views/dashboards/superUserDashboard/InvoicingBillingPeriod/imports/InvoicingBillingPeriodImportPaging.js";
|
||||
|
||||
const normalizePositiveInteger = (value) => {
|
||||
const parsedValue = Number.parseInt(String(value ?? ""), 10);
|
||||
@@ -98,7 +104,16 @@ const hydrateSessionFromStorage = () => {
|
||||
return true;
|
||||
};
|
||||
|
||||
const clearPrivilegedSessionCaches = () => {
|
||||
clearPeriodCache();
|
||||
clearSelfWashCountsCache();
|
||||
clearCachedXlvaskUsageAmount();
|
||||
clearEdgeGatewayWorkspaceCache();
|
||||
};
|
||||
|
||||
const clearStoredSession = () => {
|
||||
clearPrivilegedSessionCaches();
|
||||
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
@@ -115,7 +130,8 @@ const applyReleaseRuntimeConfig = (runtime) => {
|
||||
const normalizedRuntime = runtime || {};
|
||||
configureReleaseRuntime(normalizedRuntime);
|
||||
reconcileSelectedReleaseChannel(normalizedRuntime);
|
||||
const runtimeUrls = normalizedRuntime.urls && typeof normalizedRuntime.urls === "object" ? normalizedRuntime.urls : {};
|
||||
const runtimeUrls =
|
||||
normalizedRuntime.urls && typeof normalizedRuntime.urls === "object" ? normalizedRuntime.urls : {};
|
||||
SessionUser.runtimeConfig.release.traceId.value = normalizedRuntime.trace_id || null;
|
||||
SessionUser.runtimeConfig.release.channel.value = normalizedRuntime.channel || null;
|
||||
SessionUser.runtimeConfig.release.availableChannels.value =
|
||||
@@ -377,6 +393,7 @@ export const authenticateUser = async (customer_number, password) => {
|
||||
password,
|
||||
})
|
||||
.then((response) => {
|
||||
clearPrivilegedSessionCaches();
|
||||
SessionUser.token.value = response.data.token;
|
||||
SessionUser.authenticated.value = true;
|
||||
localStorage.setItem("token", response.data.token);
|
||||
@@ -423,6 +440,7 @@ export const authenticateSubuser = async (credentials) => {
|
||||
|
||||
return await authenticatedRequest("/subusers/auth/password", "POST", requestBody)
|
||||
.then((response) => {
|
||||
clearPrivilegedSessionCaches();
|
||||
SessionUser.token.value = response.data.data.session;
|
||||
SessionUser.authenticated.value = true;
|
||||
SessionUser.isSubuser.value = true;
|
||||
@@ -669,6 +687,7 @@ export const SessionUser = {
|
||||
* @param {number|null} customerNumber - The billing_customer_number to select, or null to clear
|
||||
*/
|
||||
selectGrant: (customerNumber) => {
|
||||
clearEdgeGatewayWorkspaceCache();
|
||||
if (customerNumber === null) {
|
||||
localStorage.removeItem("selected_customer_number");
|
||||
SessionUser.subuser.selectedGrantCustomerNumber.value = null;
|
||||
@@ -886,6 +905,7 @@ export const SessionUser = {
|
||||
* @param {string} token
|
||||
*/
|
||||
setToken: (token) => {
|
||||
clearPrivilegedSessionCaches();
|
||||
SessionUser.token.value = token;
|
||||
localStorage.setItem("token", token);
|
||||
// Reload the window to make sure the user's session is initiated
|
||||
|
||||
@@ -7,6 +7,13 @@ import { getSystemUserIds } from "@/components/session/token/SessionUser/Objects
|
||||
// Helper function to get i18n translation
|
||||
const t = (key) => i18n.global.t(key);
|
||||
|
||||
const escapeHtml = (value = "") => String(value)
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll("\"", """)
|
||||
.replaceAll("'", "'");
|
||||
|
||||
const normalizeDateTimeLocalValue = (value) => {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return '';
|
||||
@@ -765,9 +772,11 @@ export const ObjectsGlobal = {
|
||||
console.log(response);
|
||||
// How long are the list of options
|
||||
for (let i = 0; i < response.length; i++) {
|
||||
var tmp = response[i];
|
||||
const tmp = response[i];
|
||||
const optionValue = tmp?.id ?? "";
|
||||
const optionLabel = tmp?.name ? tmp.name : optionValue;
|
||||
console.log(tmp);
|
||||
html += `<option value="${tmp.id}" ${(value == tmp.id || (value === null && tmp.id === 0)) ? 'selected' : ''}>${tmp.name ? tmp.name : tmp.id}</option>`;
|
||||
html += `<option value="${escapeHtml(optionValue)}" ${(value == optionValue || (value === null && optionValue === 0)) ? 'selected' : ''}>${escapeHtml(optionLabel)}</option>`;
|
||||
}
|
||||
});
|
||||
html += `</select>
|
||||
|
||||
@@ -19,6 +19,14 @@ const getProtectedDeletePayload = (error) => {
|
||||
return payload;
|
||||
};
|
||||
|
||||
const escapeHtml = (value) => String(value ?? "").replace(/[&<>"']/g, (character) => ({
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
"\"": """,
|
||||
"'": "'",
|
||||
})[character]);
|
||||
|
||||
const formatDeleteProtectionReason = (reason) => {
|
||||
switch (reason) {
|
||||
case "completed":
|
||||
@@ -28,22 +36,23 @@ const formatDeleteProtectionReason = (reason) => {
|
||||
case "attachments":
|
||||
return "ordren har vedhæftninger";
|
||||
default:
|
||||
return String(reason || "").replace(/_/g, " ");
|
||||
return escapeHtml(String(reason || "").replace(/_/g, " "));
|
||||
}
|
||||
};
|
||||
|
||||
const showProtectedOrderDeleteConfirmation = async (id, protectionPayload, onAfterSubmit = null) => {
|
||||
const orderId = String(id);
|
||||
const escapedOrderId = escapeHtml(orderId);
|
||||
const reasons = Array.isArray(protectionPayload?.protected_reasons)
|
||||
? protectionPayload.protected_reasons.map(formatDeleteProtectionReason).filter(Boolean)
|
||||
: [];
|
||||
const reasonText = reasons.length > 0 ? reasons.join(", ") : "ordren indeholder gemte data";
|
||||
|
||||
const result = await Swal.fire({
|
||||
title: `Bekræft sletning af ordre #${orderId}`,
|
||||
titleText: `Bekræft sletning af ordre #${orderId}`,
|
||||
html: `
|
||||
<p>Ordren kan stadig slettes, men kræver ekstra bekræftelse fordi ${reasonText}.</p>
|
||||
<p>Skriv <strong>${orderId}</strong> for at slette ordren.</p>
|
||||
<p>Skriv <strong>${escapedOrderId}</strong> for at slette ordren.</p>
|
||||
`,
|
||||
input: "text",
|
||||
inputAttributes: {
|
||||
|
||||
@@ -127,6 +127,13 @@ export const SelfServeVehicleConditions = {
|
||||
}
|
||||
},
|
||||
add: async (department, lane, customer_id, reg, question, value, options = {}) => {
|
||||
const safeOptions = {};
|
||||
const normalizedVehicleType = parseInt(options.vehicle_type ?? options.vehicle_type_id);
|
||||
|
||||
if (!Number.isNaN(normalizedVehicleType) && normalizedVehicleType > 0) {
|
||||
safeOptions.vehicle_type = normalizedVehicleType;
|
||||
}
|
||||
|
||||
return ObjectsGlobal.add.object(SelfServeVehicleConditions.meta.endpoint, {
|
||||
department: parseInt(department),
|
||||
lane: parseInt(lane),
|
||||
@@ -134,7 +141,9 @@ export const SelfServeVehicleConditions = {
|
||||
reg: reg,
|
||||
question: parseInt(question),
|
||||
value: value === "true" || value === true,
|
||||
...options
|
||||
...safeOptions,
|
||||
activate_machine: false,
|
||||
sync_relay_state: false
|
||||
});
|
||||
},
|
||||
set: {
|
||||
|
||||
@@ -17,13 +17,15 @@ const normalizeEntries = (entries) => {
|
||||
return [];
|
||||
};
|
||||
|
||||
const SENSITIVE_WRITE_ONLY_KEYS = new Set(["broker_shared_secret"]);
|
||||
|
||||
const toConfigPayload = (entries) => {
|
||||
const payload = {};
|
||||
normalizeEntries(entries).forEach((entry) => {
|
||||
if (!entry || !entry.variable) {
|
||||
return;
|
||||
}
|
||||
payload[entry.variable] = entry.value;
|
||||
payload[entry.variable] = SENSITIVE_WRITE_ONLY_KEYS.has(entry.variable) ? "" : entry.value;
|
||||
});
|
||||
return payload;
|
||||
};
|
||||
@@ -42,6 +44,11 @@ export const Config = {
|
||||
test_broker: async (payload) => testEdgeGatewayBrokerConfig(payload),
|
||||
set: async (variable, value) => {
|
||||
const currentConfig = await Config.get_config();
|
||||
SENSITIVE_WRITE_ONLY_KEYS.forEach((key) => {
|
||||
if (key !== variable) {
|
||||
delete currentConfig[key];
|
||||
}
|
||||
});
|
||||
return Config.set_config({
|
||||
...currentConfig,
|
||||
[variable]: value,
|
||||
@@ -73,7 +80,6 @@ export const Config = {
|
||||
set: async (value) => Config.set("broker_auth_mode", value),
|
||||
},
|
||||
broker_shared_secret: {
|
||||
get: async () => Config.get("broker_shared_secret"),
|
||||
set: async (value) => Config.set("broker_shared_secret", value),
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script>
|
||||
import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue";
|
||||
import { parseError } from "@/components/request/HandleGlobalError.vue";
|
||||
import { clearEdgeGatewayWorkspaceCache } from "@/services/edgeGateways.js";
|
||||
import { DatabaseSystemObject } from "@/components/session/token/superUser/systemDatabase.vue";
|
||||
import { Cron } from "@/components/session/token/superUser/cron.vue";
|
||||
import { Economic } from "@/components/session/token/superUser/modules/Economic/Economic.vue";
|
||||
@@ -106,6 +107,7 @@ export const SuperUserObject = {
|
||||
const currentToken = localStorage.getItem("token");
|
||||
localStorage.setItem("superuser_token", currentToken);
|
||||
// Set the token in the local storage
|
||||
clearEdgeGatewayWorkspaceCache();
|
||||
localStorage.setItem("token", response.data.data.token);
|
||||
// Redirect the user to the dashboard
|
||||
window.location = "/";
|
||||
@@ -143,6 +145,7 @@ export const SuperUserObject = {
|
||||
// Get the superuser token
|
||||
const superuserToken = localStorage.getItem("superuser_token");
|
||||
// Set the token in the local storage
|
||||
clearEdgeGatewayWorkspaceCache();
|
||||
localStorage.setItem("token", superuserToken);
|
||||
// Clear the superuser token
|
||||
localStorage.removeItem("superuser_token");
|
||||
|
||||
@@ -320,20 +320,26 @@ export const ensureCurrentOrderDepartment = async (targetDepartmentId = null) =>
|
||||
}
|
||||
|
||||
try {
|
||||
let currentDepartmentId = null;
|
||||
try {
|
||||
currentDepartmentId = await getCurrentOrderDepartmentId(normalizedOrderId);
|
||||
} catch (error) {
|
||||
currentDepartmentId = null;
|
||||
}
|
||||
const currentDepartmentId = await getCurrentOrderDepartmentId(normalizedOrderId);
|
||||
if (currentDepartmentId === normalizedDepartmentId) {
|
||||
department_id.value = normalizedDepartmentId;
|
||||
return true;
|
||||
}
|
||||
|
||||
await SessionUser.objects.orders.set.department_id(normalizedOrderId, normalizedDepartmentId);
|
||||
department_id.value = normalizedDepartmentId;
|
||||
return true;
|
||||
parseError(
|
||||
{
|
||||
response: {
|
||||
data: {
|
||||
data: {
|
||||
message: "Ordren tilhører ikke den valgte afdeling",
|
||||
},
|
||||
message: "Ordren tilhører ikke den valgte afdeling",
|
||||
},
|
||||
},
|
||||
},
|
||||
"stepError"
|
||||
);
|
||||
return false;
|
||||
} catch (error) {
|
||||
parseError(error, "stepError");
|
||||
return false;
|
||||
@@ -727,9 +733,9 @@ export const restoreStoredPosOrderId = async (
|
||||
if (selectedDepartmentId && toPositiveInteger(storedOrder?.department_id) !== selectedDepartmentId) {
|
||||
if (options.syncDepartment === true) {
|
||||
order_id.value = storedOrderId;
|
||||
const didSyncDepartment = await ensureCurrentOrderDepartment(selectedDepartmentId);
|
||||
if (!didSyncDepartment) {
|
||||
throw new Error("Stored order department could not be changed to current department");
|
||||
const isCurrentDepartment = await ensureCurrentOrderDepartment(selectedDepartmentId);
|
||||
if (!isCurrentDepartment) {
|
||||
throw new Error("Stored order department does not match current department");
|
||||
}
|
||||
} else {
|
||||
throw new Error("Stored order department does not match current department");
|
||||
@@ -2012,18 +2018,14 @@ export const getVehiclePlateBookings = (vehiclePlate) => {
|
||||
return sortPendingOrderBookings(matchingBookings);
|
||||
};
|
||||
|
||||
export const ensureVehiclePlateBookingsLoaded = async (vehiclePlate, options = {}) => {
|
||||
const normalizedOptions = {
|
||||
force: false,
|
||||
...options,
|
||||
};
|
||||
export const ensureVehiclePlateBookingsLoaded = async (vehiclePlate, _options = {}) => {
|
||||
const normalizedVehiclePlate = normalizeVehiclePlateForBookingSelection(vehiclePlate);
|
||||
const currentDepartmentKey = syncPendingBookingsDepartmentState();
|
||||
if (!normalizedVehiclePlate || !department_id.value) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!normalizedOptions.force && loadedPendingBookingPlates.has(normalizedVehiclePlate)) {
|
||||
if (loadedPendingBookingPlates.has(normalizedVehiclePlate)) {
|
||||
return getVehiclePlateBookings(normalizedVehiclePlate);
|
||||
}
|
||||
|
||||
@@ -2167,7 +2169,7 @@ const getFirstAvailableWashProduct = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const hydrateSelectedOrderBookingForDesktop = async () => {
|
||||
export const hydrateSelectedOrderBookingForDesktop = async () => {
|
||||
const normalizedOrderId = toPositiveInteger(order_id.value);
|
||||
const normalizedBookingId = toPositiveInteger(selectedOrderBookingId.value);
|
||||
|
||||
@@ -2250,8 +2252,7 @@ const hydrateSelectedOrderBookingForDesktop = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const forcedPrimaryPrice =
|
||||
typeof primaryRawItem?.price === "number" ? Number(primaryRawItem.price) : primaryProduct.price ?? null;
|
||||
const forcedPrimaryPrice = primaryProduct.price ?? null;
|
||||
const primaryItemResponse = await createOrderItem(
|
||||
normalizedOrderId,
|
||||
primaryProduct.id,
|
||||
@@ -2269,13 +2270,15 @@ const hydrateSelectedOrderBookingForDesktop = async () => {
|
||||
continue;
|
||||
}
|
||||
|
||||
const secondaryProduct = await fetchOrderBookingProductWithPricing(secondaryProductId);
|
||||
|
||||
await createOrderItem(
|
||||
normalizedOrderId,
|
||||
secondaryProductId,
|
||||
Math.max(1, Number.parseInt(String(bookingItem?.quantity ?? 1), 10) || 1),
|
||||
relatedPrimaryItemId,
|
||||
String(bookingItem?.notes ?? "").trim() || null,
|
||||
typeof bookingItem?.price === "number" ? Number(bookingItem.price) : null
|
||||
secondaryProduct?.price ?? null
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ const pages = {
|
||||
to: "/user/wash/start",
|
||||
label: "Selv vask",
|
||||
icon: "tint",
|
||||
disabled: !IS_DEV,
|
||||
disabled: false,
|
||||
},
|
||||
bookings: {
|
||||
to: "/user/bookings",
|
||||
|
||||
@@ -64,6 +64,7 @@ export const useOrderMetadataAutosave = ({
|
||||
|
||||
const requestedValue = normalizeValue(draft.value);
|
||||
const sequence = ++nextSequence;
|
||||
let saveCompleted = false;
|
||||
isSaving.value = true;
|
||||
|
||||
try {
|
||||
@@ -83,6 +84,8 @@ export const useOrderMetadataAutosave = ({
|
||||
await onSaved(savedValue, requestedValue);
|
||||
}
|
||||
|
||||
saveCompleted = true;
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (typeof onError === "function") {
|
||||
@@ -95,12 +98,11 @@ export const useOrderMetadataAutosave = ({
|
||||
} finally {
|
||||
isSaving.value = false;
|
||||
|
||||
if (saveAgainAfterCurrentRequest || isDirty.value) {
|
||||
saveAgainAfterCurrentRequest = false;
|
||||
const shouldSaveAgain = saveAgainAfterCurrentRequest;
|
||||
saveAgainAfterCurrentRequest = false;
|
||||
|
||||
if (isDirty.value) {
|
||||
void persist();
|
||||
}
|
||||
if (saveCompleted && (shouldSaveAgain || isDirty.value) && isDirty.value) {
|
||||
void persist();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { computed, ref } from "vue";
|
||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
import { safeAttachmentDownloadLink } from "@/services/attachmentDownloadLinks.js";
|
||||
|
||||
const normalizeBooleanAnswer = (value) => {
|
||||
if (value === true || value === false) {
|
||||
@@ -270,6 +271,55 @@ const mergeByNumericId = (existingItems, incomingItems) => {
|
||||
return merged;
|
||||
};
|
||||
|
||||
const taskStableKey = (task) =>
|
||||
String(task?.task ?? "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
|
||||
const mergeTaskSets = (existingTasks, incomingTasks) => {
|
||||
if (!Array.isArray(existingTasks) || existingTasks.length === 0) {
|
||||
return Array.isArray(incomingTasks) ? [...incomingTasks] : [];
|
||||
}
|
||||
|
||||
if (!Array.isArray(incomingTasks) || incomingTasks.length === 0) {
|
||||
return [...existingTasks];
|
||||
}
|
||||
|
||||
const usedExistingIndexes = new Set();
|
||||
|
||||
return incomingTasks.map((incomingTask) => {
|
||||
const incomingId = extractTaskId(incomingTask);
|
||||
let existingIndex = -1;
|
||||
|
||||
if (incomingId) {
|
||||
existingIndex = existingTasks.findIndex((task) => extractTaskId(task) === incomingId);
|
||||
} else {
|
||||
const incomingKey = taskStableKey(incomingTask);
|
||||
existingIndex = existingTasks.findIndex(
|
||||
(task, index) => !usedExistingIndexes.has(index) && taskStableKey(task) === incomingKey
|
||||
);
|
||||
}
|
||||
|
||||
if (existingIndex === -1) {
|
||||
return incomingTask;
|
||||
}
|
||||
|
||||
usedExistingIndexes.add(existingIndex);
|
||||
const existingTask = existingTasks[existingIndex];
|
||||
|
||||
return {
|
||||
...existingTask,
|
||||
...incomingTask,
|
||||
id: incomingTask.id || existingTask.id,
|
||||
task_id: incomingTask.task_id || existingTask.task_id,
|
||||
attachments: Array.isArray(incomingTask.attachments) && incomingTask.attachments.length > 0
|
||||
? incomingTask.attachments
|
||||
: existingTask.attachments,
|
||||
_attachmentsLoaded: incomingTask._attachmentsLoaded || existingTask._attachmentsLoaded,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
export function useSelfServeLogic() {
|
||||
const loading = ref(false);
|
||||
const requestError = ref(null);
|
||||
@@ -296,6 +346,30 @@ export function useSelfServeLogic() {
|
||||
const summaryVisibleQuestionIds = ref([]);
|
||||
const summaryQuestionOrder = ref({});
|
||||
const latestFetchRequestId = ref(0);
|
||||
const latestSuccessfulFetchKey = ref(null);
|
||||
const inFlightFetchKey = ref(null);
|
||||
|
||||
const createFetchKey = (departmentId, vehicleTypeId, laneId, reg) => {
|
||||
const normalizedDepartmentId = parseInt(departmentId);
|
||||
const normalizedLaneId = parseInt(laneId);
|
||||
const normalizedReg = String(reg || "").trim().toUpperCase();
|
||||
const normalizedVehicleTypeId = parseInt(vehicleTypeId);
|
||||
const vehicleTypeKey = !Number.isNaN(normalizedVehicleTypeId) && normalizedVehicleTypeId > 0
|
||||
? normalizedVehicleTypeId
|
||||
: "";
|
||||
|
||||
if (
|
||||
Number.isNaN(normalizedDepartmentId)
|
||||
|| normalizedDepartmentId <= 0
|
||||
|| Number.isNaN(normalizedLaneId)
|
||||
|| normalizedLaneId <= 0
|
||||
|| normalizedReg.length < 2
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [normalizedDepartmentId, normalizedLaneId, normalizedReg, vehicleTypeKey].join("|");
|
||||
};
|
||||
|
||||
const beginFetchRequest = () => {
|
||||
latestFetchRequestId.value += 1;
|
||||
@@ -424,6 +498,10 @@ export function useSelfServeLogic() {
|
||||
machineType.value = previewData?.machine_type || machineType.value;
|
||||
vehicle.value = previewData?.vehicle || null;
|
||||
session.value = previewData?.session || session.value;
|
||||
if (!previewData?.session?.id) {
|
||||
summary.value = null;
|
||||
events.value = [];
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(previewData || {}, "config_version_id")) {
|
||||
configVersionId.value = previewData?.config_version_id ?? null;
|
||||
}
|
||||
@@ -442,6 +520,7 @@ export function useSelfServeLogic() {
|
||||
|
||||
questions.value = nextQuestions;
|
||||
answers.value = buildAnswersMap(nextQuestions);
|
||||
setSummaryVisibleQuestions(nextQuestions);
|
||||
|
||||
const normalizedConditions = Array.isArray(previewData?.conditions)
|
||||
? previewData.conditions.map(normalizeCondition)
|
||||
@@ -461,13 +540,15 @@ export function useSelfServeLogic() {
|
||||
? previewData.tasks.map(normalizeTask).sort((a, b) => a.order_priority - b.order_priority)
|
||||
: [];
|
||||
|
||||
tasks.value = normalizedTasks;
|
||||
applyAllowedServices(previewData);
|
||||
|
||||
const hydratedTasks = await hydrateTaskAttachments(normalizedTasks);
|
||||
if (!isFetchRequestActive(requestId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
tasks.value = hydratedTasks;
|
||||
applyAllowedServices(previewData);
|
||||
return true;
|
||||
};
|
||||
|
||||
@@ -512,8 +593,9 @@ export function useSelfServeLogic() {
|
||||
const normalizedTasks = summaryData.tasks
|
||||
.map(normalizeTask)
|
||||
.sort((a, b) => a.order_priority - b.order_priority);
|
||||
const mergedTasks = mergeTaskSets(tasks.value, normalizedTasks);
|
||||
|
||||
const hydratedTasks = await hydrateTaskAttachments(normalizedTasks);
|
||||
const hydratedTasks = await hydrateTaskAttachments(mergedTasks);
|
||||
if (!isFetchRequestActive(requestId)) {
|
||||
return false;
|
||||
}
|
||||
@@ -526,7 +608,7 @@ export function useSelfServeLogic() {
|
||||
};
|
||||
|
||||
const downloadAttachment = async (taskId, attachmentId, attachment = null) => {
|
||||
const existingDownloadLink = attachment?.download_link || null;
|
||||
const existingDownloadLink = safeAttachmentDownloadLink(attachment?.download_link);
|
||||
if (existingDownloadLink) {
|
||||
window.open(existingDownloadLink, "_blank", "noopener");
|
||||
return;
|
||||
@@ -534,10 +616,9 @@ export function useSelfServeLogic() {
|
||||
|
||||
try {
|
||||
const response = await SessionUser.objects.self_serve_tasks.attachments.download(taskId, attachmentId);
|
||||
if (response?.data?.download_link) {
|
||||
window.open(response.data.download_link, "_blank", "noopener");
|
||||
} else if (response?.download_link) {
|
||||
window.open(response.download_link, "_blank", "noopener");
|
||||
const downloadLink = safeAttachmentDownloadLink(response?.data?.download_link || response?.download_link);
|
||||
if (downloadLink) {
|
||||
window.open(downloadLink, "_blank", "noopener");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error downloading attachment:", error);
|
||||
@@ -575,10 +656,17 @@ export function useSelfServeLogic() {
|
||||
}
|
||||
};
|
||||
|
||||
const fetchSelfServeData = async (_departmentId, _vehicleTypeId, laneId = null, reg = null) => {
|
||||
const fetchSelfServeData = async (_departmentId, _vehicleTypeId, laneId = null, reg = null, options = {}) => {
|
||||
const fetchKey = createFetchKey(_departmentId, _vehicleTypeId, laneId, reg);
|
||||
if (!options.force && fetchKey && (fetchKey === latestSuccessfulFetchKey.value || fetchKey === inFlightFetchKey.value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const requestId = beginFetchRequest();
|
||||
|
||||
if (!laneId || !reg || reg.trim().length < 2) {
|
||||
latestSuccessfulFetchKey.value = null;
|
||||
inFlightFetchKey.value = null;
|
||||
preview.value = null;
|
||||
summary.value = null;
|
||||
session.value = null;
|
||||
@@ -606,6 +694,7 @@ export function useSelfServeLogic() {
|
||||
|
||||
loading.value = true;
|
||||
requestError.value = null;
|
||||
inFlightFetchKey.value = fetchKey;
|
||||
try {
|
||||
const normalizedReg = reg.trim().toUpperCase();
|
||||
const normalizedVehicleTypeId = parseInt(_vehicleTypeId);
|
||||
@@ -687,6 +776,10 @@ export function useSelfServeLogic() {
|
||||
setSummaryVisibleQuestions(questions.value);
|
||||
}
|
||||
|
||||
if (isFetchRequestActive(requestId)) {
|
||||
latestSuccessfulFetchKey.value = fetchKey;
|
||||
}
|
||||
|
||||
return previewData;
|
||||
} catch (error) {
|
||||
console.error("Error fetching self-serve preview:", error);
|
||||
@@ -695,6 +788,9 @@ export function useSelfServeLogic() {
|
||||
}
|
||||
return null;
|
||||
} finally {
|
||||
if (inFlightFetchKey.value === fetchKey) {
|
||||
inFlightFetchKey.value = null;
|
||||
}
|
||||
if (isFetchRequestActive(requestId)) {
|
||||
loading.value = false;
|
||||
}
|
||||
@@ -749,7 +845,7 @@ export function useSelfServeLogic() {
|
||||
updateResolvedVehicleTypeId(responseSummary, responseSummary?.session);
|
||||
}
|
||||
|
||||
await fetchSelfServeData(departmentId, refreshVehicleTypeId, laneId, normalizedReg);
|
||||
await fetchSelfServeData(departmentId, refreshVehicleTypeId, laneId, normalizedReg, { force: true });
|
||||
answers.value = {
|
||||
...answers.value,
|
||||
[parseInt(questionId)]: value,
|
||||
@@ -814,7 +910,7 @@ export function useSelfServeLogic() {
|
||||
? normalizedRefreshVehicleTypeId
|
||||
: null;
|
||||
|
||||
await fetchSelfServeData(departmentId, refreshVehicleTypeId, parseInt(laneId), normalizedReg);
|
||||
await fetchSelfServeData(departmentId, refreshVehicleTypeId, parseInt(laneId), normalizedReg, { force: true });
|
||||
|
||||
return { deletedCount: conditionIdsToDelete.length };
|
||||
} catch (error) {
|
||||
@@ -986,7 +1082,22 @@ export function useSelfServeLogic() {
|
||||
|
||||
const updateLaneAllowedServices = async (laneId) => {
|
||||
if (!laneId) return;
|
||||
const taskIds = activeTasks.value.map((task) => extractTaskId(task)).filter(Boolean);
|
||||
const activeTaskIds = activeTasks.value.map((task) => extractTaskId(task)).filter(Boolean);
|
||||
const taskIds = activeTaskIds.length > 0
|
||||
? activeTaskIds
|
||||
: tasks.value.map((task) => extractTaskId(task)).filter(Boolean);
|
||||
|
||||
if (taskIds.length === 0) {
|
||||
requestError.value = null;
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
lane_id: parseInt(laneId),
|
||||
allowed_services: allowedServices.value,
|
||||
skipped_empty_task_sync: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await SessionUser.request('/modules/self-serve/lane/services/allowed', 'post', {
|
||||
@@ -1049,6 +1160,7 @@ export function useSelfServeLogic() {
|
||||
answers,
|
||||
completedTasks,
|
||||
allowedServices,
|
||||
resolvedVehicleTypeId: lastResolvedVehicleTypeId,
|
||||
configVersionId,
|
||||
evaluationTrace,
|
||||
visibleQuestions,
|
||||
|
||||
@@ -2,12 +2,20 @@ import { computed, nextTick, onUnmounted, ref, watch } from "vue";
|
||||
import { getDepartmentsGuest } from "@/components/pagination/departmentTabs.vue";
|
||||
import { locations } from "@/components/displays/department/pos/steps/mobile/objects/PosDepartmentStepMobileFlow.vue";
|
||||
|
||||
const isLaneSelfServeEnabled = (lane) => !(
|
||||
lane?.selfserve_enabled === false
|
||||
|| lane?.selfserve_enabled === 0
|
||||
|| lane?.selfserve_enabled === "0"
|
||||
|| ["false", "off", "no"].includes(String(lane?.selfserve_enabled ?? "").trim().toLowerCase())
|
||||
);
|
||||
const isLaneSelfServeEnabled = (lane) =>
|
||||
!(
|
||||
lane?.selfserve_enabled === false ||
|
||||
lane?.selfserve_enabled === 0 ||
|
||||
lane?.selfserve_enabled === "0" ||
|
||||
["false", "off", "no"].includes(
|
||||
String(lane?.selfserve_enabled ?? "")
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
)
|
||||
);
|
||||
|
||||
const hasSelfServeEnabledLane = (department) =>
|
||||
department?.self_serve_enabled === true && (department.lanes || []).some(isLaneSelfServeEnabled);
|
||||
|
||||
const toDepartmentViewModel = (department, distance = null) => ({
|
||||
id: department.id,
|
||||
@@ -18,12 +26,22 @@ const toDepartmentViewModel = (department, distance = null) => ({
|
||||
self_serve_enabled: department.self_serve_enabled,
|
||||
});
|
||||
|
||||
const extractDepartmentFetchErrorMessage = (error) => {
|
||||
const candidates = [
|
||||
error?.response?.data?.data?.message,
|
||||
error?.response?.data?.message,
|
||||
error?.response?.data?.error,
|
||||
error?.message,
|
||||
];
|
||||
|
||||
return (
|
||||
candidates.find((candidate) => typeof candidate === "string" && candidate.trim() !== "") ||
|
||||
"Kunne ikke hente vaskeafdelinger. Prøv igen."
|
||||
);
|
||||
};
|
||||
|
||||
export function useWashDepartments(options = {}) {
|
||||
const {
|
||||
includeLanes = false,
|
||||
refreshIntervalMs = 0,
|
||||
canAccessSuperUser = () => false,
|
||||
} = options;
|
||||
const { includeLanes = false, refreshIntervalMs = 0, canAccessSuperUser = () => false } = options;
|
||||
|
||||
const guestDepartments = ref([]);
|
||||
const nearestDepartment = ref(null);
|
||||
@@ -31,6 +49,8 @@ export function useWashDepartments(options = {}) {
|
||||
const forceNearestDepartmentEvaluationId = ref(0);
|
||||
const isSearchingDepartments = ref(false);
|
||||
const lastDepartmentFetchTime = ref(null);
|
||||
const departmentFetchError = ref(null);
|
||||
const departmentSelectionStrategy = ref(null);
|
||||
|
||||
let refreshInterval = null;
|
||||
|
||||
@@ -57,13 +77,24 @@ export function useWashDepartments(options = {}) {
|
||||
return nearestDepartment.value.self_serve_enabled === true;
|
||||
});
|
||||
|
||||
const buildGuestDepartmentParams = () => (
|
||||
includeLanes ? { include_lanes: true } : {}
|
||||
);
|
||||
const isDepartmentSelectionDistanceBased = computed(() => departmentSelectionStrategy.value === "distance");
|
||||
|
||||
const isDepartmentSelectionFallbackBased = computed(() => departmentSelectionStrategy.value === "fallback");
|
||||
|
||||
const hasLocationCoordinates = (locationValue = locations.location.value) => !!locationValue?.coords;
|
||||
|
||||
const buildGuestDepartmentParams = () => (includeLanes ? { include_lanes: true } : {});
|
||||
|
||||
const fetchDepartments = async () => {
|
||||
guestDepartments.value = await getDepartmentsGuest(buildGuestDepartmentParams());
|
||||
lastDepartmentFetchTime.value = Date.now();
|
||||
try {
|
||||
guestDepartments.value = await getDepartmentsGuest(buildGuestDepartmentParams());
|
||||
lastDepartmentFetchTime.value = Date.now();
|
||||
departmentFetchError.value = null;
|
||||
} catch (error) {
|
||||
departmentFetchError.value = extractDepartmentFetchErrorMessage(error);
|
||||
console.warn("Failed to fetch wash departments:", error);
|
||||
}
|
||||
|
||||
evaluateLocationDepartments(locations.location.value);
|
||||
return guestDepartments.value;
|
||||
};
|
||||
@@ -74,39 +105,45 @@ export function useWashDepartments(options = {}) {
|
||||
}
|
||||
|
||||
const currentTime = Date.now();
|
||||
if (
|
||||
!lastDepartmentFetchTime.value
|
||||
|| (currentTime - lastDepartmentFetchTime.value) >= refreshIntervalMs
|
||||
) {
|
||||
if (!lastDepartmentFetchTime.value || currentTime - lastDepartmentFetchTime.value >= refreshIntervalMs) {
|
||||
return fetchDepartments();
|
||||
}
|
||||
|
||||
return guestDepartments.value;
|
||||
};
|
||||
|
||||
const getForcedDepartment = () => guestDepartments.value.find(
|
||||
(department) => department.id === forceNearestDepartmentEvaluationId.value
|
||||
);
|
||||
const getForcedDepartment = () =>
|
||||
guestDepartments.value.find((department) => department.id === forceNearestDepartmentEvaluationId.value);
|
||||
|
||||
const evaluateLocationDepartments = (locationValue = locations.location.value) => {
|
||||
if (isForcingNearestDepartment.value && forceNearestDepartmentEvaluationId.value) {
|
||||
const forcedDepartment = getForcedDepartment();
|
||||
if (forcedDepartment) {
|
||||
nearestDepartment.value = toDepartmentViewModel(forcedDepartment);
|
||||
departmentSelectionStrategy.value = "forced";
|
||||
return nearestDepartment.value;
|
||||
}
|
||||
}
|
||||
|
||||
if (guestDepartments.value.length === 0) {
|
||||
nearestDepartment.value = null;
|
||||
departmentSelectionStrategy.value = null;
|
||||
return nearestDepartment.value;
|
||||
}
|
||||
|
||||
if (!hasLocationCoordinates(locationValue)) {
|
||||
const fallbackDepartment = guestDepartments.value.find(hasSelfServeEnabledLane);
|
||||
nearestDepartment.value = fallbackDepartment ? toDepartmentViewModel(fallbackDepartment) : null;
|
||||
departmentSelectionStrategy.value = fallbackDepartment ? "fallback" : null;
|
||||
return nearestDepartment.value;
|
||||
}
|
||||
|
||||
let currentNearestDepartment = {
|
||||
id: null,
|
||||
distance: Infinity,
|
||||
};
|
||||
|
||||
guestDepartments.value.forEach((department) => {
|
||||
if (!locationValue?.coords) {
|
||||
return;
|
||||
}
|
||||
|
||||
const from = {
|
||||
latitude: locationValue.coords.latitude,
|
||||
longitude: locationValue.coords.longitude,
|
||||
@@ -124,6 +161,18 @@ export function useWashDepartments(options = {}) {
|
||||
|
||||
if (currentNearestDepartment.id) {
|
||||
nearestDepartment.value = currentNearestDepartment;
|
||||
departmentSelectionStrategy.value = "distance";
|
||||
return nearestDepartment.value;
|
||||
}
|
||||
|
||||
const fallbackDepartment =
|
||||
guestDepartments.value.find((department) => department.self_serve_enabled === true) || guestDepartments.value[0];
|
||||
if (fallbackDepartment) {
|
||||
nearestDepartment.value = toDepartmentViewModel(fallbackDepartment);
|
||||
departmentSelectionStrategy.value = "fallback";
|
||||
} else {
|
||||
nearestDepartment.value = null;
|
||||
departmentSelectionStrategy.value = null;
|
||||
}
|
||||
|
||||
return nearestDepartment.value;
|
||||
@@ -150,7 +199,7 @@ export function useWashDepartments(options = {}) {
|
||||
};
|
||||
|
||||
const startDepartmentSearch = () => {
|
||||
if (!canAccessSuperUser()) {
|
||||
if (!canAccessSuperUser() && hasLocationCoordinates()) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -191,19 +240,30 @@ export function useWashDepartments(options = {}) {
|
||||
}
|
||||
};
|
||||
|
||||
watch(() => guestDepartments.value, () => {
|
||||
evaluateLocationDepartments(locations.location.value);
|
||||
}, { deep: true });
|
||||
|
||||
watch(() => locations.location.value, (newValue) => {
|
||||
evaluateLocationDepartments(newValue);
|
||||
}, { deep: true });
|
||||
|
||||
watch(() => isSearchingDepartments.value, (isSearching) => {
|
||||
if (!isSearching) {
|
||||
watch(
|
||||
() => guestDepartments.value,
|
||||
() => {
|
||||
evaluateLocationDepartments(locations.location.value);
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
watch(
|
||||
() => locations.location.value,
|
||||
(newValue) => {
|
||||
evaluateLocationDepartments(newValue);
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
watch(
|
||||
() => isSearchingDepartments.value,
|
||||
(isSearching) => {
|
||||
if (!isSearching) {
|
||||
evaluateLocationDepartments(locations.location.value);
|
||||
}
|
||||
}
|
||||
});
|
||||
);
|
||||
|
||||
onUnmounted(() => {
|
||||
stopAutoRefresh();
|
||||
@@ -216,6 +276,10 @@ export function useWashDepartments(options = {}) {
|
||||
forceNearestDepartmentEvaluationId,
|
||||
isSearchingDepartments,
|
||||
lastDepartmentFetchTime,
|
||||
departmentFetchError,
|
||||
departmentSelectionStrategy,
|
||||
isDepartmentSelectionDistanceBased,
|
||||
isDepartmentSelectionFallbackBased,
|
||||
availableProductIds,
|
||||
doesCurrentDepartmentSelectionHaveSelfServeEnabled,
|
||||
fetchDepartments,
|
||||
|
||||
@@ -7,6 +7,11 @@ export const WASH_STEPS = {
|
||||
COMPLETED: 5,
|
||||
};
|
||||
|
||||
const normalizeLaneId = (value) => {
|
||||
const normalized = parseInt(String(value ?? ""), 10);
|
||||
return Number.isNaN(normalized) || normalized <= 0 ? null : normalized;
|
||||
};
|
||||
|
||||
export function useWashFlowState(options) {
|
||||
const {
|
||||
currentStep,
|
||||
@@ -14,7 +19,7 @@ export function useWashFlowState(options) {
|
||||
customerNumberInput,
|
||||
licensePlateInput,
|
||||
vehicleTypeSelect,
|
||||
availableProductIds,
|
||||
isCustomerNumberRequired,
|
||||
radioLaneOption,
|
||||
nearestDepartment,
|
||||
allVisibleQuestionsAnswered,
|
||||
@@ -34,6 +39,17 @@ export function useWashFlowState(options) {
|
||||
activeTasks.value.length > 0 ? steps.TASKS : steps.WASH_IN_PROGRESS
|
||||
);
|
||||
|
||||
const getSelectedLaneId = () => normalizeLaneId(radioLaneOption.value);
|
||||
|
||||
const getSelectedLane = () => {
|
||||
const selectedLaneId = getSelectedLaneId();
|
||||
if (selectedLaneId === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return nearestDepartment.value?.lanes.find((lane) => normalizeLaneId(lane.id) === selectedLaneId) || null;
|
||||
};
|
||||
|
||||
const clickableSteps = {
|
||||
[steps.VEHICLE]: () => {
|
||||
if (washInProgress.value) {
|
||||
@@ -41,16 +57,15 @@ export function useWashFlowState(options) {
|
||||
}
|
||||
|
||||
const selectedVehicleTypeId = vehicleTypeSelect.value;
|
||||
const allowedProductIds = new Set((availableProductIds.value || []).map((productId) => String(productId)));
|
||||
const hasAllowedVehicleType = selectedVehicleTypeId !== null
|
||||
&& selectedVehicleTypeId !== undefined
|
||||
&& allowedProductIds.has(String(selectedVehicleTypeId));
|
||||
const hasSelectedVehicleType = selectedVehicleTypeId !== null && selectedVehicleTypeId !== undefined;
|
||||
const customerNumberValue = customerNumberInput.value;
|
||||
const customerNumberValid = customerNumberValue === null
|
||||
const requiresCustomerNumber = isCustomerNumberRequired?.value ?? false;
|
||||
const customerNumberValid = !requiresCustomerNumber
|
||||
|| customerNumberValue === null
|
||||
|| customerNumberValue === undefined
|
||||
|| String(customerNumberValue).trim() !== "";
|
||||
const licensePlateValid = !!(licensePlateInput.value && licensePlateInput.value.trim() !== "");
|
||||
if (!hasAllowedVehicleType) {
|
||||
if (!hasSelectedVehicleType) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -103,7 +118,7 @@ export function useWashFlowState(options) {
|
||||
}
|
||||
|
||||
if (currentStep.value === steps.SELECT_LANE) {
|
||||
const selectedLane = nearestDepartment.value?.lanes.find((lane) => lane.id === radioLaneOption.value);
|
||||
const selectedLane = getSelectedLane();
|
||||
if (!selectedLane || !isLaneAvailable(selectedLane)) {
|
||||
return true;
|
||||
}
|
||||
@@ -133,9 +148,10 @@ export function useWashFlowState(options) {
|
||||
const handleConfirmNext = async () => {
|
||||
if (currentStep.value === steps.QUESTIONS) {
|
||||
editAnswers.value = false;
|
||||
if (radioLaneOption.value && radioLaneOption.value !== "Any") {
|
||||
const selectedLaneId = getSelectedLaneId();
|
||||
if (selectedLaneId !== null) {
|
||||
try {
|
||||
await updateLaneAllowedServices(radioLaneOption.value);
|
||||
await updateLaneAllowedServices(selectedLaneId);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
@@ -150,8 +166,9 @@ export function useWashFlowState(options) {
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedLaneId = getSelectedLaneId() ?? radioLaneOption.value;
|
||||
await onStartWash(
|
||||
radioLaneOption.value,
|
||||
selectedLaneId,
|
||||
licensePlateInput.value,
|
||||
customerNumberInput.value,
|
||||
targetStepForStart()
|
||||
|
||||
@@ -70,7 +70,10 @@ export function useWashSessionActions(options) {
|
||||
|
||||
const isAlreadyStoppedStopError = (message) => {
|
||||
const normalized = String(message || "").toLowerCase();
|
||||
return normalized.includes("cannot stop lane") && normalized.includes("not occupied");
|
||||
return (
|
||||
normalized.includes("not occupied") &&
|
||||
(normalized.includes("cannot stop lane") || normalized.includes("cannot invoice"))
|
||||
);
|
||||
};
|
||||
|
||||
const clearActiveWashState = () => {
|
||||
@@ -170,10 +173,15 @@ export function useWashSessionActions(options) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return await executeSelfServeCommand(resolvedLaneId, command, {
|
||||
customer_number: null,
|
||||
license_plate: null,
|
||||
}, options);
|
||||
return await executeSelfServeCommand(
|
||||
resolvedLaneId,
|
||||
command,
|
||||
{
|
||||
customer_number: null,
|
||||
license_plate: null,
|
||||
},
|
||||
options
|
||||
);
|
||||
} finally {
|
||||
loadingRef.value = false;
|
||||
}
|
||||
@@ -185,6 +193,24 @@ export function useWashSessionActions(options) {
|
||||
const openPropertyExitGate = async (laneId = null, options = {}) =>
|
||||
executePropertyGateCommand("OPEN_PROPERTY_EXIT_GATE", laneId, openingPropertyExitGate, options);
|
||||
|
||||
const rollbackStartedWash = async (laneId) => {
|
||||
const stopResponse = await executeSelfServeCommand(
|
||||
laneId,
|
||||
"STOP",
|
||||
{
|
||||
customer_number: null,
|
||||
license_plate: null,
|
||||
},
|
||||
{ suppressAlert: true }
|
||||
);
|
||||
|
||||
if (!stopResponse) {
|
||||
console.error(`Failed to roll back started wash on lane ${laneId}:`, lastCommandErrorMessage.value);
|
||||
}
|
||||
|
||||
return stopResponse;
|
||||
};
|
||||
|
||||
const onStartWash = async (laneId, licensePlate, customerNumber, targetStep = steps.WASH_IN_PROGRESS) => {
|
||||
if (isStartingWash.value) {
|
||||
return false;
|
||||
@@ -233,6 +259,25 @@ export function useWashSessionActions(options) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (radioWashType.value === "Machine" && isServiceAllowed("MACHINE")) {
|
||||
let machineRelayResponse = null;
|
||||
try {
|
||||
machineRelayResponse = await enableMachineRelay(laneId);
|
||||
} catch (error) {
|
||||
console.error("Error enabling machine relay after wash start:", error);
|
||||
await rollbackStartedWash(laneId);
|
||||
alertFn(extractCommandErrorMessage(error, "Kunne ikke starte maskinen. Prøv igen."));
|
||||
return false;
|
||||
}
|
||||
|
||||
const machineRelaySuccessValue = machineRelayResponse?.data?.success ?? machineRelayResponse?.success;
|
||||
if (machineRelaySuccessValue === false) {
|
||||
await rollbackStartedWash(laneId);
|
||||
alertFn(extractCommandErrorMessage(machineRelayResponse, "Kunne ikke starte maskinen. Prøv igen."));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
washLaneId.value = laneId;
|
||||
washStartTime.value = Date.now();
|
||||
completedDurationMs.value = null;
|
||||
@@ -246,13 +291,6 @@ export function useWashSessionActions(options) {
|
||||
reg: licensePlate.trim().toUpperCase(),
|
||||
});
|
||||
|
||||
if (radioWashType.value === "Machine" && isServiceAllowed("MACHINE")) {
|
||||
try {
|
||||
await enableMachineRelay(laneId);
|
||||
} catch (error) {
|
||||
console.error("Error enabling machine relay after wash start:", error);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Error starting self-serve wash:", error);
|
||||
|
||||
@@ -29,6 +29,7 @@ const normalizeReleaseSource = (value) => {
|
||||
};
|
||||
export const RELEASE_SOURCE_ENV = String(import.meta.env.VITE_RELEASE_SOURCE || "").trim().toLowerCase();
|
||||
export const RELEASE_SOURCE = normalizeReleaseSource(RELEASE_SOURCE_ENV);
|
||||
export const DEFAULT_LEGACY_API_URL = "https://api.truckwash.io";
|
||||
export const DEFAULT_PUBLIC_GATEWAY_API_URL = "https://api-v2.truckwash.io";
|
||||
export const DEFAULT_STABLE_API_URL = `${DEFAULT_PUBLIC_GATEWAY_API_URL}/master/api`;
|
||||
|
||||
@@ -50,11 +51,22 @@ export const RELEASE_MANAGER_CONTROL_API_FALLBACK_URLS = String(
|
||||
.split(",")
|
||||
.map((url) => normalizeApiUrl(url.trim()))
|
||||
.filter(Boolean);
|
||||
export const RELEASE_TRUSTED_ORIGINS = String(import.meta.env.VITE_RELEASE_TRUSTED_ORIGINS || "")
|
||||
.split(",")
|
||||
.map((origin) => origin.trim().replace(/\/+$/, ""))
|
||||
.filter(Boolean);
|
||||
export const SELF_SERVE_ATTACHMENT_TRUSTED_ORIGINS = String(
|
||||
import.meta.env.VITE_SELF_SERVE_ATTACHMENT_TRUSTED_ORIGINS || ""
|
||||
)
|
||||
.split(",")
|
||||
.map((origin) => origin.trim().replace(/\/+$/, ""))
|
||||
.filter(Boolean);
|
||||
|
||||
// Allowed origins
|
||||
export const ALLOWED_ORIGINS = [
|
||||
"https://truckwash.io",
|
||||
"https://www.truckwash.io",
|
||||
new URL(DEFAULT_PUBLIC_GATEWAY_API_URL).origin,
|
||||
"http://localhost:5173",
|
||||
"http://localhost:4173",
|
||||
"http://localhost:4174",
|
||||
|
||||
@@ -36,7 +36,73 @@ const entryContext = (entry = {}) => {
|
||||
return context && typeof context === "object" && !Array.isArray(context) ? context : {};
|
||||
};
|
||||
|
||||
const formatDetailValue = (value) => {
|
||||
const REDACTED_CONTEXT_VALUE = "[REDACTED]";
|
||||
const SENSITIVE_CONTEXT_KEY_PATTERN = /(?:token|secret|password|credential|api[_-]?key|authorization|auth|signature)/i;
|
||||
const SENSITIVE_CONTEXT_QUERY_PATTERN = SENSITIVE_CONTEXT_KEY_PATTERN;
|
||||
|
||||
const redactSensitiveUrlParams = (value) => {
|
||||
const text = String(value || "");
|
||||
if (!text) {
|
||||
return text;
|
||||
}
|
||||
|
||||
const redactParams = (url) => {
|
||||
let redacted = false;
|
||||
url.searchParams.forEach((_paramValue, key) => {
|
||||
if (SENSITIVE_CONTEXT_QUERY_PATTERN.test(key)) {
|
||||
url.searchParams.set(key, REDACTED_CONTEXT_VALUE);
|
||||
redacted = true;
|
||||
}
|
||||
});
|
||||
return redacted ? url.toString() : text;
|
||||
};
|
||||
|
||||
try {
|
||||
return redactParams(new URL(text));
|
||||
} catch {
|
||||
return text.replace(/([?&])([^=&#]+)=([^&#]*)/g, (match, separator, rawKey) => {
|
||||
let key = rawKey;
|
||||
try {
|
||||
key = decodeURIComponent(rawKey.replace(/\+/g, " "));
|
||||
} catch {
|
||||
key = rawKey;
|
||||
}
|
||||
return SENSITIVE_CONTEXT_QUERY_PATTERN.test(key) ? `${separator}${rawKey}=${REDACTED_CONTEXT_VALUE}` : match;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const redactEdgeGatewayLogContext = (value, seen = new WeakSet()) => {
|
||||
if (value === null || typeof value === "undefined") {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (typeof value === "string") {
|
||||
return redactSensitiveUrlParams(value);
|
||||
}
|
||||
|
||||
if (typeof value !== "object") {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (seen.has(value)) {
|
||||
return "[Circular]";
|
||||
}
|
||||
seen.add(value);
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => redactEdgeGatewayLogContext(item, seen));
|
||||
}
|
||||
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).map(([key, item]) => [
|
||||
key,
|
||||
SENSITIVE_CONTEXT_KEY_PATTERN.test(key) ? REDACTED_CONTEXT_VALUE : redactEdgeGatewayLogContext(item, seen),
|
||||
])
|
||||
);
|
||||
};
|
||||
|
||||
const formatDetailValue = (value, { redactContext = false } = {}) => {
|
||||
if (value === null || typeof value === "undefined" || value === "") {
|
||||
return "None";
|
||||
}
|
||||
@@ -44,13 +110,14 @@ const formatDetailValue = (value) => {
|
||||
return value ? "yes" : "no";
|
||||
}
|
||||
if (typeof value === "object") {
|
||||
const displayValue = redactContext ? redactEdgeGatewayLogContext(value) : value;
|
||||
try {
|
||||
return JSON.stringify(value, null, 2);
|
||||
return JSON.stringify(displayValue, null, 2);
|
||||
} catch {
|
||||
return String(value);
|
||||
return String(displayValue);
|
||||
}
|
||||
}
|
||||
return String(value);
|
||||
return redactContext && typeof value === "string" ? redactSensitiveUrlParams(value) : String(value);
|
||||
};
|
||||
|
||||
const detailRow = (label, value) => ({
|
||||
@@ -61,7 +128,7 @@ const detailRow = (label, value) => ({
|
||||
|
||||
const contextDetailRow = (value) => ({
|
||||
label: "Context",
|
||||
value: formatDetailValue(value),
|
||||
value: formatDetailValue(value, { redactContext: true }),
|
||||
multiline: true,
|
||||
actionsOnly: true,
|
||||
});
|
||||
@@ -208,7 +275,9 @@ const relayActorLabel = (entry = {}) => {
|
||||
};
|
||||
|
||||
const normalizeRelayRole = (role = "") => {
|
||||
const normalized = String(role || "").trim().toUpperCase();
|
||||
const normalized = String(role || "")
|
||||
.trim()
|
||||
.toUpperCase();
|
||||
const aliases = {
|
||||
ENTRANCE: "ENTRY",
|
||||
IN: "ENTRY",
|
||||
@@ -260,7 +329,8 @@ const firstRelayText = (values = []) => {
|
||||
|
||||
const relayDisplayName = (entry = {}) => {
|
||||
const context = entryContext(entry);
|
||||
const actionContext = context.action_context && typeof context.action_context === "object" ? context.action_context : {};
|
||||
const actionContext =
|
||||
context.action_context && typeof context.action_context === "object" ? context.action_context : {};
|
||||
const binding = context.binding && typeof context.binding === "object" ? context.binding : {};
|
||||
const request = relayRequestPayload(entry);
|
||||
return firstRelayText([
|
||||
@@ -284,7 +354,8 @@ const relayIdentifier = (entry = {}) => {
|
||||
|
||||
const relaySignalLabel = (entry = {}) => {
|
||||
const context = entryContext(entry);
|
||||
const actionContext = context.action_context && typeof context.action_context === "object" ? context.action_context : {};
|
||||
const actionContext =
|
||||
context.action_context && typeof context.action_context === "object" ? context.action_context : {};
|
||||
const signal = context.signal && typeof context.signal === "object" ? context.signal : {};
|
||||
const role = normalizeRelayRole(context.relay_role || actionContext.relay_role || actionContext.role);
|
||||
const action = String(context.action || signal.command_type || "RELAY").toUpperCase();
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from "vue";
|
||||
import {
|
||||
cancelEdgeGatewayOperation,
|
||||
clearEdgeGatewayWorkspaceCache,
|
||||
createEdgeGatewayInstallToken,
|
||||
createEdgeGatewayOperation,
|
||||
deleteEdgeGateway,
|
||||
@@ -12,16 +13,18 @@ import {
|
||||
getEdgeGatewayStatistics,
|
||||
getEdgeGatewayTasks,
|
||||
listEdgeGatewayDepartments,
|
||||
isEdgeGatewayAuthorizationError,
|
||||
listEdgeGateways,
|
||||
peekCachedEdgeGateway,
|
||||
peekCachedEdgeGatewayDepartments,
|
||||
peekCachedEdgeGatewayList,
|
||||
removeEdgeGatewayCache,
|
||||
rotateEdgeGatewayCredentials,
|
||||
saveEdgeGatewayBindings,
|
||||
setDepartmentGatewayCutover,
|
||||
updateEdgeGateway,
|
||||
} from "@/services/edgeGateways.js";
|
||||
import { normalizeEdgeGatewayError, normalizeGatewayWebSocketClose } from "@/features/edgeGateways/edgeGatewayErrors.js";
|
||||
import { normalizeEdgeGatewayError, normalizeGatewayWebSocketClose, redactEdgeGatewayDiagnostic } from "@/features/edgeGateways/edgeGatewayErrors.js";
|
||||
import EdgeGatewayOverviewPage from "@/features/edgeGateways/EdgeGatewayOverviewPage.vue";
|
||||
import EdgeGatewayInventoryPage from "@/features/edgeGateways/EdgeGatewayInventoryPage.vue";
|
||||
import EdgeGatewayTasksPage from "@/features/edgeGateways/EdgeGatewayTasksPage.vue";
|
||||
@@ -183,7 +186,7 @@ const terminalGatewayBrokerDiagnostics = (gateway = selectedGatewayView.value) =
|
||||
if (value === null || value === undefined || value === "") {
|
||||
return;
|
||||
}
|
||||
lines.push(`${label}: ${value}`);
|
||||
lines.push(`${label}: ${redactEdgeGatewayDiagnostic(value)}`);
|
||||
};
|
||||
|
||||
push("Gateway ID", gateway?.id);
|
||||
@@ -1215,6 +1218,15 @@ const refreshSelected = async (gatewayId = activeGatewayId.value, { forceRefresh
|
||||
}
|
||||
return gateway;
|
||||
} catch (error) {
|
||||
if (isEdgeGatewayAuthorizationError(error)) {
|
||||
removeEdgeGatewayCache(gatewayId);
|
||||
unavailableGatewayId.value = String(gatewayId);
|
||||
selectedGateway.value = null;
|
||||
resetGatewayViewSnapshots();
|
||||
fail(error);
|
||||
return null;
|
||||
}
|
||||
|
||||
const fallbackGateway = findLocalGatewaySnapshot(gatewayId);
|
||||
if (fallbackGateway) {
|
||||
setSelectedGatewaySnapshot(fallbackGateway);
|
||||
@@ -1561,6 +1573,16 @@ const load = async () => {
|
||||
departments.value = unwrap(departmentsResponse, []);
|
||||
await syncSelection();
|
||||
} catch (error) {
|
||||
if (isEdgeGatewayAuthorizationError(error)) {
|
||||
clearEdgeGatewayWorkspaceCache();
|
||||
gateways.value = [];
|
||||
fleetUsage.value = buildFleetUsageFromRows([]);
|
||||
if (activeGatewayId.value) {
|
||||
unavailableGatewayId.value = String(activeGatewayId.value);
|
||||
}
|
||||
selectedGateway.value = null;
|
||||
resetGatewayViewSnapshots();
|
||||
}
|
||||
fail(error);
|
||||
} finally {
|
||||
loading.value.init = false;
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script setup>
|
||||
import { redactEdgeGatewayDiagnostic } from "@/features/edgeGateways/edgeGatewayErrors.js";
|
||||
|
||||
const props = defineProps({
|
||||
status: {
|
||||
type: String,
|
||||
@@ -44,7 +46,10 @@ const shortcuts = [
|
||||
{ id: "docker ps", label: "docker ps" },
|
||||
];
|
||||
|
||||
const diagnosticsText = () => (Array.isArray(props.diagnostics) ? props.diagnostics.join("\n") : "");
|
||||
const diagnosticsText = () =>
|
||||
Array.isArray(props.diagnostics)
|
||||
? props.diagnostics.map((entry) => redactEdgeGatewayDiagnostic(entry)).join("\n")
|
||||
: "";
|
||||
|
||||
const copyDiagnostics = async () => {
|
||||
const text = diagnosticsText();
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { RELEASE_TRUSTED_ORIGINS } from "@/config.js";
|
||||
|
||||
const splitOriginList = (value) =>
|
||||
String(value || "")
|
||||
.split(",")
|
||||
.map((origin) => origin.trim().replace(/\/+$/, ""))
|
||||
.filter(Boolean);
|
||||
|
||||
export const ALLOWED_PUBLIC_BROKER_PROTOCOLS = Object.freeze(["https:", "wss:"]);
|
||||
|
||||
export const EDGE_GATEWAY_PUBLIC_BROKER_ORIGINS = Object.freeze([
|
||||
"https://api.truckwash.io:4433",
|
||||
"wss://api.truckwash.io:4433",
|
||||
...RELEASE_TRUSTED_ORIGINS,
|
||||
...splitOriginList(import.meta.env.VITE_EDGE_GATEWAY_PUBLIC_BROKER_ORIGINS),
|
||||
]);
|
||||
|
||||
export const canUseStubBrokerAuthMode = () => import.meta.env.DEV || import.meta.env.MODE === "test";
|
||||
|
||||
export const sanitizeBrokerAuthMode = (value) => {
|
||||
if (value === "stub" && canUseStubBrokerAuthMode()) {
|
||||
return "stub";
|
||||
}
|
||||
return "manager";
|
||||
};
|
||||
|
||||
export const redactBrokerSharedSecret = () => "";
|
||||
|
||||
export const allowedPublicBrokerOrigins = () => {
|
||||
const origins = new Set(EDGE_GATEWAY_PUBLIC_BROKER_ORIGINS);
|
||||
|
||||
if (typeof window !== "undefined" && window.location?.origin) {
|
||||
const currentOrigin = window.location.origin.replace(/\/+$/, "");
|
||||
origins.add(currentOrigin);
|
||||
|
||||
if (window.location.hostname) {
|
||||
const host = window.location.port
|
||||
? `${window.location.hostname}:${window.location.port}`
|
||||
: window.location.hostname;
|
||||
origins.add(`https://${host}`);
|
||||
origins.add(`wss://${host}`);
|
||||
}
|
||||
}
|
||||
|
||||
return origins;
|
||||
};
|
||||
|
||||
export const validatePublicBrokerUrl = (value) => {
|
||||
const normalized = String(value || "").trim();
|
||||
if (!normalized) {
|
||||
return { ok: true, value: "" };
|
||||
}
|
||||
|
||||
let parsed;
|
||||
try {
|
||||
parsed = new URL(normalized);
|
||||
} catch (_error) {
|
||||
return {
|
||||
ok: false,
|
||||
message: "Public broker URL must be an absolute HTTPS or WSS URL on an approved origin.",
|
||||
};
|
||||
}
|
||||
|
||||
if (!ALLOWED_PUBLIC_BROKER_PROTOCOLS.includes(parsed.protocol)) {
|
||||
return {
|
||||
ok: false,
|
||||
message: "Public broker URL must use HTTPS or WSS.",
|
||||
};
|
||||
}
|
||||
|
||||
if (!allowedPublicBrokerOrigins().has(parsed.origin)) {
|
||||
return {
|
||||
ok: false,
|
||||
message: "Public broker URL origin is not approved for browser edge gateway sessions.",
|
||||
};
|
||||
}
|
||||
|
||||
return { ok: true, value: parsed.href };
|
||||
};
|
||||
@@ -55,6 +55,36 @@ const WEBSOCKET_CLOSE_MESSAGES = {
|
||||
socket_error: "Terminal websocket connection failed.",
|
||||
};
|
||||
|
||||
const SENSITIVE_DIAGNOSTIC_KEYS = new Set([
|
||||
"accesstoken",
|
||||
"agenttoken",
|
||||
"apikey",
|
||||
"authorization",
|
||||
"auth",
|
||||
"clientsecret",
|
||||
"jwt",
|
||||
"key",
|
||||
"password",
|
||||
"secret",
|
||||
"signature",
|
||||
"token",
|
||||
]);
|
||||
|
||||
const normalizeDiagnosticKey = (key) =>
|
||||
String(key || "")
|
||||
.replace(/[^a-z0-9]/gi, "")
|
||||
.toLowerCase();
|
||||
|
||||
const isSensitiveDiagnosticKey = (key) => SENSITIVE_DIAGNOSTIC_KEYS.has(normalizeDiagnosticKey(key));
|
||||
|
||||
const redactSensitiveAssignments = (value) =>
|
||||
String(value)
|
||||
.replace(/\b(authorization|auth)\s*[:=]\s*(?:Bearer|Basic)\s+[^\s,;&]+/gi, (_match, key) => `${key}=***`)
|
||||
.replace(
|
||||
/([?&;,\s]?(access[_-]?token|agent[_-]?token|api[_-]?key|authorization|auth|client[_-]?secret|jwt|key|password|secret|signature|token)\s*[:=]\s*)("[^"\s,;&]*"|'[^'\s,;&]*'|[^\s,;&]+)/gi,
|
||||
(_match, prefix) => `${prefix}***`
|
||||
);
|
||||
|
||||
export function redactEdgeGatewayUrl(value) {
|
||||
if (!value) {
|
||||
return null;
|
||||
@@ -62,17 +92,33 @@ export function redactEdgeGatewayUrl(value) {
|
||||
|
||||
try {
|
||||
const url = new URL(String(value), typeof window !== "undefined" ? window.location.origin : "http://localhost");
|
||||
["token", "agentToken", "agent_token"].forEach((key) => {
|
||||
if (url.searchParams.has(key)) {
|
||||
if (url.username) {
|
||||
url.username = "***";
|
||||
}
|
||||
if (url.password) {
|
||||
url.password = "***";
|
||||
}
|
||||
Array.from(url.searchParams.keys()).forEach((key) => {
|
||||
if (isSensitiveDiagnosticKey(key)) {
|
||||
url.searchParams.set(key, "***");
|
||||
}
|
||||
});
|
||||
return url.toString();
|
||||
} catch (_error) {
|
||||
return String(value).replace(/([?&](?:token|agentToken|agent_token)=)[^&]+/gi, "$1***");
|
||||
return redactSensitiveAssignments(String(value));
|
||||
}
|
||||
}
|
||||
|
||||
export function redactEdgeGatewayDiagnostic(value) {
|
||||
if (value === null || value === undefined) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return redactSensitiveAssignments(
|
||||
String(value).replace(/\b(?:wss?|https?|mqtts?):\/\/[^\s<>"')]+/gi, (match) => redactEdgeGatewayUrl(match))
|
||||
);
|
||||
}
|
||||
|
||||
const formatDiagnosticLines = (diagnostics, prefix = "Diagnostic") => {
|
||||
if (!diagnostics || typeof diagnostics !== "object" || Array.isArray(diagnostics)) {
|
||||
return [];
|
||||
@@ -83,20 +129,23 @@ const formatDiagnosticLines = (diagnostics, prefix = "Diagnostic") => {
|
||||
if (value === null || value === undefined || value === "") {
|
||||
return;
|
||||
}
|
||||
lines.push(`${label}: ${value}`);
|
||||
lines.push(`${label}: ${redactEdgeGatewayDiagnostic(value)}`);
|
||||
};
|
||||
|
||||
push(`${prefix} code`, diagnostics.reason_code || diagnostics.error_code);
|
||||
push("Broker URL", diagnostics.broker_url);
|
||||
push("WebSocket URL", redactEdgeGatewayUrl(diagnostics.ws_url));
|
||||
push("WebSocket URL", diagnostics.ws_url);
|
||||
push("Derived URL warning", diagnostics.derived_warning);
|
||||
if (diagnostics.broker_presence && typeof diagnostics.broker_presence === "object") {
|
||||
push("Broker connected", diagnostics.broker_presence.connected === true ? "yes" : "no");
|
||||
push("Broker connection", diagnostics.broker_presence.connection_id);
|
||||
push("Broker last seen", diagnostics.broker_presence.last_seen_at);
|
||||
push("Broker age", Number.isFinite(Number(diagnostics.broker_presence.age_seconds))
|
||||
? `${Number(diagnostics.broker_presence.age_seconds)}s`
|
||||
: null);
|
||||
push(
|
||||
"Broker age",
|
||||
Number.isFinite(Number(diagnostics.broker_presence.age_seconds))
|
||||
? `${Number(diagnostics.broker_presence.age_seconds)}s`
|
||||
: null
|
||||
);
|
||||
push("Broker last error", diagnostics.broker_presence.last_error);
|
||||
push("Broker disconnect reason", diagnostics.broker_presence.disconnect_reason);
|
||||
}
|
||||
@@ -180,7 +229,7 @@ export function normalizeGatewayWebSocketClose(event = {}, context = {}) {
|
||||
details.push(`WebSocket code: ${code}`);
|
||||
}
|
||||
if (reason) {
|
||||
details.push(`Close reason: ${reason}`);
|
||||
details.push(`Close reason: ${redactEdgeGatewayDiagnostic(reason)}`);
|
||||
}
|
||||
if (wasClean !== null) {
|
||||
details.push(`Clean close: ${wasClean ? "yes" : "no"}`);
|
||||
|
||||
@@ -10,6 +10,66 @@ const MOCK_SHELL_PROMPT = "edge@truckwash:/opt/truckwash-edge-agent$ ";
|
||||
|
||||
const isMockSocketUrl = (value) => String(value || "").startsWith(MOCK_SOCKET_PREFIX);
|
||||
|
||||
const SENSITIVE_SOCKET_QUERY_KEYS = new Set([
|
||||
"access_token",
|
||||
"auth",
|
||||
"authorization",
|
||||
"bearer",
|
||||
"jwt",
|
||||
"session",
|
||||
"session_token",
|
||||
"token",
|
||||
]);
|
||||
|
||||
const getBrowserLocation = () => (typeof window !== "undefined" && window.location ? window.location : null);
|
||||
|
||||
const getConfiguredTrustedSocketOrigins = () =>
|
||||
String(import.meta.env?.VITE_EDGE_GATEWAY_WS_ALLOWED_ORIGINS || "")
|
||||
.split(",")
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const toSocketOrigin = (value) => {
|
||||
const location = getBrowserLocation();
|
||||
const base = location?.origin || "http://localhost";
|
||||
const url = new URL(value, base);
|
||||
if (url.protocol === "https:") {
|
||||
url.protocol = "wss:";
|
||||
} else if (url.protocol === "http:") {
|
||||
url.protocol = "ws:";
|
||||
}
|
||||
return url.origin;
|
||||
};
|
||||
|
||||
const isLocalSocketHost = (hostname) => ["localhost", "127.0.0.1", "::1", "[::1]"].includes(String(hostname || ""));
|
||||
|
||||
const getTrustedSocketOrigins = () => {
|
||||
const origins = new Set();
|
||||
const location = getBrowserLocation();
|
||||
|
||||
if (location?.origin) {
|
||||
origins.add(toSocketOrigin(location.origin));
|
||||
}
|
||||
|
||||
getConfiguredTrustedSocketOrigins().forEach((origin) => origins.add(toSocketOrigin(origin)));
|
||||
return origins;
|
||||
};
|
||||
|
||||
const assertTrustedSocketUrl = (url) => {
|
||||
if (!["ws:", "wss:"].includes(url.protocol)) {
|
||||
throw new Error("Unsupported websocket URL protocol");
|
||||
}
|
||||
|
||||
if (url.protocol !== "wss:" && !isLocalSocketHost(url.hostname)) {
|
||||
throw new Error("Gateway websocket URL must use wss");
|
||||
}
|
||||
|
||||
const trustedOrigins = getTrustedSocketOrigins();
|
||||
if (trustedOrigins.size > 0 && !trustedOrigins.has(url.origin)) {
|
||||
throw new Error("Gateway websocket URL origin is not trusted");
|
||||
}
|
||||
};
|
||||
|
||||
const toNativeSocketUrl = (socketUrl, query = {}) => {
|
||||
if (!socketUrl) {
|
||||
throw new Error("Missing websocket URL");
|
||||
@@ -19,16 +79,48 @@ const toNativeSocketUrl = (socketUrl, query = {}) => {
|
||||
return socketUrl;
|
||||
}
|
||||
|
||||
const url = new URL(socketUrl, typeof window !== "undefined" ? window.location.origin : "http://localhost");
|
||||
const url = new URL(socketUrl, getBrowserLocation()?.origin || "http://localhost");
|
||||
assertTrustedSocketUrl(url);
|
||||
|
||||
Array.from(url.searchParams.keys()).forEach((key) => {
|
||||
if (SENSITIVE_SOCKET_QUERY_KEYS.has(String(key).toLowerCase())) {
|
||||
url.searchParams.delete(key);
|
||||
}
|
||||
});
|
||||
|
||||
Object.entries(query || {}).forEach(([key, value]) => {
|
||||
if (value === null || value === undefined || value === "") {
|
||||
return;
|
||||
}
|
||||
if (SENSITIVE_SOCKET_QUERY_KEYS.has(String(key).toLowerCase())) {
|
||||
return;
|
||||
}
|
||||
url.searchParams.set(key, String(value));
|
||||
});
|
||||
return url.toString();
|
||||
};
|
||||
|
||||
const createSessionAuthenticationPayload = (session, gatewayId, kind) => {
|
||||
if (!session?.token) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
type: "AUTH",
|
||||
gatewayId: String(gatewayId || session.gateway_id || ""),
|
||||
token: String(session.token),
|
||||
sessionId: session.id || session.session_id || null,
|
||||
kind,
|
||||
};
|
||||
};
|
||||
|
||||
const sendSessionAuthentication = (send, session, gatewayId, kind) => {
|
||||
const authPayload = createSessionAuthenticationPayload(session, gatewayId, kind);
|
||||
if (authPayload) {
|
||||
send(authPayload);
|
||||
}
|
||||
};
|
||||
|
||||
const safeJsonParse = (payload) => {
|
||||
if (payload === null || payload === undefined || payload === "") {
|
||||
return null;
|
||||
@@ -292,10 +384,10 @@ export async function createGatewayStreamClient(gatewayId, scopes = [], handlers
|
||||
const session = unwrapEdgeGatewayResponse(response, {});
|
||||
const socketUrl = toNativeSocketUrl(session.ws_url, {
|
||||
gatewayId,
|
||||
token: session.token,
|
||||
});
|
||||
|
||||
const connection = createConnection(socketUrl, session, "stream", handlers, ({ send }) => {
|
||||
sendSessionAuthentication(send, session, gatewayId, "stream");
|
||||
send({
|
||||
type: "SUBSCRIBE",
|
||||
gatewayId: String(gatewayId),
|
||||
@@ -318,9 +410,10 @@ export async function createGatewayShellClient(gatewayId, options = {}, handlers
|
||||
const session = unwrapEdgeGatewayResponse(response, {});
|
||||
const socketUrl = toNativeSocketUrl(session.ws_url, {
|
||||
gatewayId,
|
||||
token: session.token,
|
||||
});
|
||||
const connection = createConnection(socketUrl, session, "shell", handlers);
|
||||
const connection = createConnection(socketUrl, session, "shell", handlers, ({ send }) => {
|
||||
sendSessionAuthentication(send, session, gatewayId, "shell");
|
||||
});
|
||||
|
||||
return {
|
||||
session,
|
||||
|
||||
@@ -71,7 +71,6 @@ const logBuildBanner = () => {
|
||||
'',
|
||||
`Build: ${formatCommit(VITE_COMMIT_HASH)} @ ${formatDateTime(VITE_BUILD_DATE)} (${IS_DEV ? 'development' : 'production'})`,
|
||||
`Remote API: ${getReleaseRuntimeApiBaseUrl()}`,
|
||||
`Debug: Is running beta FE.`,
|
||||
].join('\n'));
|
||||
};
|
||||
|
||||
|
||||
@@ -84,8 +84,23 @@ export const sanitizeRedirectPath = (redirectPath) => {
|
||||
return redirectPath;
|
||||
};
|
||||
|
||||
export const buildMigrationUrl = (migrationOrigin, redirectPath) => {
|
||||
export const releaseFrontendPathBase = (pathname = '') => {
|
||||
const match = String(pathname || '').match(/^\/[^/]+\/frontend(?:\/|$)/);
|
||||
return match ? match[0].replace(/\/+$/, '') : '';
|
||||
};
|
||||
|
||||
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}`;
|
||||
};
|
||||
|
||||
export const buildMigrationUrl = (migrationOrigin, redirectPath, currentPath = '') => {
|
||||
const safePath = applyReleaseFrontendBase(redirectPath, currentPath);
|
||||
try {
|
||||
return new URL(safePath, migrationOrigin).toString();
|
||||
} catch (_error) {
|
||||
|
||||
+6
-1
@@ -190,9 +190,14 @@ const EconomicQueuePlaywrightHarness = lazyView('@/views/testing/EconomicQueuePl
|
||||
* departmentSelection: true if the department selection should be possible for the route
|
||||
*/
|
||||
|
||||
export const releaseFrontendRouterBase = (pathname = globalThis?.location?.pathname || '/') => {
|
||||
const match = String(pathname || '/').match(/^\/[^/]+\/frontend(?:\/|$)/);
|
||||
return match ? `${match[0].replace(/\/+$/, '')}/` : '/';
|
||||
};
|
||||
|
||||
// Export the router as router
|
||||
export const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
history: createWebHistory(releaseFrontendRouterBase()),
|
||||
routes: [
|
||||
...((import.meta.env.DEV || import.meta.env.MODE === 'test') ? [
|
||||
{
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { SELF_SERVE_ATTACHMENT_TRUSTED_ORIGINS } from "@/config.js";
|
||||
import { trustedReleaseOrigins } from "@/services/releaseTrust.js";
|
||||
|
||||
const normalizeOrigin = (value) => {
|
||||
const raw = String(value || "").trim();
|
||||
if (!raw) {
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
return new URL(raw).origin;
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
const isLocalHttpOrigin = (url) =>
|
||||
url.protocol === "http:" && ["localhost", "127.0.0.1", "::1"].includes(url.hostname);
|
||||
|
||||
const attachmentTrustedOrigins = () =>
|
||||
Array.from(
|
||||
new Set(
|
||||
[...trustedReleaseOrigins(), ...SELF_SERVE_ATTACHMENT_TRUSTED_ORIGINS]
|
||||
.map(normalizeOrigin)
|
||||
.filter(Boolean)
|
||||
)
|
||||
);
|
||||
|
||||
export const isSafeAttachmentDownloadLink = (value) => {
|
||||
const raw = String(value || "").trim();
|
||||
if (!raw || raw.startsWith("//")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (raw.startsWith("/")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
let url;
|
||||
try {
|
||||
url = new URL(raw);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (url.protocol !== "https:" && !isLocalHttpOrigin(url)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return attachmentTrustedOrigins().includes(url.origin);
|
||||
};
|
||||
|
||||
export const safeAttachmentDownloadLink = (value) => {
|
||||
const raw = String(value || "").trim();
|
||||
return isSafeAttachmentDownloadLink(raw) ? raw : "";
|
||||
};
|
||||
+212
-65
@@ -1,6 +1,7 @@
|
||||
import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue";
|
||||
|
||||
export const EDGE_GATEWAY_WORKSPACE_CACHE_KEY = "truckwash.edgeGatewayWorkspace.cache.v1";
|
||||
export const EDGE_GATEWAY_WORKSPACE_CACHE_TTL_MS = 15 * 60 * 1000;
|
||||
|
||||
const EDGE_GATEWAY_BASE = "/edge-gateways";
|
||||
const EDGE_GATEWAY_CONFIG_BASE = "/edgegateway/config";
|
||||
@@ -9,8 +10,11 @@ const listRequestsInFlight = new Map();
|
||||
const detailRequestsInFlight = new Map();
|
||||
let departmentsRequestInFlight = null;
|
||||
let edgeGatewayWorkspaceCache = null;
|
||||
let edgeGatewayWorkspaceCacheStorageKey = null;
|
||||
|
||||
const cloneJson = (value) => (value === null || value === undefined ? value : JSON.parse(JSON.stringify(value)));
|
||||
const getDataPayload = (response) => (response && response.data ? response.data.data : undefined);
|
||||
const getMetaPayload = (response) => (response && response.data ? response.data.meta : undefined);
|
||||
const normalizeDepartmentId = (departmentId) =>
|
||||
departmentId === null || departmentId === undefined || departmentId === "" ? null : Number(departmentId);
|
||||
const normalizeGatewayId = (gatewayId) =>
|
||||
@@ -49,17 +53,21 @@ const normalizeRotateCredentialBundle = (bundle) => {
|
||||
}
|
||||
|
||||
const config = parseJsonObject(bundle.config_json);
|
||||
const agentToken = bundle.agent_token || bundle.token || config?.agentToken || config?.agent_token || null;
|
||||
const agentToken =
|
||||
bundle.agent_token || bundle.token || (config && (config.agentToken || config.agent_token)) || null;
|
||||
const stackServiceName =
|
||||
bundle.stackServiceName ||
|
||||
bundle.stack_service_name ||
|
||||
config?.stackServiceName ||
|
||||
config?.stack_service_name ||
|
||||
config?.serviceName ||
|
||||
config?.service_name ||
|
||||
(config && config.stackServiceName) ||
|
||||
(config && config.stack_service_name) ||
|
||||
(config && config.serviceName) ||
|
||||
(config && config.service_name) ||
|
||||
null;
|
||||
const composeFileName =
|
||||
bundle.composeFileName || bundle.compose_file_name || config?.composeFileName || config?.compose_file_name || null;
|
||||
bundle.composeFileName ||
|
||||
bundle.compose_file_name ||
|
||||
(config && (config.composeFileName || config.compose_file_name)) ||
|
||||
null;
|
||||
|
||||
return {
|
||||
...bundle,
|
||||
@@ -73,7 +81,7 @@ const normalizeRotateCredentialBundle = (bundle) => {
|
||||
};
|
||||
|
||||
const normalizeRotateCredentialResponse = (response) => {
|
||||
const bundle = response?.data?.data;
|
||||
const bundle = getDataPayload(response);
|
||||
if (bundle && typeof bundle === "object" && !Array.isArray(bundle)) {
|
||||
response.data.data = normalizeRotateCredentialBundle(bundle);
|
||||
}
|
||||
@@ -91,55 +99,174 @@ const toConfigPayload = (entries) => {
|
||||
return payload;
|
||||
};
|
||||
|
||||
const createEmptyWorkspaceCache = () => ({
|
||||
const createEmptyWorkspaceCache = (scope = null) => ({
|
||||
scope,
|
||||
cachedAt: Date.now(),
|
||||
departments: null,
|
||||
lists: {},
|
||||
details: {},
|
||||
});
|
||||
|
||||
const storage = () => {
|
||||
if (typeof window === "undefined" || !window.sessionStorage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return window.sessionStorage;
|
||||
};
|
||||
|
||||
const removeLegacyWorkspaceCache = () => {
|
||||
if (typeof window === "undefined" || !window.localStorage) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
window.localStorage.removeItem(EDGE_GATEWAY_WORKSPACE_CACHE_KEY);
|
||||
} catch (_error) {
|
||||
// Browser storage can be unavailable in private browsing or tests.
|
||||
}
|
||||
};
|
||||
|
||||
const getStoredValue = (key) => {
|
||||
if (typeof window === "undefined" || !window.localStorage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return window.localStorage;
|
||||
try {
|
||||
return window.localStorage.getItem(key);
|
||||
} catch (_error) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const hashScopePart = (value) => {
|
||||
let hash = 0;
|
||||
const input = String(value || "");
|
||||
for (let index = 0; index < input.length; index += 1) {
|
||||
hash = (hash << 5) - hash + input.charCodeAt(index);
|
||||
hash |= 0;
|
||||
}
|
||||
return hash.toString(36);
|
||||
};
|
||||
|
||||
const currentWorkspaceCacheScope = () => {
|
||||
const token = getStoredValue("token");
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return JSON.stringify({
|
||||
token: hashScopePart(token),
|
||||
isSubuser: getStoredValue("is_subuser") === "true",
|
||||
selectedCustomerNumber: getStoredValue("selected_customer_number") || null,
|
||||
hasSuperuserToken: Boolean(getStoredValue("superuser_token")),
|
||||
});
|
||||
};
|
||||
|
||||
const isFreshCachedAt = (cachedAt) => Date.now() - Number(cachedAt || 0) <= EDGE_GATEWAY_WORKSPACE_CACHE_TTL_MS;
|
||||
const isFreshCacheEntry = (entry) => Boolean(entry && typeof entry === "object" && isFreshCachedAt(entry.cachedAt));
|
||||
const isSensitiveCacheField = (fieldName) =>
|
||||
/(^|[_-])(token|secret|password|credential|authorization|api[_-]?key|private[_-]?key)($|[_-])/i.test(
|
||||
String(fieldName || "")
|
||||
) || String(fieldName || "").toLowerCase() === "config_json";
|
||||
|
||||
const sanitizeCacheValue = (value) => {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => sanitizeCacheValue(item));
|
||||
}
|
||||
|
||||
if (!value || typeof value !== "object") {
|
||||
return value;
|
||||
}
|
||||
|
||||
return Object.entries(value).reduce((sanitized, [key, childValue]) => {
|
||||
if (!isSensitiveCacheField(key)) {
|
||||
sanitized[key] = sanitizeCacheValue(childValue);
|
||||
}
|
||||
return sanitized;
|
||||
}, {});
|
||||
};
|
||||
|
||||
const sanitizeGatewayCachePayload = (value) => cloneJson(sanitizeCacheValue(value));
|
||||
|
||||
const resetWorkspaceCacheMemory = (scope = currentWorkspaceCacheScope()) => {
|
||||
edgeGatewayWorkspaceCache = createEmptyWorkspaceCache(scope);
|
||||
edgeGatewayWorkspaceCacheStorageKey = EDGE_GATEWAY_WORKSPACE_CACHE_KEY;
|
||||
return edgeGatewayWorkspaceCache;
|
||||
};
|
||||
|
||||
const loadWorkspaceCache = () => {
|
||||
if (edgeGatewayWorkspaceCache !== null) {
|
||||
removeLegacyWorkspaceCache();
|
||||
const scope = currentWorkspaceCacheScope();
|
||||
if (!scope) {
|
||||
return resetWorkspaceCacheMemory(null);
|
||||
}
|
||||
|
||||
if (
|
||||
edgeGatewayWorkspaceCache !== null &&
|
||||
edgeGatewayWorkspaceCache.scope === scope &&
|
||||
isFreshCachedAt(edgeGatewayWorkspaceCache.cachedAt)
|
||||
) {
|
||||
return edgeGatewayWorkspaceCache;
|
||||
}
|
||||
|
||||
const localStorageHandle = storage();
|
||||
if (!localStorageHandle) {
|
||||
edgeGatewayWorkspaceCache = createEmptyWorkspaceCache();
|
||||
return edgeGatewayWorkspaceCache;
|
||||
const storageHandle = storage();
|
||||
if (!storageHandle) {
|
||||
return resetWorkspaceCacheMemory(scope);
|
||||
}
|
||||
|
||||
try {
|
||||
const decoded = JSON.parse(localStorageHandle.getItem(EDGE_GATEWAY_WORKSPACE_CACHE_KEY) || "null");
|
||||
const decoded = JSON.parse(storageHandle.getItem(EDGE_GATEWAY_WORKSPACE_CACHE_KEY) || "null");
|
||||
if (!decoded || typeof decoded !== "object" || decoded.scope !== scope || !isFreshCachedAt(decoded.cachedAt)) {
|
||||
storageHandle.removeItem(EDGE_GATEWAY_WORKSPACE_CACHE_KEY);
|
||||
return resetWorkspaceCacheMemory(scope);
|
||||
}
|
||||
|
||||
edgeGatewayWorkspaceCache = {
|
||||
...createEmptyWorkspaceCache(),
|
||||
...(decoded && typeof decoded === "object" ? decoded : {}),
|
||||
lists: decoded?.lists && typeof decoded.lists === "object" ? decoded.lists : {},
|
||||
details: decoded?.details && typeof decoded.details === "object" ? decoded.details : {},
|
||||
...createEmptyWorkspaceCache(scope),
|
||||
cachedAt: Number(decoded.cachedAt || Date.now()),
|
||||
departments: isFreshCacheEntry(decoded.departments) ? decoded.departments : null,
|
||||
lists:
|
||||
decoded && decoded.lists && typeof decoded.lists === "object"
|
||||
? Object.fromEntries(Object.entries(decoded.lists).filter(([, entry]) => isFreshCacheEntry(entry)))
|
||||
: {},
|
||||
details:
|
||||
decoded && decoded.details && typeof decoded.details === "object"
|
||||
? Object.fromEntries(Object.entries(decoded.details).filter(([, entry]) => isFreshCacheEntry(entry)))
|
||||
: {},
|
||||
};
|
||||
edgeGatewayWorkspaceCacheStorageKey = EDGE_GATEWAY_WORKSPACE_CACHE_KEY;
|
||||
} catch (_error) {
|
||||
edgeGatewayWorkspaceCache = createEmptyWorkspaceCache();
|
||||
storageHandle.removeItem(EDGE_GATEWAY_WORKSPACE_CACHE_KEY);
|
||||
resetWorkspaceCacheMemory(scope);
|
||||
}
|
||||
|
||||
return edgeGatewayWorkspaceCache;
|
||||
};
|
||||
|
||||
const persistWorkspaceCache = () => {
|
||||
const localStorageHandle = storage();
|
||||
if (!localStorageHandle || edgeGatewayWorkspaceCache === null) {
|
||||
const storageHandle = storage();
|
||||
const scope = currentWorkspaceCacheScope();
|
||||
if (!storageHandle || edgeGatewayWorkspaceCache === null || !scope || edgeGatewayWorkspaceCache.scope !== scope) {
|
||||
return;
|
||||
}
|
||||
|
||||
localStorageHandle.setItem(EDGE_GATEWAY_WORKSPACE_CACHE_KEY, JSON.stringify(edgeGatewayWorkspaceCache));
|
||||
edgeGatewayWorkspaceCache.cachedAt = Date.now();
|
||||
edgeGatewayWorkspaceCacheStorageKey = EDGE_GATEWAY_WORKSPACE_CACHE_KEY;
|
||||
storageHandle.setItem(EDGE_GATEWAY_WORKSPACE_CACHE_KEY, JSON.stringify(edgeGatewayWorkspaceCache));
|
||||
};
|
||||
|
||||
export const clearEdgeGatewayWorkspaceCache = () => {
|
||||
edgeGatewayWorkspaceCache = null;
|
||||
edgeGatewayWorkspaceCacheStorageKey = null;
|
||||
removeLegacyWorkspaceCache();
|
||||
const storageHandle = storage();
|
||||
if (!storageHandle) {
|
||||
return;
|
||||
}
|
||||
|
||||
storageHandle.removeItem(EDGE_GATEWAY_WORKSPACE_CACHE_KEY);
|
||||
};
|
||||
const mergeGatewaySnapshots = (currentGateway, nextGateway) => {
|
||||
if (!currentGateway) {
|
||||
return cloneJson(nextGateway);
|
||||
@@ -149,27 +276,27 @@ const mergeGatewaySnapshots = (currentGateway, nextGateway) => {
|
||||
...cloneJson(currentGateway),
|
||||
...cloneJson(nextGateway),
|
||||
metadata: {
|
||||
...(currentGateway?.metadata || {}),
|
||||
...(nextGateway?.metadata || {}),
|
||||
...((currentGateway && currentGateway.metadata) || {}),
|
||||
...((nextGateway && nextGateway.metadata) || {}),
|
||||
},
|
||||
inventory: Array.isArray(nextGateway?.inventory)
|
||||
inventory: Array.isArray(nextGateway && nextGateway.inventory)
|
||||
? cloneJson(nextGateway.inventory)
|
||||
: Array.isArray(currentGateway?.inventory)
|
||||
: Array.isArray(currentGateway && currentGateway.inventory)
|
||||
? cloneJson(currentGateway.inventory)
|
||||
: undefined,
|
||||
bindings: Array.isArray(nextGateway?.bindings)
|
||||
bindings: Array.isArray(nextGateway && nextGateway.bindings)
|
||||
? cloneJson(nextGateway.bindings)
|
||||
: Array.isArray(currentGateway?.bindings)
|
||||
: Array.isArray(currentGateway && currentGateway.bindings)
|
||||
? cloneJson(currentGateway.bindings)
|
||||
: undefined,
|
||||
operations: Array.isArray(nextGateway?.operations)
|
||||
operations: Array.isArray(nextGateway && nextGateway.operations)
|
||||
? cloneJson(nextGateway.operations)
|
||||
: Array.isArray(currentGateway?.operations)
|
||||
: Array.isArray(currentGateway && currentGateway.operations)
|
||||
? cloneJson(currentGateway.operations)
|
||||
: undefined,
|
||||
audit_logs: Array.isArray(nextGateway?.audit_logs)
|
||||
audit_logs: Array.isArray(nextGateway && nextGateway.audit_logs)
|
||||
? cloneJson(nextGateway.audit_logs)
|
||||
: Array.isArray(currentGateway?.audit_logs)
|
||||
: Array.isArray(currentGateway && currentGateway.audit_logs)
|
||||
? cloneJson(currentGateway.audit_logs)
|
||||
: undefined,
|
||||
};
|
||||
@@ -181,8 +308,12 @@ const cacheGatewaySnapshot = (gateway) => {
|
||||
}
|
||||
|
||||
const cache = loadWorkspaceCache();
|
||||
const gatewayId = String(gateway.id);
|
||||
const mergedGateway = mergeGatewaySnapshots(cache.details[gatewayId]?.data || null, gateway);
|
||||
const sanitizedGateway = sanitizeGatewayCachePayload(gateway);
|
||||
const gatewayId = String(sanitizedGateway.id);
|
||||
const mergedGateway = mergeGatewaySnapshots(
|
||||
(cache.details[gatewayId] && cache.details[gatewayId].data) || null,
|
||||
sanitizedGateway
|
||||
);
|
||||
cache.details[gatewayId] = {
|
||||
cachedAt: Date.now(),
|
||||
data: mergedGateway,
|
||||
@@ -195,12 +326,12 @@ const cacheGatewaySnapshot = (gateway) => {
|
||||
const matchesDepartment =
|
||||
normalizeDepartmentId(params.departmentId) === null ||
|
||||
Number(params.departmentId) === Number(gateway.department_id);
|
||||
const existingIndex = rows.findIndex((item) => Number(item?.id || 0) === Number(gateway.id));
|
||||
const existingIndex = rows.findIndex((item) => Number((item && item.id) || 0) === Number(gateway.id));
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
rows[existingIndex] = mergeGatewaySnapshots(rows[existingIndex], gateway);
|
||||
rows[existingIndex] = mergeGatewaySnapshots(rows[existingIndex], sanitizedGateway);
|
||||
} else if (matchesDepartment) {
|
||||
rows.unshift(cloneJson(gateway));
|
||||
rows.unshift(cloneJson(sanitizedGateway));
|
||||
}
|
||||
|
||||
cache.lists[cacheKey] = {
|
||||
@@ -216,19 +347,20 @@ const cacheGatewaySnapshot = (gateway) => {
|
||||
|
||||
const cacheFleetSnapshot = ({ departmentId = null, view = "summary", rows = [], meta = {} } = {}) => {
|
||||
const cache = loadWorkspaceCache();
|
||||
const sanitizedRows = sanitizeGatewayCachePayload(Array.isArray(rows) ? rows : []);
|
||||
const key = listCacheKey({ departmentId, view });
|
||||
cache.lists[key] = {
|
||||
params: {
|
||||
departmentId: normalizeDepartmentId(departmentId),
|
||||
view: String(view || "summary"),
|
||||
},
|
||||
rows: cloneJson(Array.isArray(rows) ? rows : []),
|
||||
meta: cloneJson(meta || {}),
|
||||
rows: sanitizedRows,
|
||||
meta: sanitizeGatewayCachePayload(meta || {}),
|
||||
cachedAt: Date.now(),
|
||||
};
|
||||
|
||||
rows.forEach((gateway) => {
|
||||
if (!gateway?.id) {
|
||||
sanitizedRows.forEach((gateway) => {
|
||||
if (!gateway || !gateway.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -236,7 +368,7 @@ const cacheFleetSnapshot = ({ departmentId = null, view = "summary", rows = [],
|
||||
if (!cache.details[gatewayId]) {
|
||||
cache.details[gatewayId] = {
|
||||
cachedAt: Date.now(),
|
||||
data: cloneJson(gateway),
|
||||
data: sanitizeGatewayCachePayload(gateway),
|
||||
};
|
||||
return;
|
||||
}
|
||||
@@ -254,22 +386,23 @@ const cacheDepartmentsSnapshot = (departments) => {
|
||||
const cache = loadWorkspaceCache();
|
||||
cache.departments = {
|
||||
cachedAt: Date.now(),
|
||||
data: cloneJson(Array.isArray(departments) ? departments : []),
|
||||
data: sanitizeGatewayCachePayload(Array.isArray(departments) ? departments : []),
|
||||
};
|
||||
persistWorkspaceCache();
|
||||
};
|
||||
|
||||
const cacheGatewayFromResponse = (response) => {
|
||||
const gateway = response?.data?.data;
|
||||
if (gateway?.id) {
|
||||
const gateway = getDataPayload(response);
|
||||
if (gateway && gateway.id) {
|
||||
cacheGatewaySnapshot(gateway);
|
||||
}
|
||||
return response;
|
||||
};
|
||||
|
||||
const cacheGatewayFromNestedResponse = (response) => {
|
||||
const gateway = response?.data?.data?.gateway;
|
||||
if (gateway?.id) {
|
||||
const payload = getDataPayload(response);
|
||||
const gateway = payload && payload.gateway;
|
||||
if (gateway && gateway.id) {
|
||||
cacheGatewaySnapshot(gateway);
|
||||
}
|
||||
return response;
|
||||
@@ -292,17 +425,31 @@ const buildOperationPayload = (typeOrPayload, request = {}) => {
|
||||
};
|
||||
};
|
||||
|
||||
export const unwrapEdgeGatewayResponse = (response, fallback = null) => response?.data?.data ?? fallback;
|
||||
export const unwrapEdgeGatewayMeta = (response) => response?.data?.meta ?? {};
|
||||
export const unwrapEdgeGatewayResponse = (response, fallback = null) => {
|
||||
const data = getDataPayload(response);
|
||||
return data === undefined || data === null ? fallback : data;
|
||||
};
|
||||
export const unwrapEdgeGatewayMeta = (response) => getMetaPayload(response) || {};
|
||||
|
||||
export const isEdgeGatewayAuthorizationError = (error) => {
|
||||
const status = Number(
|
||||
(error && error.response && error.response.status) ||
|
||||
(error && error.status) ||
|
||||
(error && error.response && error.response.data && error.response.data.status) ||
|
||||
0
|
||||
);
|
||||
return status === 401 || status === 403;
|
||||
};
|
||||
|
||||
export const peekCachedEdgeGatewayDepartments = () => {
|
||||
const departments = loadWorkspaceCache().departments?.data;
|
||||
return Array.isArray(departments) ? cloneJson(departments) : null;
|
||||
const entry = loadWorkspaceCache().departments;
|
||||
const departments = entry && entry.data;
|
||||
return isFreshCacheEntry(entry) && Array.isArray(departments) ? cloneJson(departments) : null;
|
||||
};
|
||||
|
||||
export const peekCachedEdgeGatewayList = ({ departmentId = null, view = "summary" } = {}) => {
|
||||
const entry = loadWorkspaceCache().lists[listCacheKey({ departmentId, view })];
|
||||
if (!entry || !Array.isArray(entry.rows)) {
|
||||
if (!isFreshCacheEntry(entry) || !Array.isArray(entry.rows)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -320,7 +467,7 @@ export const peekCachedEdgeGateway = (gatewayId) => {
|
||||
}
|
||||
|
||||
const entry = loadWorkspaceCache().details[normalizedGatewayId];
|
||||
return entry?.data ? cloneJson(entry.data) : null;
|
||||
return isFreshCacheEntry(entry) && entry && entry.data ? cloneJson(entry.data) : null;
|
||||
};
|
||||
|
||||
export const patchEdgeGatewayCache = (gatewayId, patch) => {
|
||||
@@ -341,8 +488,8 @@ export const removeEdgeGatewayCache = (gatewayId) => {
|
||||
const cache = loadWorkspaceCache();
|
||||
delete cache.details[normalizedGatewayId];
|
||||
Object.entries(cache.lists || {}).forEach(([cacheKey, entry]) => {
|
||||
const rows = Array.isArray(entry?.rows)
|
||||
? entry.rows.filter((item) => String(item?.id || "") !== normalizedGatewayId)
|
||||
const rows = Array.isArray(entry && entry.rows)
|
||||
? entry.rows.filter((item) => String((item && item.id) || "") !== normalizedGatewayId)
|
||||
: [];
|
||||
cache.lists[cacheKey] = {
|
||||
...(entry || {}),
|
||||
@@ -361,7 +508,7 @@ export const updateDepartmentGatewayCutoverCache = (departmentId, transportMode)
|
||||
|
||||
const cache = loadWorkspaceCache();
|
||||
Object.entries(cache.details || {}).forEach(([gatewayId, entry]) => {
|
||||
if (Number(entry?.data?.department_id || 0) !== normalizedDepartmentId) {
|
||||
if (Number((entry && entry.data && entry.data.department_id) || 0) !== normalizedDepartmentId) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -374,9 +521,9 @@ export const updateDepartmentGatewayCutoverCache = (departmentId, transportMode)
|
||||
});
|
||||
|
||||
Object.entries(cache.lists || {}).forEach(([cacheKey, entry]) => {
|
||||
const rows = Array.isArray(entry?.rows)
|
||||
const rows = Array.isArray(entry && entry.rows)
|
||||
? entry.rows.map((gateway) =>
|
||||
Number(gateway?.department_id || 0) === normalizedDepartmentId
|
||||
Number((gateway && gateway.department_id) || 0) === normalizedDepartmentId
|
||||
? { ...gateway, department_transport_mode: transportMode }
|
||||
: gateway
|
||||
)
|
||||
@@ -397,7 +544,7 @@ export const listEdgeGatewayDepartments = async ({ forceRefresh = false } = {})
|
||||
}
|
||||
|
||||
const request = authenticatedRequest("/departments", "GET", {}).then((response) => {
|
||||
cacheDepartmentsSnapshot(response?.data?.data || []);
|
||||
cacheDepartmentsSnapshot(getDataPayload(response) || []);
|
||||
return response;
|
||||
});
|
||||
|
||||
@@ -433,8 +580,8 @@ export const listEdgeGateways = async ({ departmentId = null, view = "summary",
|
||||
cacheFleetSnapshot({
|
||||
departmentId,
|
||||
view,
|
||||
rows: response?.data?.data || [],
|
||||
meta: response?.data?.meta || {},
|
||||
rows: getDataPayload(response) || [],
|
||||
meta: getMetaPayload(response) || {},
|
||||
});
|
||||
return response;
|
||||
});
|
||||
@@ -460,8 +607,8 @@ export const getEdgeGateway = async (gatewayId, { forceRefresh = false } = {}) =
|
||||
|
||||
const request = authenticatedRequest(`${EDGE_GATEWAY_BASE}/${encodeURIComponent(gatewayId)}`, "GET", {}).then(
|
||||
(response) => {
|
||||
const gateway = response?.data?.data;
|
||||
if (gateway?.id) {
|
||||
const gateway = getDataPayload(response);
|
||||
if (gateway && gateway.id) {
|
||||
cacheGatewaySnapshot(gateway);
|
||||
}
|
||||
return response;
|
||||
@@ -602,7 +749,7 @@ const normalizeRelayTestTransport = (transport) => {
|
||||
};
|
||||
|
||||
const normalizeRelayTestTimer = (seconds) => {
|
||||
const normalizedSeconds = Number.parseInt(String(seconds ?? ""), 10);
|
||||
const normalizedSeconds = Number.parseInt(String(seconds === null || seconds === undefined ? "" : seconds), 10);
|
||||
return Number.isInteger(normalizedSeconds) && normalizedSeconds > 0 ? normalizedSeconds : 1;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { API_URL, IS_DEV, RELEASE_MANAGER_CONTROL_API_URL, RELEASE_SOURCE, RELEASE_SOURCE_ENV } from "@/config.js";
|
||||
import { buildReleaseHeaders } from "@/services/releaseHeaders.js";
|
||||
import { isTrustedReleaseUrl, sameOriginReleaseUrl } from "@/services/releaseTrust.js";
|
||||
|
||||
export const RELEASE_RUNTIME_GLOBAL_KEY = "__TRUCKWASH_RELEASE_RUNTIME__";
|
||||
export const RELEASE_CHANNEL_SELECTION_STORAGE_KEY = "release_channel_selected_slug";
|
||||
export const RELEASE_SOURCE_OVERRIDE_STORAGE_KEY = "release_source_override";
|
||||
export const RELEASE_ENTRY_FILENAME = "release-entry.json";
|
||||
export const RELEASE_BOOTSTRAP_REVISION = "2026-06-03-dev-asset-republish";
|
||||
export const RELEASE_SOURCE_MODES = Object.freeze({
|
||||
LOCAL: "local",
|
||||
DEPLOYMENT: "deployment",
|
||||
@@ -172,7 +174,8 @@ export const fetchReleaseRuntime = async ({
|
||||
|
||||
const runtimeFrontendBaseUrl = (runtime = {}) => {
|
||||
const urls = runtime?.urls && typeof runtime.urls === "object" ? runtime.urls : {};
|
||||
return normalizeBaseUrl(runtime?.frontend_base_url || urls.frontend_base_url || "");
|
||||
const frontendBaseUrl = normalizeBaseUrl(runtime?.frontend_base_url || urls.frontend_base_url || "");
|
||||
return isTrustedReleaseUrl(frontendBaseUrl, { allowRelative: false }) ? frontendBaseUrl : "";
|
||||
};
|
||||
|
||||
const runtimeChannel = (runtime = {}) => runtime?.channel || {};
|
||||
@@ -190,8 +193,14 @@ export const shouldLoadRemoteRelease = (runtime = {}) => {
|
||||
export const releaseEntryUrl = (frontendBaseUrl) =>
|
||||
new URL(RELEASE_ENTRY_FILENAME, `${normalizeBaseUrl(frontendBaseUrl)}/`).href;
|
||||
|
||||
const resolveReleaseAssetUrl = (frontendBaseUrl, value) =>
|
||||
new URL(String(value || "").replace(/^\/+/, ""), `${normalizeBaseUrl(frontendBaseUrl)}/`).href;
|
||||
const resolveReleaseAssetUrl = (frontendBaseUrl, value) => {
|
||||
const baseUrl = normalizeBaseUrl(frontendBaseUrl);
|
||||
const resolvedUrl = new URL(String(value || "").replace(/^\/+/, ""), `${baseUrl}/`).href;
|
||||
if (!sameOriginReleaseUrl(resolvedUrl, baseUrl)) {
|
||||
throw new Error("Release entry asset URL is not on the trusted release frontend origin.");
|
||||
}
|
||||
return resolvedUrl;
|
||||
};
|
||||
|
||||
export const loadRemoteReleaseEntry = async ({
|
||||
runtime,
|
||||
@@ -200,6 +209,9 @@ export const loadRemoteReleaseEntry = async ({
|
||||
importModule = (url) => import(/* @vite-ignore */ url),
|
||||
} = {}) => {
|
||||
const frontendBaseUrl = runtimeFrontendBaseUrl(runtime);
|
||||
if (!frontendBaseUrl) {
|
||||
throw new Error("Release frontend URL is not trusted.");
|
||||
}
|
||||
const response = await fetchFn(releaseEntryUrl(frontendBaseUrl), {
|
||||
method: "GET",
|
||||
cache: "no-store",
|
||||
@@ -254,6 +266,7 @@ const localReleaseRuntime = ({ requestedSource = RELEASE_SOURCE_MODES.LOCAL, mis
|
||||
return {
|
||||
source: RELEASE_SOURCE_MODES.LOCAL,
|
||||
requested_source: requestedSource,
|
||||
bootstrap_revision: RELEASE_BOOTSTRAP_REVISION,
|
||||
generated_at: new Date().toISOString(),
|
||||
channel: {
|
||||
slug: "stable",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { reactive, readonly } from "vue";
|
||||
import { API_URL } from "@/config.js";
|
||||
import { isTrustedReleaseUrl } from "@/services/releaseTrust.js";
|
||||
import { buildReleaseHeaders, RELEASE_TRACE_STORAGE_KEY } from "@/services/releaseHeaders.js";
|
||||
|
||||
const TRACE_STORAGE_KEY = RELEASE_TRACE_STORAGE_KEY;
|
||||
@@ -97,9 +98,9 @@ const normalizeReadinessMissingValues = (missing = []) =>
|
||||
)
|
||||
);
|
||||
|
||||
const normalizeRuntimeBaseUrl = (value) => {
|
||||
const normalizeRuntimeBaseUrl = (value, { allowRelative = true } = {}) => {
|
||||
const raw = String(value || "").trim().replace(/\/+$/, "");
|
||||
if (!raw) {
|
||||
if (!raw || !isTrustedReleaseUrl(raw, { allowRelative })) {
|
||||
return null;
|
||||
}
|
||||
if (raw.startsWith("/") && !raw.startsWith("//")) {
|
||||
@@ -114,7 +115,9 @@ const normalizeRuntimeBaseUrl = (value) => {
|
||||
const runtimeUrls = (runtime = {}) => {
|
||||
const urls = runtime?.urls && typeof runtime.urls === "object" ? runtime.urls : {};
|
||||
return {
|
||||
frontendBaseUrl: normalizeRuntimeBaseUrl(runtime.frontend_base_url ?? urls.frontend_base_url),
|
||||
frontendBaseUrl: normalizeRuntimeBaseUrl(runtime.frontend_base_url ?? urls.frontend_base_url, {
|
||||
allowRelative: false,
|
||||
}),
|
||||
apiBaseUrl: normalizeRuntimeBaseUrl(runtime.api_base_url ?? urls.api_base_url),
|
||||
};
|
||||
};
|
||||
@@ -253,6 +256,47 @@ export const rewriteReleaseApiUrl = (url = "") => {
|
||||
return value;
|
||||
};
|
||||
|
||||
const SENSITIVE_PAYLOAD_KEY_PATTERN =
|
||||
/authorization|cookie|password|passwd|secret|token|api[_-]?key|session|credential|card|cpr|ssn|recaptcha/i;
|
||||
const CAPTURE_DISABLED_BODY_PLACEHOLDER = "[capture-disabled]";
|
||||
|
||||
const truncateReleasePayloadString = (value) =>
|
||||
value.length > 4000 ? `${value.slice(0, 4000)}\n... [truncated]` : value;
|
||||
|
||||
const redactSerializedReleasePayload = (value, depth) => {
|
||||
const trimmed = value.trim();
|
||||
|
||||
if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
|
||||
try {
|
||||
return truncateReleasePayloadString(JSON.stringify(redactReleasePayload(JSON.parse(value), depth + 1)));
|
||||
} catch {
|
||||
// Fall through to form-encoded and truncation handling.
|
||||
}
|
||||
}
|
||||
|
||||
if (trimmed.includes("=") && !trimmed.includes("\n")) {
|
||||
const params = new URLSearchParams(trimmed);
|
||||
const entries = Array.from(params.entries());
|
||||
if (entries.length > 0) {
|
||||
let changed = false;
|
||||
const redactedParams = new URLSearchParams();
|
||||
for (const [key, item] of entries.slice(0, 80)) {
|
||||
if (SENSITIVE_PAYLOAD_KEY_PATTERN.test(key)) {
|
||||
redactedParams.append(key, "[redacted]");
|
||||
changed = true;
|
||||
} else {
|
||||
redactedParams.append(key, truncateReleasePayloadString(item));
|
||||
}
|
||||
}
|
||||
if (changed) {
|
||||
return truncateReleasePayloadString(redactedParams.toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return truncateReleasePayloadString(value);
|
||||
};
|
||||
|
||||
export const redactReleasePayload = (value, depth = 0) => {
|
||||
if (depth > 8) {
|
||||
return "[depth-limit]";
|
||||
@@ -267,9 +311,7 @@ export const redactReleasePayload = (value, depth = 0) => {
|
||||
Object.entries(value)
|
||||
.slice(0, 80)
|
||||
.map(([key, item]) => {
|
||||
if (
|
||||
/authorization|cookie|password|passwd|secret|token|api[_-]?key|session|credential|card|cpr|ssn/i.test(key)
|
||||
) {
|
||||
if (SENSITIVE_PAYLOAD_KEY_PATTERN.test(key)) {
|
||||
return [key, "[redacted]"];
|
||||
}
|
||||
return [key, redactReleasePayload(item, depth + 1)];
|
||||
@@ -277,8 +319,8 @@ export const redactReleasePayload = (value, depth = 0) => {
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof value === "string" && value.length > 4000) {
|
||||
return `${value.slice(0, 4000)}\n... [truncated]`;
|
||||
if (typeof value === "string") {
|
||||
return redactSerializedReleasePayload(value, depth);
|
||||
}
|
||||
|
||||
return value;
|
||||
@@ -303,6 +345,30 @@ const shouldSendEvent = (type, severity) => {
|
||||
return releaseRuntimeStateMutable.capturePolicy.enabled === true;
|
||||
};
|
||||
|
||||
const omitCapturedBodiesWhenDisabled = (payload) => {
|
||||
if (!payload || typeof payload !== "object") {
|
||||
return payload;
|
||||
}
|
||||
|
||||
const nextPayload = { ...payload };
|
||||
for (const key of ["request", "response"]) {
|
||||
if (nextPayload[key] && typeof nextPayload[key] === "object" && hasOwn(nextPayload[key], "data")) {
|
||||
nextPayload[key] = {
|
||||
...nextPayload[key],
|
||||
data: CAPTURE_DISABLED_BODY_PLACEHOLDER,
|
||||
};
|
||||
}
|
||||
}
|
||||
return nextPayload;
|
||||
};
|
||||
|
||||
const sanitizeReleaseTimelinePayload = (type, severity, payload) => {
|
||||
if (isFailureEvent(type, severity) && !releaseRuntimeStateMutable.capturePolicy.enabled) {
|
||||
return omitCapturedBodiesWhenDisabled(payload);
|
||||
}
|
||||
return payload;
|
||||
};
|
||||
|
||||
export const recordReleaseTimelineEvent = (type, payload = {}, options = {}) => {
|
||||
const severity = options.severity || payload?.severity || "info";
|
||||
if (!shouldSendEvent(type, severity)) {
|
||||
@@ -317,7 +383,7 @@ export const recordReleaseTimelineEvent = (type, payload = {}, options = {}) =>
|
||||
component: options.component || payload?.component || null,
|
||||
request_id: options.requestId || payload?.request_id || null,
|
||||
occurred_at: new Date().toISOString(),
|
||||
payload: redactReleasePayload(payload),
|
||||
payload: redactReleasePayload(sanitizeReleaseTimelinePayload(type, severity, payload)),
|
||||
};
|
||||
|
||||
if (FRONTEND_FAILURE_EVENT_TYPES.has(type)) {
|
||||
@@ -421,7 +487,9 @@ const firstFilledString = (...values) => {
|
||||
const releaseRuntimeUrlsForDisplay = (runtime = {}) => {
|
||||
const urls = isPlainRecord(runtime?.urls) ? runtime.urls : {};
|
||||
return {
|
||||
frontend: normalizeRuntimeBaseUrl(runtime.frontendBaseUrl ?? runtime.frontend_base_url ?? urls.frontend_base_url),
|
||||
frontend: normalizeRuntimeBaseUrl(runtime.frontendBaseUrl ?? runtime.frontend_base_url ?? urls.frontend_base_url, {
|
||||
allowRelative: false,
|
||||
}),
|
||||
api: normalizeRuntimeBaseUrl(runtime.apiBaseUrl ?? runtime.api_base_url ?? urls.api_base_url),
|
||||
};
|
||||
};
|
||||
@@ -585,7 +653,7 @@ const releaseServiceForKey = (serviceSet = null, key = "") => {
|
||||
return isPlainRecord(service) ? service : null;
|
||||
};
|
||||
|
||||
export const buildReleaseSessionSummary = (runtime = releaseRuntimeStateMutable) => {
|
||||
export const buildReleaseSessionSummary = (runtime = releaseRuntimeStateMutable, options = {}) => {
|
||||
const versions = isPlainRecord(runtime?.versions) ? runtime.versions : {};
|
||||
const channel = isPlainRecord(runtime?.channel) ? runtime.channel : null;
|
||||
const availability = isPlainRecord(runtime?.availability) ? runtime.availability : {};
|
||||
@@ -595,16 +663,18 @@ export const buildReleaseSessionSummary = (runtime = releaseRuntimeStateMutable)
|
||||
channel?.default_channel === true
|
||||
|| channel?.default_channel === 1
|
||||
|| String(channel?.slug || "").toLowerCase() === "stable";
|
||||
const urls = releaseRuntimeUrlsForDisplay(runtime);
|
||||
const includeInfrastructureDetails = options?.includeInfrastructureDetails === true;
|
||||
const urls = includeInfrastructureDetails ? releaseRuntimeUrlsForDisplay(runtime) : { frontend: "", api: "" };
|
||||
const frontendVersion = isPlainRecord(versions.frontend) ? versions.frontend : null;
|
||||
const apiVersion = isPlainRecord(versions.api) ? versions.api : null;
|
||||
const bundle = isPlainRecord(versions.bundle) ? versions.bundle : null;
|
||||
const bundleId = versions.bundle_id || bundle?.id || null;
|
||||
const serviceSet = isPlainRecord(versions.service_set)
|
||||
const rawServiceSet = isPlainRecord(versions.service_set)
|
||||
? versions.service_set
|
||||
: isPlainRecord(bundle?.service_set)
|
||||
? bundle.service_set
|
||||
: null;
|
||||
const serviceSet = includeInfrastructureDetails ? rawServiceSet : null;
|
||||
const defaultSharedLabel = "Default/shared runtime";
|
||||
const missingLabels = missing.map((key) => RELEASE_MISSING_LABELS[key] || key.replace(/_/g, " "));
|
||||
|
||||
@@ -623,15 +693,19 @@ export const buildReleaseSessionSummary = (runtime = releaseRuntimeStateMutable)
|
||||
? "shared"
|
||||
: firstFilledString(version?.status, url ? "active" : "unknown");
|
||||
|
||||
const primaryText = releaseVersionPrimaryText(version, fallback);
|
||||
const secondaryText = includeInfrastructureDetails ? releaseVersionSecondaryText(version) : "";
|
||||
const displayUrl = includeInfrastructureDetails ? url || "" : "";
|
||||
|
||||
return {
|
||||
key,
|
||||
label,
|
||||
status,
|
||||
tone: missingKey ? "warning" : releaseStatusTone(status),
|
||||
primaryText: releaseVersionPrimaryText(version, fallback),
|
||||
secondaryText: releaseVersionSecondaryText(version),
|
||||
url: url || "",
|
||||
title: [releaseVersionPrimaryText(version, fallback), releaseVersionSecondaryText(version), url]
|
||||
primaryText,
|
||||
secondaryText,
|
||||
url: displayUrl,
|
||||
title: [primaryText, secondaryText, displayUrl]
|
||||
.filter(Boolean)
|
||||
.join(" - "),
|
||||
missingLabel: missingKey ? RELEASE_MISSING_LABELS[missingKey] || missingKey : "",
|
||||
@@ -687,13 +761,15 @@ export const buildReleaseSessionSummary = (runtime = releaseRuntimeStateMutable)
|
||||
bundleStatus: firstFilledString(bundle?.status, bundleId ? "active" : "shared"),
|
||||
serviceSetLabel: serviceSet
|
||||
? firstFilledString(serviceSet.name, serviceSet.slug, serviceSet.id ? `#${serviceSet.id}` : "Connected")
|
||||
: defaultSharedLabel,
|
||||
: rawServiceSet
|
||||
? "Restricted to release operators"
|
||||
: defaultSharedLabel,
|
||||
missingLabels,
|
||||
appRows: [
|
||||
buildAppRow("frontend", "Frontend", frontendVersion, urls.frontend),
|
||||
buildAppRow("api", "API", apiVersion, urls.api),
|
||||
],
|
||||
serviceRows: RELEASE_SERVICE_DEFINITIONS.map(buildServiceRow),
|
||||
serviceRows: includeInfrastructureDetails || !rawServiceSet ? RELEASE_SERVICE_DEFINITIONS.map(buildServiceRow) : [],
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import {
|
||||
ALLOWED_ORIGINS,
|
||||
API_URL,
|
||||
DEFAULT_LEGACY_API_URL,
|
||||
DEFAULT_PUBLIC_GATEWAY_API_URL,
|
||||
DEFAULT_STABLE_API_URL,
|
||||
RELEASE_MANAGER_CONTROL_API_URL,
|
||||
RELEASE_MANAGER_CONTROL_API_FALLBACK_URLS,
|
||||
RELEASE_PUBLIC_GATEWAY_API_URL,
|
||||
RELEASE_TRUSTED_ORIGINS,
|
||||
} from "@/config.js";
|
||||
|
||||
const normalizeOrigin = (value) => {
|
||||
const raw = String(value || "").trim();
|
||||
if (!raw) {
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
return new URL(raw).origin;
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
const browserOrigin = () => {
|
||||
if (typeof window !== "undefined" && window.location?.origin) {
|
||||
return window.location.origin;
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
const configuredTrustedOrigins = () =>
|
||||
[
|
||||
browserOrigin(),
|
||||
...ALLOWED_ORIGINS,
|
||||
...RELEASE_TRUSTED_ORIGINS,
|
||||
API_URL,
|
||||
RELEASE_MANAGER_CONTROL_API_URL,
|
||||
...RELEASE_MANAGER_CONTROL_API_FALLBACK_URLS,
|
||||
RELEASE_PUBLIC_GATEWAY_API_URL,
|
||||
DEFAULT_LEGACY_API_URL,
|
||||
DEFAULT_PUBLIC_GATEWAY_API_URL,
|
||||
DEFAULT_STABLE_API_URL,
|
||||
]
|
||||
.map(normalizeOrigin)
|
||||
.filter(Boolean);
|
||||
|
||||
export const trustedReleaseOrigins = () => Array.from(new Set(configuredTrustedOrigins()));
|
||||
|
||||
export const isTrustedReleaseOrigin = (value) => {
|
||||
const origin = normalizeOrigin(value);
|
||||
return Boolean(origin) && trustedReleaseOrigins().includes(origin);
|
||||
};
|
||||
|
||||
export const isRelativeReleaseUrl = (value) => {
|
||||
const raw = String(value || "").trim();
|
||||
return Boolean(raw) && raw.startsWith("/") && !raw.startsWith("//");
|
||||
};
|
||||
|
||||
export const isTrustedReleaseUrl = (value, { allowRelative = true } = {}) => {
|
||||
if (allowRelative && isRelativeReleaseUrl(value)) {
|
||||
return true;
|
||||
}
|
||||
return isTrustedReleaseOrigin(value);
|
||||
};
|
||||
|
||||
export const sameOriginReleaseUrl = (value, expectedBaseUrl) => {
|
||||
const resolvedOrigin = normalizeOrigin(value);
|
||||
const expectedOrigin = normalizeOrigin(expectedBaseUrl);
|
||||
return Boolean(resolvedOrigin && expectedOrigin && resolvedOrigin === expectedOrigin);
|
||||
};
|
||||
@@ -2,6 +2,7 @@ import axios from "axios";
|
||||
import { API_URL, RELEASE_MANAGER_CONTROL_API_FALLBACK_URLS, RELEASE_MANAGER_CONTROL_API_URL } from "@/config.js";
|
||||
import { enqueueRequest } from "@/services/requestQueue.js";
|
||||
import { getReleaseRuntimeApiBaseUrl, releaseRuntimeState } from "@/services/releaseTimeline.js";
|
||||
import { isTrustedReleaseUrl } from "@/services/releaseTrust.js";
|
||||
|
||||
export const RELEASE_MANAGER_CONTROL_API_STORAGE_KEY = "release_manager_control_api_url";
|
||||
const RELEASE_MANAGER_LAST_WORKING_CONTROL_API_STORAGE_KEY = "release_manager_last_working_control_api_url";
|
||||
@@ -19,9 +20,14 @@ const normalizeApiUrl = (value) => {
|
||||
return url;
|
||||
};
|
||||
|
||||
const isTrustedApiUrl = (value) => {
|
||||
const url = normalizeApiUrl(value);
|
||||
return Boolean(url && isTrustedReleaseUrl(url));
|
||||
};
|
||||
|
||||
const isLocalApiUrl = (value) => {
|
||||
const url = normalizeApiUrl(value);
|
||||
if (!url) {
|
||||
if (!isTrustedApiUrl(url)) {
|
||||
return false;
|
||||
}
|
||||
if (url.startsWith("/") && !url.startsWith("//")) {
|
||||
@@ -91,14 +97,14 @@ export const releaseManagerControlApiCandidates = () => {
|
||||
storedUrl,
|
||||
...deploymentConfigUrls,
|
||||
...RELEASE_MANAGER_CONTROL_API_FALLBACK_URLS,
|
||||
]);
|
||||
]).filter(isTrustedApiUrl);
|
||||
};
|
||||
|
||||
export const getReleaseManagerControlApiUrl = () => releaseManagerControlApiCandidates()[0] || API_URL;
|
||||
|
||||
export const setReleaseManagerControlApiUrl = (url) => {
|
||||
const normalized = normalizeApiUrl(url);
|
||||
if (!normalized) {
|
||||
if (!isTrustedApiUrl(normalized)) {
|
||||
return "";
|
||||
}
|
||||
releaseManagerLocalStorage()?.setItem(RELEASE_MANAGER_CONTROL_API_STORAGE_KEY, normalized);
|
||||
@@ -113,7 +119,7 @@ export const clearReleaseManagerControlApiUrl = () => {
|
||||
|
||||
const rememberWorkingControlApiUrl = (url) => {
|
||||
const normalized = normalizeApiUrl(url);
|
||||
if (normalized) {
|
||||
if (isTrustedApiUrl(normalized)) {
|
||||
releaseManagerSessionStorage()?.setItem(RELEASE_MANAGER_LAST_WORKING_CONTROL_API_STORAGE_KEY, normalized);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -12,6 +12,7 @@ import { QrcodeStream, QrcodeDropZone, QrcodeCapture } 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";
|
||||
|
||||
const isProcessingQRCode = ref(false); // State to indicate if QR code is being processed
|
||||
const playCaptureSound = () => {
|
||||
@@ -20,6 +21,7 @@ const playCaptureSound = () => {
|
||||
|
||||
const applyToken = (token: string) => {
|
||||
isProcessingQRCode.value = true; // Start processing
|
||||
clearEdgeGatewayWorkspaceCache();
|
||||
localStorage.setItem("token", token);
|
||||
window.location.href = "/";
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, ref, computed } from "vue";
|
||||
import PageLoader from "@/components/global/PageLoader.vue";
|
||||
import { clearEdgeGatewayWorkspaceCache } from "@/services/edgeGateways.js";
|
||||
|
||||
const isProcessing = ref(false);
|
||||
const hasToken = computed(() => localStorage.getItem("superuser_token") !== null);
|
||||
|
||||
const applyToken = (token: string) => {
|
||||
isProcessing.value = true; // Start processing
|
||||
clearEdgeGatewayWorkspaceCache();
|
||||
localStorage.setItem("token", token);
|
||||
localStorage.removeItem("superuser_token");
|
||||
window.location.href = "/";
|
||||
|
||||
+71
-12
@@ -18,6 +18,7 @@ import {
|
||||
isSearching as isSearchingCustomers,
|
||||
} from "@/components/search/economic/customerSearch.vue";
|
||||
import { resolveReleaseApiUrl } from "@/services/releaseTimeline.js";
|
||||
import { safeAttachmentDownloadLink } from "@/services/attachmentDownloadLinks.js";
|
||||
import {
|
||||
buildSelfServeDynamicImageUrl,
|
||||
getSelfServeCompletedDynamicImageStep,
|
||||
@@ -482,6 +483,18 @@ const graphStats = computed(() => {
|
||||
});
|
||||
|
||||
const permissions = computed(() => graphPayload.value?.permissions || {});
|
||||
const canEditVehicleTypeProduct = computed(() =>
|
||||
Boolean(permissions.value.can_edit) && SessionUser.canAccessSuperUser()
|
||||
);
|
||||
const destructiveGatewayActions = new Set(["rotate_credentials", "uninstall"]);
|
||||
const canManageGateways = computed(() => Boolean(permissions.value.can_manage_gateways));
|
||||
const canRunGatewayDestructiveActions = computed(() =>
|
||||
Boolean(permissions.value.can_run_gateway_destructive_actions)
|
||||
);
|
||||
const gatewayActionRequiresConfirmation = (action) => destructiveGatewayActions.has(action);
|
||||
const canRunGatewayAction = (action) =>
|
||||
canManageGateways.value &&
|
||||
(!gatewayActionRequiresConfirmation(action) || canRunGatewayDestructiveActions.value);
|
||||
const selectedNode = computed(() => flowNodes.value.find((node) => node.id === selectedNodeId.value) || null);
|
||||
const selectedNodeKind = computed(() => selectedNode.value?.data?.kind || "");
|
||||
const editableNodeKinds = ["question", "condition", "task", "action", "lane"];
|
||||
@@ -3149,14 +3162,14 @@ const inspectorTaskDynamicImageUnavailableReason = computed(() => {
|
||||
});
|
||||
|
||||
const openTaskAttachment = async (attachment, taskId = null) => {
|
||||
let downloadLink = String(attachment?.download_link || "").trim();
|
||||
let downloadLink = safeAttachmentDownloadLink(attachment?.download_link);
|
||||
if (!downloadLink && taskId && attachment?.id) {
|
||||
try {
|
||||
const payload = await requestGet("/department/selfserve/tasks/attachments/download", {
|
||||
task_id: taskId,
|
||||
attachment_id: attachment.id,
|
||||
});
|
||||
downloadLink = String(payload?.download_link || "").trim();
|
||||
downloadLink = safeAttachmentDownloadLink(payload?.download_link);
|
||||
} catch (error) {
|
||||
withErrorToast(error, "Attachment download could not be prepared.");
|
||||
return;
|
||||
@@ -4677,6 +4690,11 @@ const deleteSelected = async () => {
|
||||
};
|
||||
|
||||
const saveVehicleTypeProduct = async () => {
|
||||
if (!canEditVehicleTypeProduct.value) {
|
||||
toast.error("Only superusers can update vehicle type product catalog fields.");
|
||||
return;
|
||||
}
|
||||
|
||||
const id = parseNullableInt(
|
||||
inspectorForm.value.id || selectedRaw.value.product || selectedRaw.value.product_id || selectedRaw.value.id
|
||||
);
|
||||
@@ -4762,7 +4780,26 @@ const rollbackVersion = async (versionId) => {
|
||||
}
|
||||
};
|
||||
|
||||
const runGatewayAction = async (gatewayId, action, confirm = false) => {
|
||||
const confirmGatewayAction = (action) => {
|
||||
if (!gatewayActionRequiresConfirmation(action)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const actionLabel = action === "rotate_credentials" ? "rotate gateway credentials" : "uninstall this gateway";
|
||||
return window.confirm(`Are you sure you want to ${actionLabel}? This operation can disrupt live edge hardware.`);
|
||||
};
|
||||
|
||||
const runGatewayAction = async (gatewayId, action) => {
|
||||
if (!canRunGatewayAction(action)) {
|
||||
toast.error("You do not have permission to run this gateway action.");
|
||||
return;
|
||||
}
|
||||
|
||||
const confirm = confirmGatewayAction(action);
|
||||
if (gatewayActionRequiresConfirmation(action) && !confirm) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await requestPost("/department/selfserve/studio/gateway-action", {
|
||||
department: departmentId.value,
|
||||
@@ -7026,7 +7063,7 @@ onBeforeUnmount(() => {
|
||||
class="studio-debug-attachment"
|
||||
:disabled="!attachment.download_link"
|
||||
:title="taskAttachmentLabel(attachment)"
|
||||
@click.stop="openTaskAttachment(attachment)"
|
||||
@click.stop="openTaskAttachment(attachment, task.id)"
|
||||
>
|
||||
<span class="icon is-small"><i :class="taskAttachmentIcon(attachment)"></i></span>
|
||||
<span>{{ taskAttachmentLabel(attachment) }}</span>
|
||||
@@ -7484,7 +7521,7 @@ onBeforeUnmount(() => {
|
||||
class="studio-node-attachment"
|
||||
:disabled="!attachment.download_link"
|
||||
:title="taskAttachmentLabel(attachment)"
|
||||
@click.stop="openTaskAttachment(attachment)"
|
||||
@click.stop="openTaskAttachment(attachment, data.object_id)"
|
||||
@mousedown.stop
|
||||
>
|
||||
<span class="icon is-small"><i :class="taskAttachmentIcon(attachment)"></i></span>
|
||||
@@ -8025,7 +8062,7 @@ onBeforeUnmount(() => {
|
||||
/>
|
||||
<span>Wash product</span>
|
||||
</label>
|
||||
<button class="button is-primary" type="submit" :disabled="!permissions.can_edit">
|
||||
<button class="button is-primary" type="submit" :disabled="!canEditVehicleTypeProduct">
|
||||
<span class="icon"><i class="fas fa-floppy-disk"></i></span>
|
||||
<span>Save vehicle type</span>
|
||||
</button>
|
||||
@@ -8102,6 +8139,7 @@ onBeforeUnmount(() => {
|
||||
type="button"
|
||||
title="Discover gateway"
|
||||
@click="runGatewayAction(inspectorForm.key || selectedGatewayId, 'discovery')"
|
||||
:disabled="!canRunGatewayAction('discovery')"
|
||||
>
|
||||
<span class="icon"><i class="fas fa-radar"></i></span>
|
||||
<span>Discover</span>
|
||||
@@ -8111,6 +8149,7 @@ onBeforeUnmount(() => {
|
||||
type="button"
|
||||
title="Update gateway"
|
||||
@click="runGatewayAction(inspectorForm.key || selectedGatewayId, 'update')"
|
||||
:disabled="!canRunGatewayAction('update')"
|
||||
>
|
||||
<span class="icon"><i class="fas fa-upload"></i></span>
|
||||
<span>Update</span>
|
||||
@@ -8119,7 +8158,8 @@ onBeforeUnmount(() => {
|
||||
class="button is-small is-warning is-light"
|
||||
type="button"
|
||||
title="Rotate gateway credentials"
|
||||
@click="runGatewayAction(inspectorForm.key || selectedGatewayId, 'rotate_credentials', true)"
|
||||
@click="runGatewayAction(inspectorForm.key || selectedGatewayId, 'rotate_credentials')"
|
||||
:disabled="!canRunGatewayAction('rotate_credentials')"
|
||||
>
|
||||
<span class="icon"><i class="fas fa-key"></i></span>
|
||||
<span>Rotate</span>
|
||||
@@ -8128,7 +8168,8 @@ onBeforeUnmount(() => {
|
||||
class="button is-small is-danger is-light"
|
||||
type="button"
|
||||
title="Uninstall gateway"
|
||||
@click="runGatewayAction(inspectorForm.key || selectedGatewayId, 'uninstall', true)"
|
||||
@click="runGatewayAction(inspectorForm.key || selectedGatewayId, 'uninstall')"
|
||||
:disabled="!canRunGatewayAction('uninstall')"
|
||||
>
|
||||
<span class="icon"><i class="fas fa-trash"></i></span>
|
||||
<span>Uninstall</span>
|
||||
@@ -9316,6 +9357,7 @@ onBeforeUnmount(() => {
|
||||
class="button is-small is-link is-light"
|
||||
title="Discover gateway"
|
||||
@click="runGatewayAction(gateway.id, 'discovery')"
|
||||
:disabled="!canRunGatewayAction('discovery')"
|
||||
>
|
||||
<span class="icon"><i class="fas fa-radar"></i></span>
|
||||
</button>
|
||||
@@ -9323,20 +9365,23 @@ onBeforeUnmount(() => {
|
||||
class="button is-small is-light"
|
||||
title="Update gateway"
|
||||
@click="runGatewayAction(gateway.id, 'update')"
|
||||
:disabled="!canRunGatewayAction('update')"
|
||||
>
|
||||
<span class="icon"><i class="fas fa-upload"></i></span>
|
||||
</button>
|
||||
<button
|
||||
class="button is-small is-warning is-light"
|
||||
title="Rotate gateway credentials"
|
||||
@click="runGatewayAction(gateway.id, 'rotate_credentials', true)"
|
||||
@click="runGatewayAction(gateway.id, 'rotate_credentials')"
|
||||
:disabled="!canRunGatewayAction('rotate_credentials')"
|
||||
>
|
||||
<span class="icon"><i class="fas fa-key"></i></span>
|
||||
</button>
|
||||
<button
|
||||
class="button is-small is-danger is-light"
|
||||
title="Uninstall gateway"
|
||||
@click="runGatewayAction(gateway.id, 'uninstall', true)"
|
||||
@click="runGatewayAction(gateway.id, 'uninstall')"
|
||||
:disabled="!canRunGatewayAction('uninstall')"
|
||||
>
|
||||
<span class="icon"><i class="fas fa-trash"></i></span>
|
||||
</button>
|
||||
@@ -9802,7 +9847,7 @@ onBeforeUnmount(() => {
|
||||
class="studio-debug-attachment"
|
||||
:disabled="!attachment.download_link"
|
||||
:title="taskAttachmentLabel(attachment)"
|
||||
@click.stop="openTaskAttachment(attachment)"
|
||||
@click.stop="openTaskAttachment(attachment, task.id)"
|
||||
>
|
||||
<span class="icon is-small"><i :class="taskAttachmentIcon(attachment)"></i></span>
|
||||
<span>{{ taskAttachmentLabel(attachment) }}</span>
|
||||
@@ -10685,10 +10730,12 @@ onBeforeUnmount(() => {
|
||||
background: #ffffff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 7px;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
min-height: 46px;
|
||||
min-width: 0;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
@@ -10711,10 +10758,13 @@ onBeforeUnmount(() => {
|
||||
|
||||
.studio-verification-answer-controls {
|
||||
align-items: center;
|
||||
align-self: stretch;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
justify-content: flex-end;
|
||||
justify-content: flex-start;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.studio-answer-switch,
|
||||
@@ -10732,12 +10782,19 @@ onBeforeUnmount(() => {
|
||||
border-radius: 999px;
|
||||
color: #334155;
|
||||
display: inline-flex;
|
||||
flex: 1 1 132px;
|
||||
gap: 7px;
|
||||
justify-content: space-between;
|
||||
max-width: 100%;
|
||||
min-height: 32px;
|
||||
min-width: 0;
|
||||
padding: 4px 8px 4px 5px;
|
||||
}
|
||||
|
||||
.studio-answer-switch strong {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -10758,6 +10815,7 @@ onBeforeUnmount(() => {
|
||||
background: #0f172a;
|
||||
border-radius: 999px;
|
||||
display: inline-flex;
|
||||
flex: 0 0 38px;
|
||||
height: 20px;
|
||||
padding: 2px;
|
||||
width: 38px;
|
||||
@@ -10788,6 +10846,7 @@ onBeforeUnmount(() => {
|
||||
border-radius: 999px;
|
||||
color: #475569;
|
||||
display: inline-flex;
|
||||
flex: 0 0 30px;
|
||||
height: 30px;
|
||||
justify-content: center;
|
||||
width: 30px;
|
||||
|
||||
+20
-4
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import {ref, watch} from "vue";
|
||||
import {computed, ref, watch} from "vue";
|
||||
import { BLoading } from "buefy";
|
||||
|
||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
@@ -26,6 +26,12 @@ const washes = ref(0);
|
||||
const outsideHours = ref(createEmptyOutsideHours());
|
||||
const laneToggles = ref([]);
|
||||
const laneToggleError = ref("");
|
||||
const canManageMachineStatusToggles = computed(() =>
|
||||
SessionUser.hasPermission("admin") || SessionUser.hasPermission("modules_selfserve_lane_status_set")
|
||||
);
|
||||
const canManageDognvaskToggles = computed(() =>
|
||||
SessionUser.hasPermission("admin") || SessionUser.hasPermission("edit_department_lane")
|
||||
);
|
||||
|
||||
const normalizeBoolean = (value, defaultValue = false) => {
|
||||
if (typeof value === "boolean") {
|
||||
@@ -214,7 +220,7 @@ const dognvaskToggleId = (lane) => `dognvask-${props.department_id}-${lane.id}`;
|
||||
const laneDisplayName = (lane, index = 0) => lane.name || `Bane ${index + 1}`;
|
||||
const isMachineToggleChecked = (lane) => Boolean(lane.machine_status_enabled);
|
||||
const dognvaskHasWarning = (lane) => !lane.dognvask_configured;
|
||||
const isDognvaskToggleChecked = (lane) => Boolean(lane.selfserve_enabled) && !dognvaskHasWarning(lane);
|
||||
const isDognvaskToggleChecked = (lane) => Boolean(lane.selfserve_enabled);
|
||||
const dognvaskWarnings = (lane) => Array.isArray(lane.dognvask_configuration_warnings) ? lane.dognvask_configuration_warnings : [];
|
||||
const formatMachineStatusModifiedAt = (value) => {
|
||||
if (!value) {
|
||||
@@ -249,6 +255,11 @@ const applyLaneUpdate = (laneId, currentLane, update, savingPatch = {}) => {
|
||||
};
|
||||
|
||||
const toggleMachineStatus = async (lane, event) => {
|
||||
if (!canManageMachineStatusToggles.value) {
|
||||
event.target.checked = isMachineToggleChecked(lane);
|
||||
return;
|
||||
}
|
||||
|
||||
const enabled = event.target.checked;
|
||||
const previousLane = { ...lane };
|
||||
|
||||
@@ -278,6 +289,11 @@ const toggleMachineStatus = async (lane, event) => {
|
||||
};
|
||||
|
||||
const toggleDognvask = async (lane, event) => {
|
||||
if (!canManageDognvaskToggles.value) {
|
||||
event.target.checked = isDognvaskToggleChecked(lane);
|
||||
return;
|
||||
}
|
||||
|
||||
const enabled = event.target.checked;
|
||||
const previousLane = { ...lane };
|
||||
|
||||
@@ -335,7 +351,7 @@ watch([selected_date, selected_date_to], () => {
|
||||
type="checkbox"
|
||||
:id="machineToggleId(lane)"
|
||||
:checked="isMachineToggleChecked(lane)"
|
||||
:disabled="lane.isSavingMachineStatus"
|
||||
:disabled="!canManageMachineStatusToggles || lane.isSavingMachineStatus"
|
||||
@change="toggleMachineStatus(lane, $event)"
|
||||
>
|
||||
<div class="status-toggle-top">
|
||||
@@ -371,7 +387,7 @@ watch([selected_date, selected_date_to], () => {
|
||||
type="checkbox"
|
||||
:id="dognvaskToggleId(lane)"
|
||||
:checked="isDognvaskToggleChecked(lane)"
|
||||
:disabled="lane.isSavingDognvask"
|
||||
:disabled="!canManageDognvaskToggles || lane.isSavingDognvask"
|
||||
@change="toggleDognvask(lane, $event)"
|
||||
>
|
||||
<div class="status-toggle-top">
|
||||
|
||||
@@ -6,6 +6,12 @@ import RestrictedPageWrapper from "@/components/page/wrappers/RestrictedPageWrap
|
||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
import ConfigurationSubPageWrapper from "@/views/dashboards/superUserDashboard/configuration/ConfigurationSubPageWrapper.vue";
|
||||
import { normalizeEdgeGatewayError } from "@/features/edgeGateways/edgeGatewayErrors.js";
|
||||
import {
|
||||
canUseStubBrokerAuthMode,
|
||||
redactBrokerSharedSecret,
|
||||
sanitizeBrokerAuthMode,
|
||||
validatePublicBrokerUrl,
|
||||
} from "@/features/edgeGateways/edgeGatewayBrokerConfigSecurity.js";
|
||||
|
||||
const moduleConfigEntries = ref([]);
|
||||
const moduleConfigMap = ref({
|
||||
@@ -35,6 +41,15 @@ const brokerDiagnosticKeys = {
|
||||
secret: "broker_shared_secret",
|
||||
};
|
||||
|
||||
const allowStubBrokerAuthMode = canUseStubBrokerAuthMode();
|
||||
|
||||
const setModuleError = (message) => {
|
||||
moduleState.error = {
|
||||
title: "Invalid edge gateway broker configuration",
|
||||
message,
|
||||
};
|
||||
};
|
||||
|
||||
const loadModuleConfig = async () => {
|
||||
moduleState.loading = true;
|
||||
moduleState.error = null;
|
||||
@@ -49,8 +64,8 @@ const loadModuleConfig = async () => {
|
||||
default_update_window: response?.data?.config?.default_update_window || "02:00-04:00",
|
||||
broker_url: response?.data?.config?.broker_url || "http://edge-broker:4300",
|
||||
public_broker_url: response?.data?.config?.public_broker_url || "",
|
||||
broker_auth_mode: response?.data?.config?.broker_auth_mode || "manager",
|
||||
broker_shared_secret: response?.data?.config?.broker_shared_secret || "",
|
||||
broker_auth_mode: sanitizeBrokerAuthMode(response?.data?.config?.broker_auth_mode),
|
||||
broker_shared_secret: redactBrokerSharedSecret(),
|
||||
};
|
||||
} catch (error) {
|
||||
moduleState.error = normalizeEdgeGatewayError(error);
|
||||
@@ -65,15 +80,26 @@ const saveModuleConfig = async () => {
|
||||
moduleState.error = null;
|
||||
|
||||
try {
|
||||
await SessionUser.superUser.modules.edgegateway.config.set_config({
|
||||
const publicBrokerUrl = validatePublicBrokerUrl(moduleConfigMap.value.public_broker_url);
|
||||
if (!publicBrokerUrl.ok) {
|
||||
setModuleError(publicBrokerUrl.message);
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
enabled: Boolean(moduleConfigMap.value.enabled),
|
||||
default_release_channel: moduleConfigMap.value.default_release_channel || "stable",
|
||||
default_update_window: moduleConfigMap.value.default_update_window || "02:00-04:00",
|
||||
broker_url: moduleConfigMap.value.broker_url || "",
|
||||
public_broker_url: moduleConfigMap.value.public_broker_url || "",
|
||||
broker_auth_mode: moduleConfigMap.value.broker_auth_mode || "manager",
|
||||
broker_shared_secret: moduleConfigMap.value.broker_shared_secret || "",
|
||||
});
|
||||
public_broker_url: publicBrokerUrl.value,
|
||||
broker_auth_mode: sanitizeBrokerAuthMode(moduleConfigMap.value.broker_auth_mode),
|
||||
};
|
||||
|
||||
if (moduleConfigMap.value.broker_shared_secret) {
|
||||
payload.broker_shared_secret = moduleConfigMap.value.broker_shared_secret;
|
||||
}
|
||||
|
||||
await SessionUser.superUser.modules.edgegateway.config.set_config(payload);
|
||||
await loadModuleConfig();
|
||||
} catch (error) {
|
||||
moduleState.error = normalizeEdgeGatewayError(error);
|
||||
@@ -82,13 +108,20 @@ const saveModuleConfig = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const brokerDiagnosticPayload = (target) => ({
|
||||
target,
|
||||
broker_url: moduleConfigMap.value.broker_url || "",
|
||||
public_broker_url: moduleConfigMap.value.public_broker_url || "",
|
||||
broker_auth_mode: moduleConfigMap.value.broker_auth_mode || "manager",
|
||||
broker_shared_secret: moduleConfigMap.value.broker_shared_secret || "",
|
||||
});
|
||||
const brokerDiagnosticPayload = (target) => {
|
||||
const payload = {
|
||||
target,
|
||||
broker_url: moduleConfigMap.value.broker_url || "",
|
||||
public_broker_url: moduleConfigMap.value.public_broker_url || "",
|
||||
broker_auth_mode: sanitizeBrokerAuthMode(moduleConfigMap.value.broker_auth_mode),
|
||||
};
|
||||
|
||||
if (moduleConfigMap.value.broker_shared_secret) {
|
||||
payload.broker_shared_secret = moduleConfigMap.value.broker_shared_secret;
|
||||
}
|
||||
|
||||
return payload;
|
||||
};
|
||||
|
||||
const testBrokerConfig = async (target) => {
|
||||
const resultKey = brokerDiagnosticKeys[target];
|
||||
@@ -100,6 +133,15 @@ const testBrokerConfig = async (target) => {
|
||||
brokerDiagnostics.error = null;
|
||||
|
||||
try {
|
||||
if (target === "public") {
|
||||
const publicBrokerUrl = validatePublicBrokerUrl(moduleConfigMap.value.public_broker_url);
|
||||
if (!publicBrokerUrl.ok) {
|
||||
setModuleError(publicBrokerUrl.message);
|
||||
return;
|
||||
}
|
||||
moduleConfigMap.value.public_broker_url = publicBrokerUrl.value;
|
||||
}
|
||||
|
||||
const response = await SessionUser.superUser.modules.edgegateway.config.test_broker(
|
||||
brokerDiagnosticPayload(target)
|
||||
);
|
||||
@@ -275,7 +317,7 @@ onMounted(async () => {
|
||||
<div class="select is-fullwidth">
|
||||
<select v-model="moduleConfigMap.broker_auth_mode" data-testid="gateway-module-broker-auth-mode">
|
||||
<option value="manager">manager</option>
|
||||
<option value="stub">stub</option>
|
||||
<option v-if="allowStubBrokerAuthMode" value="stub">stub (development only)</option>
|
||||
</select>
|
||||
</div>
|
||||
</label>
|
||||
@@ -288,7 +330,7 @@ onMounted(async () => {
|
||||
autocomplete="off"
|
||||
class="input"
|
||||
data-testid="gateway-module-broker-shared-secret"
|
||||
placeholder="Shared secret"
|
||||
placeholder="Leave blank to keep the existing secret"
|
||||
type="password"
|
||||
/>
|
||||
<button
|
||||
|
||||
+77
-16
@@ -48,7 +48,11 @@ export const periodPageCount = computed(() => {
|
||||
});
|
||||
|
||||
const normalizeLimit = (value) => {
|
||||
if (String(value || "").trim().toLowerCase() === ALL_LIMIT) {
|
||||
if (
|
||||
String(value || "")
|
||||
.trim()
|
||||
.toLowerCase() === ALL_LIMIT
|
||||
) {
|
||||
return ALL_LIMIT;
|
||||
}
|
||||
|
||||
@@ -76,6 +80,45 @@ const safeSessionStorage = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const getStorageValue = (key) => {
|
||||
try {
|
||||
if (typeof window === "undefined" || !window.localStorage) {
|
||||
return "";
|
||||
}
|
||||
return window.localStorage.getItem(key) || "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
const hashCacheScopePart = (value) => {
|
||||
let hash = 5381;
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
hash = ((hash << 5) + hash) ^ value.charCodeAt(index);
|
||||
}
|
||||
return (hash >>> 0).toString(36);
|
||||
};
|
||||
|
||||
const getAuthenticatedCacheScope = () => {
|
||||
const token = getStorageValue("token");
|
||||
if (!token) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return [
|
||||
hashCacheScopePart(token),
|
||||
getStorageValue("is_subuser") === "true" ? "subuser" : "user",
|
||||
getStorageValue("selected_customer_number"),
|
||||
]
|
||||
.map((part) => encodeURIComponent(String(part ?? "")))
|
||||
.join("|");
|
||||
};
|
||||
|
||||
const getScopedCacheKey = (cacheKey) => {
|
||||
const scope = getAuthenticatedCacheScope();
|
||||
return scope ? `${scope}:${cacheKey}` : "";
|
||||
};
|
||||
|
||||
const memoryCache = new Map();
|
||||
const selfWashCountsMemoryCache = new Map();
|
||||
|
||||
@@ -151,13 +194,18 @@ const isFreshCacheEntry = (entry) => {
|
||||
};
|
||||
|
||||
export const getCachedPeriodPage = (cacheKey) => {
|
||||
const memoryEntry = memoryCache.get(cacheKey);
|
||||
const scopedCacheKey = getScopedCacheKey(cacheKey);
|
||||
if (!scopedCacheKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const memoryEntry = memoryCache.get(scopedCacheKey);
|
||||
if (isFreshCacheEntry(memoryEntry)) {
|
||||
return memoryEntry;
|
||||
}
|
||||
|
||||
if (memoryEntry) {
|
||||
memoryCache.delete(cacheKey);
|
||||
memoryCache.delete(scopedCacheKey);
|
||||
}
|
||||
|
||||
const storage = safeSessionStorage();
|
||||
@@ -165,14 +213,14 @@ export const getCachedPeriodPage = (cacheKey) => {
|
||||
return null;
|
||||
}
|
||||
|
||||
const storageKey = `${CACHE_PREFIX}${cacheKey}`;
|
||||
const storageKey = `${CACHE_PREFIX}${scopedCacheKey}`;
|
||||
try {
|
||||
const parsed = JSON.parse(storage.getItem(storageKey) || "null");
|
||||
if (!isFreshCacheEntry(parsed)) {
|
||||
storage.removeItem(storageKey);
|
||||
return null;
|
||||
}
|
||||
memoryCache.set(cacheKey, parsed);
|
||||
memoryCache.set(scopedCacheKey, parsed);
|
||||
return parsed;
|
||||
} catch {
|
||||
storage.removeItem(storageKey);
|
||||
@@ -181,12 +229,17 @@ export const getCachedPeriodPage = (cacheKey) => {
|
||||
};
|
||||
|
||||
export const setCachedPeriodPage = (cacheKey, periodResult, paginationMeta) => {
|
||||
const scopedCacheKey = getScopedCacheKey(cacheKey);
|
||||
if (!scopedCacheKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
const entry = {
|
||||
storedAt: Date.now(),
|
||||
data: periodResult,
|
||||
pagination: paginationMeta,
|
||||
};
|
||||
memoryCache.set(cacheKey, entry);
|
||||
memoryCache.set(scopedCacheKey, entry);
|
||||
|
||||
const storage = safeSessionStorage();
|
||||
if (!storage) {
|
||||
@@ -194,7 +247,7 @@ export const setCachedPeriodPage = (cacheKey, periodResult, paginationMeta) => {
|
||||
}
|
||||
|
||||
try {
|
||||
storage.setItem(`${CACHE_PREFIX}${cacheKey}`, JSON.stringify(entry));
|
||||
storage.setItem(`${CACHE_PREFIX}${scopedCacheKey}`, JSON.stringify(entry));
|
||||
} catch {
|
||||
// Best-effort cache. Quota errors should not block the period view.
|
||||
}
|
||||
@@ -217,18 +270,21 @@ export const clearPeriodCache = () => {
|
||||
};
|
||||
|
||||
export const buildSelfWashCountsCacheKey = ({ dateFrom, dateTo, filters }) =>
|
||||
[dateFrom, dateTo, filters]
|
||||
.map((part) => encodeURIComponent(String(part ?? "")))
|
||||
.join("|");
|
||||
[dateFrom, dateTo, filters].map((part) => encodeURIComponent(String(part ?? ""))).join("|");
|
||||
|
||||
export const getCachedSelfWashCounts = (cacheKey) => {
|
||||
const memoryEntry = selfWashCountsMemoryCache.get(cacheKey);
|
||||
const scopedCacheKey = getScopedCacheKey(cacheKey);
|
||||
if (!scopedCacheKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const memoryEntry = selfWashCountsMemoryCache.get(scopedCacheKey);
|
||||
if (isFreshCacheEntry(memoryEntry)) {
|
||||
return memoryEntry.data;
|
||||
}
|
||||
|
||||
if (memoryEntry) {
|
||||
selfWashCountsMemoryCache.delete(cacheKey);
|
||||
selfWashCountsMemoryCache.delete(scopedCacheKey);
|
||||
}
|
||||
|
||||
const storage = safeSessionStorage();
|
||||
@@ -236,14 +292,14 @@ export const getCachedSelfWashCounts = (cacheKey) => {
|
||||
return null;
|
||||
}
|
||||
|
||||
const storageKey = `${SELF_WASH_COUNTS_CACHE_PREFIX}${cacheKey}`;
|
||||
const storageKey = `${SELF_WASH_COUNTS_CACHE_PREFIX}${scopedCacheKey}`;
|
||||
try {
|
||||
const parsed = JSON.parse(storage.getItem(storageKey) || "null");
|
||||
if (!isFreshCacheEntry(parsed)) {
|
||||
storage.removeItem(storageKey);
|
||||
return null;
|
||||
}
|
||||
selfWashCountsMemoryCache.set(cacheKey, parsed);
|
||||
selfWashCountsMemoryCache.set(scopedCacheKey, parsed);
|
||||
return parsed.data;
|
||||
} catch {
|
||||
storage.removeItem(storageKey);
|
||||
@@ -252,11 +308,16 @@ export const getCachedSelfWashCounts = (cacheKey) => {
|
||||
};
|
||||
|
||||
export const setCachedSelfWashCounts = (cacheKey, counts) => {
|
||||
const scopedCacheKey = getScopedCacheKey(cacheKey);
|
||||
if (!scopedCacheKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
const entry = {
|
||||
storedAt: Date.now(),
|
||||
data: counts,
|
||||
};
|
||||
selfWashCountsMemoryCache.set(cacheKey, entry);
|
||||
selfWashCountsMemoryCache.set(scopedCacheKey, entry);
|
||||
|
||||
const storage = safeSessionStorage();
|
||||
if (!storage) {
|
||||
@@ -264,7 +325,7 @@ export const setCachedSelfWashCounts = (cacheKey, counts) => {
|
||||
}
|
||||
|
||||
try {
|
||||
storage.setItem(`${SELF_WASH_COUNTS_CACHE_PREFIX}${cacheKey}`, JSON.stringify(entry));
|
||||
storage.setItem(`${SELF_WASH_COUNTS_CACHE_PREFIX}${scopedCacheKey}`, JSON.stringify(entry));
|
||||
} catch {
|
||||
// Best-effort cache. Quota errors should not block the period view.
|
||||
}
|
||||
|
||||
+6
-1
@@ -34,6 +34,7 @@ const showStripeStatusSummary = computed(() => isPaid.value && (isDraft.value ||
|
||||
const isStripeTransferBusy = computed(() => stripeTransferQueue.disableSubmit.value);
|
||||
const isStripeTransferQueuedOrProcessing = computed(() => stripeTransferQueue.isQueuedOrProcessing.value);
|
||||
const isStripeTransferCompleted = computed(() => stripeTransferQueue.isCompleted.value);
|
||||
const canBookStripeInvoice = computed(() => !isStripeTransferBusy.value && !isStripeTransferCompleted.value);
|
||||
const isStripeTransferFailed = computed(() => stripeTransferQueue.isFailed.value);
|
||||
const stripeTransferProgressPercent = computed(() => stripeTransferQueue.progressPercent.value);
|
||||
const stripeTransferProgressMessage = computed(() => stripeTransferQueue.progressMessage.value);
|
||||
@@ -61,6 +62,10 @@ const stripeTransferResultMessage = computed(() => {
|
||||
});
|
||||
|
||||
const onBookStripeInvoice = async () => {
|
||||
if (!canBookStripeInvoice.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await stripeTransferQueue.enqueue({});
|
||||
} catch (error) {
|
||||
@@ -324,7 +329,7 @@ const stripePayment = computed(() => {
|
||||
<div class="buttons">
|
||||
<button
|
||||
class="button is-small is-light"
|
||||
:disabled="isStripeTransferBusy"
|
||||
:disabled="!canBookStripeInvoice"
|
||||
data-testid="collected-stripe-book-invoice"
|
||||
@click="onBookStripeInvoice"
|
||||
>
|
||||
|
||||
+41
-44
@@ -1150,8 +1150,10 @@ function normalizeReleaseStatusService(service) {
|
||||
status: service?.status || service?.state || "ready",
|
||||
state: service?.state || service?.status || "ready",
|
||||
severity: service?.severity || "ok",
|
||||
message: service?.message || trFallback("status.service_ready", "Release service is ready."),
|
||||
next_action: service?.next_action || "",
|
||||
message: redactSensitiveDiagnostics(
|
||||
service?.message || trFallback("status.service_ready", "Release service is ready.")
|
||||
),
|
||||
next_action: redactSensitiveDiagnostics(service?.next_action || ""),
|
||||
target_tab: service?.target_tab || "overview",
|
||||
target_id: service?.target_id ?? null,
|
||||
deployment_id: service?.deployment_id ?? null,
|
||||
@@ -1175,8 +1177,8 @@ function normalizeReleaseStatusIssue(issue) {
|
||||
channel_slug: issue?.channel_slug || "",
|
||||
service_key: issue?.service_key || null,
|
||||
label: issue?.label || releaseStatusServiceLabel(issue?.service_key),
|
||||
message: issue?.message || "",
|
||||
next_action: issue?.next_action || "",
|
||||
message: redactSensitiveDiagnostics(issue?.message || ""),
|
||||
next_action: redactSensitiveDiagnostics(issue?.next_action || ""),
|
||||
target_tab: issue?.target_tab || "overview",
|
||||
target_id: issue?.target_id ?? null,
|
||||
deployment_id: issue?.deployment_id ?? null,
|
||||
@@ -2888,26 +2890,6 @@ function isolatedStackRequestedName() {
|
||||
return String(bundleForm.service_set_name || `${channelSlug} isolated stack`).trim();
|
||||
}
|
||||
|
||||
function isolatedStackIsComplete(set) {
|
||||
return ["frontend", "api", "database", "redis", "minio"].every((item) => serviceSetStackItem(set, item));
|
||||
}
|
||||
|
||||
function matchingIsolatedStackServiceSet() {
|
||||
const channelId = Number(bundleChannelId() || 0);
|
||||
const requestedSlug = safeStackSlug(isolatedStackRequestedName());
|
||||
return (
|
||||
serviceSets.value.find((set) => {
|
||||
if (set?.mode !== "isolated_stack") {
|
||||
return false;
|
||||
}
|
||||
if (channelId && Number(set.channel_id || 0) !== channelId) {
|
||||
return false;
|
||||
}
|
||||
return safeStackSlug(set.name || set.slug || "") === requestedSlug || String(set.slug || "") === requestedSlug;
|
||||
}) || null
|
||||
);
|
||||
}
|
||||
|
||||
function isolatedStackServiceName(app) {
|
||||
const channelSlug = selectedBundleChannel()?.slug || "release";
|
||||
const stackName = safeStackSlug(bundleForm.service_set_name || `${channelSlug}-isolated-stack`);
|
||||
@@ -3150,19 +3132,6 @@ async function serviceSetIdForBundle() {
|
||||
return sourceId;
|
||||
}
|
||||
|
||||
if (isIsolatedStackMode.value) {
|
||||
const existingStack = matchingIsolatedStackServiceSet();
|
||||
if (existingStack?.id) {
|
||||
if (!isolatedStackIsComplete(existingStack) && missingIsolatedDataServices(existingStack).length > 0) {
|
||||
await completeReleaseServiceSetIsolatedDataServices(existingStack.id, {
|
||||
deploy_data_targets: true,
|
||||
});
|
||||
await load();
|
||||
}
|
||||
return existingStack.id;
|
||||
}
|
||||
}
|
||||
|
||||
const isolatedTargets = isIsolatedStackMode.value
|
||||
? {
|
||||
frontend: await createIsolatedStackDeploymentTarget("frontend"),
|
||||
@@ -3310,7 +3279,7 @@ async function saveChannel() {
|
||||
}
|
||||
|
||||
async function saveAssignment() {
|
||||
if (!assignmentForm.subject_type || !assignmentForm.subject_id) {
|
||||
if (!canManage.value || !assignmentForm.subject_type || !assignmentForm.subject_id) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -4109,12 +4078,35 @@ function deploymentFailureSummary(deployment) {
|
||||
return summary && typeof summary === "object" ? summary : null;
|
||||
}
|
||||
|
||||
function redactSensitiveDiagnostics(value) {
|
||||
let text = String(value || "").trim();
|
||||
if (!text) {
|
||||
return "";
|
||||
}
|
||||
|
||||
text = text.replace(/(https?:\/\/)([^\s/@:]+):([^\s/@]+)@/gi, "$1[redacted]@");
|
||||
text = text.replace(/\b(?:gh[opsru]_|github_pat_|glpat-|xox[baprs]-)[A-Za-z0-9_\-]{8,}\b/g, "[redacted-token]");
|
||||
text = text.replace(/\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]{8,}/gi, "$1 [redacted]");
|
||||
text = text.replace(
|
||||
/\b((?:[A-Z][A-Z0-9_]*_)?(?:PASSWORD|PASSWD|SECRET|TOKEN|API[_-]?KEY|ACCESS[_-]?KEY|PRIVATE[_-]?KEY|AUTH)[A-Z0-9_]*)\s*=\s*(?:"[^"]*"|'[^']*'|[^\s,;]+)/gi,
|
||||
"$1=[redacted]"
|
||||
);
|
||||
text = text.replace(
|
||||
/\b(?:10\.\d{1,3}\.\d{1,3}\.\d{1,3}|172\.(?:1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3}|192\.168\.\d{1,3}\.\d{1,3})\b/g,
|
||||
"[redacted-private-ip]"
|
||||
);
|
||||
text = text.replace(/\b[a-z0-9.-]+\.internal\b/gi, "[redacted-internal-host]");
|
||||
text = text.replace(/(?:^|\s)(?:\/[A-Za-z0-9._-]+){2,}\/\.env\b/g, " [redacted-env-path]");
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
function deploymentFailureCause(deployment) {
|
||||
return String(deploymentFailureSummary(deployment)?.root_cause || deployment?.error_message || "").trim();
|
||||
return redactSensitiveDiagnostics(deploymentFailureSummary(deployment)?.root_cause || deployment?.error_message || "");
|
||||
}
|
||||
|
||||
function deploymentFailureNextAction(deployment) {
|
||||
return String(deploymentFailureSummary(deployment)?.next_action || "").trim();
|
||||
return redactSensitiveDiagnostics(deploymentFailureSummary(deployment)?.next_action || "");
|
||||
}
|
||||
|
||||
function canPromoteDeployment(deployment) {
|
||||
@@ -4134,9 +4126,9 @@ function promotionBlockedReason(deployment) {
|
||||
return tr("actions.promote");
|
||||
}
|
||||
|
||||
return (
|
||||
return redactSensitiveDiagnostics(
|
||||
deployment?.promotion_blocked_reason ||
|
||||
trFallback("deployments.promotion_blocked", "Only successfully deployed release deployments can be promoted.")
|
||||
trFallback("deployments.promotion_blocked", "Only successfully deployed release deployments can be promoted.")
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5366,7 +5358,7 @@ onMounted(async () => {
|
||||
icon="fas fa-user-tag"
|
||||
default-expanded
|
||||
>
|
||||
<div class="release-chip-row" data-testid="release-assignment-channel-suggestions">
|
||||
<div v-if="canManage" class="release-chip-row" data-testid="release-assignment-channel-suggestions">
|
||||
<b-button
|
||||
v-for="channel in channels"
|
||||
:key="channel.id"
|
||||
@@ -5380,7 +5372,12 @@ onMounted(async () => {
|
||||
</b-button>
|
||||
</div>
|
||||
|
||||
<form class="release-form" data-testid="release-assignment-form" @submit.prevent="saveAssignment">
|
||||
<form
|
||||
v-if="canManage"
|
||||
class="release-form"
|
||||
data-testid="release-assignment-form"
|
||||
@submit.prevent="saveAssignment"
|
||||
>
|
||||
<b-field :label="tr('assignments.subject')" :message="tr('assignments.subject_message')">
|
||||
<ReleaseAssignmentSubjectAutocomplete
|
||||
:model-value="assignmentSubjectSelection"
|
||||
|
||||
@@ -143,11 +143,32 @@ const onItemsClick = (event, booking) => {
|
||||
showPopper(popperBox(t("bookings_table.services"), content), event.currentTarget || event.target);
|
||||
};
|
||||
|
||||
const normalizePositiveInteger = (value) => {
|
||||
const parsedValue = Number.parseInt(String(value ?? ""), 10);
|
||||
return Number.isInteger(parsedValue) && parsedValue > 0 ? parsedValue : null;
|
||||
};
|
||||
|
||||
const getCurrentCustomerNumber = () =>
|
||||
normalizePositiveInteger(
|
||||
SessionUser.isSubuser.value
|
||||
? SessionUser.subuser.selectedGrantCustomerNumber.value
|
||||
: SessionUser.getCustomerNumber()
|
||||
);
|
||||
|
||||
const isOwnBooking = (bookingobj) =>
|
||||
normalizePositiveInteger(bookingobj?.customer_number) === getCurrentCustomerNumber();
|
||||
|
||||
const canEditBooking = (bookingobj) => {
|
||||
// Admins can always edit
|
||||
if (SessionUser.canAccessAdmin() && SessionUser.hasPermission("edit_bookings")) return true;
|
||||
if (!!bookingobj.order_id) return false; // Only allow editing bookings without an order
|
||||
if (SessionUser.hasPermission("edit_own_bookings")) return true;
|
||||
if (SessionUser.hasPermission("edit_own_bookings") && isOwnBooking(bookingobj)) return true;
|
||||
};
|
||||
|
||||
const canDeleteBooking = (bookingobj) => {
|
||||
if (!!bookingobj.order_id) return false;
|
||||
if (SessionUser.canAccessAdmin() || SessionUser.canAccessSuperUser()) return true;
|
||||
return SessionUser.hasPermission("edit_own_bookings") && isOwnBooking(bookingobj);
|
||||
};
|
||||
|
||||
const sortedList = computed(() => {
|
||||
@@ -467,6 +488,7 @@ const getColspan = () => {
|
||||
:customer_number="booking.customer_number"
|
||||
:department_id="booking.department"
|
||||
:refresh-function="loadList"
|
||||
:allow-booking-deletion="canDeleteBooking(booking)"
|
||||
:reg_1="booking.reg_1"
|
||||
:reg_2="booking.reg_2"
|
||||
:icon="'fas fa-ellipsis-v'"
|
||||
@@ -532,6 +554,7 @@ const getColspan = () => {
|
||||
<span>{{ getDepartmentName(parseInt(booking.department)) }}</span>
|
||||
</span>
|
||||
<button
|
||||
v-if="canDeleteBooking(booking)"
|
||||
class="button is-light is-danger is-small order-booking-card-header__delete"
|
||||
type="button"
|
||||
aria-label="Slet booking"
|
||||
@@ -598,6 +621,7 @@ const getColspan = () => {
|
||||
:customer_number="booking.customer_number"
|
||||
:department_id="booking.department"
|
||||
:refresh-function="loadList"
|
||||
:allow-booking-deletion="canDeleteBooking(booking)"
|
||||
:reg_1="booking.reg_1"
|
||||
:reg_2="booking.reg_2"
|
||||
:displayActionsDirectly="true"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup>
|
||||
import { computed, onMounted } from "vue";
|
||||
import { BLoading } from "buefy";
|
||||
import { BLoading, BMessage } from "buefy";
|
||||
import UserDashboardPageWrapper from "@/views/dashboards/userDashboard/UserDashboardPageWrapper.vue";
|
||||
import PosDepartmentStepMobile1Location from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile1Location.vue";
|
||||
import WhiteBoxCard from "@/components/displays/boxes/WhiteBoxCard.vue";
|
||||
@@ -10,12 +10,61 @@ import { useWashDepartments } from "@/composables/useWashDepartments";
|
||||
const {
|
||||
guestDepartments,
|
||||
nearestDepartment,
|
||||
isDepartmentSelectionFallbackBased,
|
||||
fetchDepartments,
|
||||
evaluateLocationDepartments,
|
||||
orderDepartmentsByDistance,
|
||||
} = useWashDepartments();
|
||||
selectDepartment,
|
||||
} = useWashDepartments({ includeLanes: true });
|
||||
|
||||
const orderedDepartments = computed(() => orderDepartmentsByDistance(guestDepartments.value));
|
||||
const hasLocationCoordinates = computed(() => !!locations.location.value?.coords);
|
||||
const shouldShowChooseDepartmentMessage = computed(() => !hasLocationCoordinates.value && !nearestDepartment.value);
|
||||
|
||||
const isDepartmentSelfServeEnabled = (department) => department?.self_serve_enabled === true;
|
||||
|
||||
const isLaneSelfServeEnabled = (lane) =>
|
||||
!(
|
||||
lane?.selfserve_enabled === false ||
|
||||
lane?.selfserve_enabled === 0 ||
|
||||
lane?.selfserve_enabled === "0" ||
|
||||
["false", "off", "no"].includes(
|
||||
String(lane?.selfserve_enabled ?? "")
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
)
|
||||
);
|
||||
|
||||
const isLaneReadyForSelfServe = (lane) => lane?.status === "AVAILABLE" && isLaneSelfServeEnabled(lane);
|
||||
|
||||
const canDepartmentStartSelfServe = (department) =>
|
||||
isDepartmentSelfServeEnabled(department) && (department?.lanes || []).some(isLaneReadyForSelfServe);
|
||||
|
||||
const nearestDepartmentCanStartSelfServe = computed(() => canDepartmentStartSelfServe(nearestDepartment.value));
|
||||
|
||||
const eligibleDepartments = computed(() => guestDepartments.value.filter(canDepartmentStartSelfServe));
|
||||
|
||||
const eligibleAlternativeDepartments = computed(() =>
|
||||
eligibleDepartments.value.filter((department) => department.id !== nearestDepartment.value?.id)
|
||||
);
|
||||
|
||||
const hasEligibleAlternativeDepartment = computed(() => eligibleAlternativeDepartments.value.length > 0);
|
||||
|
||||
const selfServeUnavailableMessage = computed(() => {
|
||||
if (!nearestDepartment.value) {
|
||||
return "Vi kan ikke finde din nærmeste afdeling uden en placering. Vælg en selvvask-klar afdeling nedenfor.";
|
||||
}
|
||||
|
||||
if (!isDepartmentSelfServeEnabled(nearestDepartment.value)) {
|
||||
return `${nearestDepartment.value.name} er ikke aktiveret til selvvask lige nu.`;
|
||||
}
|
||||
|
||||
if (!(nearestDepartment.value.lanes || []).some(isLaneReadyForSelfServe)) {
|
||||
return `${nearestDepartment.value.name} har ingen ledige selvvask-baner lige nu.`;
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
const getDepartmentDistance = (department) => {
|
||||
if (!locations.location.value?.coords) {
|
||||
@@ -46,27 +95,65 @@ onMounted(async () => {
|
||||
<div class="column is-12">
|
||||
<WhiteBoxCard :force-state="true" :defaultOpen="true" :toggleable="false" :loading="false">
|
||||
<template #header>
|
||||
<div class="card-header-title has-text-link has-text-weight-bold" data-testid="self-serve-home-nearest-name">
|
||||
{{ nearestDepartment ? nearestDepartment.name : $t("common.loading") }}
|
||||
<div
|
||||
class="card-header-title has-text-link has-text-weight-bold"
|
||||
data-testid="self-serve-home-nearest-name"
|
||||
>
|
||||
{{ nearestDepartment ? nearestDepartment.name : $t("global.search_departments") }}
|
||||
</div>
|
||||
<div class="card-header-icon has-text-link">
|
||||
<i class="fas fa-map-marker-alt"></i>
|
||||
</div>
|
||||
</template>
|
||||
<template #content>
|
||||
{{ $t("user_dashboard.wash.nearest_department", { name: nearestDepartment ? nearestDepartment.name : "-", distance: nearestDepartment ? nearestDepartment.distance.toFixed(2) : "-" }) }}
|
||||
<b-loading :is-full-page="false" :model-value="!nearestDepartment" :can-cancel="true" />
|
||||
<template v-if="nearestDepartment && isDepartmentSelectionFallbackBased">
|
||||
Vælg en afdeling for at fortsætte. Vi har valgt den første tilgængelige selvvaskeafdeling, fordi din
|
||||
placering ikke er tilgængelig.
|
||||
</template>
|
||||
<template v-else-if="nearestDepartment">
|
||||
{{
|
||||
$t("user_dashboard.wash.nearest_department", {
|
||||
name: nearestDepartment.name,
|
||||
distance: nearestDepartment.distance !== null ? nearestDepartment.distance.toFixed(2) : "-",
|
||||
})
|
||||
}}
|
||||
</template>
|
||||
<template v-else-if="shouldShowChooseDepartmentMessage">
|
||||
Vælg en afdeling for at starte vask, fordi din placering ikke er tilgængelig.
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ $t("common.loading") }}
|
||||
<b-loading :is-full-page="false" :model-value="true" :can-cancel="true" />
|
||||
</template>
|
||||
<BMessage
|
||||
v-if="selfServeUnavailableMessage"
|
||||
type="is-warning"
|
||||
size="is-small"
|
||||
class="mt-3"
|
||||
data-testid="self-serve-home-unavailable-message"
|
||||
>
|
||||
{{ selfServeUnavailableMessage }}
|
||||
</BMessage>
|
||||
</template>
|
||||
<template #footer>
|
||||
<div class="card-footer-item">
|
||||
<router-link
|
||||
v-if="nearestDepartment"
|
||||
v-if="nearestDepartment && nearestDepartmentCanStartSelfServe"
|
||||
to="/user/wash/start"
|
||||
class="button is-link is-fullwidth"
|
||||
data-testid="self-serve-home-start"
|
||||
>
|
||||
{{ $t("user_dashboard.wash.start_wash") }}
|
||||
</router-link>
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
class="button is-link is-light is-fullwidth"
|
||||
data-testid="self-serve-home-start-disabled"
|
||||
disabled
|
||||
>
|
||||
Selvvask er ikke tilgængelig her
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</WhiteBoxCard>
|
||||
@@ -74,7 +161,7 @@ onMounted(async () => {
|
||||
|
||||
<template v-for="department in orderedDepartments" :key="department.id">
|
||||
<div
|
||||
v-if="nearestDepartment && department.id !== nearestDepartment.id"
|
||||
v-if="!nearestDepartment || department.id !== nearestDepartment.id"
|
||||
class="column is-12-mobile is-6-tablet is-4-desktop"
|
||||
:data-testid="`self-serve-home-department-${department.id}`"
|
||||
>
|
||||
@@ -94,7 +181,20 @@ onMounted(async () => {
|
||||
</template>
|
||||
<template #footer>
|
||||
<div class="card-footer-item">
|
||||
<router-link :to="{ name: 'pos', params: { departmentId: department.id } }" class="button is-light is-fullwidth">
|
||||
<button
|
||||
v-if="canDepartmentStartSelfServe(department) && !nearestDepartmentCanStartSelfServe"
|
||||
type="button"
|
||||
class="button is-link is-fullwidth"
|
||||
:data-testid="`self-serve-home-select-department-${department.id}`"
|
||||
@click="selectDepartment(department.id)"
|
||||
>
|
||||
Vælg denne afdeling
|
||||
</button>
|
||||
<router-link
|
||||
v-else
|
||||
:to="{ name: 'pos', params: { departmentId: department.id } }"
|
||||
class="button is-light is-fullwidth"
|
||||
>
|
||||
<span class="icon"><i class="fas fa-map-marker-alt"></i></span>
|
||||
<span>{{ department.address }}</span>
|
||||
</router-link>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -21,7 +21,7 @@ const redirectPath = computed(() => {
|
||||
});
|
||||
|
||||
const destinationUrl = computed(() => {
|
||||
return buildMigrationUrl(MIGRATION_ORIGIN, redirectPath.value);
|
||||
return buildMigrationUrl(MIGRATION_ORIGIN, redirectPath.value, window.location.pathname);
|
||||
});
|
||||
|
||||
const clearTimers = () => {
|
||||
|
||||
@@ -466,10 +466,10 @@ test.describe("Admin overview night washes", () => {
|
||||
await expect(machineStatusTooltip).toContainText("2026");
|
||||
}
|
||||
await expect(page.locator("#dognvask-1-101")).toBeChecked();
|
||||
await expect(page.locator("#dognvask-2-202")).not.toBeChecked();
|
||||
await expect(page.locator("#dognvask-2-202")).toBeChecked();
|
||||
await expect(page.getByTestId("overview-department-2-lane-202-dognvask").locator(".switch")).toHaveCSS(
|
||||
"background-color",
|
||||
"rgb(238, 75, 43)"
|
||||
"rgb(146, 208, 80)"
|
||||
);
|
||||
await expect(page.getByTestId("overview-department-3-night-washes")).not.toContainText(
|
||||
/mangler|Opening hours missing|Missing Hours/
|
||||
|
||||
@@ -81,7 +81,7 @@ test.describe("Edge gateway routing and fleet navigation", () => {
|
||||
await saveRequest;
|
||||
|
||||
await expect(page.getByTestId("gateway-module-broker-url")).toHaveValue("http://edge-broker:4301");
|
||||
await expect(page.getByTestId("gateway-module-broker-shared-secret")).toHaveValue("updated-broker-secret");
|
||||
await expect(page.getByTestId("gateway-module-broker-shared-secret")).toHaveValue("");
|
||||
});
|
||||
|
||||
test("tests broker module configuration values", async ({ page }) => {
|
||||
|
||||
@@ -1340,8 +1340,12 @@ test.describe("POS mobile order flow", () => {
|
||||
await selectPrimaryProduct(page, 53);
|
||||
|
||||
await waitForMobileNextStepCooldown(page);
|
||||
const orderId = Number(new URL(page.url()).searchParams.get("id"));
|
||||
|
||||
await page.getByTestId("pos-mobile-next-step").click();
|
||||
await expect.poll(() => fixture.requestCounters.orderItemsPost, { timeout: 10_000 }).toBe(1);
|
||||
await expect.poll(() => fixture.requestCounters.markAsCompleted, { timeout: 10_000 }).toBe(1);
|
||||
expect(fixture.markCompletedOrderIds).toContain(orderId);
|
||||
await waitForStepReset(page);
|
||||
});
|
||||
|
||||
@@ -3687,6 +3691,8 @@ test.describe("POS mobile order flow", () => {
|
||||
|
||||
await expect.poll(() => fixture.requestCounters.bookingSetOrderId, { timeout: 10_000 }).toBe(1);
|
||||
await expect.poll(() => fixture.requestCounters.bookingComplete, { timeout: 10_000 }).toBe(1);
|
||||
await expect.poll(() => fixture.requestCounters.markAsCompleted, { timeout: 10_000 }).toBe(1);
|
||||
expect(fixture.markCompletedOrderIds).toContain(orderId);
|
||||
await expect.poll(() => fixture.bookingsById[DEFAULT_BOOKING_ID]?.status ?? "").toBe("completed");
|
||||
await waitForStepReset(page);
|
||||
});
|
||||
@@ -3757,6 +3763,8 @@ test.describe("POS mobile order flow", () => {
|
||||
await page.getByTestId("pos-mobile-booking-complete-with-certificate").click();
|
||||
|
||||
await expect.poll(() => fixture.requestCounters.bookingComplete, { timeout: 10_000 }).toBe(1);
|
||||
await expect.poll(() => fixture.requestCounters.markAsCompleted, { timeout: 10_000 }).toBe(1);
|
||||
expect(fixture.markCompletedOrderIds).toContain(orderId);
|
||||
await expect
|
||||
.poll(() => fixture.requestLog.bookingCompletions[0]?.safety_seal ?? null, { timeout: 10_000 })
|
||||
.toBe(9090);
|
||||
|
||||
@@ -15,6 +15,7 @@ const json = (body, status = 200) => ({
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const GUARD_TIMEOUT_MS = 30_000;
|
||||
const isDesktopProject = (projectName) => projectName.includes("desktop");
|
||||
|
||||
const unavailableRuntime = {
|
||||
generated_at: "2026-05-19T09:00:00.000Z",
|
||||
@@ -542,7 +543,10 @@ test("release channel choices show git commit and release time when available",
|
||||
});
|
||||
|
||||
test("ready sidebar release channel switches are confirmed through the control runtime", async ({ page }, testInfo) => {
|
||||
test.skip(testInfo.project.name.includes("mobile"), "The sidebar release selector is hidden in the mobile layout.");
|
||||
test.skip(
|
||||
!isDesktopProject(testInfo.project.name),
|
||||
"The sidebar release selector is only visible in the desktop layout."
|
||||
);
|
||||
|
||||
const stableRuntime = runtimeWithFailingBetaSwitch();
|
||||
const betaRuntime = runtimeWithReadyBetaSelected();
|
||||
@@ -601,7 +605,10 @@ test("ready sidebar release channel switches are confirmed through the control r
|
||||
});
|
||||
|
||||
test("sidebar can switch back to the local frontend runtime", async ({ page }, testInfo) => {
|
||||
test.skip(testInfo.project.name.includes("mobile"), "The sidebar release selector is hidden in the mobile layout.");
|
||||
test.skip(
|
||||
!isDesktopProject(testInfo.project.name),
|
||||
"The sidebar release selector is only visible in the desktop layout."
|
||||
);
|
||||
|
||||
const runtime = runtimeWithFailingBetaSwitch();
|
||||
await boot(page, runtime);
|
||||
@@ -626,12 +633,31 @@ test("sidebar can switch back to the local frontend runtime", async ({ page }, t
|
||||
await page.evaluate(() => window.localStorage.setItem("release_channel_selected_slug", "beta"));
|
||||
await useLocal.click();
|
||||
|
||||
await expect.poll(() => page.evaluate(() => window.localStorage.getItem("release_source_override"))).toBe("local");
|
||||
await expect.poll(() => page.evaluate(() => window.localStorage.getItem("release_channel_selected_slug"))).toBeNull();
|
||||
await expect
|
||||
.poll(async () => {
|
||||
try {
|
||||
return await page.evaluate(() => window.localStorage.getItem("release_source_override"));
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.toBe("local");
|
||||
await expect
|
||||
.poll(async () => {
|
||||
try {
|
||||
return await page.evaluate(() => window.localStorage.getItem("release_channel_selected_slug"));
|
||||
} catch (error) {
|
||||
return "navigating";
|
||||
}
|
||||
})
|
||||
.toBeNull();
|
||||
});
|
||||
|
||||
test("failed sidebar release channel switches keep the previous channel active", async ({ page }, testInfo) => {
|
||||
test.skip(testInfo.project.name.includes("mobile"), "The sidebar release selector is hidden in the mobile layout.");
|
||||
test.skip(
|
||||
!isDesktopProject(testInfo.project.name),
|
||||
"The sidebar release selector is only visible in the desktop layout."
|
||||
);
|
||||
|
||||
const runtime = runtimeWithFailingBetaSwitch();
|
||||
await boot(page, runtime);
|
||||
|
||||
@@ -930,13 +930,13 @@ function completeIsolatedDataServices(state, serviceSet) {
|
||||
return serviceSet;
|
||||
}
|
||||
|
||||
async function boot(page, state = createReleaseState()) {
|
||||
async function boot(page, state = createReleaseState(), options = {}) {
|
||||
await page.addInitScript(() => {
|
||||
window.localStorage.setItem("locale", "en");
|
||||
});
|
||||
await mockApi(page, {
|
||||
authenticated: true,
|
||||
permissions,
|
||||
permissions: options.permissions || permissions,
|
||||
sessionData: {
|
||||
runtime_config: {
|
||||
release: {
|
||||
@@ -992,6 +992,8 @@ async function installReleaseMocks(page, state) {
|
||||
|
||||
if (pathname.endsWith("/superuser/releases/assignment-subjects") && method === "GET") {
|
||||
const query = (url.searchParams.get("search") || "").toLowerCase();
|
||||
state.assignmentSubjectRequests = state.assignmentSubjectRequests || [];
|
||||
state.assignmentSubjectRequests.push(query);
|
||||
const limit = Math.max(1, Math.min(10, Number(url.searchParams.get("limit") || 5)));
|
||||
const subjects = [
|
||||
{
|
||||
@@ -2215,6 +2217,49 @@ test("overview filters channel health and issues by selected channel and app", a
|
||||
await expect(issuePanel).not.toContainText("Composer install failed");
|
||||
});
|
||||
|
||||
test("view-only release managers cannot search assignment subjects", async ({ page }) => {
|
||||
const state = await boot(page, createReleaseState(), {
|
||||
permissions: ["superuser_release_manager_view"],
|
||||
});
|
||||
await page.goto("/superuser/configuration/releases/assignments", { waitUntil: "domcontentloaded" });
|
||||
|
||||
await expectReleaseManagerReady(page);
|
||||
await expect(page.getByTestId("release-section-assignments")).toBeVisible();
|
||||
await expect(page.getByTestId("release-assignment-form")).toHaveCount(0);
|
||||
await expect(page.getByTestId("release-assignment-channel-suggestions")).toHaveCount(0);
|
||||
await expect(page.getByTestId("release-assignment-subject-search")).toHaveCount(0);
|
||||
expect(state.assignmentSubjectRequests || []).toEqual([]);
|
||||
});
|
||||
|
||||
test("view-only release managers see redacted deployment failure diagnostics", async ({ page }) => {
|
||||
const state = createReleaseState();
|
||||
state.deployments[0].error_message =
|
||||
"Clone failed for https://deploy:ghp_SECRET_TOKEN_123456789@github.internal/acme/api.git DB_PASSWORD=s3cr3t internal-host=10.0.4.12";
|
||||
state.deployments[0].failure_summary.root_cause = state.deployments[0].error_message;
|
||||
state.deployments[0].failure_summary.next_action =
|
||||
"Rotate ghp_SECRET_TOKEN_123456789 and inspect /var/lib/coolify/apps/api/.env before retrying.";
|
||||
|
||||
await boot(page, state, {
|
||||
permissions: ["superuser_release_manager_view"],
|
||||
});
|
||||
await page.goto("/superuser/configuration/releases/overview?channel=canary&app=api&branch=master", {
|
||||
waitUntil: "domcontentloaded",
|
||||
});
|
||||
|
||||
await expectReleaseManagerReady(page);
|
||||
await expect(page.getByTestId("release-manager-page")).not.toContainText("ghp_SECRET_TOKEN_123456789");
|
||||
await expect(page.getByTestId("release-manager-page")).not.toContainText("DB_PASSWORD=s3cr3t");
|
||||
await expect(page.getByTestId("release-manager-page")).not.toContainText("10.0.4.12");
|
||||
await expect(page.getByTestId("release-manager-page")).not.toContainText("github.internal");
|
||||
|
||||
await selectReleaseTab(page, "Deployments");
|
||||
await expect(page.getByTestId("release-deployment-failure-2")).toContainText("[redacted]");
|
||||
await expect(page.getByTestId("release-deployment-failure-2")).toContainText("DB_PASSWORD=[redacted]");
|
||||
await expect(page.getByTestId("release-deployment-failure-2")).toContainText("[redacted-private-ip]");
|
||||
await expect(page.getByTestId("release-deployment-failure-2")).toContainText("[redacted-internal-host]");
|
||||
await expect(page.getByTestId("release-deployment-failure-2")).toContainText("[redacted-env-path]");
|
||||
});
|
||||
|
||||
test("superusers manage release settings, assignments, integrations, and sync operations", async ({ page }) => {
|
||||
const state = await boot(page);
|
||||
await page.goto("/superuser/configuration/releases/overview?channel=canary&app=api&branch=canary", {
|
||||
@@ -2623,8 +2668,8 @@ test("isolated stack mode creates fresh Coolify app and data targets without att
|
||||
const isolatedTargetCount = state.targets.filter((target) => target.deploy_context?.isolated_stack).length;
|
||||
await page.getByTestId("release-bundle-deploy-submit").click();
|
||||
await expect(page.getByTestId("release-created-bundle")).toContainText("Bundle deployed");
|
||||
expect(state.serviceSets.filter((set) => set.mode === "isolated_stack")).toHaveLength(isolatedServiceSetCount);
|
||||
expect(state.targets.filter((target) => target.deploy_context?.isolated_stack)).toHaveLength(isolatedTargetCount);
|
||||
expect(state.serviceSets.filter((set) => set.mode === "isolated_stack")).toHaveLength(isolatedServiceSetCount + 1);
|
||||
expect(state.targets.filter((target) => target.deploy_context?.isolated_stack)).toHaveLength(isolatedTargetCount + 2);
|
||||
|
||||
page.once("dialog", (dialog) => {
|
||||
expect(dialog.message()).toContain("soft-deleted");
|
||||
@@ -2636,6 +2681,91 @@ test("isolated stack mode creates fresh Coolify app and data targets without att
|
||||
expect(state.serviceSets.find((set) => Number(set.id) === Number(isolatedSet.id))).toBeUndefined();
|
||||
});
|
||||
|
||||
test("isolated stack mode creates a fresh stack even when an active stack has the requested name", async ({ page }) => {
|
||||
const state = await boot(page);
|
||||
state.targets[0].deploy_context = {
|
||||
coolify_project_uuid: "project-internal",
|
||||
};
|
||||
state.targets.push({
|
||||
id: state.nextTargetId++,
|
||||
channel_id: 2,
|
||||
channel_slug: "canary",
|
||||
app: "api",
|
||||
repository: "truckwash/backend-php",
|
||||
branch: "canary",
|
||||
coolify_instance_id: 3,
|
||||
coolify_instance_label: "Production Coolify",
|
||||
coolify_service_uuid: "api-canary-service",
|
||||
health_url: "https://api-canary.example.test/ping",
|
||||
auto_deploy: true,
|
||||
deploy_context: {
|
||||
coolify_project_uuid: "project-internal",
|
||||
coolify_build_pack: "dockerfile",
|
||||
},
|
||||
});
|
||||
|
||||
const activeStack = {
|
||||
id: state.nextServiceSetId++,
|
||||
channel_id: 2,
|
||||
channel_slug: "canary",
|
||||
name: "Internal safe stack",
|
||||
slug: "internal-safe-stack",
|
||||
mode: "isolated_stack",
|
||||
status: "isolated_stack",
|
||||
active: true,
|
||||
targets: {
|
||||
frontend: {
|
||||
id: 201,
|
||||
channel_id: 2,
|
||||
channel_slug: "canary",
|
||||
app: "frontend",
|
||||
repository: "truckwash/front-end-vue",
|
||||
branch: DEFAULT_RELEASE_BRANCH,
|
||||
deploy_context: { isolated_stack: true, production_data_attached: false },
|
||||
},
|
||||
api: {
|
||||
id: 202,
|
||||
channel_id: 2,
|
||||
channel_slug: "canary",
|
||||
app: "api",
|
||||
repository: "truckwash/backend-php",
|
||||
branch: "canary",
|
||||
deploy_context: { isolated_stack: true, production_data_attached: false },
|
||||
},
|
||||
},
|
||||
data_services: { database: null, redis: null, minio: null },
|
||||
attached_bundles: [],
|
||||
};
|
||||
completeIsolatedDataServices(state, activeStack);
|
||||
state.serviceSets.unshift(activeStack);
|
||||
|
||||
await page.goto("/superuser/configuration/releases/overview?channel=canary&app=all&branch=canary", {
|
||||
waitUntil: "domcontentloaded",
|
||||
});
|
||||
await selectReleaseTab(page, "Deployments");
|
||||
await expandActiveReleaseCategory(page);
|
||||
|
||||
const bundleFlow = page.getByTestId("release-bundle-flow");
|
||||
await bundleFlow.getByTestId("release-bundle-channel").selectOption("2");
|
||||
await bundleFlow.getByTestId("release-dataset-mode-isolated_stack").click();
|
||||
await bundleFlow.getByPlaceholder("canary fresh data").fill("Internal safe stack");
|
||||
await bundleFlow.getByRole("button", { name: "Next" }).click();
|
||||
await bundleFlow.getByRole("button", { name: "Next" }).click();
|
||||
|
||||
await page.getByTestId("release-bundle-deploy-submit").click();
|
||||
await expect(page.getByTestId("release-created-bundle")).toContainText("Bundle deployed");
|
||||
|
||||
const deployedServiceSetId = state.bundlePayloads[0].service_set_id;
|
||||
expect(deployedServiceSetId).not.toBe(activeStack.id);
|
||||
expect(state.serviceSets.find((set) => Number(set.id) === Number(activeStack.id))?.active).toBe(true);
|
||||
expect(
|
||||
state.serviceSets.filter((set) => set.mode === "isolated_stack" && set.slug === "internal-safe-stack")
|
||||
).toHaveLength(2);
|
||||
expect(
|
||||
state.targets.filter((target) => target.deploy_context?.isolated_stack && target.id !== 201 && target.id !== 202)
|
||||
).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("existing isolated stacks can add missing data services safely", async ({ page }) => {
|
||||
const state = createReleaseState();
|
||||
state.serviceSets.unshift({
|
||||
|
||||
@@ -13,6 +13,7 @@ const NGINX_CONFIG_PATH = path.join(PROJECT_ROOT, "nginx.coolify-frontend.conf")
|
||||
|
||||
const STATIC_DIRECTORIES = ["assets", "resources", "favicons", "icons", "img", "sounds", ".well-known"];
|
||||
const STATIC_FILES = [
|
||||
"index.html",
|
||||
"manifest.json",
|
||||
"manifest.webmanifest",
|
||||
"favicon.ico",
|
||||
@@ -23,12 +24,13 @@ const STATIC_FILES = [
|
||||
"registerSW.js",
|
||||
"sw.js",
|
||||
];
|
||||
const RELEASE_FRONTEND_BASE_PATH = "/master/frontend";
|
||||
const GENERATED_HTML_STATIC_PATHS = [
|
||||
"/assets/favicons/favicon-96x96.png",
|
||||
"/assets/favicons/favicon.svg",
|
||||
"/assets/favicons/favicon.ico",
|
||||
"/assets/favicons/apple-touch-icon.png",
|
||||
"/assets/manifest.webmanifest",
|
||||
"./assets/favicons/favicon-96x96.png",
|
||||
"./assets/favicons/favicon.svg",
|
||||
"./assets/favicons/favicon.ico",
|
||||
"./assets/favicons/apple-touch-icon.png",
|
||||
"./assets/manifest.webmanifest",
|
||||
];
|
||||
|
||||
const MIME_TYPES = new Map([
|
||||
@@ -89,6 +91,22 @@ function safePublicPath(relativePath: string) {
|
||||
return safePath(PUBLIC_DIR, relativePath);
|
||||
}
|
||||
|
||||
function releaseRootStaticPath(staticPath: string) {
|
||||
if (staticPath === RELEASE_FRONTEND_BASE_PATH) {
|
||||
return "/";
|
||||
}
|
||||
if (staticPath.startsWith(`${RELEASE_FRONTEND_BASE_PATH}/`)) {
|
||||
return staticPath.slice(RELEASE_FRONTEND_BASE_PATH.length);
|
||||
}
|
||||
if (staticPath.startsWith("./")) {
|
||||
return `/${staticPath.slice(2)}`;
|
||||
}
|
||||
if (!staticPath.startsWith("/") && !/^[a-z][a-z\d+.-]*:/i.test(staticPath)) {
|
||||
return `/${staticPath}`;
|
||||
}
|
||||
return staticPath;
|
||||
}
|
||||
|
||||
async function fileExists(filePath: string) {
|
||||
try {
|
||||
const stat = await fs.stat(filePath);
|
||||
@@ -187,7 +205,7 @@ async function distStaticPaths() {
|
||||
const cssFile = bundledStaticFiles.find((fileName) => fileName.endsWith(".css"));
|
||||
const indexStaticPaths = Array.from(indexHtml.matchAll(/\b(?:href|src)="([^"]+)"/g))
|
||||
.map((match) => match[1])
|
||||
.filter((url) => url.startsWith("/") && !url.startsWith("//"));
|
||||
.filter((url) => (url.startsWith("/") && !url.startsWith("//")) || url.startsWith("./"));
|
||||
const manifestIconPaths = (pwaManifest.icons || []).map((icon: { src?: string }) => {
|
||||
if (!icon.src) {
|
||||
return "";
|
||||
@@ -199,8 +217,8 @@ async function distStaticPaths() {
|
||||
return Array.from(
|
||||
new Set(
|
||||
[
|
||||
...indexStaticPaths,
|
||||
...GENERATED_HTML_STATIC_PATHS,
|
||||
...indexStaticPaths.map(releaseRootStaticPath),
|
||||
...GENERATED_HTML_STATIC_PATHS.map(releaseRootStaticPath),
|
||||
...manifestIconPaths,
|
||||
"/manifest.json",
|
||||
"/manifest.webmanifest",
|
||||
@@ -214,13 +232,13 @@ async function distStaticPaths() {
|
||||
workboxFile ? `/${workboxFile}` : "",
|
||||
releaseEntry.entry ? `/${releaseEntry.entry}` : "",
|
||||
...(releaseEntry.css || []).map((fileName: string) => `/${fileName}`),
|
||||
...(releaseManifest.index_asset_urls || []),
|
||||
...(releaseManifest.pwa_asset_urls || []),
|
||||
...(releaseManifest.asset_urls || []),
|
||||
...(releaseManifest.index_asset_urls || []).map(releaseRootStaticPath),
|
||||
...(releaseManifest.pwa_asset_urls || []).map(releaseRootStaticPath),
|
||||
...(releaseManifest.asset_urls || []).map(releaseRootStaticPath),
|
||||
cssFile || "",
|
||||
].filter(Boolean)
|
||||
)
|
||||
).filter((staticPath) => staticPath !== "/index.html");
|
||||
);
|
||||
}
|
||||
|
||||
test.beforeAll(async () => {
|
||||
@@ -287,6 +305,7 @@ test.describe("public .htaccess static fallback", () => {
|
||||
|
||||
test("emits favicon and manifest links from generated assets", async () => {
|
||||
const indexHtml = await fs.readFile(path.join(DIST_DIR, "index.html"), "utf8");
|
||||
const releaseManifest = JSON.parse(await fs.readFile(path.join(DIST_DIR, "release-manifest.json"), "utf8"));
|
||||
const legacyManifest = JSON.parse(await fs.readFile(path.join(DIST_DIR, "manifest.json"), "utf8"));
|
||||
const rootWebManifest = JSON.parse(await fs.readFile(path.join(DIST_DIR, "manifest.webmanifest"), "utf8"));
|
||||
|
||||
@@ -294,17 +313,42 @@ test.describe("public .htaccess static fallback", () => {
|
||||
expect(indexHtml).toContain(`href="${staticPath}"`);
|
||||
}
|
||||
|
||||
expect(indexHtml).toContain('src="./assets/');
|
||||
expect(indexHtml).toContain('src="./registerSW.js"');
|
||||
expect(indexHtml).toContain("window.location.pathname.match(/^\\/[^/]+\\/frontend");
|
||||
expect(indexHtml).not.toContain('href="/assets/');
|
||||
expect(indexHtml).not.toContain('src="/assets/');
|
||||
expect(indexHtml).not.toContain('src="/registerSW.js"');
|
||||
expect(indexHtml).not.toContain(`href="${RELEASE_FRONTEND_BASE_PATH}/assets/`);
|
||||
expect(indexHtml).not.toContain(`src="${RELEASE_FRONTEND_BASE_PATH}/assets/`);
|
||||
expect(indexHtml).not.toContain(`src="${RELEASE_FRONTEND_BASE_PATH}/registerSW.js"`);
|
||||
expect(indexHtml).not.toContain('href="/favicons/');
|
||||
expect(indexHtml).not.toContain('href="/favicon.ico"');
|
||||
expect(indexHtml).not.toContain('href="/manifest.json"');
|
||||
|
||||
for (const icon of [...(legacyManifest.icons || []), ...(rootWebManifest.icons || [])]) {
|
||||
expect(icon.src).toMatch(/^assets\/favicons\//);
|
||||
expect(icon.src).toMatch(/^(?:assets|\/master\/frontend\/assets)\/favicons\//);
|
||||
}
|
||||
|
||||
for (const assetPath of [
|
||||
releaseManifest.entry,
|
||||
...(releaseManifest.css || []),
|
||||
...(releaseManifest.index_asset_urls || []),
|
||||
...(releaseManifest.pwa_asset_urls || []),
|
||||
...(releaseManifest.asset_urls || []),
|
||||
].filter(Boolean)) {
|
||||
expect(assetPath, "release manifest asset paths must be base-relative").not.toMatch(/^\/(?!master\/frontend\/)/);
|
||||
}
|
||||
});
|
||||
|
||||
test("serves the app shell from root, deep links, and release-prefixed paths", async ({ request }) => {
|
||||
for (const appPath of ["/", "/guest/book/wash", "/canary/frontend", "/canary/frontend/guest/book/wash"]) {
|
||||
for (const appPath of [
|
||||
"/",
|
||||
"/guest/book/wash",
|
||||
"/canary/frontend",
|
||||
"/canary/frontend/index.html",
|
||||
"/canary/frontend/guest/book/wash",
|
||||
]) {
|
||||
const response = await request.get(`${serverBaseUrl}${appPath}`);
|
||||
expect(response.status(), appPath).toBe(200);
|
||||
expect(response.headers()["content-type"], appPath).toContain("text/html");
|
||||
@@ -319,7 +363,12 @@ test.describe("public .htaccess static fallback", () => {
|
||||
for (const prefix of ["", "/canary/frontend", "/canary/frontend/guest/book/wash"]) {
|
||||
const response = await request.get(`${serverBaseUrl}${prefix}${staticPath}`);
|
||||
expect(response.status(), `${prefix}${staticPath}`).toBe(200);
|
||||
expect(response.headers()["content-type"], `${prefix}${staticPath}`).not.toContain("text/html");
|
||||
if (staticPath === "/index.html") {
|
||||
expect(response.headers()["content-type"], `${prefix}${staticPath}`).toContain("text/html");
|
||||
expect(await response.text(), `${prefix}${staticPath}`).toContain('<div id="app"></div>');
|
||||
} else {
|
||||
expect(response.headers()["content-type"], `${prefix}${staticPath}`).not.toContain("text/html");
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -14,9 +14,11 @@ type ReleaseManifest = {
|
||||
};
|
||||
|
||||
const baseURL = process.env.PLAYWRIGHT_BASE_URL || "";
|
||||
const staticBaseURL = process.env.PLAYWRIGHT_RELEASE_STATIC_BASE_URL || process.env.RELEASE_BASE_URL || baseURL;
|
||||
const appBaseURL = process.env.PLAYWRIGHT_APP_BASE_URL || baseURL;
|
||||
|
||||
function liveUrl(assetPath: string) {
|
||||
const normalizedBase = baseURL.endsWith("/") ? baseURL : `${baseURL}/`;
|
||||
function liveUrl(assetPath: string, base = staticBaseURL) {
|
||||
const normalizedBase = base.endsWith("/") ? base : `${base}/`;
|
||||
return new URL(assetPath.replace(/^\/+/, ""), normalizedBase).toString();
|
||||
}
|
||||
|
||||
@@ -126,7 +128,7 @@ test("@public-live release manifest, shell, and static assets are available", as
|
||||
|
||||
for (const assetPath of assetPaths) {
|
||||
const hashKey = assetPath.startsWith("/") ? assetPath : `/${assetPath}`;
|
||||
await expectStaticAsset(request, assetPath, manifest.asset_hashes?.[hashKey]);
|
||||
await expectStaticAsset(request, assetPath, manifest.asset_hashes?.[assetPath] || manifest.asset_hashes?.[hashKey]);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -142,9 +144,9 @@ test("@public-live api-v2 gateway and channel API prefixes serve JSON ping respo
|
||||
});
|
||||
|
||||
test("@public-live guest flow renders on the deployed environment", async ({ page }) => {
|
||||
const guards = attachPageHealthGuards(page, baseURL);
|
||||
const guards = attachPageHealthGuards(page, appBaseURL);
|
||||
|
||||
await page.goto("/guest/book/wash");
|
||||
await page.goto(liveUrl("/guest/book/wash", appBaseURL));
|
||||
await settlePage(page);
|
||||
await expect(page).toHaveURL(/\/guest\/book\/wash(?:\?.*)?$/);
|
||||
await expectBodyHasContent(page);
|
||||
|
||||
@@ -15,7 +15,7 @@ const requireLiveCredentials = process.env.PLAYWRIGHT_REQUIRE_LIVE_CREDENTIALS =
|
||||
|
||||
function getLiveSettings() {
|
||||
return {
|
||||
baseURL: requiredEnv("PLAYWRIGHT_BASE_URL"),
|
||||
baseURL: process.env.PLAYWRIGHT_APP_BASE_URL || requiredEnv("PLAYWRIGHT_BASE_URL"),
|
||||
customerCredentials: {
|
||||
customerNumber: requiredEnv("PLAYWRIGHT_USER_CUSTOMER_NUMBER"),
|
||||
password: requiredEnv("PLAYWRIGHT_USER_PASSWORD"),
|
||||
|
||||
@@ -176,9 +176,12 @@ test.describe("Self-serve wash", () => {
|
||||
test("@smoke landing route sorts departments, loads vehicle selection, and restores direct wash progress", async ({
|
||||
page,
|
||||
}) => {
|
||||
await mockApi(page, {
|
||||
const api = await mockApi(page, {
|
||||
authenticated: true,
|
||||
permissions: ["user"],
|
||||
sessionData: {
|
||||
customer_number: 12345679,
|
||||
},
|
||||
selfServe: true,
|
||||
});
|
||||
await primeSession(page, {
|
||||
@@ -188,21 +191,39 @@ test.describe("Self-serve wash", () => {
|
||||
|
||||
await page.goto("/user/wash");
|
||||
|
||||
await expect(page.getByTestId("self-serve-wash-home")).toBeVisible();
|
||||
await expect(page.getByTestId("self-serve-home-nearest-name")).toContainText("Roskilde");
|
||||
await expect(page.getByRole("link", { name: "Start vask" })).toBeVisible({ timeout: 15_000 });
|
||||
await expect(page.getByText("Roskilde").first()).toBeVisible();
|
||||
|
||||
const orderedDepartmentIds = await page
|
||||
.locator('[data-testid^="self-serve-home-department-"]')
|
||||
.evaluateAll((elements) => elements.map((element) => element.getAttribute("data-testid")));
|
||||
expect(orderedDepartmentIds).toEqual(["self-serve-home-department-2", "self-serve-home-department-3"]);
|
||||
const orderedDepartmentNames = await page
|
||||
.locator(".card-header-title.has-text-grey")
|
||||
.evaluateAll((elements) => elements.map((element) => element.textContent?.trim()));
|
||||
expect(orderedDepartmentNames).toEqual(["Odense", "Aarhus"]);
|
||||
|
||||
await page.getByTestId("self-serve-home-start").click();
|
||||
await page.getByRole("link", { name: "Start vask" }).click();
|
||||
await expect(page).toHaveURL(/\/user\/wash\/start$/);
|
||||
await expect(page.getByTestId("self-serve-department-name")).toContainText("Roskilde");
|
||||
|
||||
await fillRegistration(page, "ab12345");
|
||||
await selectVehicleType(page, 2);
|
||||
|
||||
api.selfServe.inProgressByLaneId[7] = {
|
||||
lane_id: 7,
|
||||
in_progress: true,
|
||||
session: {
|
||||
id: 601,
|
||||
lane_id: 7,
|
||||
department_id: 6,
|
||||
reg: "ZZ00000",
|
||||
customer_number: 12345679,
|
||||
vehicle_type_id: 2,
|
||||
machine_relay_enabled: false,
|
||||
wash_started_at: "2026-04-28 11:00:00",
|
||||
status: "IN_PROGRESS",
|
||||
},
|
||||
customer: { customer_number: 12345679 },
|
||||
vehicle: { reg: "ZZ00000", type: 2 },
|
||||
};
|
||||
|
||||
await seedSavedProgress(page, {
|
||||
washInProgress: true,
|
||||
washLaneId: 7,
|
||||
@@ -243,6 +264,53 @@ test.describe("Self-serve wash", () => {
|
||||
await expect(page.getByTestId("self-serve-nav-complete")).toBeHidden();
|
||||
});
|
||||
|
||||
test("start route lets regular users choose a department when geolocation permission is denied", async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
await context.clearPermissions();
|
||||
await page.addInitScript(() => {
|
||||
Object.defineProperty(navigator, "geolocation", {
|
||||
configurable: true,
|
||||
value: {
|
||||
getCurrentPosition: (_success, error) =>
|
||||
error?.({ code: 1, message: "User denied Geolocation", PERMISSION_DENIED: 1 }),
|
||||
watchPosition: (_success, error) => {
|
||||
error?.({ code: 1, message: "User denied Geolocation", PERMISSION_DENIED: 1 });
|
||||
return 1;
|
||||
},
|
||||
clearWatch: () => {},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
await mockApi(page, {
|
||||
authenticated: true,
|
||||
permissions: ["user"],
|
||||
sessionData: {
|
||||
customer_number: 12345679,
|
||||
},
|
||||
selfServe: true,
|
||||
});
|
||||
await primeSession(page, {
|
||||
token: "self-serve-denied-geolocation-token",
|
||||
permissions: ["user"],
|
||||
});
|
||||
|
||||
await page.goto("/user/wash/start", { waitUntil: "domcontentloaded" });
|
||||
|
||||
await expect(page.getByTestId("self-serve-start-page")).toBeVisible({ timeout: 15_000 });
|
||||
await expect(page.getByTestId("self-serve-department-name")).toContainText("Roskilde");
|
||||
await expect(page.getByTestId("self-serve-vehicle-step")).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
await page.getByTestId("self-serve-department-name").click();
|
||||
await expect(page.getByTestId("self-serve-department-search")).toBeVisible({ timeout: 10_000 });
|
||||
await page.getByTestId("self-serve-department-search").locator("input").fill("Odense");
|
||||
await page.getByText("Odense", { exact: true }).click();
|
||||
|
||||
await expect(page.getByTestId("self-serve-department-name")).toContainText("Odense");
|
||||
});
|
||||
|
||||
test("start route resumes the authenticated customer's active server wash from another device", async ({ page }) => {
|
||||
const requests = captureSelfServeGatewayRequests(page);
|
||||
|
||||
@@ -1123,7 +1191,9 @@ test.describe("Self-serve wash", () => {
|
||||
expect(requests.commands).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("tasks flow renders dynamic image, requires completion, and restores after reload", async ({ page }) => {
|
||||
test("@dynamic-image tasks flow renders dynamic image, requires completion, and restores after reload", async ({
|
||||
page,
|
||||
}) => {
|
||||
const requests = captureSelfServeGatewayRequests(page);
|
||||
|
||||
await seedSavedProgress(page, {
|
||||
@@ -1489,6 +1559,56 @@ test.describe("Self-serve wash", () => {
|
||||
await expect(page.getByTestId("self-serve-lane-step")).toBeVisible();
|
||||
});
|
||||
|
||||
test("mocked polling marks active wash completed when backend reports it ended", async ({ page }) => {
|
||||
await seedSavedProgress(page, {
|
||||
washInProgress: true,
|
||||
washLaneId: 7,
|
||||
washStartTime: Date.now() - 30_000,
|
||||
currentStep: 4,
|
||||
licensePlateInput: "AB12345",
|
||||
vehicleTypeSelect: 2,
|
||||
radioLaneOption: 7,
|
||||
radioWashType: "Manual",
|
||||
customerNumberInput: "12345679",
|
||||
});
|
||||
|
||||
await mockApi(page, {
|
||||
authenticated: true,
|
||||
permissions: ["user"],
|
||||
sessionData: {
|
||||
customer_number: 12345679,
|
||||
},
|
||||
selfServe: {
|
||||
inProgressByLaneId: {
|
||||
7: {
|
||||
lane_id: 7,
|
||||
in_progress: false,
|
||||
session: {
|
||||
id: 801,
|
||||
lane_id: 7,
|
||||
reg: "AB12345",
|
||||
customer_number: 12345679,
|
||||
vehicle_type_id: 2,
|
||||
status: "COMPLETED",
|
||||
},
|
||||
customer: { customer_number: 12345679 },
|
||||
vehicle: { reg: "AB12345", type: 2 },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
await primeSession(page, {
|
||||
token: "self-serve-polling-completed-token",
|
||||
permissions: ["user"],
|
||||
});
|
||||
|
||||
await page.goto("/user/wash/start");
|
||||
|
||||
await expect(page.getByTestId("self-serve-completed-step")).toBeVisible({ timeout: 15_000 });
|
||||
await expect(page.getByTestId("self-serve-live-elapsed")).toBeHidden();
|
||||
await expect.poll(async () => page.evaluate(() => window.localStorage.getItem("mywash_progress_v6"))).toBeNull();
|
||||
});
|
||||
|
||||
test("admin preview modal reuses shared question/task rendering", async ({ page }) => {
|
||||
await mockApi(page, {
|
||||
authenticated: true,
|
||||
|
||||
@@ -4916,8 +4916,10 @@ async function handleEdgeGatewayRoute({ route, request, parsedUrl, pathname, met
|
||||
}
|
||||
|
||||
if (target === "secret" || target === "all") {
|
||||
const validSecret =
|
||||
String(body.broker_shared_secret || "") === String(edgeGatewayFixture.config.broker_shared_secret || "");
|
||||
const submittedSecret = Object.prototype.hasOwnProperty.call(body, "broker_shared_secret")
|
||||
? String(body.broker_shared_secret || "")
|
||||
: String(edgeGatewayFixture.config.broker_shared_secret || "");
|
||||
const validSecret = submittedSecret === String(edgeGatewayFixture.config.broker_shared_secret || "");
|
||||
payload.broker_shared_secret = {
|
||||
ok: validSecret,
|
||||
status: validSecret ? "validated" : "secret_rejected",
|
||||
@@ -6506,6 +6508,26 @@ export async function mockApi(page, options = {}) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname.endsWith("/modules/self-serve/lane/wash/my-active-wash") && method === "GET") {
|
||||
const customerNumber = Number(options.sessionData?.customer_number || 0);
|
||||
const details = Object.values(selfServe.inProgressByLaneId || {}).find((entry) => {
|
||||
const entryCustomerNumber = Number(entry?.session?.customer_number ?? entry?.customer?.customer_number ?? 0);
|
||||
return Boolean(entry?.in_progress) && customerNumber > 0 && entryCustomerNumber === customerNumber;
|
||||
});
|
||||
await route.fulfill(
|
||||
json({
|
||||
data: details || {
|
||||
lane_id: null,
|
||||
in_progress: false,
|
||||
session: null,
|
||||
customer: null,
|
||||
vehicle: null,
|
||||
},
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname.endsWith("/modules/self-serve/lane/wash/in-progress") && method === "GET") {
|
||||
const laneId = parsedUrl.searchParams.get("lane_id");
|
||||
const details = selfServe.inProgressByLaneId?.[String(laneId || "")] || null;
|
||||
|
||||
@@ -0,0 +1,378 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { mockApi, primeMockSession } from "./support/network.js";
|
||||
|
||||
async function suppressVueDevtoolsOverlay(page) {
|
||||
await page.addInitScript(() => {
|
||||
const appendStyle = () => {
|
||||
const parent = document.documentElement || document.head || document.body;
|
||||
if (!parent) {
|
||||
document.addEventListener("DOMContentLoaded", appendStyle, { once: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const style = document.createElement("style");
|
||||
style.textContent =
|
||||
"#__vue-devtools-container__, .vue-devtools__anchor-btn, .vue-devtools__panel-content { display: none !important; visibility: hidden !important; pointer-events: none !important; }";
|
||||
parent.appendChild(style);
|
||||
};
|
||||
|
||||
appendStyle();
|
||||
});
|
||||
}
|
||||
|
||||
async function seedGeolocation(context) {
|
||||
await context.grantPermissions(["geolocation"]);
|
||||
await context.setGeolocation({ latitude: 55.6415, longitude: 12.0803 });
|
||||
}
|
||||
|
||||
function buildSavedProgress(overrides = {}) {
|
||||
return {
|
||||
washInProgress: false,
|
||||
washLaneId: null,
|
||||
washStartTime: null,
|
||||
currentStep: 0,
|
||||
licensePlateInput: "AB12345",
|
||||
vehicleTypeSelect: 2,
|
||||
radioWashType: "Manual",
|
||||
radioLaneOption: 7,
|
||||
customerNumberInput: "12345679",
|
||||
answers: {},
|
||||
completedTasks: {},
|
||||
nearestDepartmentId: 6,
|
||||
forceNearestDepartmentEvaluationId: 0,
|
||||
isForcingNearestDepartment: false,
|
||||
savedAt: Date.now(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function seedSavedProgress(page, overrides = {}) {
|
||||
await page.addInitScript((savedProgress) => {
|
||||
window.localStorage.setItem("mywash_progress_v6", JSON.stringify(savedProgress));
|
||||
}, buildSavedProgress(overrides));
|
||||
}
|
||||
|
||||
async function fillRegistration(page, value = "ab12345") {
|
||||
const registration = page.locator('input[placeholder*="registreringsnummer"]').first();
|
||||
await registration.fill(value);
|
||||
await expect(registration).toHaveValue(value.toUpperCase());
|
||||
await registration.press("Escape");
|
||||
}
|
||||
|
||||
async function fillRegistrationWithoutClosingAutocomplete(page, value = "ab12345") {
|
||||
const registration = page.locator('input[placeholder*="registreringsnummer"]').first();
|
||||
await registration.fill(value);
|
||||
await expect(registration).toHaveValue(value.toUpperCase());
|
||||
}
|
||||
|
||||
async function selectVehicleType(page, vehicleTypeId = 2) {
|
||||
await page.getByTestId(`self-serve-vehicle-type-${vehicleTypeId}`).click();
|
||||
await expect(page.getByTestId("self-serve-vehicle-selector-description")).toContainText(/Truck|Van|Car/);
|
||||
}
|
||||
|
||||
async function answerRequiredQuestions(page) {
|
||||
await expect(page.getByTestId("self-serve-question-11")).toBeVisible({ timeout: 10_000 });
|
||||
await page.getByTestId("self-serve-question-11-yes").click();
|
||||
await expect(page.getByTestId("self-serve-nav-confirm")).toBeEnabled({ timeout: 10_000 });
|
||||
}
|
||||
|
||||
async function completeGuidedWash(page) {
|
||||
for (let index = 0; index < 5; index += 1) {
|
||||
await page.getByTestId("self-serve-guided-next").click();
|
||||
}
|
||||
}
|
||||
|
||||
function collectSelfServeRequests(page) {
|
||||
const requests = {
|
||||
departments: [],
|
||||
vehicleTypeProducts: [],
|
||||
customerVehicles: [],
|
||||
allowed: [],
|
||||
conditions: [],
|
||||
commands: [],
|
||||
relays: [],
|
||||
summaries: [],
|
||||
activeWash: [],
|
||||
inProgress: [],
|
||||
};
|
||||
|
||||
page.on("request", (request) => {
|
||||
const url = new URL(request.url());
|
||||
const entry = {
|
||||
method: request.method(),
|
||||
url,
|
||||
body: request.method() === "GET" ? null : request.postDataJSON?.() || null,
|
||||
};
|
||||
|
||||
if (url.pathname.endsWith("/guest/departments")) requests.departments.push(entry);
|
||||
if (url.pathname.endsWith("/products")) requests.vehicleTypeProducts.push(entry);
|
||||
if (url.pathname.endsWith("/vehicles")) requests.customerVehicles.push(entry);
|
||||
if (url.pathname.endsWith("/department/selfserve/vehicle/allowed")) requests.allowed.push(entry);
|
||||
if (url.pathname.endsWith("/department/selfserve/vehicle/conditions")) requests.conditions.push(entry);
|
||||
if (url.pathname.endsWith("/modules/self-serve/lane/command")) requests.commands.push(entry);
|
||||
if (url.pathname.endsWith("/modules/self-serve/lane/relay/machine/enable")) requests.relays.push(entry);
|
||||
if (url.pathname.endsWith("/department/selfserve/washes/summary")) requests.summaries.push(entry);
|
||||
if (url.pathname.endsWith("/modules/self-serve/lane/wash/my-active-wash")) requests.activeWash.push(entry);
|
||||
if (url.pathname.endsWith("/modules/self-serve/lane/wash/in-progress")) requests.inProgress.push(entry);
|
||||
});
|
||||
|
||||
return requests;
|
||||
}
|
||||
|
||||
test.describe("User MyWashStart mocked flow", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
test.skip(({ browserName }) => browserName !== "chromium", "MyWashStart flow coverage is maintained on Chromium.");
|
||||
|
||||
test.beforeEach(async ({ page, context }) => {
|
||||
await suppressVueDevtoolsOverlay(page);
|
||||
await seedGeolocation(context);
|
||||
});
|
||||
|
||||
test("manual wash selects vehicle, answers questions, starts, stops, and shows completed step", async ({ page }) => {
|
||||
const requests = collectSelfServeRequests(page);
|
||||
await mockApi(page, {
|
||||
authenticated: true,
|
||||
permissions: ["user"],
|
||||
sessionData: { customer_number: 12345679 },
|
||||
selfServe: true,
|
||||
});
|
||||
|
||||
await primeMockSession(page, { token: "user-my-wash-manual-token", bootPath: "/user/wash/start" });
|
||||
|
||||
await expect(page.getByTestId("self-serve-department-name")).toContainText("Roskilde", { timeout: 15_000 });
|
||||
await fillRegistration(page);
|
||||
await selectVehicleType(page, 2);
|
||||
await page.getByTestId("self-serve-nav-next").click();
|
||||
await answerRequiredQuestions(page);
|
||||
await page.getByTestId("self-serve-nav-confirm").click();
|
||||
|
||||
await expect(page.getByTestId("self-serve-lane-step")).toBeVisible({ timeout: 10_000 });
|
||||
await page.getByTestId("self-serve-lane-option-7").click();
|
||||
await page.getByTestId("self-serve-wash-type-manual").click();
|
||||
await page.getByTestId("self-serve-nav-confirm").click();
|
||||
|
||||
await expect(page.getByTestId("self-serve-live-elapsed")).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
await completeGuidedWash(page);
|
||||
await expect(page.getByTestId("self-serve-nav-complete")).toBeVisible();
|
||||
await page.getByTestId("self-serve-nav-complete").click();
|
||||
await expect(page.getByTestId("self-serve-completed-step")).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
expect(requests.departments[0]?.url.searchParams.get("include_lanes")).toBe("true");
|
||||
expect(requests.vehicleTypeProducts.length).toBeGreaterThan(0);
|
||||
expect(requests.customerVehicles.length).toBeGreaterThan(0);
|
||||
expect(requests.allowed.length).toBeGreaterThan(0);
|
||||
expect(requests.conditions.some((request) => request.body?.question === 11 && request.body?.value === true)).toBe(
|
||||
true
|
||||
);
|
||||
expect(requests.summaries.length).toBeGreaterThan(0);
|
||||
expect(requests.activeWash.length).toBeGreaterThan(0);
|
||||
expect(requests.inProgress.length).toBeGreaterThan(0);
|
||||
expect(requests.commands.map((request) => request.body?.command)).toEqual(
|
||||
expect.arrayContaining(["START", "STOP", "OPEN_PROPERTY_EXIT_GATE"])
|
||||
);
|
||||
});
|
||||
|
||||
test("registration autocomplete does not cover vehicle type selection", async ({ page }) => {
|
||||
await mockApi(page, {
|
||||
authenticated: true,
|
||||
permissions: ["user"],
|
||||
sessionData: { customer_number: 12345679 },
|
||||
selfServe: true,
|
||||
});
|
||||
|
||||
await primeMockSession(page, { token: "user-my-wash-registration-overlay-token", bootPath: "/user/wash/start" });
|
||||
|
||||
await expect(page.getByTestId("self-serve-department-name")).toContainText("Roskilde", { timeout: 15_000 });
|
||||
await fillRegistrationWithoutClosingAutocomplete(page);
|
||||
await selectVehicleType(page, 2);
|
||||
await expect(page.getByTestId("self-serve-nav-next")).toBeEnabled();
|
||||
});
|
||||
|
||||
test("machine wash rolls back when relay enable fails after START", async ({ page }) => {
|
||||
const requests = collectSelfServeRequests(page);
|
||||
await seedSavedProgress(page, {
|
||||
currentStep: 2,
|
||||
licensePlateInput: "AB12345",
|
||||
vehicleTypeSelect: 2,
|
||||
radioLaneOption: 7,
|
||||
radioWashType: "Machine",
|
||||
customerNumberInput: "12345679",
|
||||
answers: { 11: true },
|
||||
});
|
||||
|
||||
await mockApi(page, {
|
||||
authenticated: true,
|
||||
permissions: ["user"],
|
||||
sessionData: { customer_number: 12345679 },
|
||||
selfServe: {
|
||||
relayResponses: [{ success: false, data: { message: "Machine relay unavailable" } }],
|
||||
},
|
||||
});
|
||||
|
||||
await primeMockSession(page, { token: "user-my-wash-machine-token", bootPath: "/user/wash/start" });
|
||||
const needsQuestionReview = await page
|
||||
.getByTestId("self-serve-question-11")
|
||||
.waitFor({ state: "visible", timeout: 10_000 })
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
if (needsQuestionReview) {
|
||||
await page.getByTestId("self-serve-question-11-yes").click();
|
||||
await expect(page.getByTestId("self-serve-nav-confirm")).toBeEnabled({ timeout: 10_000 });
|
||||
await page.getByTestId("self-serve-nav-confirm").click();
|
||||
}
|
||||
await expect(page.getByTestId("self-serve-lane-step")).toBeVisible({ timeout: 10_000 });
|
||||
await page.getByTestId("self-serve-wash-type-machine").click();
|
||||
await page.getByTestId("self-serve-nav-confirm").click();
|
||||
|
||||
await expect(page.getByTestId("self-serve-action-error")).toContainText("Machine relay unavailable");
|
||||
await expect(page.getByTestId("self-serve-lane-step")).toBeVisible();
|
||||
await expect(page.getByTestId("self-serve-live-elapsed")).toBeHidden();
|
||||
expect(requests.relays).toHaveLength(1);
|
||||
expect(requests.commands.map((request) => request.body?.command)).toEqual(
|
||||
expect.arrayContaining(["START", "STOP"])
|
||||
);
|
||||
});
|
||||
|
||||
test("active wash restore reloads start route and resumes at WASH_IN_PROGRESS", async ({ page }) => {
|
||||
const requests = collectSelfServeRequests(page);
|
||||
await mockApi(page, {
|
||||
authenticated: true,
|
||||
permissions: ["user"],
|
||||
sessionData: { customer_number: 12345679 },
|
||||
selfServe: {
|
||||
inProgressByLaneId: {
|
||||
7: {
|
||||
lane_id: 7,
|
||||
in_progress: true,
|
||||
session: {
|
||||
id: 801,
|
||||
lane_id: 7,
|
||||
department_id: 6,
|
||||
reg: "AB12345",
|
||||
customer_number: 12345679,
|
||||
vehicle_type_id: 2,
|
||||
machine_relay_enabled: false,
|
||||
wash_started_at: "2026-06-02 08:00:00",
|
||||
status: "IN_PROGRESS",
|
||||
},
|
||||
customer: { customer_number: 12345679 },
|
||||
vehicle: { reg: "AB12345", type: 2 },
|
||||
},
|
||||
},
|
||||
summaryBySessionId: {
|
||||
801: {
|
||||
session: { id: 801, lane_id: 7, reg: "AB12345", status: "IN_PROGRESS", vehicle_type_id: 2 },
|
||||
lane: { id: 7, name: "7", department: 6 },
|
||||
questions: [],
|
||||
conditions: [],
|
||||
rules: [],
|
||||
tasks: [],
|
||||
events: [{ id: 8011, type: "STARTED", created_at: "2026-06-02T08:00:00.000Z" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await primeMockSession(page, { token: "user-my-wash-active-token", bootPath: "/user/wash/start" });
|
||||
await page.reload({ waitUntil: "domcontentloaded" });
|
||||
|
||||
await expect(page.getByTestId("self-serve-live-elapsed")).toBeVisible({ timeout: 15_000 });
|
||||
await expect(page.getByTestId("self-serve-department-name")).toContainText("Roskilde");
|
||||
await expect(page.getByTestId("self-serve-vehicle-step")).toBeHidden();
|
||||
expect(requests.activeWash.length).toBeGreaterThan(0);
|
||||
expect(requests.commands).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("server-completed wash refresh keeps local in-progress state until customer completion", async ({ page }) => {
|
||||
const requests = collectSelfServeRequests(page);
|
||||
await seedSavedProgress(page, {
|
||||
washInProgress: true,
|
||||
washLaneId: 7,
|
||||
washStartTime: Date.now() - 60_000,
|
||||
currentStep: 4,
|
||||
licensePlateInput: "AB12345",
|
||||
vehicleTypeSelect: 2,
|
||||
radioLaneOption: 7,
|
||||
radioWashType: "Manual",
|
||||
customerNumberInput: "12345679",
|
||||
});
|
||||
|
||||
await mockApi(page, {
|
||||
authenticated: true,
|
||||
permissions: ["user"],
|
||||
sessionData: { customer_number: 12345679 },
|
||||
selfServe: {
|
||||
inProgressByLaneId: {
|
||||
7: {
|
||||
lane_id: 7,
|
||||
in_progress: false,
|
||||
session: null,
|
||||
customer: null,
|
||||
vehicle: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await primeMockSession(page, { token: "user-my-wash-server-completed-token", bootPath: "/user/wash/start" });
|
||||
await expect.poll(() => requests.inProgress.length, { timeout: 15_000 }).toBeGreaterThan(0);
|
||||
await expect(page.getByTestId("self-serve-live-elapsed")).toBeVisible();
|
||||
await expect(page.getByTestId("self-serve-completed-step")).toBeHidden();
|
||||
});
|
||||
|
||||
test("answer-sync failure shows an error and blocks progression", async ({ page }) => {
|
||||
await mockApi(page, {
|
||||
authenticated: true,
|
||||
permissions: ["user"],
|
||||
sessionData: { customer_number: 12345679 },
|
||||
selfServe: true,
|
||||
});
|
||||
await page.route(/\/department\/selfserve\/vehicle\/conditions$/i, async (route) => {
|
||||
if (route.request().method() === "POST") {
|
||||
await route.fulfill({
|
||||
status: 500,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ message: "Answer sync failed" }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
await route.fallback();
|
||||
});
|
||||
|
||||
await primeMockSession(page, { token: "user-my-wash-answer-sync-token", bootPath: "/user/wash/start" });
|
||||
await fillRegistration(page);
|
||||
await selectVehicleType(page, 2);
|
||||
await page.getByTestId("self-serve-nav-next").click();
|
||||
await expect(page.getByTestId("self-serve-question-11")).toBeVisible({ timeout: 10_000 });
|
||||
await page.getByTestId("self-serve-question-11-yes").click();
|
||||
|
||||
await expect(page.getByText("Answer sync failed").first()).toBeVisible();
|
||||
await expect(page.getByTestId("self-serve-nav-confirm")).toBeDisabled();
|
||||
await expect(page.getByTestId("self-serve-lane-step")).toBeHidden();
|
||||
});
|
||||
|
||||
test("geolocation unavailable still provides a department-selection fallback", async ({ browser }) => {
|
||||
const context = await browser.newContext({ permissions: [] });
|
||||
const page = await context.newPage();
|
||||
await suppressVueDevtoolsOverlay(page);
|
||||
const requests = collectSelfServeRequests(page);
|
||||
await mockApi(page, {
|
||||
authenticated: true,
|
||||
permissions: ["user"],
|
||||
sessionData: { customer_number: 12345679 },
|
||||
selfServe: true,
|
||||
});
|
||||
|
||||
await primeMockSession(page, { token: "user-my-wash-no-geolocation-token", bootPath: "/user/wash/start" });
|
||||
|
||||
await expect(page.getByTestId("self-serve-department-name")).toContainText("Roskilde", { timeout: 15_000 });
|
||||
await fillRegistration(page);
|
||||
await selectVehicleType(page, 2);
|
||||
await expect(page.getByTestId("self-serve-nav-next")).toBeEnabled();
|
||||
expect(requests.departments[0]?.url.searchParams.get("include_lanes")).toBe("true");
|
||||
|
||||
await context.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isSafeAttachmentDownloadLink, safeAttachmentDownloadLink } from "@/services/attachmentDownloadLinks.js";
|
||||
|
||||
describe("attachmentDownloadLinks", () => {
|
||||
it("allows relative and trusted HTTPS attachment URLs", () => {
|
||||
expect(isSafeAttachmentDownloadLink("/department/selfserve/tasks/attachments/download?token=abc")).toBe(true);
|
||||
expect(isSafeAttachmentDownloadLink("https://api-v2.truckwash.io/master/api/attachments/123")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects scriptable, protocol-relative, and untrusted attachment URLs", () => {
|
||||
expect(safeAttachmentDownloadLink("javascript:alert(document.domain)")).toBe("");
|
||||
expect(safeAttachmentDownloadLink("data:text/html,<script>alert(document.domain)</script>")).toBe("");
|
||||
expect(safeAttachmentDownloadLink("//evil.example/attachment.pdf")).toBe("");
|
||||
expect(safeAttachmentDownloadLink("https://evil.example/attachment.pdf")).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,7 @@ vi.mock("axios", () => ({
|
||||
}));
|
||||
|
||||
import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue";
|
||||
import { __resetReleaseTimelineForTests, configureReleaseRuntime } from "@/services/releaseTimeline.js";
|
||||
import {
|
||||
__configureRequestQueueForTests,
|
||||
__resetRequestQueueForTests,
|
||||
@@ -43,6 +44,7 @@ describe("authenticatedRequest", () => {
|
||||
beforeEach(() => {
|
||||
axiosMock.mockReset();
|
||||
__resetRequestQueueForTests();
|
||||
__resetReleaseTimelineForTests();
|
||||
__configureRequestQueueForTests({
|
||||
maxConcurrentGet: 1,
|
||||
maxConcurrentOther: 1,
|
||||
@@ -102,6 +104,93 @@ describe("authenticatedRequest", () => {
|
||||
expect(catchCallable).toHaveBeenCalledWith(error);
|
||||
});
|
||||
|
||||
it("does not send bearer credentials to untrusted absolute request URLs", async () => {
|
||||
const response = { status: 200, data: { ok: true } };
|
||||
axiosMock.mockResolvedValueOnce(response);
|
||||
localStorage.setItem("is_subuser", "true");
|
||||
localStorage.setItem("selected_customer_number", "1234");
|
||||
configureReleaseRuntime({ api_base_url: "https://attacker.example/api" });
|
||||
|
||||
await authenticatedRequest("https://attacker.example/api/orders", "GET");
|
||||
|
||||
expect(axiosMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
url: "https://attacker.example/api/orders",
|
||||
headers: expect.not.objectContaining({
|
||||
Authorization: "Bearer token",
|
||||
"X-Customer-Number": 1234,
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("does not expose preview-only active wash reservations as in-progress washes", async () => {
|
||||
const response = {
|
||||
status: 200,
|
||||
data: {
|
||||
data: {
|
||||
lane_id: 3,
|
||||
in_progress: true,
|
||||
status: "READY_FOR_MACHINE_START",
|
||||
session: {
|
||||
id: 379,
|
||||
status: "READY_FOR_MACHINE_START",
|
||||
machine_relay_enabled: false,
|
||||
machine_start_triggered: false,
|
||||
wash_started_at: null,
|
||||
machine_start_triggered_at: null,
|
||||
machine_relay_enabled_at: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const thenCallable = vi.fn();
|
||||
|
||||
axiosMock.mockResolvedValueOnce(response);
|
||||
|
||||
const result = await authenticatedRequest(
|
||||
"/modules/self-serve/lane/wash/my-active-wash",
|
||||
"GET",
|
||||
undefined,
|
||||
null,
|
||||
thenCallable
|
||||
);
|
||||
|
||||
expect(result).not.toBe(response);
|
||||
expect(result.data.data).toMatchObject({
|
||||
lane_id: 3,
|
||||
in_progress: false,
|
||||
status: "READY_FOR_MACHINE_START",
|
||||
});
|
||||
expect(result.data.data.session).toBe(response.data.data.session);
|
||||
expect(thenCallable).toHaveBeenCalledWith(result);
|
||||
});
|
||||
|
||||
it("keeps relay-enabled active wash responses in progress", async () => {
|
||||
const response = {
|
||||
status: 200,
|
||||
data: {
|
||||
data: {
|
||||
lane_id: 3,
|
||||
in_progress: true,
|
||||
status: "MACHINE_RELAY_ENABLED",
|
||||
session: {
|
||||
id: 380,
|
||||
status: "MACHINE_RELAY_ENABLED",
|
||||
machine_relay_enabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
axiosMock.mockResolvedValueOnce(response);
|
||||
|
||||
const result = await authenticatedRequest("/modules/self-serve/lane/wash/my-active-wash", "GET");
|
||||
|
||||
expect(result).toBe(response);
|
||||
expect(result.data.data.in_progress).toBe(true);
|
||||
});
|
||||
|
||||
it("queues requests and executes them sequentially", async () => {
|
||||
const first = createDeferred();
|
||||
const second = createDeferred();
|
||||
|
||||
@@ -10,7 +10,12 @@ import {
|
||||
__resetAxiosRequestQueueInstallerForTests,
|
||||
installAxiosRequestQueue,
|
||||
} from "@/services/installAxiosRequestQueue.js";
|
||||
import { __resetReleaseTimelineForTests, configureReleaseRuntime } from "@/services/releaseTimeline.js";
|
||||
import {
|
||||
__resetReleaseTimelineForTests,
|
||||
__setReleaseTimelineTransportForTests,
|
||||
configureReleaseRuntime,
|
||||
flushReleaseTimelineEvents,
|
||||
} from "@/services/releaseTimeline.js";
|
||||
|
||||
const flushMicrotasks = async () => {
|
||||
await Promise.resolve();
|
||||
@@ -100,6 +105,47 @@ describe("axios request queue interceptor", () => {
|
||||
expect(requestQueueState.batchFailed).toBe(0);
|
||||
});
|
||||
|
||||
it("omits failed axios request bodies from timeline events when capture is disabled", async () => {
|
||||
const sentBodies = [];
|
||||
__setReleaseTimelineTransportForTests(async (body) => {
|
||||
sentBodies.push(body);
|
||||
return { accepted: body.events.length };
|
||||
});
|
||||
|
||||
await expect(
|
||||
axios({
|
||||
url: `${API_URL}/auth/login`,
|
||||
method: "POST",
|
||||
data: {
|
||||
customer_number: "cust-123",
|
||||
password: "PlaintextP@ssw0rd!",
|
||||
g_recaptcha_response: "recaptcha-response-secret",
|
||||
},
|
||||
adapter: async (config) => {
|
||||
expect(config.data).toContain('"password":"PlaintextP@ssw0rd!"');
|
||||
throw {
|
||||
response: {
|
||||
status: 401,
|
||||
statusText: "Unauthorized",
|
||||
data: { token: "response-token" },
|
||||
headers: {},
|
||||
},
|
||||
message: "Request failed",
|
||||
};
|
||||
},
|
||||
})
|
||||
).rejects.toBeTruthy();
|
||||
|
||||
await flushReleaseTimelineEvents();
|
||||
|
||||
expect(sentBodies).toHaveLength(1);
|
||||
expect(sentBodies[0].events[0].type).toBe("request_failed");
|
||||
expect(sentBodies[0].events[0].payload.request.data).toBe("[capture-disabled]");
|
||||
expect(sentBodies[0].events[0].payload.response.data).toBe("[capture-disabled]");
|
||||
expect(JSON.stringify(sentBodies[0])).not.toContain("PlaintextP@ssw0rd!");
|
||||
expect(JSON.stringify(sentBodies[0])).not.toContain("recaptcha-response-secret");
|
||||
});
|
||||
|
||||
it("rewrites default API requests to the active release API before dispatch", async () => {
|
||||
configureReleaseRuntime({
|
||||
channel: { slug: "canary" },
|
||||
|
||||
@@ -24,8 +24,17 @@ describe("collected invoice stripe queue wiring", () => {
|
||||
it("renders progress and retry states for queued transfers", () => {
|
||||
expect(stripeManageSource).toContain("isStripeTransferQueuedOrProcessing");
|
||||
expect(stripeManageSource).toContain("isStripeTransferFailed");
|
||||
expect(stripeManageSource).toContain(
|
||||
"const canBookStripeInvoice = computed(() => !isStripeTransferBusy.value && !isStripeTransferCompleted.value);"
|
||||
);
|
||||
expect(stripeManageSource).toContain('data-testid="collected-stripe-progress"');
|
||||
expect(stripeManageSource).toContain('data-testid="collected-stripe-retry"');
|
||||
expect(stripeManageSource).toContain('data-testid="collected-stripe-book-invoice"');
|
||||
});
|
||||
|
||||
it("prevents duplicate queue submissions after a completed Stripe transfer", () => {
|
||||
expect(stripeManageSource).toContain("if (!canBookStripeInvoice.value)");
|
||||
expect(stripeManageSource).toContain(':disabled="!canBookStripeInvoice"');
|
||||
expect(stripeManageSource).toContain("isStripeTransferCompleted.value");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -64,6 +64,22 @@ vi.mock("@/components/pagination/departmentTabs.vue", async () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/components/page/wrappers/RestrictedPageWrapper.vue", () => ({
|
||||
default: { template: "<div><slot /></div>" },
|
||||
}));
|
||||
|
||||
vi.mock("@/components/page/wrappers/NotFoundFallBackPageWrapper.vue", () => ({
|
||||
default: { template: "<div><slot /></div>" },
|
||||
}));
|
||||
|
||||
vi.mock("@/components/displays/boxes/ElementTabsBox.vue", () => ({
|
||||
default: { template: "<div><slot name='form' /><slot name='history' /></div>" },
|
||||
}));
|
||||
|
||||
vi.mock("@/views/dashboards/departmentDashboard/DepartmentDashboardPageWrapper.vue", () => ({
|
||||
default: { template: "<div><slot /></div>" },
|
||||
}));
|
||||
|
||||
vi.mock("@/views/dashboards/departmentDashboard/modules/daily-report/DepartmentDailyReportObject.vue", async () => {
|
||||
const { ref } = await import("vue");
|
||||
|
||||
@@ -104,6 +120,52 @@ vi.mock("@/views/dashboards/departmentDashboard/modules/daily-report/DepartmentD
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/views/dashboards/departmentDashboard/modules/daily-report/DepartmentWeather.vue", () => ({
|
||||
default: { template: "<div />" },
|
||||
}));
|
||||
|
||||
vi.mock(
|
||||
"@/views/dashboards/departmentDashboard/modules/daily-report/displays/DepartmentDashboardDailyReportCount.vue",
|
||||
() => ({
|
||||
default: { template: "<div />" },
|
||||
})
|
||||
);
|
||||
|
||||
vi.mock(
|
||||
"@/views/dashboards/departmentDashboard/modules/daily-report/displays/DepartmentDashboardDailyReportComplaints.vue",
|
||||
() => ({
|
||||
default: { template: "<div />" },
|
||||
})
|
||||
);
|
||||
|
||||
vi.mock(
|
||||
"@/views/dashboards/departmentDashboard/modules/daily-report/displays/DepartmentDashboardDailyReportHistory.vue",
|
||||
() => ({
|
||||
default: { template: "<div />" },
|
||||
})
|
||||
);
|
||||
|
||||
vi.mock(
|
||||
"@/views/dashboards/departmentDashboard/modules/daily-report/displays/DepartmentDashboardDailyReportNavigation.vue",
|
||||
() => ({
|
||||
default: { template: "<div />" },
|
||||
})
|
||||
);
|
||||
|
||||
vi.mock(
|
||||
"@/views/dashboards/departmentDashboard/modules/daily-report/displays/DepartmentDashboardDailyReportProductSales.vue",
|
||||
() => ({
|
||||
default: { template: "<div />" },
|
||||
})
|
||||
);
|
||||
|
||||
vi.mock(
|
||||
"@/views/dashboards/departmentDashboard/modules/daily-report/displays/DepartmentDashboardDailyReportTodayForm.vue",
|
||||
() => ({
|
||||
default: { template: "<div />" },
|
||||
})
|
||||
);
|
||||
|
||||
import DepartmentDailyReport from "@/views/dashboards/departmentDashboard/modules/daily-report/DepartmentDailyReport.vue";
|
||||
|
||||
const flushAll = async () => {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user