Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2f671024ab | ||
|
|
605a7ca433 | ||
|
|
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 |
@@ -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
|
||||
|
||||
@@ -23,6 +23,7 @@ jobs:
|
||||
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
|
||||
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 +99,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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -5,18 +5,27 @@ 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;
|
||||
}
|
||||
|
||||
@@ -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
+29
-11
@@ -7,7 +7,6 @@
|
||||
"": {
|
||||
"name": "truckwashdashboardsfrontend",
|
||||
"version": "0.0.0",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@azure/msal-browser": "^4.12.0",
|
||||
"@bubblewrap/cli": "^1.23.0",
|
||||
@@ -42,6 +41,7 @@
|
||||
"dayspan-vuetify": "^0.4.0",
|
||||
"flow": "^0.2.3",
|
||||
"html2canvas": "^1.4.1",
|
||||
"pinia": "^3.0.4",
|
||||
"qrcode": "^1.5.4",
|
||||
"sass": "^1.81.1",
|
||||
"sweetalert2": "11.22.4",
|
||||
@@ -5070,7 +5070,6 @@
|
||||
"version": "7.7.9",
|
||||
"resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-7.7.9.tgz",
|
||||
"integrity": "sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/devtools-shared": "^7.7.9",
|
||||
@@ -5086,7 +5085,6 @@
|
||||
"version": "7.7.9",
|
||||
"resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-7.7.9.tgz",
|
||||
"integrity": "sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"rfdc": "^1.4.1"
|
||||
@@ -5738,7 +5736,6 @@
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz",
|
||||
"integrity": "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
@@ -6426,7 +6423,6 @@
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-4.0.5.tgz",
|
||||
"integrity": "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-what": "^5.2.0"
|
||||
@@ -8989,7 +8985,6 @@
|
||||
"version": "5.5.0",
|
||||
"resolved": "https://registry.npmjs.org/is-what/-/is-what-5.5.0.tgz",
|
||||
"integrity": "sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
@@ -9572,7 +9567,6 @@
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz",
|
||||
"integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/mkdirp": {
|
||||
@@ -10110,7 +10104,6 @@
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz",
|
||||
"integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/performance-now": {
|
||||
@@ -10172,6 +10165,34 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/pinia": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.4.tgz",
|
||||
"integrity": "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==",
|
||||
"dependencies": {
|
||||
"@vue/devtools-api": "^7.7.7"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/posva"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": ">=4.5.0",
|
||||
"vue": "^3.5.11"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/pinia/node_modules/@vue/devtools-api": {
|
||||
"version": "7.7.9",
|
||||
"resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-7.7.9.tgz",
|
||||
"integrity": "sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g==",
|
||||
"dependencies": {
|
||||
"@vue/devtools-kit": "^7.7.9"
|
||||
}
|
||||
},
|
||||
"node_modules/pixelmatch": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/pixelmatch/-/pixelmatch-4.0.2.tgz",
|
||||
@@ -10729,7 +10750,6 @@
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz",
|
||||
"integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/rgbcolor": {
|
||||
@@ -11647,7 +11667,6 @@
|
||||
"version": "14.0.1",
|
||||
"resolved": "https://registry.npmjs.org/speakingurl/-/speakingurl-14.0.1.tgz",
|
||||
"integrity": "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
@@ -11919,7 +11938,6 @@
|
||||
"version": "2.2.6",
|
||||
"resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.6.tgz",
|
||||
"integrity": "sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"copy-anything": "^4"
|
||||
|
||||
+1
-1
@@ -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",
|
||||
@@ -87,6 +86,7 @@
|
||||
"dayspan-vuetify": "^0.4.0",
|
||||
"flow": "^0.2.3",
|
||||
"html2canvas": "^1.4.1",
|
||||
"pinia": "^3.0.4",
|
||||
"qrcode": "^1.5.4",
|
||||
"sass": "^1.81.1",
|
||||
"sweetalert2": "11.22.4",
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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,100 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from "vue";
|
||||
import { BSkeleton } from "buefy";
|
||||
|
||||
const props = defineProps<{
|
||||
src: string | null;
|
||||
alt?: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "error"): void;
|
||||
(e: "load"): void;
|
||||
}>();
|
||||
|
||||
const isLoading = ref(false);
|
||||
|
||||
watch(
|
||||
() => props.src,
|
||||
(src) => {
|
||||
isLoading.value = !!src;
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
const onLoad = () => {
|
||||
isLoading.value = false;
|
||||
emit("load");
|
||||
};
|
||||
|
||||
const onError = () => {
|
||||
isLoading.value = false;
|
||||
emit("error");
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="src"
|
||||
class="self-serve-dynamic-image-frame mb-4"
|
||||
:class="{ 'is-loading': isLoading }"
|
||||
data-testid="self-serve-dynamic-image-frame"
|
||||
>
|
||||
<b-skeleton
|
||||
v-if="isLoading"
|
||||
class="self-serve-dynamic-image-skeleton"
|
||||
width="100%"
|
||||
height="100%"
|
||||
data-testid="self-serve-dynamic-image-skeleton"
|
||||
/>
|
||||
<img
|
||||
:key="src"
|
||||
:src="src"
|
||||
:alt="alt || 'Machine status'"
|
||||
data-testid="self-serve-dynamic-image"
|
||||
class="self-serve-dynamic-image"
|
||||
:class="{ 'is-loading': isLoading }"
|
||||
@load="onLoad"
|
||||
@error="onError"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.self-serve-dynamic-image-frame {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@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%;
|
||||
border-radius: 4px;
|
||||
display: block;
|
||||
object-fit: contain;
|
||||
transition: opacity 120ms ease;
|
||||
}
|
||||
|
||||
.self-serve-dynamic-image.is-loading {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { BIcon, BMessage, BRadioButton } from "buefy";
|
||||
|
||||
defineProps<{
|
||||
const props = defineProps<{
|
||||
lanes: Array<any>;
|
||||
selectedLaneId: number | string | null;
|
||||
washType: string;
|
||||
@@ -14,6 +15,8 @@ const emit = defineEmits<{
|
||||
(e: "update:selectedLaneId", value: number): void;
|
||||
(e: "update:washType", value: string): void;
|
||||
}>();
|
||||
|
||||
const isSelectedMachineAvailable = computed(() => props.isMachineAvailable(props.selectedLaneId));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -92,16 +95,16 @@ const emit = defineEmits<{
|
||||
:model-value="washType"
|
||||
native-value="Machine"
|
||||
type="is-link"
|
||||
:disabled="!isMachineAvailable(selectedLaneId)"
|
||||
:title="!isMachineAvailable(selectedLaneId) ? $t('self_wash.machine_unavailable_for_lane') : null"
|
||||
:aria-label="!isMachineAvailable(selectedLaneId) ? $t('self_wash.machine_unavailable_for_lane') : null"
|
||||
:disabled="!isSelectedMachineAvailable"
|
||||
:title="!isSelectedMachineAvailable ? $t('self_wash.unavailable') : null"
|
||||
:aria-label="!isSelectedMachineAvailable ? $t('self_wash.unavailable') : null"
|
||||
data-testid="self-serve-wash-type-machine"
|
||||
@update:model-value="emit('update:washType', 'Machine')"
|
||||
@input="emit('update:washType', 'Machine')"
|
||||
>
|
||||
<span>
|
||||
<span>{{ $t("self_wash.machine") }}<br /></span>
|
||||
<span v-if="!isMachineAvailable(selectedLaneId)">
|
||||
<span v-if="!isSelectedMachineAvailable">
|
||||
<small>
|
||||
<b-icon icon="times-circle" type="is-danger" pack="fas" class="mr-1" />
|
||||
{{ $t("self_wash.unavailable") }}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from "vue";
|
||||
import { BIcon, BSkeleton } from "buefy";
|
||||
import { BIcon } from "buefy";
|
||||
import SelfServeDynamicImageFrame from "@/components/displays/selfServe/SelfServeDynamicImageFrame.vue";
|
||||
import SelfServeTaskList from "@/components/displays/selfServe/SelfServeTaskList.vue";
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -18,12 +18,6 @@ const emit = defineEmits<{
|
||||
(e: "clear-dynamic-image"): void;
|
||||
}>();
|
||||
|
||||
const isDynamicImageLoading = ref(false);
|
||||
|
||||
watch(() => props.dynamicImageUrl, (dynamicImageUrl) => {
|
||||
isDynamicImageLoading.value = !!dynamicImageUrl;
|
||||
}, { immediate: true });
|
||||
|
||||
const emitToggleTask = (taskId: number, value: boolean) => {
|
||||
emit("toggle-task", taskId, value);
|
||||
};
|
||||
@@ -32,12 +26,7 @@ const emitDownloadAttachment = (taskId: number, attachmentId: number | undefined
|
||||
emit("download-attachment", taskId, attachmentId, attachment);
|
||||
};
|
||||
|
||||
const onDynamicImageLoad = () => {
|
||||
isDynamicImageLoading.value = false;
|
||||
};
|
||||
|
||||
const onDynamicImageError = () => {
|
||||
isDynamicImageLoading.value = false;
|
||||
emit("clear-dynamic-image");
|
||||
};
|
||||
</script>
|
||||
@@ -58,30 +47,11 @@ const onDynamicImageError = () => {
|
||||
<span class="ml-2">{{ $t("self_wash.loading_data") }}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="dynamicImageUrl"
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
<SelfServeDynamicImageFrame
|
||||
:src="dynamicImageUrl"
|
||||
alt="Machine status"
|
||||
@error="onDynamicImageError"
|
||||
/>
|
||||
<div v-show="allVisibleQuestionsAnswered && !editAnswers" class="notification is-info is-light mb-4">
|
||||
<h1 class="title has-text-centered mb-2" v-if="activeTasks.length > 0">{{ $t("self_wash.start_machine") }}</h1>
|
||||
<h1 class="title has-text-centered mb-2" v-else>{{ $t("self_wash.questions_answered") }}</h1>
|
||||
@@ -95,41 +65,3 @@ const onDynamicImageError = () => {
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.self-serve-dynamic-image-frame {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@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%;
|
||||
border-radius: 4px;
|
||||
display: block;
|
||||
object-fit: contain;
|
||||
transition: opacity 120ms ease;
|
||||
}
|
||||
|
||||
.self-serve-dynamic-image.is-loading {
|
||||
opacity: 0;
|
||||
}
|
||||
</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)" display-actions-directly>
|
||||
<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)
|
||||
@@ -18,22 +19,24 @@ export const authenticatedRequest = (url, method, data, catchCallable = null, th
|
||||
//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,
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,7 @@
|
||||
import { computed, ref } from "vue";
|
||||
import { createPinia, defineStore, getActivePinia, setActivePinia, storeToRefs } from "pinia";
|
||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
import { safeAttachmentDownloadLink } from "@/services/attachmentDownloadLinks.js";
|
||||
|
||||
const normalizeBooleanAnswer = (value) => {
|
||||
if (value === true || value === false) {
|
||||
@@ -270,7 +272,26 @@ const mergeByNumericId = (existingItems, incomingItems) => {
|
||||
return merged;
|
||||
};
|
||||
|
||||
export function useSelfServeLogic() {
|
||||
let selfServeLogicStoreInstance = 0;
|
||||
let fallbackSelfServePinia = null;
|
||||
|
||||
const ensureSelfServePinia = () => {
|
||||
if (typeof document === "undefined") {
|
||||
fallbackSelfServePinia ||= createPinia();
|
||||
setActivePinia(fallbackSelfServePinia);
|
||||
return fallbackSelfServePinia;
|
||||
}
|
||||
|
||||
const activePinia = getActivePinia();
|
||||
if (activePinia) {
|
||||
return activePinia;
|
||||
}
|
||||
fallbackSelfServePinia ||= createPinia();
|
||||
setActivePinia(fallbackSelfServePinia);
|
||||
return fallbackSelfServePinia;
|
||||
};
|
||||
|
||||
const createSelfServeLogicStore = (storeId) => defineStore(storeId, () => {
|
||||
const loading = ref(false);
|
||||
const requestError = ref(null);
|
||||
const preview = ref(null);
|
||||
@@ -526,7 +547,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 +555,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);
|
||||
@@ -1075,4 +1095,50 @@ export function useSelfServeLogic() {
|
||||
answerQuestion,
|
||||
removeAnswer,
|
||||
};
|
||||
});
|
||||
|
||||
export function useSelfServeLogic(options = {}) {
|
||||
const pinia = ensureSelfServePinia();
|
||||
const explicitStoreId = typeof options === "string" ? options : options?.storeId;
|
||||
const storeId = explicitStoreId || `selfServeLogic:${++selfServeLogicStoreInstance}`;
|
||||
const store = createSelfServeLogicStore(storeId)(pinia);
|
||||
const refs = storeToRefs(store);
|
||||
const {
|
||||
fetchSelfServeData,
|
||||
fetchWashSummary,
|
||||
syncVehicleAnswer,
|
||||
clearVehicleAnswers,
|
||||
evaluateRule,
|
||||
evaluateCondition,
|
||||
isQuestionVisible,
|
||||
isTaskActive,
|
||||
isServiceAllowed,
|
||||
updateLaneAllowedServices,
|
||||
enableMachineRelay,
|
||||
isImage,
|
||||
downloadAttachment,
|
||||
reset,
|
||||
answerQuestion,
|
||||
removeAnswer,
|
||||
} = store;
|
||||
|
||||
return {
|
||||
...refs,
|
||||
fetchSelfServeData,
|
||||
fetchWashSummary,
|
||||
syncVehicleAnswer,
|
||||
clearVehicleAnswers,
|
||||
evaluateRule,
|
||||
evaluateCondition,
|
||||
isQuestionVisible,
|
||||
isTaskActive,
|
||||
isServiceAllowed,
|
||||
updateLaneAllowedServices,
|
||||
enableMachineRelay,
|
||||
isImage,
|
||||
downloadAttachment,
|
||||
reset,
|
||||
answerQuestion,
|
||||
removeAnswer,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,12 +2,17 @@ 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 toDepartmentViewModel = (department, distance = null) => ({
|
||||
id: department.id,
|
||||
@@ -18,12 +23,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 +46,7 @@ export function useWashDepartments(options = {}) {
|
||||
const forceNearestDepartmentEvaluationId = ref(0);
|
||||
const isSearchingDepartments = ref(false);
|
||||
const lastDepartmentFetchTime = ref(null);
|
||||
const departmentFetchError = ref(null);
|
||||
|
||||
let refreshInterval = null;
|
||||
|
||||
@@ -57,13 +73,18 @@ export function useWashDepartments(options = {}) {
|
||||
return nearestDepartment.value.self_serve_enabled === true;
|
||||
});
|
||||
|
||||
const buildGuestDepartmentParams = () => (
|
||||
includeLanes ? { include_lanes: true } : {}
|
||||
);
|
||||
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,19 +95,15 @@ 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) {
|
||||
@@ -191,19 +208,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 +244,7 @@ export function useWashDepartments(options = {}) {
|
||||
forceNearestDepartmentEvaluationId,
|
||||
isSearchingDepartments,
|
||||
lastDepartmentFetchTime,
|
||||
departmentFetchError,
|
||||
availableProductIds,
|
||||
doesCurrentDepartmentSelectionHaveSelfServeEnabled,
|
||||
fetchDepartments,
|
||||
|
||||
@@ -170,10 +170,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 +190,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;
|
||||
@@ -227,12 +250,31 @@ export function useWashSessionActions(options) {
|
||||
const startResponse = await executeSelfServeCommand(laneId, "START", {
|
||||
customer_number: parseInt(customerNumber),
|
||||
license_plate: licensePlate.trim().toUpperCase(),
|
||||
defer_relay_side_effects: true,
|
||||
defer_relay_side_effects: false,
|
||||
});
|
||||
if (!startResponse) {
|
||||
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 +288,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,6 +51,16 @@ 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 = [
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -4595,7 +4595,6 @@
|
||||
"loading_vehicle_types": "@.capitalize:{'words.generated.indlæser'} @:{'words.generated.tilgængelige'} @:{'words.generated.køretøjstyper'}...",
|
||||
"lane_unavailable": "Vaskebanen er ikke tilgængelig",
|
||||
"machine": "@.capitalize:{'words.generated.maskine'}",
|
||||
"machine_unavailable_for_lane": "Maskinvask er ikke tilgængelig for den valgte bane. Vælg manuel vask eller en anden ledig bane.",
|
||||
"no_departments_found": "@:{'templates.generated.compat.departments.no_departments'}",
|
||||
"occupied": "Optaget",
|
||||
"open_property_access_gate": "@:{'words.generated.abn'} adgangsport",
|
||||
|
||||
@@ -4705,7 +4705,6 @@
|
||||
"loading_vehicle_types": "@.capitalize:{'words.generated.verf'}?@:{'words.generated.gbare'} @:{'words.generated.fahrzeugtypen'} @:{'words.generated.werden'} @:{'words.generated.geladen'}...",
|
||||
"lane_unavailable": "Waschspur nicht verfugbar",
|
||||
"machine": "@:{'words.generated.maschine'}",
|
||||
"machine_unavailable_for_lane": "Maschinenwasche ist fur die ausgewahlte Spur nicht verfugbar. Wahlen Sie manuelle Wasche oder eine andere freie Spur.",
|
||||
"no_departments_found": "@:{'templates.generated.compat.departments.no_departments'}",
|
||||
"occupied": "Belegt",
|
||||
"open_property_access_gate": "Grundstueckszugangstor @:{'words.generated.oeffnen'}",
|
||||
|
||||
@@ -4429,7 +4429,6 @@
|
||||
"loading_vehicle_types": "@.capitalize:{'words.generated.loading'} @:{'words.generated.available'} @:{'words.generated.vehicle'} @:{'words.generated.types'}...",
|
||||
"lane_unavailable": "Lane unavailable",
|
||||
"machine": "@.capitalize:{'words.generated.machine'}",
|
||||
"machine_unavailable_for_lane": "Machine wash is unavailable for the selected lane. Choose manual wash or another available lane.",
|
||||
"no_departments_found": "@:{'templates.generated.compat.departments.no_departments'}",
|
||||
"occupied": "Occupied",
|
||||
"open_property_access_gate": "@.capitalize:{'words.generated.open'} @:{'words.generated.property'} @:{'words.generated.access'} @:{'words.generated.gate'}",
|
||||
|
||||
@@ -4224,7 +4224,6 @@
|
||||
"loading_vehicle_types": "@:{'templates.generated.compat.self_wash.loading_vehicle_types'}",
|
||||
"lane_unavailable": "@:{'templates.generated.compat.self_wash.lane_unavailable'}",
|
||||
"machine": "@:{'templates.generated.compat.self_wash.machine'}",
|
||||
"machine_unavailable_for_lane": "@:{'templates.generated.compat.self_wash.machine_unavailable_for_lane'}",
|
||||
"no_departments_found": "@:{'templates.generated.compat.self_wash.no_departments_found'}",
|
||||
"no_vehicles_found": "@:vehicles.no_vehicles",
|
||||
"occupied": "@:{'templates.generated.compat.self_wash.occupied'}",
|
||||
|
||||
@@ -4706,7 +4706,6 @@
|
||||
"loading_vehicle_types": "@.capitalize:{'words.generated.laster'} @:{'words.generated.inn'} @:{'words.generated.tilgjengelige'} @:{'words.generated.kjøretøytyper'}...",
|
||||
"lane_unavailable": "Vaskebanen er ikke tilgjengelig",
|
||||
"machine": "@.capitalize:{'words.generated.maskin'}",
|
||||
"machine_unavailable_for_lane": "Maskinvask er ikke tilgjengelig for valgt bane. Velg manuell vask eller en annen ledig bane.",
|
||||
"no_departments_found": "@:{'templates.generated.compat.departments.no_departments'}",
|
||||
"occupied": "Okkupert",
|
||||
"open_property_access_gate": "@:{'words.generated.aapne'} @:{'words.generated.eiendommens'} inngangsport",
|
||||
|
||||
@@ -4756,7 +4756,6 @@
|
||||
"loading_vehicle_types": "@.capitalize:{'words.generated.laser'} @:{'words.generated.in'} @:{'words.generated.tillgangliga'} @:{'words.generated.fordonstyper'}...",
|
||||
"lane_unavailable": "Tvattbanan ar inte tillganglig",
|
||||
"machine": "@.capitalize:{'words.generated.machine'}",
|
||||
"machine_unavailable_for_lane": "Maskintvatt ar inte tillganglig for vald bana. Valj manuell tvatt eller en annan ledig bana.",
|
||||
"no_departments_found": "@.capitalize:{'words.generated.inga'} @:{'words.generated.avdelningar'} @:{'words.generated.hittades'}",
|
||||
"occupied": "Upptagen",
|
||||
"open_property_access_gate": "@:{'words.generated.oeppna'} @:{'words.generated.fastighetens'} ingangsgrind",
|
||||
|
||||
@@ -3663,7 +3663,6 @@
|
||||
"loading_vehicle_types": "Indlæser tilgængelige køretøjstyper...",
|
||||
"lane_unavailable": "Vaskebanen er ikke tilgængelig",
|
||||
"machine": "Maskine",
|
||||
"machine_unavailable_for_lane": "Maskinvask er ikke tilgængelig for den valgte bane. Vælg manuel vask eller en anden ledig bane.",
|
||||
"vehicle_step_missing_customer_number": "Indtast kundenummer for at fortsætte.",
|
||||
"vehicle_step_missing_department": "Vent på, at nærmeste afdeling er indlæst, før du fortsætter.",
|
||||
"vehicle_step_missing_registration": "Indtast registreringsnummer for at fortsætte.",
|
||||
|
||||
@@ -3663,7 +3663,6 @@
|
||||
"loading_vehicle_types": "Verf?gbare Fahrzeugtypen werden geladen...",
|
||||
"lane_unavailable": "Waschspur nicht verfugbar",
|
||||
"machine": "Maschine",
|
||||
"machine_unavailable_for_lane": "Maschinenwasche ist fur die ausgewahlte Spur nicht verfugbar. Wahlen Sie manuelle Wasche oder eine andere freie Spur.",
|
||||
"vehicle_step_missing_customer_number": "Geben Sie Ihre Kundennummer ein, um fortzufahren.",
|
||||
"vehicle_step_missing_department": "Warten Sie, bis die nachste Abteilung geladen ist, bevor Sie fortfahren.",
|
||||
"vehicle_step_missing_registration": "Geben Sie das Kennzeichen ein, um fortzufahren.",
|
||||
|
||||
@@ -3662,7 +3662,6 @@
|
||||
"loading_vehicle_types": "Loading available vehicle types...",
|
||||
"lane_unavailable": "Lane unavailable",
|
||||
"machine": "Machine",
|
||||
"machine_unavailable_for_lane": "Machine wash is unavailable for the selected lane. Choose manual wash or another available lane.",
|
||||
"vehicle_step_missing_customer_number": "Enter your customer number to continue.",
|
||||
"vehicle_step_missing_department": "Wait for the nearest department to load before continuing.",
|
||||
"vehicle_step_missing_registration": "Enter your registration number to continue.",
|
||||
|
||||
@@ -3663,7 +3663,6 @@
|
||||
"loading_vehicle_types": "Laster inn tilgjengelige kjøretøytyper...",
|
||||
"lane_unavailable": "Vaskebanen er ikke tilgjengelig",
|
||||
"machine": "Maskin",
|
||||
"machine_unavailable_for_lane": "Maskinvask er ikke tilgjengelig for valgt bane. Velg manuell vask eller en annen ledig bane.",
|
||||
"vehicle_step_missing_customer_number": "Skriv inn kundenummer for a fortsette.",
|
||||
"vehicle_step_missing_department": "Vent til naermeste avdeling er lastet inn for du fortsetter.",
|
||||
"vehicle_step_missing_registration": "Skriv inn registreringsnummer for a fortsette.",
|
||||
|
||||
@@ -3663,7 +3663,6 @@
|
||||
"loading_vehicle_types": "Läser in tillgängliga fordonstyper...",
|
||||
"lane_unavailable": "Tvattbanan ar inte tillganglig",
|
||||
"machine": "Machine",
|
||||
"machine_unavailable_for_lane": "Maskintvatt ar inte tillganglig for vald bana. Valj manuell tvatt eller en annan ledig bana.",
|
||||
"vehicle_step_missing_customer_number": "Ange kundnummer for att fortsatta.",
|
||||
"vehicle_step_missing_department": "Vanta tills narmaste avdelning har laddats innan du fortsatter.",
|
||||
"vehicle_step_missing_registration": "Ange registreringsnummer for att fortsatta.",
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
"loading_vehicle_types": "@.capitalize:{'terms.glossary.indlæser'} @:{'terms.glossary.tilgængelige'} @:{'terms.glossary.køretøjstyper'}...",
|
||||
"lane_unavailable": "Vaskebanen er ikke tilgængelig",
|
||||
"machine": "@.capitalize:{'terms.glossary.maskine'}",
|
||||
"machine_unavailable_for_lane": "Maskinvask er ikke tilgængelig for den valgte bane. Vælg manuel vask eller en anden ledig bane.",
|
||||
"no_departments_found": "@:{'phrases.compat.departments.no_departments'}",
|
||||
"occupied": "Optaget",
|
||||
"open_property_access_gate": "@:{'terms.glossary.abn'} adgangsport",
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
"loading_vehicle_types": "@.capitalize:{'terms.glossary.verf'}?@:{'terms.glossary.gbare'} @:{'terms.glossary.fahrzeugtypen'} @:{'terms.glossary.werden'} @:{'terms.glossary.geladen'}...",
|
||||
"lane_unavailable": "Waschspur nicht verfugbar",
|
||||
"machine": "@:{'terms.glossary.maschine'}",
|
||||
"machine_unavailable_for_lane": "Maschinenwasche ist fur die ausgewahlte Spur nicht verfugbar. Wahlen Sie manuelle Wasche oder eine andere freie Spur.",
|
||||
"no_departments_found": "@:{'phrases.compat.departments.no_departments'}",
|
||||
"occupied": "Belegt",
|
||||
"open_property_access_gate": "Grundstueckszugangstor @:{'terms.glossary.oeffnen'}",
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
"loading_vehicle_types": "@.capitalize:{'terms.glossary.loading'} @:{'terms.glossary.available'} @:{'terms.glossary.vehicle'} @:{'terms.glossary.types'}...",
|
||||
"lane_unavailable": "Lane unavailable",
|
||||
"machine": "@.capitalize:{'terms.glossary.machine'}",
|
||||
"machine_unavailable_for_lane": "Machine wash is unavailable for the selected lane. Choose manual wash or another available lane.",
|
||||
"no_departments_found": "@:{'phrases.compat.departments.no_departments'}",
|
||||
"occupied": "Occupied",
|
||||
"open_property_access_gate": "@.capitalize:{'terms.glossary.open'} @:{'terms.glossary.property'} @:{'terms.glossary.access'} @:{'terms.glossary.gate'}",
|
||||
|
||||
@@ -21,7 +21,6 @@
|
||||
"loading_vehicle_types": "@:{'phrases.compat.self_wash.loading_vehicle_types'}",
|
||||
"lane_unavailable": "@:{'phrases.compat.self_wash.lane_unavailable'}",
|
||||
"machine": "@:{'phrases.compat.self_wash.machine'}",
|
||||
"machine_unavailable_for_lane": "@:{'phrases.compat.self_wash.machine_unavailable_for_lane'}",
|
||||
"no_departments_found": "@:{'phrases.compat.self_wash.no_departments_found'}",
|
||||
"no_vehicles_found": "@:vehicles.no_vehicles",
|
||||
"occupied": "@:{'phrases.compat.self_wash.occupied'}",
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
"loading_vehicle_types": "@.capitalize:{'terms.glossary.laster'} @:{'terms.glossary.inn'} @:{'terms.glossary.tilgjengelige'} @:{'terms.glossary.kjøretøytyper'}...",
|
||||
"lane_unavailable": "Vaskebanen er ikke tilgjengelig",
|
||||
"machine": "@.capitalize:{'terms.glossary.maskin'}",
|
||||
"machine_unavailable_for_lane": "Maskinvask er ikke tilgjengelig for valgt bane. Velg manuell vask eller en annen ledig bane.",
|
||||
"no_departments_found": "@:{'phrases.compat.departments.no_departments'}",
|
||||
"occupied": "Okkupert",
|
||||
"open_property_access_gate": "@:{'terms.glossary.aapne'} @:{'terms.glossary.eiendommens'} inngangsport",
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
"loading_vehicle_types": "@.capitalize:{'terms.glossary.laser'} @:{'terms.glossary.in'} @:{'terms.glossary.tillgangliga'} @:{'terms.glossary.fordonstyper'}...",
|
||||
"lane_unavailable": "Tvattbanan ar inte tillganglig",
|
||||
"machine": "@.capitalize:{'terms.glossary.machine'}",
|
||||
"machine_unavailable_for_lane": "Maskintvatt ar inte tillganglig for vald bana. Valj manuell tvatt eller en annan ledig bana.",
|
||||
"no_departments_found": "@.capitalize:{'terms.glossary.inga'} @:{'terms.glossary.avdelningar'} @:{'terms.glossary.hittades'}",
|
||||
"occupied": "Upptagen",
|
||||
"open_property_access_gate": "@:{'terms.glossary.oeppna'} @:{'terms.glossary.fastighetens'} ingangsgrind",
|
||||
|
||||
+2
-1
@@ -10,6 +10,7 @@ import applyMiddleware from '@/middleware/index';
|
||||
import store from './store/user.vue'
|
||||
import { createHead } from "@vueuse/head";
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import Router from "@/router";
|
||||
import App from "@/App.vue";
|
||||
import Buefy from 'buefy'
|
||||
@@ -71,7 +72,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'));
|
||||
};
|
||||
|
||||
@@ -109,6 +109,7 @@ applyMiddleware(Router)
|
||||
const app = createApp(App)
|
||||
.use(Router)
|
||||
.use(createHead())
|
||||
.use(createPinia())
|
||||
.use(store)
|
||||
.use(ToastPlugin)
|
||||
.use(Buefy)
|
||||
|
||||
@@ -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,5 +1,6 @@
|
||||
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";
|
||||
@@ -172,7 +173,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 +192,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 +208,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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import { defineStore } from "pinia";
|
||||
|
||||
const createValidationState = () => ({ valid: true, items: [], errors: [], warnings: [] });
|
||||
|
||||
const createGatewayWorkspaceState = () => ({ gateways: [], relays: [], lanes: [], issues: [], actions: [] });
|
||||
|
||||
const createSimulatorForm = () => ({
|
||||
lane_id: null,
|
||||
reg: "TEST123",
|
||||
customer_number: null,
|
||||
config_source: "draft",
|
||||
include_hardware: true,
|
||||
hardware_mode: "studio",
|
||||
});
|
||||
|
||||
const createPathEditorQuestionDraft = () => ({
|
||||
question: "New question",
|
||||
description: "",
|
||||
scope: "department_lane",
|
||||
vehicle_mode: "current",
|
||||
vehicle_type_values: [],
|
||||
});
|
||||
|
||||
const createPathEditorForm = () => ({
|
||||
path_key: "",
|
||||
previous_path_key: "",
|
||||
answers: {},
|
||||
machine_allowed: true,
|
||||
task: "Start machine",
|
||||
description: "",
|
||||
services: ["MACHINE"],
|
||||
program_button: "",
|
||||
press_reset: false,
|
||||
press_start: true,
|
||||
extra_buttons: [],
|
||||
dynamic_images_vehicle_type: "0",
|
||||
});
|
||||
|
||||
const createVirtualHardwareForm = () => ({
|
||||
gateway_key: "virtual-main",
|
||||
gateway_label: "Virtual Studio Gateway",
|
||||
relay_id: "",
|
||||
role: "MACHINE",
|
||||
label: "",
|
||||
});
|
||||
|
||||
const createScopeFilters = () => ({
|
||||
lane_id: "",
|
||||
vehicle_type_id: "",
|
||||
});
|
||||
|
||||
const createCanvasFilters = () => ({
|
||||
...createScopeFilters(),
|
||||
machine_type_id: "",
|
||||
dynamic_image_id: "",
|
||||
});
|
||||
|
||||
export const useSelfServeStudioStore = defineStore("selfServeStudio", {
|
||||
state: () => ({
|
||||
flowNodes: [],
|
||||
flowEdges: [],
|
||||
graphPayload: null,
|
||||
lookups: {},
|
||||
validation: createValidationState(),
|
||||
gatewayWorkspace: createGatewayWorkspaceState(),
|
||||
versions: [],
|
||||
activePanel: "flow",
|
||||
selectedNodeId: null,
|
||||
inspectorForm: {},
|
||||
simulatorForm: createSimulatorForm(),
|
||||
simulatorResult: null,
|
||||
simulatorAnswerOverrides: {},
|
||||
simulatorShowJson: false,
|
||||
simulatorDynamicImageHidden: false,
|
||||
simulatorDynamicImageError: "",
|
||||
simulatorCompletedTasks: {},
|
||||
isSimulatorRunning: false,
|
||||
simulatorRunReason: "",
|
||||
simulatorRequestSequence: 0,
|
||||
simulatorLastRequestKey: "",
|
||||
simulatorLastError: "",
|
||||
isRestoringSimulatorStorage: false,
|
||||
pathOutcomes: null,
|
||||
isLoadingPathOutcomes: false,
|
||||
pathOutcomesError: "",
|
||||
pathOutcomesRequestKey: "",
|
||||
selectedPathOutcomeId: null,
|
||||
selectedPathCaseSignature: "",
|
||||
selectedPathCaseIndex: 0,
|
||||
pathCaseRunSignatures: {},
|
||||
pathCaseConfirmingSignature: "",
|
||||
pathEditorDirty: false,
|
||||
isHydratingPathEditor: false,
|
||||
pathEditorTaskTitleOverrides: {},
|
||||
editingPathEditorTaskTitleKey: "",
|
||||
editingPathEditorTaskTitle: "",
|
||||
pathVerificationDynamicImageHidden: false,
|
||||
pathVerificationDynamicImageError: "",
|
||||
isPathVerificationDynamicImageLoading: false,
|
||||
pathEditorQuestionDraftVisible: false,
|
||||
pathEditorQuestionDraft: createPathEditorQuestionDraft(),
|
||||
pathEditorForm: createPathEditorForm(),
|
||||
simulatorCustomerSearchQuery: "",
|
||||
selectedSimulatorCustomer: null,
|
||||
showSimulatorCustomerResults: false,
|
||||
scopeGuideDraft: createScopeFilters(),
|
||||
isScopeGuideManuallyOpen: false,
|
||||
isSyncingScopeFromRoute: false,
|
||||
isWritingScopeToRoute: false,
|
||||
virtualHardwareForm: createVirtualHardwareForm(),
|
||||
laneEditorMode: "edit",
|
||||
selectedLaneManagementId: null,
|
||||
laneForm: {},
|
||||
lanePreviewHidden: false,
|
||||
inspectorLanePreviewHidden: false,
|
||||
inspectorTaskDynamicImageHidden: false,
|
||||
inspectorTaskDynamicImageError: "",
|
||||
searchTerm: "",
|
||||
filters: createCanvasFilters(),
|
||||
isLoading: false,
|
||||
isSaving: false,
|
||||
isUploadingTaskAttachment: false,
|
||||
deletingTaskAttachmentId: null,
|
||||
isStudioFullscreen: false,
|
||||
hasNativeStudioFullscreen: false,
|
||||
suppressLayoutSave: false,
|
||||
exportText: "",
|
||||
importText: "",
|
||||
draggedTemplateKind: null,
|
||||
unfilteredNodePositions: null,
|
||||
}),
|
||||
actions: {
|
||||
resetPathEditorQuestionDraft() {
|
||||
this.pathEditorQuestionDraft = createPathEditorQuestionDraft();
|
||||
},
|
||||
resetPathEditorForm() {
|
||||
this.pathEditorForm = createPathEditorForm();
|
||||
},
|
||||
applyRouteScope(scopeFilters = {}) {
|
||||
const nextScope = {
|
||||
...createScopeFilters(),
|
||||
...scopeFilters,
|
||||
};
|
||||
this.scopeGuideDraft = nextScope;
|
||||
this.filters = {
|
||||
...this.filters,
|
||||
...nextScope,
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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 = "/";
|
||||
|
||||
+67
-6
@@ -95,6 +95,25 @@ const getDominantWeather = (entries) => {
|
||||
return dominant;
|
||||
};
|
||||
|
||||
const getProductivityStatus = (washes, hours) => {
|
||||
if (hours <= 0) {
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
const productivity = washes / hours;
|
||||
return productivity >= 1.3 ? "healthy" : productivity >= 1.0 ? "degraded" : "unhealthy";
|
||||
};
|
||||
|
||||
const getNormalizedTime = (entry) => {
|
||||
const timeValue = typeof entry?.time === "string" ? entry.time : "";
|
||||
const normalizedTime = timeValue.slice(0, 5);
|
||||
return /^\d{2}:\d{2}$/.test(normalizedTime) ? normalizedTime : null;
|
||||
};
|
||||
|
||||
const formatMergedNumber = (value) => {
|
||||
return Number.isInteger(value) ? value : Number(value.toFixed(2));
|
||||
};
|
||||
|
||||
const summarizeEntriesByDay = (entries) => {
|
||||
const groupedByDate = new Map();
|
||||
|
||||
@@ -120,14 +139,48 @@ const summarizeEntriesByDay = (entries) => {
|
||||
time: "00:00",
|
||||
current: dayEntries.some((entry) => entry?.current === true),
|
||||
weather: getDominantWeather(dayEntries),
|
||||
washes: Number.isInteger(washes) ? washes : Number(washes.toFixed(2)),
|
||||
washes: formatMergedNumber(washes),
|
||||
hours: Number(hours.toFixed(2)),
|
||||
/**
|
||||
* Below 1.0 cars/workhour = unhealthy (red)
|
||||
* 1.0-1.3 cars/workhour = degraded (yellow)
|
||||
* Above 1.3 cars/workhour = healthy (green)
|
||||
*/
|
||||
status: hours > 0 ? (washes / hours >= 1.3 ? "healthy" : washes / hours >= 1.0 ? "degraded" : "unhealthy") : "unknown",
|
||||
status: getProductivityStatus(washes, hours),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const summarizeEntriesByHourOfDay = (entries) => {
|
||||
const groupedByTime = new Map();
|
||||
|
||||
entries.forEach((entry) => {
|
||||
const time = getNormalizedTime(entry);
|
||||
if (!time) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!groupedByTime.has(time)) {
|
||||
groupedByTime.set(time, []);
|
||||
}
|
||||
groupedByTime.get(time).push(entry);
|
||||
});
|
||||
|
||||
return Array.from(groupedByTime.entries())
|
||||
.sort(([timeA], [timeB]) => timeA.localeCompare(timeB))
|
||||
.map(([time, hourEntries]) => {
|
||||
const washes = hourEntries.reduce((sum, entry) => sum + toNumberOrZero(entry?.washes), 0);
|
||||
const hours = hourEntries.reduce((sum, entry) => sum + toNumberOrZero(entry?.hours), 0);
|
||||
|
||||
return {
|
||||
date: null,
|
||||
test_id_date: "multi-day",
|
||||
time,
|
||||
current: hourEntries.some((entry) => entry?.current === true),
|
||||
weather: getDominantWeather(hourEntries),
|
||||
washes: formatMergedNumber(washes),
|
||||
hours: Number(hours.toFixed(2)),
|
||||
status: getProductivityStatus(washes, hours),
|
||||
};
|
||||
});
|
||||
};
|
||||
@@ -136,6 +189,10 @@ const hourlyDisplayedState = computed(() => {
|
||||
return state.value.filter((entry) => hasOperationalData(entry));
|
||||
});
|
||||
|
||||
const hourlyMergedDisplayedState = computed(() => {
|
||||
return summarizeEntriesByHourOfDay(hourlyDisplayedState.value).filter((entry) => hasOperationalData(entry));
|
||||
});
|
||||
|
||||
const dailyDisplayedState = computed(() => {
|
||||
return summarizeEntriesByDay(state.value).filter((entry) => hasOperationalData(entry));
|
||||
});
|
||||
@@ -144,6 +201,9 @@ const displayedState = computed(() => {
|
||||
if (summarizeDaily.value && isMultiDaySelection.value) {
|
||||
return dailyDisplayedState.value;
|
||||
}
|
||||
if (isMultiDaySelection.value) {
|
||||
return hourlyMergedDisplayedState.value;
|
||||
}
|
||||
return hourlyDisplayedState.value;
|
||||
});
|
||||
|
||||
@@ -167,7 +227,9 @@ const getEntryKey = (entry, index) => {
|
||||
};
|
||||
|
||||
const getEntryTestId = (prefix, entry) => {
|
||||
const date = isDateString(entry?.date) ? entry.date : "no-date";
|
||||
const date = typeof entry?.test_id_date === "string"
|
||||
? entry.test_id_date
|
||||
: isDateString(entry?.date) ? entry.date : "no-date";
|
||||
const time = typeof entry?.time === "string" && entry.time !== "" ? entry.time : "no-time";
|
||||
return `department-weather-${prefix}-${date}-${time}`;
|
||||
};
|
||||
@@ -177,9 +239,8 @@ const getHourDetailsSlotKey = (entry) => {
|
||||
return null;
|
||||
}
|
||||
|
||||
const timeValue = typeof entry?.time === "string" ? entry.time : "";
|
||||
const normalizedTime = timeValue.slice(0, 5);
|
||||
if (!/^\d{2}:\d{2}$/.test(normalizedTime)) {
|
||||
const normalizedTime = getNormalizedTime(entry);
|
||||
if (!normalizedTime) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
+136
-112
@@ -1,5 +1,6 @@
|
||||
<script setup>
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
import { storeToRefs } from "pinia";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { Handle, Position, VueFlow, useVueFlow } from "@vue-flow/core";
|
||||
import "@vue-flow/core/dist/style.css";
|
||||
@@ -18,6 +19,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,
|
||||
@@ -33,6 +35,7 @@ import {
|
||||
formatSelfServeTaskButtons,
|
||||
normalizeSelfServeTaskButtons,
|
||||
} from "@/components/session/token/SessionUser/Objects/selfServeTaskButtons.js";
|
||||
import { useSelfServeStudioStore } from "@/stores/selfServeStudioStore.js";
|
||||
import { resolveAuditIssueNodeIds } from "./auditIssueNavigation.js";
|
||||
|
||||
const currentRoute = useRoute();
|
||||
@@ -366,111 +369,88 @@ const AUTO_LAYOUT_RUNTIME_STAGE_ORDER = {
|
||||
finish: 2,
|
||||
};
|
||||
|
||||
const flowNodes = ref([]);
|
||||
const flowEdges = ref([]);
|
||||
const graphPayload = ref(null);
|
||||
const lookups = ref({});
|
||||
const validation = ref({ valid: true, items: [], errors: [], warnings: [] });
|
||||
const gatewayWorkspace = ref({ gateways: [], relays: [], lanes: [], issues: [], actions: [] });
|
||||
const versions = ref([]);
|
||||
const activePanel = ref(panelFromRoute() || "flow");
|
||||
const selectedNodeId = ref(null);
|
||||
const inspectorForm = ref({});
|
||||
const simulatorForm = ref({
|
||||
lane_id: null,
|
||||
reg: "TEST123",
|
||||
customer_number: null,
|
||||
config_source: "draft",
|
||||
include_hardware: true,
|
||||
hardware_mode: "studio",
|
||||
});
|
||||
const simulatorResult = ref(null);
|
||||
const simulatorAnswerOverrides = ref({});
|
||||
const simulatorShowJson = ref(false);
|
||||
const simulatorDynamicImageHidden = ref(false);
|
||||
const simulatorDynamicImageError = ref("");
|
||||
const simulatorCompletedTasks = ref({});
|
||||
const isSimulatorRunning = ref(false);
|
||||
const simulatorRunReason = ref("");
|
||||
const studioStore = useSelfServeStudioStore();
|
||||
const {
|
||||
flowNodes,
|
||||
flowEdges,
|
||||
graphPayload,
|
||||
lookups,
|
||||
validation,
|
||||
gatewayWorkspace,
|
||||
versions,
|
||||
activePanel,
|
||||
selectedNodeId,
|
||||
inspectorForm,
|
||||
simulatorForm,
|
||||
simulatorResult,
|
||||
simulatorAnswerOverrides,
|
||||
simulatorShowJson,
|
||||
simulatorDynamicImageHidden,
|
||||
simulatorDynamicImageError,
|
||||
simulatorCompletedTasks,
|
||||
isSimulatorRunning,
|
||||
simulatorRunReason,
|
||||
simulatorRequestSequence,
|
||||
simulatorLastRequestKey,
|
||||
simulatorLastError,
|
||||
isRestoringSimulatorStorage,
|
||||
pathOutcomes,
|
||||
isLoadingPathOutcomes,
|
||||
pathOutcomesError,
|
||||
pathOutcomesRequestKey,
|
||||
selectedPathOutcomeId,
|
||||
selectedPathCaseSignature,
|
||||
selectedPathCaseIndex,
|
||||
pathCaseRunSignatures,
|
||||
pathCaseConfirmingSignature,
|
||||
pathEditorDirty,
|
||||
isHydratingPathEditor,
|
||||
pathEditorTaskTitleOverrides,
|
||||
editingPathEditorTaskTitleKey,
|
||||
editingPathEditorTaskTitle,
|
||||
pathVerificationDynamicImageHidden,
|
||||
pathVerificationDynamicImageError,
|
||||
isPathVerificationDynamicImageLoading,
|
||||
pathEditorQuestionDraftVisible,
|
||||
pathEditorQuestionDraft,
|
||||
pathEditorForm,
|
||||
simulatorCustomerSearchQuery,
|
||||
selectedSimulatorCustomer,
|
||||
showSimulatorCustomerResults,
|
||||
scopeGuideDraft,
|
||||
isScopeGuideManuallyOpen,
|
||||
isSyncingScopeFromRoute,
|
||||
isWritingScopeToRoute,
|
||||
virtualHardwareForm,
|
||||
laneEditorMode,
|
||||
selectedLaneManagementId,
|
||||
laneForm,
|
||||
lanePreviewHidden,
|
||||
inspectorLanePreviewHidden,
|
||||
inspectorTaskDynamicImageHidden,
|
||||
inspectorTaskDynamicImageError,
|
||||
searchTerm,
|
||||
filters,
|
||||
isLoading,
|
||||
isSaving,
|
||||
isUploadingTaskAttachment,
|
||||
deletingTaskAttachmentId,
|
||||
isStudioFullscreen,
|
||||
hasNativeStudioFullscreen,
|
||||
suppressLayoutSave,
|
||||
exportText,
|
||||
importText,
|
||||
draggedTemplateKind,
|
||||
unfilteredNodePositions,
|
||||
} = storeToRefs(studioStore);
|
||||
|
||||
activePanel.value = panelFromRoute() || "flow";
|
||||
studioStore.applyRouteScope(scopeFiltersFromRoute());
|
||||
|
||||
const simulatorRunTimer = ref(null);
|
||||
const simulatorRequestSequence = ref(0);
|
||||
const simulatorLastRequestKey = ref("");
|
||||
const simulatorLastError = ref("");
|
||||
const isRestoringSimulatorStorage = ref(false);
|
||||
const pathOutcomes = ref(null);
|
||||
const isLoadingPathOutcomes = ref(false);
|
||||
const pathOutcomesError = ref("");
|
||||
const pathOutcomesRequestKey = ref("");
|
||||
const pathOutcomesAbortController = ref(null);
|
||||
const selectedPathOutcomeId = ref(null);
|
||||
const selectedPathCaseSignature = ref("");
|
||||
const selectedPathCaseIndex = ref(0);
|
||||
const pathCaseRunSignatures = ref({});
|
||||
const pathCaseConfirmingSignature = ref("");
|
||||
const pathEditorDirty = ref(false);
|
||||
const isHydratingPathEditor = ref(false);
|
||||
const pathEditorTaskTitleOverrides = ref({});
|
||||
const editingPathEditorTaskTitleKey = ref("");
|
||||
const editingPathEditorTaskTitle = ref("");
|
||||
const pathVerificationDynamicImageHidden = ref(false);
|
||||
const pathVerificationDynamicImageError = ref("");
|
||||
const isPathVerificationDynamicImageLoading = ref(false);
|
||||
const pathEditorQuestionDraftVisible = ref(false);
|
||||
const pathEditorQuestionDraft = ref(emptyPathEditorQuestionDraft());
|
||||
const pathEditorForm = ref({
|
||||
path_key: "",
|
||||
previous_path_key: "",
|
||||
answers: {},
|
||||
machine_allowed: true,
|
||||
task: "Start machine",
|
||||
description: "",
|
||||
services: ["MACHINE"],
|
||||
program_button: "",
|
||||
press_reset: false,
|
||||
press_start: true,
|
||||
extra_buttons: [],
|
||||
dynamic_images_vehicle_type: "0",
|
||||
});
|
||||
const simulatorCustomerSearchQuery = ref("");
|
||||
const selectedSimulatorCustomer = ref(null);
|
||||
const showSimulatorCustomerResults = ref(false);
|
||||
const scopeGuideDraft = ref(scopeFiltersFromRoute());
|
||||
const isScopeGuideManuallyOpen = ref(false);
|
||||
const isSyncingScopeFromRoute = ref(false);
|
||||
const isWritingScopeToRoute = ref(false);
|
||||
const virtualHardwareForm = ref({
|
||||
gateway_key: "virtual-main",
|
||||
gateway_label: "Virtual Studio Gateway",
|
||||
relay_id: "",
|
||||
role: "MACHINE",
|
||||
label: "",
|
||||
});
|
||||
const laneEditorMode = ref("edit");
|
||||
const selectedLaneManagementId = ref(null);
|
||||
const laneForm = ref({});
|
||||
const lanePreviewHidden = ref(false);
|
||||
const inspectorLanePreviewHidden = ref(false);
|
||||
const inspectorTaskDynamicImageHidden = ref(false);
|
||||
const inspectorTaskDynamicImageError = ref("");
|
||||
const searchTerm = ref("");
|
||||
const filters = ref({
|
||||
...scopeFiltersFromRoute(),
|
||||
machine_type_id: "",
|
||||
dynamic_image_id: "",
|
||||
});
|
||||
const isLoading = ref(false);
|
||||
const isSaving = ref(false);
|
||||
const isUploadingTaskAttachment = ref(false);
|
||||
const deletingTaskAttachmentId = ref(null);
|
||||
const isStudioFullscreen = ref(false);
|
||||
const hasNativeStudioFullscreen = ref(false);
|
||||
const layoutSaveTimer = ref(null);
|
||||
const suppressLayoutSave = ref(false);
|
||||
const exportText = ref("");
|
||||
const importText = ref("");
|
||||
const draggedTemplateKind = ref(null);
|
||||
const studioRoot = ref(null);
|
||||
const unfilteredNodePositions = ref(null);
|
||||
|
||||
const graphStats = computed(() => {
|
||||
const counts = {};
|
||||
@@ -482,6 +462,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 +3141,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 +4669,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 +4759,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 +7042,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 +7500,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 +8041,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 +8118,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 +8128,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 +8137,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 +8147,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 +9336,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 +9344,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 +9826,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>
|
||||
|
||||
+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"
|
||||
|
||||
@@ -55,6 +55,7 @@ const hasStartFormUserInput = ref(false);
|
||||
const shouldRestoreServerActiveWash = ref(false);
|
||||
const isRestoringServerActiveWash = ref(false);
|
||||
const isSyncingActiveWash = ref(false);
|
||||
const hasConfirmedActiveWashOnServer = ref(false);
|
||||
const MIN_FINISHING_SCREEN_MS = 250;
|
||||
const RECENT_COMPLETED_WASH_KEY = "mywash_recent_completed_v1";
|
||||
const RECENT_COMPLETED_WASH_SUPPRESSION_MS = 10 * 60 * 1000;
|
||||
@@ -83,6 +84,7 @@ const {
|
||||
isForcingNearestDepartment,
|
||||
forceNearestDepartmentEvaluationId,
|
||||
isSearchingDepartments,
|
||||
departmentFetchError,
|
||||
availableProductIds,
|
||||
doesCurrentDepartmentSelectionHaveSelfServeEnabled,
|
||||
fetchDepartments,
|
||||
@@ -436,6 +438,8 @@ const extractErrorMessage = (error: any, fallback: string) => {
|
||||
return fallback;
|
||||
};
|
||||
|
||||
const getRequestStatus = (error: any) => Number(error?.response?.status ?? error?.status ?? 0);
|
||||
|
||||
watch(dynamicImageUrl, () => {
|
||||
hideDynamicImage.value = false;
|
||||
});
|
||||
@@ -654,18 +658,28 @@ const fetchSelfServeData = async () => {
|
||||
await fetchSelfServeDataInternal(departmentId, vehicleTypeSelect.value || null, laneId, normalizedReg);
|
||||
};
|
||||
|
||||
const getServerActiveWashCandidates = () => {
|
||||
const SERVER_ACTIVE_WASH_ENDPOINT = "/modules/self-serve/lane/wash/my-active-wash";
|
||||
|
||||
const findServerActiveWashCandidate = (laneId: number | string | null) => {
|
||||
const normalizedLaneId = normalizeLaneId(laneId);
|
||||
if (!normalizedLaneId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const departments = Array.isArray(guestDepartments.value) ? guestDepartments.value : [];
|
||||
return departments.flatMap((department: any) => {
|
||||
for (const department of departments) {
|
||||
const lanes = Array.isArray(department?.lanes) ? department.lanes : [];
|
||||
return lanes
|
||||
.map((lane: any) => ({
|
||||
const lane = lanes.find((candidateLane: any) => normalizeLaneId(candidateLane?.id) === normalizedLaneId);
|
||||
if (lane) {
|
||||
return {
|
||||
department,
|
||||
lane,
|
||||
laneId: normalizeLaneId(lane?.id),
|
||||
}))
|
||||
.filter((candidate: any) => candidate.laneId);
|
||||
});
|
||||
laneId: normalizedLaneId,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { laneId: normalizedLaneId };
|
||||
};
|
||||
|
||||
const isAuthenticatedCustomerActiveWash = (details: any, customerNumber: number) => {
|
||||
@@ -769,31 +783,34 @@ const fetchServerActiveWash = async () => {
|
||||
return null;
|
||||
}
|
||||
|
||||
const candidates = getServerActiveWashCandidates();
|
||||
if (candidates.length === 0) {
|
||||
let response = null;
|
||||
try {
|
||||
response = await SessionUser.request(SERVER_ACTIVE_WASH_ENDPOINT, "GET");
|
||||
} catch (error) {
|
||||
if (getRequestStatus(error) === 404) {
|
||||
return null;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
const details = unwrapApiData(response);
|
||||
if (!isAuthenticatedCustomerActiveWash(details, customerNumber)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const responses = await Promise.allSettled(
|
||||
candidates.map(async (candidate: any) => {
|
||||
const response = await SessionUser.request("/modules/self-serve/lane/wash/in-progress", "GET", {
|
||||
lane_id: candidate.laneId,
|
||||
});
|
||||
return {
|
||||
...candidate,
|
||||
details: unwrapApiData(response),
|
||||
};
|
||||
})
|
||||
);
|
||||
const laneId = normalizeLaneId(details?.lane_id ?? details?.session?.lane_id);
|
||||
const activeWashCandidate = findServerActiveWashCandidate(laneId);
|
||||
const activeWash = {
|
||||
...(activeWashCandidate || {}),
|
||||
details,
|
||||
};
|
||||
|
||||
const activeMatches = responses
|
||||
.filter((entry): entry is PromiseFulfilledResult<any> => entry.status === "fulfilled")
|
||||
.map((entry) => entry.value)
|
||||
.filter((entry) => isAuthenticatedCustomerActiveWash(entry.details, customerNumber))
|
||||
.filter((entry) => !isRecentlyCompletedActiveWash(entry))
|
||||
.sort((left, right) => activeWashTimestamp(right.details) - activeWashTimestamp(left.details));
|
||||
if (isRecentlyCompletedActiveWash(activeWash)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return activeMatches[0] || null;
|
||||
return activeWash;
|
||||
};
|
||||
|
||||
const applyServerActiveWash = async (activeWash: any) => {
|
||||
@@ -817,6 +834,7 @@ const applyServerActiveWash = async (activeWash: any) => {
|
||||
}
|
||||
|
||||
washInProgress.value = true;
|
||||
hasConfirmedActiveWashOnServer.value = true;
|
||||
washLaneId.value = laneId;
|
||||
radioLaneOption.value = laneId;
|
||||
licensePlateInput.value = reg;
|
||||
@@ -845,7 +863,10 @@ const applyServerActiveWash = async (activeWash: any) => {
|
||||
|
||||
const summary = await fetchWashSummary(summaryParams, false);
|
||||
if (!summary) {
|
||||
await fetchSelfServeDataInternal(departmentId || nearestDepartment.value?.id, selectedVehicleTypeId, laneId, reg);
|
||||
const fallbackDepartmentId = departmentId || nearestDepartment.value?.id;
|
||||
if (fallbackDepartmentId) {
|
||||
await fetchSelfServeDataInternal(fallbackDepartmentId, selectedVehicleTypeId, laneId, reg);
|
||||
}
|
||||
}
|
||||
|
||||
saveProgress("serverActiveWash");
|
||||
@@ -898,6 +919,7 @@ const clearLocalActiveWashFromServer = () => {
|
||||
completedDurationMs.value = washStartTime.value ? now.value - washStartTime.value : 0;
|
||||
currentStep.value = steps.COMPLETED;
|
||||
washInProgress.value = false;
|
||||
hasConfirmedActiveWashOnServer.value = false;
|
||||
washLaneId.value = null;
|
||||
washStartTime.value = null;
|
||||
shouldRestoreServerActiveWash.value = false;
|
||||
@@ -932,9 +954,15 @@ const syncActiveWashWithServer = async () => {
|
||||
details?.session?.customer_number ?? details?.customer?.customer_number
|
||||
);
|
||||
const serverStillMatchesCurrentWash =
|
||||
!!details?.in_progress && (!customerNumber || !serverCustomerNumber || serverCustomerNumber === customerNumber);
|
||||
!!details?.in_progress && (!customerNumber || serverCustomerNumber === customerNumber);
|
||||
const serverReportedNoActiveWash = details?.in_progress === false;
|
||||
const shouldPreserveUnconfirmedLocalWash = serverReportedNoActiveWash && !hasConfirmedActiveWashOnServer.value;
|
||||
|
||||
if (!serverStillMatchesCurrentWash || isRecentlyCompletedActiveWash({ details, laneId })) {
|
||||
if (shouldPreserveUnconfirmedLocalWash) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isWithinStartGracePeriod =
|
||||
washStartTime.value && Date.now() - washStartTime.value < WASH_START_SERVER_SYNC_GRACE_MS;
|
||||
if (isWithinStartGracePeriod && !isRecentlyCompletedActiveWash({ details, laneId })) {
|
||||
@@ -945,6 +973,7 @@ const syncActiveWashWithServer = async () => {
|
||||
return;
|
||||
}
|
||||
|
||||
hasConfirmedActiveWashOnServer.value = true;
|
||||
const session = details?.session || {};
|
||||
const vehicle = details?.vehicle || {};
|
||||
const serverReg = normalizeLicensePlate(session?.reg ?? vehicle?.reg);
|
||||
@@ -1158,6 +1187,7 @@ const onConfirmNext = async () => {
|
||||
const onCloseCompleted = () => {
|
||||
stopElapsedTimer();
|
||||
washInProgress.value = false;
|
||||
hasConfirmedActiveWashOnServer.value = false;
|
||||
washLaneId.value = null;
|
||||
washStartTime.value = null;
|
||||
completedDurationMs.value = null;
|
||||
@@ -1328,10 +1358,15 @@ watch(
|
||||
|
||||
watch([() => washInProgress.value, () => washLaneId.value], ([inProgress]) => {
|
||||
if (inProgress) {
|
||||
if (isRestoring.value) {
|
||||
return;
|
||||
}
|
||||
hasConfirmedActiveWashOnServer.value = true;
|
||||
startActiveWashRefresh();
|
||||
return;
|
||||
}
|
||||
|
||||
hasConfirmedActiveWashOnServer.value = false;
|
||||
stopActiveWashRefresh();
|
||||
});
|
||||
|
||||
@@ -1380,6 +1415,9 @@ watch(
|
||||
if (!restoring) {
|
||||
ensureEffectiveLaneSelection();
|
||||
fetchSelfServeData();
|
||||
if (washInProgress.value) {
|
||||
startActiveWashRefresh();
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -1436,10 +1474,31 @@ watch(
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<b-message
|
||||
v-if="departmentFetchError"
|
||||
type="is-warning"
|
||||
has-icon
|
||||
:closable="false"
|
||||
data-testid="self-serve-departments-error"
|
||||
>
|
||||
<div class="is-flex is-align-items-center is-justify-content-space-between is-flex-wrap-wrap">
|
||||
<span class="mr-3">{{ departmentFetchError }}</span>
|
||||
<b-button
|
||||
size="is-small"
|
||||
type="is-warning is-light"
|
||||
icon-pack="fas"
|
||||
icon-left="sync-alt"
|
||||
data-testid="self-serve-departments-retry"
|
||||
@click="fetchDepartments"
|
||||
>
|
||||
{{ $t("common.try_again") }}
|
||||
</b-button>
|
||||
</div>
|
||||
</b-message>
|
||||
|
||||
<b-message
|
||||
v-if="selfServeDataError"
|
||||
type="is-danger"
|
||||
has-icon
|
||||
:closable="false"
|
||||
data-testid="self-serve-runtime-error"
|
||||
>
|
||||
|
||||
@@ -265,11 +265,11 @@ test.describe("Admin daily report", () => {
|
||||
await page.goto("/admin/1/modules/daily-report?departmentIds=1&dateFrom=2026-03-23&dateTo=2026-03-24");
|
||||
|
||||
await expect(page.getByTestId("daily-report-page")).toBeVisible();
|
||||
await expect(page.getByTestId("department-weather-hours-2026-03-23-06:00")).toHaveText("1");
|
||||
await expect(page.getByTestId("department-weather-hours-2026-03-23-19:00")).toHaveText("19");
|
||||
await expect(page.getByTestId("department-weather-hours-2026-03-23-20:00")).toHaveText("21");
|
||||
await expect(page.getByTestId("department-weather-productivity-2026-03-23-06:00")).toHaveClass(/red/);
|
||||
await expect(page.getByTestId("department-weather-productivity-2026-03-24-08:00")).toHaveClass(/gray/);
|
||||
await expect(page.getByTestId("department-weather-hours-multi-day-06:00")).toHaveText("1");
|
||||
await expect(page.getByTestId("department-weather-hours-multi-day-19:00")).toHaveText("19");
|
||||
await expect(page.getByTestId("department-weather-hours-multi-day-20:00")).toHaveText("21");
|
||||
await expect(page.getByTestId("department-weather-productivity-multi-day-06:00")).toHaveClass(/red/);
|
||||
await expect(page.getByTestId("department-weather-productivity-multi-day-08:00")).toHaveClass(/gray/);
|
||||
|
||||
await page.getByTestId("department-weather-summary-toggle").click();
|
||||
|
||||
@@ -319,9 +319,9 @@ test.describe("Admin daily report", () => {
|
||||
await page.goto("/admin/1/modules/daily-report?departmentIds=1&dateFrom=2026-03-23&dateTo=2026-03-24");
|
||||
|
||||
await expect(page.getByTestId("daily-report-page")).toBeVisible();
|
||||
await expect(page.getByTestId("department-weather-hours-2026-03-23-12:00")).toHaveText("1");
|
||||
await expect(page.getByTestId("department-weather-hours-2026-03-24-13:00")).toHaveText("1");
|
||||
await expect(page.getByTestId("department-weather-hours-2026-03-24-14:00")).toHaveCount(0);
|
||||
await expect(page.getByTestId("department-weather-hours-multi-day-12:00")).toHaveText("1");
|
||||
await expect(page.getByTestId("department-weather-hours-multi-day-13:00")).toHaveText("1");
|
||||
await expect(page.getByTestId("department-weather-hours-multi-day-14:00")).toHaveCount(0);
|
||||
|
||||
await page.getByTestId("department-weather-summary-toggle").click();
|
||||
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -31,8 +31,6 @@ const expectedFuldfValues = {
|
||||
|
||||
const expectedSelfWashValues = {
|
||||
"self_wash.lane_unavailable": "Vaskebanen er ikke tilgængelig",
|
||||
"self_wash.machine_unavailable_for_lane":
|
||||
"Maskinvask er ikke tilgængelig for den valgte bane. Vælg manuel vask eller en anden ledig bane.",
|
||||
"self_wash.vehicle_step_missing_customer_number": "Indtast kundenummer for at fortsætte.",
|
||||
"self_wash.vehicle_step_missing_department": "Vent på, at nærmeste afdeling er indlæst, før du fortsætter.",
|
||||
"self_wash.vehicle_step_missing_registration": "Indtast registreringsnummer for at fortsætte.",
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
@@ -705,7 +726,7 @@ test.describe("Self-serve wash", () => {
|
||||
expect(startCommandRequest.postDataJSON?.()).toMatchObject({
|
||||
command: "START",
|
||||
customer_number: 12345679,
|
||||
defer_relay_side_effects: true,
|
||||
defer_relay_side_effects: false,
|
||||
});
|
||||
await page.waitForTimeout(250);
|
||||
expect(orderFilters.some((filters) => filters.includes("customer_id:777"))).toBe(false);
|
||||
|
||||
@@ -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,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,26 @@ 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("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