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:
|
jobs:
|
||||||
upload-qodana-config:
|
upload-qodana-config:
|
||||||
runs-on: ubuntu-latest
|
runs-on: [self-hosted, Linux, X64, default]
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
upload-qodana-config:
|
upload-qodana-config:
|
||||||
runs-on: ubuntu-latest
|
runs-on: [self-hosted, Linux, X64, default]
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ jobs:
|
|||||||
PLAYWRIGHT_RELEASE_API_BASE_URL: https://api-v2.truckwash.io
|
PLAYWRIGHT_RELEASE_API_BASE_URL: https://api-v2.truckwash.io
|
||||||
PLAYWRIGHT_RELEASE_API_PING_PATHS: /ping,/master/api/ping,/canary/api/ping,/stable/api/ping
|
PLAYWRIGHT_RELEASE_API_PING_PATHS: /ping,/master/api/ping,/canary/api/ping,/stable/api/ping
|
||||||
RELEASE_BUILD_ID: ${{ github.run_id }}-${{ github.run_attempt }}
|
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_EXPECTED_COMMIT: ${{ github.sha }}
|
||||||
RELEASE_WAIT_INITIAL_SECONDS: 45
|
RELEASE_WAIT_INITIAL_SECONDS: 45
|
||||||
RELEASE_WAIT_TIMEOUT_SECONDS: 600
|
RELEASE_WAIT_TIMEOUT_SECONDS: 600
|
||||||
@@ -98,7 +99,7 @@ jobs:
|
|||||||
-X POST "$RELEASE_MANAGER_GATE_URL" \
|
-X POST "$RELEASE_MANAGER_GATE_URL" \
|
||||||
-H "Authorization: Bearer $RELEASE_MANAGER_GATE_TOKEN" \
|
-H "Authorization: Bearer $RELEASE_MANAGER_GATE_TOKEN" \
|
||||||
-H "Content-Type: application/json" \
|
-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:
|
env:
|
||||||
RELEASE_MANAGER_GATE_URL: ${{ secrets.RELEASE_MANAGER_GATE_URL || 'https://api.truckwash.io/release/gate/test-runs' }}
|
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 }}
|
RELEASE_MANAGER_GATE_TOKEN: ${{ secrets.RELEASE_MANAGER_GATE_TOKEN }}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ concurrency:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
format-tests:
|
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]
|
runs-on: [self-hosted, Linux, X64, default]
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
|
|||||||
@@ -38,6 +38,14 @@ To use another remote API route:
|
|||||||
$env:VITE_API_PROXY_BASE_PATH="/canary/api"; npm run dev
|
$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:
|
For compatible local gateways that expect the `/api` prefix to be preserved:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
|
|||||||
@@ -5,18 +5,27 @@ server {
|
|||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
index index.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$ {
|
location ~ ^/(release-entry|release-manifest)\.json$ {
|
||||||
add_header Cache-Control "no-store";
|
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;
|
try_files $uri =404;
|
||||||
}
|
}
|
||||||
|
|
||||||
location ~ ^/(master|beta|canary|internal)/frontend/(release-entry|release-manifest)\.json$ {
|
location ~ ^/(master|beta|canary|internal)/frontend/(release-entry|release-manifest)\.json$ {
|
||||||
add_header Cache-Control "no-store";
|
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;
|
try_files /$2.json =404;
|
||||||
}
|
}
|
||||||
|
|
||||||
location ~ ^/(?:.+/)?(?<static_asset_path>(?:assets|resources|favicons|icons|img|sounds|\.well-known)/.+)$ {
|
location ~ ^/(?:.+/)?(?<static_asset_path>(?:assets|resources|favicons|icons|img|sounds|\.well-known)/.+)$ {
|
||||||
add_header Cache-Control "public, max-age=31536000, immutable";
|
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;
|
try_files /$static_asset_path =404;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,11 +39,15 @@ server {
|
|||||||
|
|
||||||
location /assets/ {
|
location /assets/ {
|
||||||
add_header Cache-Control "public, max-age=31536000, immutable";
|
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;
|
try_files $uri =404;
|
||||||
}
|
}
|
||||||
|
|
||||||
location ~ ^/(master|beta|canary|internal)/frontend/assets/ {
|
location ~ ^/(master|beta|canary|internal)/frontend/assets/ {
|
||||||
add_header Cache-Control "public, max-age=31536000, immutable";
|
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;
|
rewrite ^/(master|beta|canary|internal)/frontend/(.*)$ /$2 break;
|
||||||
try_files $uri =404;
|
try_files $uri =404;
|
||||||
}
|
}
|
||||||
|
|||||||
+113
-19
@@ -4624,7 +4624,7 @@ paths:
|
|||||||
tags:
|
tags:
|
||||||
- Self-Serve
|
- Self-Serve
|
||||||
summary: Add vehicle condition
|
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
|
operationId: addSelfserveVehicleCondition
|
||||||
requestBody:
|
requestBody:
|
||||||
required: true
|
required: true
|
||||||
@@ -4659,14 +4659,6 @@ paths:
|
|||||||
type: integer
|
type: integer
|
||||||
nullable: true
|
nullable: true
|
||||||
description: Alias for vehicle_type.
|
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:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: Successfully added vehicle condition
|
description: Successfully added vehicle condition
|
||||||
@@ -4683,7 +4675,7 @@ paths:
|
|||||||
tags:
|
tags:
|
||||||
- Self-Serve
|
- Self-Serve
|
||||||
summary: Update vehicle condition
|
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
|
operationId: updateSelfserveVehicleCondition
|
||||||
parameters:
|
parameters:
|
||||||
- name: id
|
- name: id
|
||||||
@@ -4719,14 +4711,6 @@ paths:
|
|||||||
type: integer
|
type: integer
|
||||||
nullable: true
|
nullable: true
|
||||||
description: Alias for vehicle_type.
|
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:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: Successfully updated vehicle condition
|
description: Successfully updated vehicle condition
|
||||||
@@ -5402,7 +5386,7 @@ paths:
|
|||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
type: object
|
type: object
|
||||||
required: [department, gateway_id, action]
|
required: [department, gateway_id, action, confirm]
|
||||||
properties:
|
properties:
|
||||||
department: { type: integer }
|
department: { type: integer }
|
||||||
gateway_id: { type: integer }
|
gateway_id: { type: integer }
|
||||||
@@ -8704,6 +8688,116 @@ paths:
|
|||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/SelfServeLaneStatus'
|
$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:
|
/modules/self-serve/lane/wash/in-progress:
|
||||||
get:
|
get:
|
||||||
tags:
|
tags:
|
||||||
|
|||||||
Generated
+29
-11
@@ -7,7 +7,6 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "truckwashdashboardsfrontend",
|
"name": "truckwashdashboardsfrontend",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"hasInstallScript": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@azure/msal-browser": "^4.12.0",
|
"@azure/msal-browser": "^4.12.0",
|
||||||
"@bubblewrap/cli": "^1.23.0",
|
"@bubblewrap/cli": "^1.23.0",
|
||||||
@@ -42,6 +41,7 @@
|
|||||||
"dayspan-vuetify": "^0.4.0",
|
"dayspan-vuetify": "^0.4.0",
|
||||||
"flow": "^0.2.3",
|
"flow": "^0.2.3",
|
||||||
"html2canvas": "^1.4.1",
|
"html2canvas": "^1.4.1",
|
||||||
|
"pinia": "^3.0.4",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"sass": "^1.81.1",
|
"sass": "^1.81.1",
|
||||||
"sweetalert2": "11.22.4",
|
"sweetalert2": "11.22.4",
|
||||||
@@ -5070,7 +5070,6 @@
|
|||||||
"version": "7.7.9",
|
"version": "7.7.9",
|
||||||
"resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-7.7.9.tgz",
|
"resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-7.7.9.tgz",
|
||||||
"integrity": "sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA==",
|
"integrity": "sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vue/devtools-shared": "^7.7.9",
|
"@vue/devtools-shared": "^7.7.9",
|
||||||
@@ -5086,7 +5085,6 @@
|
|||||||
"version": "7.7.9",
|
"version": "7.7.9",
|
||||||
"resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-7.7.9.tgz",
|
"resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-7.7.9.tgz",
|
||||||
"integrity": "sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA==",
|
"integrity": "sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"rfdc": "^1.4.1"
|
"rfdc": "^1.4.1"
|
||||||
@@ -5738,7 +5736,6 @@
|
|||||||
"version": "2.9.0",
|
"version": "2.9.0",
|
||||||
"resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz",
|
"resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz",
|
||||||
"integrity": "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==",
|
"integrity": "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/antfu"
|
"url": "https://github.com/sponsors/antfu"
|
||||||
@@ -6426,7 +6423,6 @@
|
|||||||
"version": "4.0.5",
|
"version": "4.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-4.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-4.0.5.tgz",
|
||||||
"integrity": "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==",
|
"integrity": "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"is-what": "^5.2.0"
|
"is-what": "^5.2.0"
|
||||||
@@ -8989,7 +8985,6 @@
|
|||||||
"version": "5.5.0",
|
"version": "5.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/is-what/-/is-what-5.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/is-what/-/is-what-5.5.0.tgz",
|
||||||
"integrity": "sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==",
|
"integrity": "sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
@@ -9572,7 +9567,6 @@
|
|||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz",
|
||||||
"integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==",
|
"integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/mkdirp": {
|
"node_modules/mkdirp": {
|
||||||
@@ -10110,7 +10104,6 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz",
|
||||||
"integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==",
|
"integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/performance-now": {
|
"node_modules/performance-now": {
|
||||||
@@ -10172,6 +10165,34 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"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": {
|
"node_modules/pixelmatch": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/pixelmatch/-/pixelmatch-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/pixelmatch/-/pixelmatch-4.0.2.tgz",
|
||||||
@@ -10729,7 +10750,6 @@
|
|||||||
"version": "1.4.1",
|
"version": "1.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz",
|
||||||
"integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==",
|
"integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/rgbcolor": {
|
"node_modules/rgbcolor": {
|
||||||
@@ -11647,7 +11667,6 @@
|
|||||||
"version": "14.0.1",
|
"version": "14.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/speakingurl/-/speakingurl-14.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/speakingurl/-/speakingurl-14.0.1.tgz",
|
||||||
"integrity": "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==",
|
"integrity": "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "BSD-3-Clause",
|
"license": "BSD-3-Clause",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
@@ -11919,7 +11938,6 @@
|
|||||||
"version": "2.2.6",
|
"version": "2.2.6",
|
||||||
"resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.6.tgz",
|
"resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.6.tgz",
|
||||||
"integrity": "sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==",
|
"integrity": "sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"copy-anything": "^4"
|
"copy-anything": "^4"
|
||||||
|
|||||||
+1
-1
@@ -11,7 +11,6 @@
|
|||||||
"format:tests:commit": "node scripts/pre-commit-format-tests.mjs",
|
"format:tests:commit": "node scripts/pre-commit-format-tests.mjs",
|
||||||
"format:tests:check": "prettier --check \"tests/**/*.{js,ts}\"",
|
"format:tests:check": "prettier --check \"tests/**/*.{js,ts}\"",
|
||||||
"prepare": "node scripts/prepare-husky.mjs",
|
"prepare": "node scripts/prepare-husky.mjs",
|
||||||
"postinstall": "node scripts/postinstall-sync-playwright-root-links.mjs",
|
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"preview:prod": "npm run build && npm run preview -- --host 127.0.0.1 --port 4173",
|
"preview:prod": "npm run build && npm run preview -- --host 127.0.0.1 --port 4173",
|
||||||
"text:fix-encoding": "node scripts/text-encoding.mjs fix",
|
"text:fix-encoding": "node scripts/text-encoding.mjs fix",
|
||||||
@@ -87,6 +86,7 @@
|
|||||||
"dayspan-vuetify": "^0.4.0",
|
"dayspan-vuetify": "^0.4.0",
|
||||||
"flow": "^0.2.3",
|
"flow": "^0.2.3",
|
||||||
"html2canvas": "^1.4.1",
|
"html2canvas": "^1.4.1",
|
||||||
|
"pinia": "^3.0.4",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"sass": "^1.81.1",
|
"sass": "^1.81.1",
|
||||||
"sweetalert2": "11.22.4",
|
"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
|
echo "Set RELEASE_DEPLOY_URL or RELEASE_DEPLOY_HOST, RELEASE_DEPLOY_USER, and RELEASE_DEPLOY_PASSWORD." >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
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
|
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() {
|
upload_file() {
|
||||||
local source_file="$1"
|
local source_file="$1"
|
||||||
local remote_file="$2"
|
local remote_file="$2"
|
||||||
if [[ -f "$source_file" ]]; then
|
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
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
for directory in assets resources favicons icons img sounds .well-known; do
|
for directory in assets resources favicons icons img sounds .well-known; do
|
||||||
if [[ -d "$DIST_DIR/$directory" ]]; then
|
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
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ async function verifyShell(baseUrl, shellPath) {
|
|||||||
|
|
||||||
async function verifyRelease(baseUrl) {
|
async function verifyRelease(baseUrl) {
|
||||||
const expectedCommit = process.env.RELEASE_EXPECTED_COMMIT || process.env.GITHUB_SHA || "";
|
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 manifest = await fetchJson(baseUrl, "release-manifest.json");
|
||||||
const releaseEntry = await fetchJson(baseUrl, "release-entry.json");
|
const releaseEntry = await fetchJson(baseUrl, "release-entry.json");
|
||||||
|
|
||||||
|
|||||||
@@ -97,6 +97,10 @@ const props = defineProps({
|
|||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
|
allowBookingDeletion: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
department_lane_id: {
|
department_lane_id: {
|
||||||
type: Number,
|
type: Number,
|
||||||
default: null,
|
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 flatBuiltInMenuSections = computed(() => {
|
||||||
const sections = [];
|
const sections = [];
|
||||||
|
|
||||||
@@ -1806,7 +1815,7 @@ const flatBuiltInMenuSections = computed(() => {
|
|||||||
),
|
),
|
||||||
})
|
})
|
||||||
: null,
|
: null,
|
||||||
!props.order_id
|
canDeleteOrderBooking.value
|
||||||
? buildMenuAction("booking-delete", {
|
? buildMenuAction("booking-delete", {
|
||||||
icon: "fas fa-trash-alt",
|
icon: "fas fa-trash-alt",
|
||||||
label: t("admin.pos.settings_wheel.delete_booking"),
|
label: t("admin.pos.settings_wheel.delete_booking"),
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
selectPreferredStripeTerminalReaderId,
|
selectPreferredStripeTerminalReaderId,
|
||||||
STRIPE_TERMINAL_STATUS,
|
STRIPE_TERMINAL_STATUS,
|
||||||
} from "@/components/displays/department/pos/displays/stripeTerminalReaders.js";
|
} from "@/components/displays/department/pos/displays/stripeTerminalReaders.js";
|
||||||
|
import { normalizeStripeInvoice } from "@/components/displays/department/pos/displays/stripeEmailInvoice.js";
|
||||||
|
|
||||||
const POLLING_INTERVAL_MS = 5000;
|
const POLLING_INTERVAL_MS = 5000;
|
||||||
const STRIPE_TERMINAL_SETUP_REQUIRED_CODE = 'stripe_terminal_setup_required';
|
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 paymentIntent = computed(() => StripeModule.paymentIntents.paymentIntent.value);
|
||||||
const isTerminalPaymentCaptured = computed(() => StripeModule.paymentIntents.isPaymentIntentAmountReceived(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 hasStripeEmailInvoice = computed(() => stripeInvoice.value !== null);
|
||||||
const isStripeEmailInvoicePaid = computed(() => stripeInvoice.value?.paid === true);
|
const isStripeEmailInvoicePaid = computed(() => stripeInvoice.value?.paid === true);
|
||||||
const isStripeEmailInvoiceTerminalState = computed(() => {
|
const isStripeEmailInvoiceTerminalState = computed(() => {
|
||||||
@@ -202,7 +180,7 @@ const loadStripeInvoiceState = async () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await getOrder(props.order_id, true);
|
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;
|
stripeInvoice.value = nextInvoice;
|
||||||
if (nextInvoice) {
|
if (nextInvoice) {
|
||||||
emailPanelState.value = 'tracking';
|
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 acceptedOrderAttachmentFileTypes = "image/*,application/pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx";
|
||||||
const imageExtensions = [".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".svg"];
|
const imageExtensions = [".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".svg"];
|
||||||
const officeExtensions = [".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx"];
|
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({
|
const props = defineProps({
|
||||||
order: {
|
order: {
|
||||||
@@ -324,7 +334,21 @@ const hasCachedPreviewSource = (attachmentId) => {
|
|||||||
return Object.prototype.hasOwnProperty.call(previewSourcesById.value, 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) {
|
if (!downloadLink) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -340,7 +364,12 @@ const createEmbeddablePreviewUrl = async (downloadLink) => {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const objectUrl = URL.createObjectURL(fileBlob);
|
const safePreviewBlob = createSafePreviewBlob(fileBlob, previewKind);
|
||||||
|
if (!safePreviewBlob) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const objectUrl = URL.createObjectURL(safePreviewBlob);
|
||||||
generatedObjectUrls.add(objectUrl);
|
generatedObjectUrls.add(objectUrl);
|
||||||
return objectUrl;
|
return objectUrl;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -372,7 +401,7 @@ const ensurePreviewSource = async (attachment) => {
|
|||||||
attachment.id,
|
attachment.id,
|
||||||
false
|
false
|
||||||
);
|
);
|
||||||
const previewSource = await createEmbeddablePreviewUrl(downloadLink);
|
const previewSource = await createEmbeddablePreviewUrl(downloadLink, previewKind);
|
||||||
previewSourcesById.value = {
|
previewSourcesById.value = {
|
||||||
...previewSourcesById.value,
|
...previewSourcesById.value,
|
||||||
[attachment.id]: previewSource,
|
[attachment.id]: previewSource,
|
||||||
@@ -517,6 +546,7 @@ const toggleDropdown = async () => {
|
|||||||
:src="activePreviewSource"
|
:src="activePreviewSource"
|
||||||
class="order-attachments-preview-panel__document"
|
class="order-attachments-preview-panel__document"
|
||||||
title="Attachment preview"
|
title="Attachment preview"
|
||||||
|
sandbox
|
||||||
></iframe>
|
></iframe>
|
||||||
<a
|
<a
|
||||||
v-else-if="activePreviewKind === 'link'"
|
v-else-if="activePreviewKind === 'link'"
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ const duplicateDetailsExpanded = ref(false);
|
|||||||
const pendingNextResolution = ref(false);
|
const pendingNextResolution = ref(false);
|
||||||
const isDesktopLastWashCopying = ref(false);
|
const isDesktopLastWashCopying = ref(false);
|
||||||
let desktopStep1CoordinationPromise = Promise.resolve({ canProceed: true });
|
let desktopStep1CoordinationPromise = Promise.resolve({ canProceed: true });
|
||||||
|
let focusOnReg1TimeoutId = null;
|
||||||
const isDesktopStep1Active = computed(() => getCurrentStep() === 1);
|
const isDesktopStep1Active = computed(() => getCurrentStep() === 1);
|
||||||
|
|
||||||
const setTab = (tab) => {
|
const setTab = (tab) => {
|
||||||
@@ -117,7 +118,16 @@ watch(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const focusOnReg1 = () => {
|
const focusOnReg1 = () => {
|
||||||
setTimeout(() => {
|
if (focusOnReg1TimeoutId !== null) {
|
||||||
|
clearTimeout(focusOnReg1TimeoutId);
|
||||||
|
}
|
||||||
|
|
||||||
|
focusOnReg1TimeoutId = setTimeout(() => {
|
||||||
|
focusOnReg1TimeoutId = null;
|
||||||
|
if (typeof document === "undefined") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const reg1Input = document.getElementById("reg_1");
|
const reg1Input = document.getElementById("reg_1");
|
||||||
if (reg1Input) {
|
if (reg1Input) {
|
||||||
reg1Input.focus();
|
reg1Input.focus();
|
||||||
@@ -267,12 +277,8 @@ const bookingSelectionObjects = computed(() => {
|
|||||||
const contentSegments = [
|
const contentSegments = [
|
||||||
`${t("admin.pos.order_booking_selector.customer_label")}: ${booking?.customer_name || t("admin.pos.not_found")}`,
|
`${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("admin.pos.order_booking_selector.plates_label")}: ${plateText || t("admin.pos.not_found")}`,
|
||||||
`${t("common.reference")}: ${
|
`${t("common.reference")}: ${getOrderBookingReferenceValue(booking) || t("admin.pos.not_found")}`,
|
||||||
getOrderBookingReferenceValue(booking) || t("admin.pos.not_found")
|
`${t("common.services")}: ${getOrderBookingServiceText(booking) || t("admin.pos.not_found")}`,
|
||||||
}`,
|
|
||||||
`${t("common.services")}: ${
|
|
||||||
getOrderBookingServiceText(booking) || t("admin.pos.not_found")
|
|
||||||
}`,
|
|
||||||
];
|
];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -681,6 +687,10 @@ onMounted(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
|
if (focusOnReg1TimeoutId !== null) {
|
||||||
|
clearTimeout(focusOnReg1TimeoutId);
|
||||||
|
focusOnReg1TimeoutId = null;
|
||||||
|
}
|
||||||
clearDesktopStep1PreflightHandler(handleDesktopStep1Preflight);
|
clearDesktopStep1PreflightHandler(handleDesktopStep1Preflight);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -752,11 +762,7 @@ watch(
|
|||||||
<ButtonsBox class="pos-actions pos-actions--stacked">
|
<ButtonsBox class="pos-actions pos-actions--stacked">
|
||||||
<Cancel tabindex="2" class="is-fullwidth" />
|
<Cancel tabindex="2" class="is-fullwidth" />
|
||||||
</ButtonsBox>
|
</ButtonsBox>
|
||||||
<div
|
<div v-if="shouldShowActionRailControls" class="pos-shell-actions__rail" data-testid="pos-step-1-action-rail">
|
||||||
v-if="shouldShowActionRailControls"
|
|
||||||
class="pos-shell-actions__rail"
|
|
||||||
data-testid="pos-step-1-action-rail"
|
|
||||||
>
|
|
||||||
<PosDesktopDuplicateWarning
|
<PosDesktopDuplicateWarning
|
||||||
v-if="shouldShowDuplicateWarningInActionRail"
|
v-if="shouldShowDuplicateWarningInActionRail"
|
||||||
:title="t('admin.pos.warning')"
|
:title="t('admin.pos.warning')"
|
||||||
|
|||||||
+2
-9
@@ -299,14 +299,12 @@ const getSelectedBookingId = () => {
|
|||||||
|
|
||||||
const completeStep2Order = async ({
|
const completeStep2Order = async ({
|
||||||
bookingSafetySeal = null,
|
bookingSafetySeal = null,
|
||||||
markOrderCompleted = false,
|
|
||||||
}: {
|
}: {
|
||||||
bookingSafetySeal?: string | null;
|
bookingSafetySeal?: string | null;
|
||||||
markOrderCompleted?: boolean;
|
|
||||||
} = {}) => {
|
} = {}) => {
|
||||||
await finalizeCurrentMobileOrder({
|
await finalizeCurrentMobileOrder({
|
||||||
bookingSafetySeal,
|
bookingSafetySeal,
|
||||||
markOrderCompleted,
|
markOrderCompleted: true,
|
||||||
});
|
});
|
||||||
popups.select("completed_transaction", {
|
popups.select("completed_transaction", {
|
||||||
message: `Order #${order_id.value} successfully created.`,
|
message: `Order #${order_id.value} successfully created.`,
|
||||||
@@ -327,14 +325,12 @@ const step2 = async () => {
|
|||||||
const resolvedSafetySeal = getResolvedMobileSafetySeal();
|
const resolvedSafetySeal = getResolvedMobileSafetySeal();
|
||||||
const hasResolvedSafetySeal = isNonEmptyString(resolvedSafetySeal);
|
const hasResolvedSafetySeal = isNonEmptyString(resolvedSafetySeal);
|
||||||
const requiresBookingCompletionPopup = Boolean(selectedBookingId && hasWashCertificateInBasket);
|
const requiresBookingCompletionPopup = Boolean(selectedBookingId && hasWashCertificateInBasket);
|
||||||
const shouldMarkOrderAsCompleted = !selectedBookingId && hasWashCertificateInBasket;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (requiresBookingCompletionPopup && hasResolvedSafetySeal) {
|
if (requiresBookingCompletionPopup && hasResolvedSafetySeal) {
|
||||||
syncMobileSafetySealState(resolvedSafetySeal);
|
syncMobileSafetySealState(resolvedSafetySeal);
|
||||||
await completeStep2Order({
|
await completeStep2Order({
|
||||||
bookingSafetySeal: resolvedSafetySeal,
|
bookingSafetySeal: resolvedSafetySeal,
|
||||||
markOrderCompleted: false,
|
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -344,7 +340,6 @@ const step2 = async () => {
|
|||||||
syncMobileSafetySealState(safetySeal);
|
syncMobileSafetySealState(safetySeal);
|
||||||
await completeStep2Order({
|
await completeStep2Order({
|
||||||
bookingSafetySeal: String(safetySeal ?? ""),
|
bookingSafetySeal: String(safetySeal ?? ""),
|
||||||
markOrderCompleted: false,
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -358,9 +353,7 @@ const step2 = async () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await completeStep2Order({
|
await completeStep2Order();
|
||||||
markOrderCompleted: shouldMarkOrderAsCompleted,
|
|
||||||
});
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
errors.value.push(error);
|
errors.value.push(error);
|
||||||
console.warn("An error occurred while completing the mobile order:", 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 CACHE_PREFIX = "xlvask-usage-amount:";
|
||||||
const memoryCache = new Map();
|
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 = () => {
|
const safeSessionStorage = () => {
|
||||||
try {
|
try {
|
||||||
if (typeof window === "undefined" || !window.sessionStorage) {
|
if (typeof window === "undefined" || !window.sessionStorage) {
|
||||||
@@ -15,9 +54,7 @@ const safeSessionStorage = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const normalizeUsageLogId = (objectOrId) => {
|
const normalizeUsageLogId = (objectOrId) => {
|
||||||
const value = typeof objectOrId === "object"
|
const value = typeof objectOrId === "object" ? objectOrId?.usage_log_id ?? objectOrId?.id : objectOrId;
|
||||||
? objectOrId?.usage_log_id ?? objectOrId?.id
|
|
||||||
: objectOrId;
|
|
||||||
const parsed = Number.parseInt(String(value ?? ""), 10);
|
const parsed = Number.parseInt(String(value ?? ""), 10);
|
||||||
return Number.isInteger(parsed) && parsed > 0 ? parsed : 0;
|
return Number.isInteger(parsed) && parsed > 0 ? parsed : 0;
|
||||||
};
|
};
|
||||||
@@ -34,17 +71,18 @@ export const buildXlvaskUsageAmountCacheKey = (objectOrId) => {
|
|||||||
|
|
||||||
export const getCachedXlvaskUsageAmount = (objectOrId) => {
|
export const getCachedXlvaskUsageAmount = (objectOrId) => {
|
||||||
const cacheKey = buildXlvaskUsageAmountCacheKey(objectOrId);
|
const cacheKey = buildXlvaskUsageAmountCacheKey(objectOrId);
|
||||||
if (!cacheKey) {
|
const scopedCacheKey = cacheKey ? getScopedCacheKey(cacheKey) : "";
|
||||||
|
if (!scopedCacheKey) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const memoryEntry = memoryCache.get(cacheKey);
|
const memoryEntry = memoryCache.get(scopedCacheKey);
|
||||||
if (isFreshEntry(memoryEntry)) {
|
if (isFreshEntry(memoryEntry)) {
|
||||||
return memoryEntry.data;
|
return memoryEntry.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (memoryEntry) {
|
if (memoryEntry) {
|
||||||
memoryCache.delete(cacheKey);
|
memoryCache.delete(scopedCacheKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
const storage = safeSessionStorage();
|
const storage = safeSessionStorage();
|
||||||
@@ -52,14 +90,14 @@ export const getCachedXlvaskUsageAmount = (objectOrId) => {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const storageKey = `${CACHE_PREFIX}${cacheKey}`;
|
const storageKey = `${CACHE_PREFIX}${scopedCacheKey}`;
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(storage.getItem(storageKey) || "null");
|
const parsed = JSON.parse(storage.getItem(storageKey) || "null");
|
||||||
if (!isFreshEntry(parsed)) {
|
if (!isFreshEntry(parsed)) {
|
||||||
storage.removeItem(storageKey);
|
storage.removeItem(storageKey);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
memoryCache.set(cacheKey, parsed);
|
memoryCache.set(scopedCacheKey, parsed);
|
||||||
return parsed.data;
|
return parsed.data;
|
||||||
} catch {
|
} catch {
|
||||||
storage.removeItem(storageKey);
|
storage.removeItem(storageKey);
|
||||||
@@ -69,7 +107,8 @@ export const getCachedXlvaskUsageAmount = (objectOrId) => {
|
|||||||
|
|
||||||
export const setCachedXlvaskUsageAmount = (objectOrId, data) => {
|
export const setCachedXlvaskUsageAmount = (objectOrId, data) => {
|
||||||
const cacheKey = buildXlvaskUsageAmountCacheKey(objectOrId);
|
const cacheKey = buildXlvaskUsageAmountCacheKey(objectOrId);
|
||||||
if (!cacheKey) {
|
const scopedCacheKey = cacheKey ? getScopedCacheKey(cacheKey) : "";
|
||||||
|
if (!scopedCacheKey) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,7 +116,7 @@ export const setCachedXlvaskUsageAmount = (objectOrId, data) => {
|
|||||||
storedAt: Date.now(),
|
storedAt: Date.now(),
|
||||||
data,
|
data,
|
||||||
};
|
};
|
||||||
memoryCache.set(cacheKey, entry);
|
memoryCache.set(scopedCacheKey, entry);
|
||||||
|
|
||||||
const storage = safeSessionStorage();
|
const storage = safeSessionStorage();
|
||||||
if (!storage) {
|
if (!storage) {
|
||||||
@@ -85,7 +124,7 @@ export const setCachedXlvaskUsageAmount = (objectOrId, data) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
storage.setItem(`${CACHE_PREFIX}${cacheKey}`, JSON.stringify(entry));
|
storage.setItem(`${CACHE_PREFIX}${scopedCacheKey}`, JSON.stringify(entry));
|
||||||
} catch {
|
} catch {
|
||||||
// Best-effort cache. Quota errors should not block XL Vask usage rows.
|
// 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) => {
|
export const clearCachedXlvaskUsageAmount = (objectOrId = null) => {
|
||||||
const storage = safeSessionStorage();
|
const storage = safeSessionStorage();
|
||||||
const cacheKey = objectOrId === null ? "" : buildXlvaskUsageAmountCacheKey(objectOrId);
|
const cacheKey = objectOrId === null ? "" : buildXlvaskUsageAmountCacheKey(objectOrId);
|
||||||
|
const scopedCacheKey = cacheKey ? getScopedCacheKey(cacheKey) : "";
|
||||||
|
|
||||||
if (cacheKey) {
|
if (scopedCacheKey) {
|
||||||
memoryCache.delete(cacheKey);
|
memoryCache.delete(scopedCacheKey);
|
||||||
try {
|
try {
|
||||||
storage?.removeItem(`${CACHE_PREFIX}${cacheKey}`);
|
storage?.removeItem(`${CACHE_PREFIX}${scopedCacheKey}`);
|
||||||
} catch {
|
} catch {
|
||||||
// Best-effort cache cleanup.
|
// 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">
|
<script setup lang="ts">
|
||||||
|
import { computed } from "vue";
|
||||||
import { BIcon, BMessage, BRadioButton } from "buefy";
|
import { BIcon, BMessage, BRadioButton } from "buefy";
|
||||||
|
|
||||||
defineProps<{
|
const props = defineProps<{
|
||||||
lanes: Array<any>;
|
lanes: Array<any>;
|
||||||
selectedLaneId: number | string | null;
|
selectedLaneId: number | string | null;
|
||||||
washType: string;
|
washType: string;
|
||||||
@@ -14,6 +15,8 @@ const emit = defineEmits<{
|
|||||||
(e: "update:selectedLaneId", value: number): void;
|
(e: "update:selectedLaneId", value: number): void;
|
||||||
(e: "update:washType", value: string): void;
|
(e: "update:washType", value: string): void;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
const isSelectedMachineAvailable = computed(() => props.isMachineAvailable(props.selectedLaneId));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -92,16 +95,16 @@ const emit = defineEmits<{
|
|||||||
:model-value="washType"
|
:model-value="washType"
|
||||||
native-value="Machine"
|
native-value="Machine"
|
||||||
type="is-link"
|
type="is-link"
|
||||||
:disabled="!isMachineAvailable(selectedLaneId)"
|
:disabled="!isSelectedMachineAvailable"
|
||||||
:title="!isMachineAvailable(selectedLaneId) ? $t('self_wash.machine_unavailable_for_lane') : null"
|
:title="!isSelectedMachineAvailable ? $t('self_wash.unavailable') : null"
|
||||||
:aria-label="!isMachineAvailable(selectedLaneId) ? $t('self_wash.machine_unavailable_for_lane') : null"
|
:aria-label="!isSelectedMachineAvailable ? $t('self_wash.unavailable') : null"
|
||||||
data-testid="self-serve-wash-type-machine"
|
data-testid="self-serve-wash-type-machine"
|
||||||
@update:model-value="emit('update:washType', 'Machine')"
|
@update:model-value="emit('update:washType', 'Machine')"
|
||||||
@input="emit('update:washType', 'Machine')"
|
@input="emit('update:washType', 'Machine')"
|
||||||
>
|
>
|
||||||
<span>
|
<span>
|
||||||
<span>{{ $t("self_wash.machine") }}<br /></span>
|
<span>{{ $t("self_wash.machine") }}<br /></span>
|
||||||
<span v-if="!isMachineAvailable(selectedLaneId)">
|
<span v-if="!isSelectedMachineAvailable">
|
||||||
<small>
|
<small>
|
||||||
<b-icon icon="times-circle" type="is-danger" pack="fas" class="mr-1" />
|
<b-icon icon="times-circle" type="is-danger" pack="fas" class="mr-1" />
|
||||||
{{ $t("self_wash.unavailable") }}
|
{{ $t("self_wash.unavailable") }}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, watch } from "vue";
|
import { BIcon } from "buefy";
|
||||||
import { BIcon, BSkeleton } from "buefy";
|
import SelfServeDynamicImageFrame from "@/components/displays/selfServe/SelfServeDynamicImageFrame.vue";
|
||||||
import SelfServeTaskList from "@/components/displays/selfServe/SelfServeTaskList.vue";
|
import SelfServeTaskList from "@/components/displays/selfServe/SelfServeTaskList.vue";
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -18,12 +18,6 @@ const emit = defineEmits<{
|
|||||||
(e: "clear-dynamic-image"): void;
|
(e: "clear-dynamic-image"): void;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const isDynamicImageLoading = ref(false);
|
|
||||||
|
|
||||||
watch(() => props.dynamicImageUrl, (dynamicImageUrl) => {
|
|
||||||
isDynamicImageLoading.value = !!dynamicImageUrl;
|
|
||||||
}, { immediate: true });
|
|
||||||
|
|
||||||
const emitToggleTask = (taskId: number, value: boolean) => {
|
const emitToggleTask = (taskId: number, value: boolean) => {
|
||||||
emit("toggle-task", taskId, value);
|
emit("toggle-task", taskId, value);
|
||||||
};
|
};
|
||||||
@@ -32,12 +26,7 @@ const emitDownloadAttachment = (taskId: number, attachmentId: number | undefined
|
|||||||
emit("download-attachment", taskId, attachmentId, attachment);
|
emit("download-attachment", taskId, attachmentId, attachment);
|
||||||
};
|
};
|
||||||
|
|
||||||
const onDynamicImageLoad = () => {
|
|
||||||
isDynamicImageLoading.value = false;
|
|
||||||
};
|
|
||||||
|
|
||||||
const onDynamicImageError = () => {
|
const onDynamicImageError = () => {
|
||||||
isDynamicImageLoading.value = false;
|
|
||||||
emit("clear-dynamic-image");
|
emit("clear-dynamic-image");
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
@@ -58,30 +47,11 @@ const onDynamicImageError = () => {
|
|||||||
<span class="ml-2">{{ $t("self_wash.loading_data") }}</span>
|
<span class="ml-2">{{ $t("self_wash.loading_data") }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<SelfServeDynamicImageFrame
|
||||||
v-if="dynamicImageUrl"
|
:src="dynamicImageUrl"
|
||||||
class="self-serve-dynamic-image-frame mb-4"
|
alt="Machine status"
|
||||||
:class="{ 'is-loading': isDynamicImageLoading }"
|
@error="onDynamicImageError"
|
||||||
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>
|
|
||||||
<div v-show="allVisibleQuestionsAnswered && !editAnswers" class="notification is-info is-light mb-4">
|
<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-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>
|
<h1 class="title has-text-centered mb-2" v-else>{{ $t("self_wash.questions_answered") }}</h1>
|
||||||
@@ -95,41 +65,3 @@ const onDynamicImageError = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</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 Swal from "sweetalert2";
|
||||||
import { usePaginatedListInstance } from "@/components/pagination/paginatedList.vue";
|
import { usePaginatedListInstance } from "@/components/pagination/paginatedList.vue";
|
||||||
import { SessionUser } from "@/components/session/token/SessionUser.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({
|
const props = defineProps({
|
||||||
objects: {
|
objects: {
|
||||||
@@ -23,6 +25,8 @@ const canDisableAccess = () =>
|
|||||||
const canResendInvite = (subuser) =>
|
const canResendInvite = (subuser) =>
|
||||||
(props.showCustomer ? SessionUser.canAccessSuperUser() : SessionUser.canAccessUser() || SessionUser.hasPermission("SUBUSERS_EDIT"))
|
(props.showCustomer ? SessionUser.canAccessSuperUser() : SessionUser.canAccessUser() || SessionUser.hasPermission("SUBUSERS_EDIT"))
|
||||||
&& Boolean(subuser?.can_resend_invite ?? subuser?.setup_required);
|
&& Boolean(subuser?.can_resend_invite ?? subuser?.setup_required);
|
||||||
|
const hasRowActions = (subuser) =>
|
||||||
|
(canEditPermissions() && subuser?.grant_id) || canResendInvite(subuser) || (canDisableAccess() && subuser?.grant_id);
|
||||||
|
|
||||||
const formatDateTime = (dateString) => {
|
const formatDateTime = (dateString) => {
|
||||||
if (!dateString) {
|
if (!dateString) {
|
||||||
@@ -241,14 +245,6 @@ const onResendInvite = async (subuser) => {
|
|||||||
|
|
||||||
<td>
|
<td>
|
||||||
<div>{{ subuser.grant_note || "-" }}</div>
|
<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>
|
||||||
|
|
||||||
<td>{{ formatDateTime(subuser.created_at) }}</td>
|
<td>{{ formatDateTime(subuser.created_at) }}</td>
|
||||||
@@ -256,36 +252,42 @@ const onResendInvite = async (subuser) => {
|
|||||||
|
|
||||||
<td>
|
<td>
|
||||||
<div class="buttons is-justify-content-flex-end action-buttons">
|
<div class="buttons is-justify-content-flex-end action-buttons">
|
||||||
<button
|
<ActionSettingsWheelButton v-if="hasRowActions(subuser)" display-actions-directly>
|
||||||
v-if="canEditPermissions() && subuser.grant_id"
|
<template #actions>
|
||||||
class="button is-small"
|
<ActionSettingsWheelItem
|
||||||
type="button"
|
v-if="canEditPermissions() && subuser.grant_id"
|
||||||
:data-testid="`subuser-permissions-${subuser.id}`"
|
icon="fas fa-pen"
|
||||||
@click="onEditPermissions(subuser)"
|
label="Redigér note"
|
||||||
>
|
:click-action="() => onEditNote(subuser)"
|
||||||
Tilladelser
|
:test-id="`subuser-note-${subuser.id}`"
|
||||||
</button>
|
/>
|
||||||
|
|
||||||
<button
|
<ActionSettingsWheelItem
|
||||||
v-if="canResendInvite(subuser)"
|
v-if="canEditPermissions() && subuser.grant_id"
|
||||||
class="button is-small"
|
icon="fas fa-user-shield"
|
||||||
type="button"
|
label="Tilladelser"
|
||||||
:data-testid="`subuser-resend-${subuser.id}`"
|
:click-action="() => onEditPermissions(subuser)"
|
||||||
@click="onResendInvite(subuser)"
|
:test-id="`subuser-permissions-${subuser.id}`"
|
||||||
>
|
/>
|
||||||
Gensend
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
<ActionSettingsWheelItem
|
||||||
v-if="canDisableAccess() && subuser.grant_id"
|
v-if="canResendInvite(subuser)"
|
||||||
class="button is-small"
|
icon="fas fa-paper-plane"
|
||||||
:class="subuser.grant_enabled ? 'is-danger is-light' : 'is-success is-light'"
|
label="Gensend"
|
||||||
type="button"
|
:click-action="() => onResendInvite(subuser)"
|
||||||
:data-testid="`subuser-toggle-${subuser.id}`"
|
:test-id="`subuser-resend-${subuser.id}`"
|
||||||
@click="onToggleEnabled(subuser, !subuser.grant_enabled)"
|
/>
|
||||||
>
|
|
||||||
{{ subuser.grant_enabled ? "Deaktivér" : "Aktivér" }}
|
<ActionSettingsWheelItem
|
||||||
</button>
|
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>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import LoadButtonWhileAwait from "@/components/request/LoadButtonWhileAwait.vue"
|
|||||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||||
import TwoFactorVerify from "@/components/forms/auth/TwoFactorVerify.vue";
|
import TwoFactorVerify from "@/components/forms/auth/TwoFactorVerify.vue";
|
||||||
import { isPasskeySupported, authenticateWithPasskey } from "@/services/PasskeyAuthService.js";
|
import { isPasskeySupported, authenticateWithPasskey } from "@/services/PasskeyAuthService.js";
|
||||||
|
import { clearEdgeGatewayWorkspaceCache } from "@/services/edgeGateways.js";
|
||||||
|
|
||||||
const employees = ref([]);
|
const employees = ref([]);
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
@@ -52,6 +53,7 @@ const login = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Save the token in the local storage
|
// Save the token in the local storage
|
||||||
|
clearEdgeGatewayWorkspaceCache();
|
||||||
localStorage.setItem('token', data.token);
|
localStorage.setItem('token', data.token);
|
||||||
// Redirect to the dashboard
|
// Redirect to the dashboard
|
||||||
window.location.href = '/admin';
|
window.location.href = '/admin';
|
||||||
@@ -72,6 +74,7 @@ const loginWithPasskey = async () => {
|
|||||||
const result = await authenticateWithPasskey('employee', null, recaptchaToken);
|
const result = await authenticateWithPasskey('employee', null, recaptchaToken);
|
||||||
|
|
||||||
if (result.token) {
|
if (result.token) {
|
||||||
|
clearEdgeGatewayWorkspaceCache();
|
||||||
localStorage.setItem('token', result.token);
|
localStorage.setItem('token', result.token);
|
||||||
window.location.href = '/admin';
|
window.location.href = '/admin';
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { parseError, clearErrors, addError, getError } from "@/components/reques
|
|||||||
import LoadButtonWhileAwait from "@/components/request/LoadButtonWhileAwait.vue";
|
import LoadButtonWhileAwait from "@/components/request/LoadButtonWhileAwait.vue";
|
||||||
import { isPasskeySupported, authenticateWithPasskey } from "@/services/PasskeyAuthService.js";
|
import { isPasskeySupported, authenticateWithPasskey } from "@/services/PasskeyAuthService.js";
|
||||||
import TwoFactorVerify from "@/components/forms/auth/TwoFactorVerify.vue";
|
import TwoFactorVerify from "@/components/forms/auth/TwoFactorVerify.vue";
|
||||||
|
import { clearEdgeGatewayWorkspaceCache } from "@/services/edgeGateways.js";
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
@@ -68,6 +69,7 @@ const login = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Save the token in the local storage
|
// Save the token in the local storage
|
||||||
|
clearEdgeGatewayWorkspaceCache();
|
||||||
localStorage.setItem('token', data.token);
|
localStorage.setItem('token', data.token);
|
||||||
// Set the success message
|
// Set the success message
|
||||||
successMessage.value = "Du er nu logget ind!"
|
successMessage.value = "Du er nu logget ind!"
|
||||||
@@ -103,6 +105,7 @@ const loginWithPasskey = async () => {
|
|||||||
const result = await authenticateWithPasskey('user', customerNum, recaptchaToken);
|
const result = await authenticateWithPasskey('user', customerNum, recaptchaToken);
|
||||||
console.log('Passkey authentication result:', result);
|
console.log('Passkey authentication result:', result);
|
||||||
if (result.token) {
|
if (result.token) {
|
||||||
|
clearEdgeGatewayWorkspaceCache();
|
||||||
localStorage.setItem('token', result.token);
|
localStorage.setItem('token', result.token);
|
||||||
successMessage.value = "Du er nu logget ind!";
|
successMessage.value = "Du er nu logget ind!";
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
|||||||
import { isPasskeySupported, authenticateWithPasskey } from "@/services/PasskeyAuthService.js";
|
import { isPasskeySupported, authenticateWithPasskey } from "@/services/PasskeyAuthService.js";
|
||||||
import { getSubuserPasswordPolicyError } from "@/services/subuserPasswordPolicy.js";
|
import { getSubuserPasswordPolicyError } from "@/services/subuserPasswordPolicy.js";
|
||||||
import TwoFactorVerify from "@/components/forms/auth/TwoFactorVerify.vue";
|
import TwoFactorVerify from "@/components/forms/auth/TwoFactorVerify.vue";
|
||||||
|
import { clearEdgeGatewayWorkspaceCache } from "@/services/edgeGateways.js";
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
@@ -69,6 +70,7 @@ const login = async () => {
|
|||||||
|
|
||||||
// Save the session token
|
// Save the session token
|
||||||
if (data.session) {
|
if (data.session) {
|
||||||
|
clearEdgeGatewayWorkspaceCache();
|
||||||
localStorage.setItem('token', data.session);
|
localStorage.setItem('token', data.session);
|
||||||
localStorage.setItem('is_subuser', 'true');
|
localStorage.setItem('is_subuser', 'true');
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
@@ -97,6 +99,7 @@ const loginWithPasskey = async () => {
|
|||||||
// Subuser login returns 'session' token, user login returns 'token'
|
// Subuser login returns 'session' token, user login returns 'token'
|
||||||
const sessionToken = result.session || result.token;
|
const sessionToken = result.session || result.token;
|
||||||
if (sessionToken) {
|
if (sessionToken) {
|
||||||
|
clearEdgeGatewayWorkspaceCache();
|
||||||
localStorage.setItem('token', sessionToken);
|
localStorage.setItem('token', sessionToken);
|
||||||
localStorage.setItem('is_subuser', 'true');
|
localStorage.setItem('is_subuser', 'true');
|
||||||
// Reload the page to update the UI
|
// Reload the page to update the UI
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useI18n } from 'vue-i18n';
|
|||||||
import { verify2FA } from "@/services/TwoFactorAuthService.js";
|
import { verify2FA } from "@/services/TwoFactorAuthService.js";
|
||||||
import { parseError, getError, clearErrors } from "@/components/request/HandleGlobalError.vue";
|
import { parseError, getError, clearErrors } from "@/components/request/HandleGlobalError.vue";
|
||||||
import LoadButtonWhileAwait from "@/components/request/LoadButtonWhileAwait.vue";
|
import LoadButtonWhileAwait from "@/components/request/LoadButtonWhileAwait.vue";
|
||||||
|
import { clearEdgeGatewayWorkspaceCache } from "@/services/edgeGateways.js";
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -43,9 +44,11 @@ const verify = async () => {
|
|||||||
|
|
||||||
// Handle the response based on user type
|
// Handle the response based on user type
|
||||||
if (props.userType === 'subuser' && result.session) {
|
if (props.userType === 'subuser' && result.session) {
|
||||||
|
clearEdgeGatewayWorkspaceCache();
|
||||||
localStorage.setItem('token', result.session);
|
localStorage.setItem('token', result.session);
|
||||||
localStorage.setItem('is_subuser', 'true');
|
localStorage.setItem('is_subuser', 'true');
|
||||||
} else if (result.token) {
|
} else if (result.token) {
|
||||||
|
clearEdgeGatewayWorkspaceCache();
|
||||||
localStorage.setItem('token', result.token);
|
localStorage.setItem('token', result.token);
|
||||||
localStorage.removeItem('is_subuser');
|
localStorage.removeItem('is_subuser');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ const customer_suggestions = ref([
|
|||||||
const isSettingCustomer = ref(false);
|
const isSettingCustomer = ref(false);
|
||||||
const isSettingCustomerToInteger = ref(0);
|
const isSettingCustomerToInteger = ref(0);
|
||||||
const isSettingCustomerStartTime = ref(null);
|
const isSettingCustomerStartTime = ref(null);
|
||||||
|
let customerSuggestionsRequestId = 0;
|
||||||
|
|
||||||
const isSettingCustomerTo = (customer_number) => {
|
const isSettingCustomerTo = (customer_number) => {
|
||||||
return isSettingCustomerToInteger.value === parseInt(customer_number);
|
return isSettingCustomerToInteger.value === parseInt(customer_number);
|
||||||
@@ -88,10 +89,17 @@ const getCustomerSuggestions = () => {
|
|||||||
if (!props.reg_1) {
|
if (!props.reg_1) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const requestedReg1 = props.reg_1;
|
||||||
|
const requestId = ++customerSuggestionsRequestId;
|
||||||
|
|
||||||
SessionUser.request("/department/vehicle/customer-suggestions", "GET", {
|
SessionUser.request("/department/vehicle/customer-suggestions", "GET", {
|
||||||
reg_1: props.reg_1,
|
reg_1: requestedReg1,
|
||||||
})
|
})
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
|
if (requestId !== customerSuggestionsRequestId || requestedReg1 !== props.reg_1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Assuming the response contains an array of customer suggestions
|
// Assuming the response contains an array of customer suggestions
|
||||||
console.log("Customer suggestions:", response.data.data);
|
console.log("Customer suggestions:", response.data.data);
|
||||||
let suggestions = [];
|
let suggestions = [];
|
||||||
@@ -126,6 +134,7 @@ watch(
|
|||||||
if (newValue) {
|
if (newValue) {
|
||||||
getCustomerSuggestions();
|
getCustomerSuggestions();
|
||||||
} else {
|
} else {
|
||||||
|
customerSuggestionsRequestId += 1;
|
||||||
customer_suggestions.value = [];
|
customer_suggestions.value = [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -118,6 +118,7 @@ const impersonatedUserRoleId = computed(() => {
|
|||||||
const canGrantMissingPermissions = computed(() =>
|
const canGrantMissingPermissions = computed(() =>
|
||||||
hasSuperuserToken.value && !SessionUser.isSubuser.value && impersonatedUserRoleId.value !== null
|
hasSuperuserToken.value && !SessionUser.isSubuser.value && impersonatedUserRoleId.value !== null
|
||||||
);
|
);
|
||||||
|
const canInspectReleaseRuntime = computed(() => hasSuperuserToken.value || SessionUser.canAccessAdmin?.() === true);
|
||||||
|
|
||||||
const userDetailRows = computed(() => {
|
const userDetailRows = computed(() => {
|
||||||
if (SessionUser.isSubuser.value) {
|
if (SessionUser.isSubuser.value) {
|
||||||
@@ -284,7 +285,12 @@ const resolvePingUrl = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const activeApiUrl = computed(() => getReleaseRuntimeApiBaseUrl());
|
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 () => {
|
const measurePingLatency = async () => {
|
||||||
if (typeof fetch !== "function") {
|
if (typeof fetch !== "function") {
|
||||||
@@ -646,9 +652,10 @@ onBeforeUnmount(() => {
|
|||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<aside class="request-queue-progress__side request-queue-progress__side--runtime" data-testid="request-queue-runtime-box">
|
<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>
|
<template v-if="canInspectReleaseRuntime">
|
||||||
<div class="request-queue-progress__section-content request-queue-progress__section-content--release">
|
<div class="request-queue-progress__section-title">Session release</div>
|
||||||
<ul class="request-queue-progress__meta-list">
|
<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">
|
<li class="request-queue-progress__meta-item">
|
||||||
<span class="request-queue-progress__meta-label">Channel</span>
|
<span class="request-queue-progress__meta-label">Channel</span>
|
||||||
<span
|
<span
|
||||||
@@ -771,11 +778,15 @@ onBeforeUnmount(() => {
|
|||||||
</li>
|
</li>
|
||||||
</ul>
|
</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">
|
<ul class="request-queue-progress__meta-list">
|
||||||
<li class="request-queue-progress__meta-item">
|
<li class="request-queue-progress__meta-item">
|
||||||
<span class="request-queue-progress__meta-label">API URL</span>
|
<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>
|
||||||
<li class="request-queue-progress__meta-item">
|
<li class="request-queue-progress__meta-item">
|
||||||
<span class="request-queue-progress__meta-label">Current host</span>
|
<span class="request-queue-progress__meta-label">Current host</span>
|
||||||
|
|||||||
@@ -48,10 +48,13 @@ export const getDepartmentDescription = (id) => {
|
|||||||
|
|
||||||
export const getDepartmentsGuest = async (queryParams = {}) => {
|
export const getDepartmentsGuest = async (queryParams = {}) => {
|
||||||
isLoading.value = true;
|
isLoading.value = true;
|
||||||
const queryString = new URLSearchParams(queryParams).toString();
|
try {
|
||||||
const request = await unauthenticatedRequest("/guest/departments" + (queryString ? `?${queryString}` : ""), "get");
|
const queryString = new URLSearchParams(queryParams).toString();
|
||||||
departments.value = sortByDepartmentPriorityOrder(request.data.data || []);
|
const request = await unauthenticatedRequest("/guest/departments" + (queryString ? `?${queryString}` : ""), "get");
|
||||||
isLoading.value = false;
|
departments.value = sortByDepartmentPriorityOrder(request.data.data || []);
|
||||||
return departments.value;
|
return departments.value;
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import {API_URL} from "@/config.js";
|
import {API_URL} from "@/config.js";
|
||||||
|
import { clearEdgeGatewayWorkspaceCache } from "@/services/edgeGateways.js";
|
||||||
|
|
||||||
export const currentDepartment = ref(null);
|
export const currentDepartment = ref(null);
|
||||||
export const selectDepartment = (department) => {
|
export const selectDepartment = (department) => {
|
||||||
@@ -31,6 +32,7 @@ export const exitSession = () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
clearEdgeGatewayWorkspaceCache();
|
||||||
localStorage.removeItem('token');
|
localStorage.removeItem('token');
|
||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import { enqueueRequest } from "@/services/requestQueue.js";
|
import { enqueueRequest } from "@/services/requestQueue.js";
|
||||||
import { buildCurrentReleaseHeaders, resolveReleaseApiUrl } from "@/services/releaseTimeline.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)
|
* 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');
|
//throw new Error('No token was found, unable to make authenticated request');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const requestUrl = resolveReleaseApiUrl(url);
|
||||||
|
const canSendCredentials = isTrustedReleaseUrl(requestUrl);
|
||||||
|
|
||||||
// Build headers
|
// Build headers
|
||||||
const headers = {
|
const headers = {
|
||||||
...buildCurrentReleaseHeaders(),
|
...buildCurrentReleaseHeaders(),
|
||||||
};
|
};
|
||||||
if (token && token.length > 0) {
|
if (canSendCredentials && token && token.length > 0) {
|
||||||
headers.Authorization = `Bearer ${token}`;
|
headers.Authorization = `Bearer ${token}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add X-Customer-Number header if subuser has selected a grant
|
// Add X-Customer-Number header if subuser has selected a grant
|
||||||
const isSubuser = localStorage.getItem('is_subuser') === 'true';
|
const isSubuser = localStorage.getItem('is_subuser') === 'true';
|
||||||
const selectedCustomerNumber = getSelectedCustomerNumber();
|
const selectedCustomerNumber = getSelectedCustomerNumber();
|
||||||
if (isSubuser && selectedCustomerNumber) {
|
if (canSendCredentials && isSubuser && selectedCustomerNumber) {
|
||||||
headers['X-Customer-Number'] = selectedCustomerNumber;
|
headers['X-Customer-Number'] = selectedCustomerNumber;
|
||||||
}
|
}
|
||||||
|
|
||||||
const requestUrl = resolveReleaseApiUrl(url);
|
|
||||||
return enqueueRequest(
|
return enqueueRequest(
|
||||||
() => axios({
|
() => axios({
|
||||||
method,
|
method,
|
||||||
|
|||||||
@@ -58,6 +58,12 @@ import {
|
|||||||
releaseChannelRuntimeRequestParams,
|
releaseChannelRuntimeRequestParams,
|
||||||
setReleaseChannelSwitchNoticePrincipal,
|
setReleaseChannelSwitchNoticePrincipal,
|
||||||
} from "@/services/releaseChannelAvailability.js";
|
} 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 normalizePositiveInteger = (value) => {
|
||||||
const parsedValue = Number.parseInt(String(value ?? ""), 10);
|
const parsedValue = Number.parseInt(String(value ?? ""), 10);
|
||||||
@@ -98,7 +104,16 @@ const hydrateSessionFromStorage = () => {
|
|||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const clearPrivilegedSessionCaches = () => {
|
||||||
|
clearPeriodCache();
|
||||||
|
clearSelfWashCountsCache();
|
||||||
|
clearCachedXlvaskUsageAmount();
|
||||||
|
clearEdgeGatewayWorkspaceCache();
|
||||||
|
};
|
||||||
|
|
||||||
const clearStoredSession = () => {
|
const clearStoredSession = () => {
|
||||||
|
clearPrivilegedSessionCaches();
|
||||||
|
|
||||||
if (typeof window === "undefined") {
|
if (typeof window === "undefined") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -115,7 +130,8 @@ const applyReleaseRuntimeConfig = (runtime) => {
|
|||||||
const normalizedRuntime = runtime || {};
|
const normalizedRuntime = runtime || {};
|
||||||
configureReleaseRuntime(normalizedRuntime);
|
configureReleaseRuntime(normalizedRuntime);
|
||||||
reconcileSelectedReleaseChannel(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.traceId.value = normalizedRuntime.trace_id || null;
|
||||||
SessionUser.runtimeConfig.release.channel.value = normalizedRuntime.channel || null;
|
SessionUser.runtimeConfig.release.channel.value = normalizedRuntime.channel || null;
|
||||||
SessionUser.runtimeConfig.release.availableChannels.value =
|
SessionUser.runtimeConfig.release.availableChannels.value =
|
||||||
@@ -377,6 +393,7 @@ export const authenticateUser = async (customer_number, password) => {
|
|||||||
password,
|
password,
|
||||||
})
|
})
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
|
clearPrivilegedSessionCaches();
|
||||||
SessionUser.token.value = response.data.token;
|
SessionUser.token.value = response.data.token;
|
||||||
SessionUser.authenticated.value = true;
|
SessionUser.authenticated.value = true;
|
||||||
localStorage.setItem("token", response.data.token);
|
localStorage.setItem("token", response.data.token);
|
||||||
@@ -423,6 +440,7 @@ export const authenticateSubuser = async (credentials) => {
|
|||||||
|
|
||||||
return await authenticatedRequest("/subusers/auth/password", "POST", requestBody)
|
return await authenticatedRequest("/subusers/auth/password", "POST", requestBody)
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
|
clearPrivilegedSessionCaches();
|
||||||
SessionUser.token.value = response.data.data.session;
|
SessionUser.token.value = response.data.data.session;
|
||||||
SessionUser.authenticated.value = true;
|
SessionUser.authenticated.value = true;
|
||||||
SessionUser.isSubuser.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
|
* @param {number|null} customerNumber - The billing_customer_number to select, or null to clear
|
||||||
*/
|
*/
|
||||||
selectGrant: (customerNumber) => {
|
selectGrant: (customerNumber) => {
|
||||||
|
clearEdgeGatewayWorkspaceCache();
|
||||||
if (customerNumber === null) {
|
if (customerNumber === null) {
|
||||||
localStorage.removeItem("selected_customer_number");
|
localStorage.removeItem("selected_customer_number");
|
||||||
SessionUser.subuser.selectedGrantCustomerNumber.value = null;
|
SessionUser.subuser.selectedGrantCustomerNumber.value = null;
|
||||||
@@ -886,6 +905,7 @@ export const SessionUser = {
|
|||||||
* @param {string} token
|
* @param {string} token
|
||||||
*/
|
*/
|
||||||
setToken: (token) => {
|
setToken: (token) => {
|
||||||
|
clearPrivilegedSessionCaches();
|
||||||
SessionUser.token.value = token;
|
SessionUser.token.value = token;
|
||||||
localStorage.setItem("token", token);
|
localStorage.setItem("token", token);
|
||||||
// Reload the window to make sure the user's session is initiated
|
// 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
|
// Helper function to get i18n translation
|
||||||
const t = (key) => i18n.global.t(key);
|
const t = (key) => i18n.global.t(key);
|
||||||
|
|
||||||
|
const escapeHtml = (value = "") => String(value)
|
||||||
|
.replaceAll("&", "&")
|
||||||
|
.replaceAll("<", "<")
|
||||||
|
.replaceAll(">", ">")
|
||||||
|
.replaceAll("\"", """)
|
||||||
|
.replaceAll("'", "'");
|
||||||
|
|
||||||
const normalizeDateTimeLocalValue = (value) => {
|
const normalizeDateTimeLocalValue = (value) => {
|
||||||
if (value === null || value === undefined || value === '') {
|
if (value === null || value === undefined || value === '') {
|
||||||
return '';
|
return '';
|
||||||
@@ -765,9 +772,11 @@ export const ObjectsGlobal = {
|
|||||||
console.log(response);
|
console.log(response);
|
||||||
// How long are the list of options
|
// How long are the list of options
|
||||||
for (let i = 0; i < response.length; i++) {
|
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);
|
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>
|
html += `</select>
|
||||||
|
|||||||
@@ -19,6 +19,14 @@ const getProtectedDeletePayload = (error) => {
|
|||||||
return payload;
|
return payload;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const escapeHtml = (value) => String(value ?? "").replace(/[&<>"']/g, (character) => ({
|
||||||
|
"&": "&",
|
||||||
|
"<": "<",
|
||||||
|
">": ">",
|
||||||
|
"\"": """,
|
||||||
|
"'": "'",
|
||||||
|
})[character]);
|
||||||
|
|
||||||
const formatDeleteProtectionReason = (reason) => {
|
const formatDeleteProtectionReason = (reason) => {
|
||||||
switch (reason) {
|
switch (reason) {
|
||||||
case "completed":
|
case "completed":
|
||||||
@@ -28,22 +36,23 @@ const formatDeleteProtectionReason = (reason) => {
|
|||||||
case "attachments":
|
case "attachments":
|
||||||
return "ordren har vedhæftninger";
|
return "ordren har vedhæftninger";
|
||||||
default:
|
default:
|
||||||
return String(reason || "").replace(/_/g, " ");
|
return escapeHtml(String(reason || "").replace(/_/g, " "));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const showProtectedOrderDeleteConfirmation = async (id, protectionPayload, onAfterSubmit = null) => {
|
const showProtectedOrderDeleteConfirmation = async (id, protectionPayload, onAfterSubmit = null) => {
|
||||||
const orderId = String(id);
|
const orderId = String(id);
|
||||||
|
const escapedOrderId = escapeHtml(orderId);
|
||||||
const reasons = Array.isArray(protectionPayload?.protected_reasons)
|
const reasons = Array.isArray(protectionPayload?.protected_reasons)
|
||||||
? protectionPayload.protected_reasons.map(formatDeleteProtectionReason).filter(Boolean)
|
? protectionPayload.protected_reasons.map(formatDeleteProtectionReason).filter(Boolean)
|
||||||
: [];
|
: [];
|
||||||
const reasonText = reasons.length > 0 ? reasons.join(", ") : "ordren indeholder gemte data";
|
const reasonText = reasons.length > 0 ? reasons.join(", ") : "ordren indeholder gemte data";
|
||||||
|
|
||||||
const result = await Swal.fire({
|
const result = await Swal.fire({
|
||||||
title: `Bekræft sletning af ordre #${orderId}`,
|
titleText: `Bekræft sletning af ordre #${orderId}`,
|
||||||
html: `
|
html: `
|
||||||
<p>Ordren kan stadig slettes, men kræver ekstra bekræftelse fordi ${reasonText}.</p>
|
<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",
|
input: "text",
|
||||||
inputAttributes: {
|
inputAttributes: {
|
||||||
|
|||||||
@@ -127,6 +127,13 @@ export const SelfServeVehicleConditions = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
add: async (department, lane, customer_id, reg, question, value, options = {}) => {
|
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, {
|
return ObjectsGlobal.add.object(SelfServeVehicleConditions.meta.endpoint, {
|
||||||
department: parseInt(department),
|
department: parseInt(department),
|
||||||
lane: parseInt(lane),
|
lane: parseInt(lane),
|
||||||
@@ -134,7 +141,9 @@ export const SelfServeVehicleConditions = {
|
|||||||
reg: reg,
|
reg: reg,
|
||||||
question: parseInt(question),
|
question: parseInt(question),
|
||||||
value: value === "true" || value === true,
|
value: value === "true" || value === true,
|
||||||
...options
|
...safeOptions,
|
||||||
|
activate_machine: false,
|
||||||
|
sync_relay_state: false
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
set: {
|
set: {
|
||||||
|
|||||||
@@ -17,13 +17,15 @@ const normalizeEntries = (entries) => {
|
|||||||
return [];
|
return [];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const SENSITIVE_WRITE_ONLY_KEYS = new Set(["broker_shared_secret"]);
|
||||||
|
|
||||||
const toConfigPayload = (entries) => {
|
const toConfigPayload = (entries) => {
|
||||||
const payload = {};
|
const payload = {};
|
||||||
normalizeEntries(entries).forEach((entry) => {
|
normalizeEntries(entries).forEach((entry) => {
|
||||||
if (!entry || !entry.variable) {
|
if (!entry || !entry.variable) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
payload[entry.variable] = entry.value;
|
payload[entry.variable] = SENSITIVE_WRITE_ONLY_KEYS.has(entry.variable) ? "" : entry.value;
|
||||||
});
|
});
|
||||||
return payload;
|
return payload;
|
||||||
};
|
};
|
||||||
@@ -42,6 +44,11 @@ export const Config = {
|
|||||||
test_broker: async (payload) => testEdgeGatewayBrokerConfig(payload),
|
test_broker: async (payload) => testEdgeGatewayBrokerConfig(payload),
|
||||||
set: async (variable, value) => {
|
set: async (variable, value) => {
|
||||||
const currentConfig = await Config.get_config();
|
const currentConfig = await Config.get_config();
|
||||||
|
SENSITIVE_WRITE_ONLY_KEYS.forEach((key) => {
|
||||||
|
if (key !== variable) {
|
||||||
|
delete currentConfig[key];
|
||||||
|
}
|
||||||
|
});
|
||||||
return Config.set_config({
|
return Config.set_config({
|
||||||
...currentConfig,
|
...currentConfig,
|
||||||
[variable]: value,
|
[variable]: value,
|
||||||
@@ -73,7 +80,6 @@ export const Config = {
|
|||||||
set: async (value) => Config.set("broker_auth_mode", value),
|
set: async (value) => Config.set("broker_auth_mode", value),
|
||||||
},
|
},
|
||||||
broker_shared_secret: {
|
broker_shared_secret: {
|
||||||
get: async () => Config.get("broker_shared_secret"),
|
|
||||||
set: async (value) => Config.set("broker_shared_secret", value),
|
set: async (value) => Config.set("broker_shared_secret", value),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script>
|
<script>
|
||||||
import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue";
|
import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue";
|
||||||
import { parseError } from "@/components/request/HandleGlobalError.vue";
|
import { parseError } from "@/components/request/HandleGlobalError.vue";
|
||||||
|
import { clearEdgeGatewayWorkspaceCache } from "@/services/edgeGateways.js";
|
||||||
import { DatabaseSystemObject } from "@/components/session/token/superUser/systemDatabase.vue";
|
import { DatabaseSystemObject } from "@/components/session/token/superUser/systemDatabase.vue";
|
||||||
import { Cron } from "@/components/session/token/superUser/cron.vue";
|
import { Cron } from "@/components/session/token/superUser/cron.vue";
|
||||||
import { Economic } from "@/components/session/token/superUser/modules/Economic/Economic.vue";
|
import { Economic } from "@/components/session/token/superUser/modules/Economic/Economic.vue";
|
||||||
@@ -106,6 +107,7 @@ export const SuperUserObject = {
|
|||||||
const currentToken = localStorage.getItem("token");
|
const currentToken = localStorage.getItem("token");
|
||||||
localStorage.setItem("superuser_token", currentToken);
|
localStorage.setItem("superuser_token", currentToken);
|
||||||
// Set the token in the local storage
|
// Set the token in the local storage
|
||||||
|
clearEdgeGatewayWorkspaceCache();
|
||||||
localStorage.setItem("token", response.data.data.token);
|
localStorage.setItem("token", response.data.data.token);
|
||||||
// Redirect the user to the dashboard
|
// Redirect the user to the dashboard
|
||||||
window.location = "/";
|
window.location = "/";
|
||||||
@@ -143,6 +145,7 @@ export const SuperUserObject = {
|
|||||||
// Get the superuser token
|
// Get the superuser token
|
||||||
const superuserToken = localStorage.getItem("superuser_token");
|
const superuserToken = localStorage.getItem("superuser_token");
|
||||||
// Set the token in the local storage
|
// Set the token in the local storage
|
||||||
|
clearEdgeGatewayWorkspaceCache();
|
||||||
localStorage.setItem("token", superuserToken);
|
localStorage.setItem("token", superuserToken);
|
||||||
// Clear the superuser token
|
// Clear the superuser token
|
||||||
localStorage.removeItem("superuser_token");
|
localStorage.removeItem("superuser_token");
|
||||||
|
|||||||
@@ -320,20 +320,26 @@ export const ensureCurrentOrderDepartment = async (targetDepartmentId = null) =>
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let currentDepartmentId = null;
|
const currentDepartmentId = await getCurrentOrderDepartmentId(normalizedOrderId);
|
||||||
try {
|
|
||||||
currentDepartmentId = await getCurrentOrderDepartmentId(normalizedOrderId);
|
|
||||||
} catch (error) {
|
|
||||||
currentDepartmentId = null;
|
|
||||||
}
|
|
||||||
if (currentDepartmentId === normalizedDepartmentId) {
|
if (currentDepartmentId === normalizedDepartmentId) {
|
||||||
department_id.value = normalizedDepartmentId;
|
department_id.value = normalizedDepartmentId;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
await SessionUser.objects.orders.set.department_id(normalizedOrderId, normalizedDepartmentId);
|
parseError(
|
||||||
department_id.value = normalizedDepartmentId;
|
{
|
||||||
return true;
|
response: {
|
||||||
|
data: {
|
||||||
|
data: {
|
||||||
|
message: "Ordren tilhører ikke den valgte afdeling",
|
||||||
|
},
|
||||||
|
message: "Ordren tilhører ikke den valgte afdeling",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"stepError"
|
||||||
|
);
|
||||||
|
return false;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
parseError(error, "stepError");
|
parseError(error, "stepError");
|
||||||
return false;
|
return false;
|
||||||
@@ -727,9 +733,9 @@ export const restoreStoredPosOrderId = async (
|
|||||||
if (selectedDepartmentId && toPositiveInteger(storedOrder?.department_id) !== selectedDepartmentId) {
|
if (selectedDepartmentId && toPositiveInteger(storedOrder?.department_id) !== selectedDepartmentId) {
|
||||||
if (options.syncDepartment === true) {
|
if (options.syncDepartment === true) {
|
||||||
order_id.value = storedOrderId;
|
order_id.value = storedOrderId;
|
||||||
const didSyncDepartment = await ensureCurrentOrderDepartment(selectedDepartmentId);
|
const isCurrentDepartment = await ensureCurrentOrderDepartment(selectedDepartmentId);
|
||||||
if (!didSyncDepartment) {
|
if (!isCurrentDepartment) {
|
||||||
throw new Error("Stored order department could not be changed to current department");
|
throw new Error("Stored order department does not match current department");
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
throw new Error("Stored order department does not match current department");
|
throw new Error("Stored order department does not match current department");
|
||||||
@@ -2012,18 +2018,14 @@ export const getVehiclePlateBookings = (vehiclePlate) => {
|
|||||||
return sortPendingOrderBookings(matchingBookings);
|
return sortPendingOrderBookings(matchingBookings);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ensureVehiclePlateBookingsLoaded = async (vehiclePlate, options = {}) => {
|
export const ensureVehiclePlateBookingsLoaded = async (vehiclePlate, _options = {}) => {
|
||||||
const normalizedOptions = {
|
|
||||||
force: false,
|
|
||||||
...options,
|
|
||||||
};
|
|
||||||
const normalizedVehiclePlate = normalizeVehiclePlateForBookingSelection(vehiclePlate);
|
const normalizedVehiclePlate = normalizeVehiclePlateForBookingSelection(vehiclePlate);
|
||||||
const currentDepartmentKey = syncPendingBookingsDepartmentState();
|
const currentDepartmentKey = syncPendingBookingsDepartmentState();
|
||||||
if (!normalizedVehiclePlate || !department_id.value) {
|
if (!normalizedVehiclePlate || !department_id.value) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!normalizedOptions.force && loadedPendingBookingPlates.has(normalizedVehiclePlate)) {
|
if (loadedPendingBookingPlates.has(normalizedVehiclePlate)) {
|
||||||
return getVehiclePlateBookings(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 normalizedOrderId = toPositiveInteger(order_id.value);
|
||||||
const normalizedBookingId = toPositiveInteger(selectedOrderBookingId.value);
|
const normalizedBookingId = toPositiveInteger(selectedOrderBookingId.value);
|
||||||
|
|
||||||
@@ -2250,8 +2252,7 @@ const hydrateSelectedOrderBookingForDesktop = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const forcedPrimaryPrice =
|
const forcedPrimaryPrice = primaryProduct.price ?? null;
|
||||||
typeof primaryRawItem?.price === "number" ? Number(primaryRawItem.price) : primaryProduct.price ?? null;
|
|
||||||
const primaryItemResponse = await createOrderItem(
|
const primaryItemResponse = await createOrderItem(
|
||||||
normalizedOrderId,
|
normalizedOrderId,
|
||||||
primaryProduct.id,
|
primaryProduct.id,
|
||||||
@@ -2269,13 +2270,15 @@ const hydrateSelectedOrderBookingForDesktop = async () => {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const secondaryProduct = await fetchOrderBookingProductWithPricing(secondaryProductId);
|
||||||
|
|
||||||
await createOrderItem(
|
await createOrderItem(
|
||||||
normalizedOrderId,
|
normalizedOrderId,
|
||||||
secondaryProductId,
|
secondaryProductId,
|
||||||
Math.max(1, Number.parseInt(String(bookingItem?.quantity ?? 1), 10) || 1),
|
Math.max(1, Number.parseInt(String(bookingItem?.quantity ?? 1), 10) || 1),
|
||||||
relatedPrimaryItemId,
|
relatedPrimaryItemId,
|
||||||
String(bookingItem?.notes ?? "").trim() || null,
|
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 requestedValue = normalizeValue(draft.value);
|
||||||
const sequence = ++nextSequence;
|
const sequence = ++nextSequence;
|
||||||
|
let saveCompleted = false;
|
||||||
isSaving.value = true;
|
isSaving.value = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -83,6 +84,8 @@ export const useOrderMetadataAutosave = ({
|
|||||||
await onSaved(savedValue, requestedValue);
|
await onSaved(savedValue, requestedValue);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
saveCompleted = true;
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (typeof onError === "function") {
|
if (typeof onError === "function") {
|
||||||
@@ -95,12 +98,11 @@ export const useOrderMetadataAutosave = ({
|
|||||||
} finally {
|
} finally {
|
||||||
isSaving.value = false;
|
isSaving.value = false;
|
||||||
|
|
||||||
if (saveAgainAfterCurrentRequest || isDirty.value) {
|
const shouldSaveAgain = saveAgainAfterCurrentRequest;
|
||||||
saveAgainAfterCurrentRequest = false;
|
saveAgainAfterCurrentRequest = false;
|
||||||
|
|
||||||
if (isDirty.value) {
|
if (saveCompleted && (shouldSaveAgain || isDirty.value) && isDirty.value) {
|
||||||
void persist();
|
void persist();
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { computed, ref } from "vue";
|
import { computed, ref } from "vue";
|
||||||
|
import { createPinia, defineStore, getActivePinia, setActivePinia, storeToRefs } from "pinia";
|
||||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||||
|
import { safeAttachmentDownloadLink } from "@/services/attachmentDownloadLinks.js";
|
||||||
|
|
||||||
const normalizeBooleanAnswer = (value) => {
|
const normalizeBooleanAnswer = (value) => {
|
||||||
if (value === true || value === false) {
|
if (value === true || value === false) {
|
||||||
@@ -270,7 +272,26 @@ const mergeByNumericId = (existingItems, incomingItems) => {
|
|||||||
return merged;
|
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 loading = ref(false);
|
||||||
const requestError = ref(null);
|
const requestError = ref(null);
|
||||||
const preview = ref(null);
|
const preview = ref(null);
|
||||||
@@ -526,7 +547,7 @@ export function useSelfServeLogic() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const downloadAttachment = async (taskId, attachmentId, attachment = null) => {
|
const downloadAttachment = async (taskId, attachmentId, attachment = null) => {
|
||||||
const existingDownloadLink = attachment?.download_link || null;
|
const existingDownloadLink = safeAttachmentDownloadLink(attachment?.download_link);
|
||||||
if (existingDownloadLink) {
|
if (existingDownloadLink) {
|
||||||
window.open(existingDownloadLink, "_blank", "noopener");
|
window.open(existingDownloadLink, "_blank", "noopener");
|
||||||
return;
|
return;
|
||||||
@@ -534,10 +555,9 @@ export function useSelfServeLogic() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await SessionUser.objects.self_serve_tasks.attachments.download(taskId, attachmentId);
|
const response = await SessionUser.objects.self_serve_tasks.attachments.download(taskId, attachmentId);
|
||||||
if (response?.data?.download_link) {
|
const downloadLink = safeAttachmentDownloadLink(response?.data?.download_link || response?.download_link);
|
||||||
window.open(response.data.download_link, "_blank", "noopener");
|
if (downloadLink) {
|
||||||
} else if (response?.download_link) {
|
window.open(downloadLink, "_blank", "noopener");
|
||||||
window.open(response.download_link, "_blank", "noopener");
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error downloading attachment:", error);
|
console.error("Error downloading attachment:", error);
|
||||||
@@ -1075,4 +1095,50 @@ export function useSelfServeLogic() {
|
|||||||
answerQuestion,
|
answerQuestion,
|
||||||
removeAnswer,
|
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 { getDepartmentsGuest } from "@/components/pagination/departmentTabs.vue";
|
||||||
import { locations } from "@/components/displays/department/pos/steps/mobile/objects/PosDepartmentStepMobileFlow.vue";
|
import { locations } from "@/components/displays/department/pos/steps/mobile/objects/PosDepartmentStepMobileFlow.vue";
|
||||||
|
|
||||||
const isLaneSelfServeEnabled = (lane) => !(
|
const isLaneSelfServeEnabled = (lane) =>
|
||||||
lane?.selfserve_enabled === false
|
!(
|
||||||
|| lane?.selfserve_enabled === 0
|
lane?.selfserve_enabled === false ||
|
||||||
|| lane?.selfserve_enabled === "0"
|
lane?.selfserve_enabled === 0 ||
|
||||||
|| ["false", "off", "no"].includes(String(lane?.selfserve_enabled ?? "").trim().toLowerCase())
|
lane?.selfserve_enabled === "0" ||
|
||||||
);
|
["false", "off", "no"].includes(
|
||||||
|
String(lane?.selfserve_enabled ?? "")
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
const toDepartmentViewModel = (department, distance = null) => ({
|
const toDepartmentViewModel = (department, distance = null) => ({
|
||||||
id: department.id,
|
id: department.id,
|
||||||
@@ -18,12 +23,22 @@ const toDepartmentViewModel = (department, distance = null) => ({
|
|||||||
self_serve_enabled: department.self_serve_enabled,
|
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 = {}) {
|
export function useWashDepartments(options = {}) {
|
||||||
const {
|
const { includeLanes = false, refreshIntervalMs = 0, canAccessSuperUser = () => false } = options;
|
||||||
includeLanes = false,
|
|
||||||
refreshIntervalMs = 0,
|
|
||||||
canAccessSuperUser = () => false,
|
|
||||||
} = options;
|
|
||||||
|
|
||||||
const guestDepartments = ref([]);
|
const guestDepartments = ref([]);
|
||||||
const nearestDepartment = ref(null);
|
const nearestDepartment = ref(null);
|
||||||
@@ -31,6 +46,7 @@ export function useWashDepartments(options = {}) {
|
|||||||
const forceNearestDepartmentEvaluationId = ref(0);
|
const forceNearestDepartmentEvaluationId = ref(0);
|
||||||
const isSearchingDepartments = ref(false);
|
const isSearchingDepartments = ref(false);
|
||||||
const lastDepartmentFetchTime = ref(null);
|
const lastDepartmentFetchTime = ref(null);
|
||||||
|
const departmentFetchError = ref(null);
|
||||||
|
|
||||||
let refreshInterval = null;
|
let refreshInterval = null;
|
||||||
|
|
||||||
@@ -57,13 +73,18 @@ export function useWashDepartments(options = {}) {
|
|||||||
return nearestDepartment.value.self_serve_enabled === true;
|
return nearestDepartment.value.self_serve_enabled === true;
|
||||||
});
|
});
|
||||||
|
|
||||||
const buildGuestDepartmentParams = () => (
|
const buildGuestDepartmentParams = () => (includeLanes ? { include_lanes: true } : {});
|
||||||
includeLanes ? { include_lanes: true } : {}
|
|
||||||
);
|
|
||||||
|
|
||||||
const fetchDepartments = async () => {
|
const fetchDepartments = async () => {
|
||||||
guestDepartments.value = await getDepartmentsGuest(buildGuestDepartmentParams());
|
try {
|
||||||
lastDepartmentFetchTime.value = Date.now();
|
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);
|
evaluateLocationDepartments(locations.location.value);
|
||||||
return guestDepartments.value;
|
return guestDepartments.value;
|
||||||
};
|
};
|
||||||
@@ -74,19 +95,15 @@ export function useWashDepartments(options = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const currentTime = Date.now();
|
const currentTime = Date.now();
|
||||||
if (
|
if (!lastDepartmentFetchTime.value || currentTime - lastDepartmentFetchTime.value >= refreshIntervalMs) {
|
||||||
!lastDepartmentFetchTime.value
|
|
||||||
|| (currentTime - lastDepartmentFetchTime.value) >= refreshIntervalMs
|
|
||||||
) {
|
|
||||||
return fetchDepartments();
|
return fetchDepartments();
|
||||||
}
|
}
|
||||||
|
|
||||||
return guestDepartments.value;
|
return guestDepartments.value;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getForcedDepartment = () => guestDepartments.value.find(
|
const getForcedDepartment = () =>
|
||||||
(department) => department.id === forceNearestDepartmentEvaluationId.value
|
guestDepartments.value.find((department) => department.id === forceNearestDepartmentEvaluationId.value);
|
||||||
);
|
|
||||||
|
|
||||||
const evaluateLocationDepartments = (locationValue = locations.location.value) => {
|
const evaluateLocationDepartments = (locationValue = locations.location.value) => {
|
||||||
if (isForcingNearestDepartment.value && forceNearestDepartmentEvaluationId.value) {
|
if (isForcingNearestDepartment.value && forceNearestDepartmentEvaluationId.value) {
|
||||||
@@ -191,19 +208,30 @@ export function useWashDepartments(options = {}) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
watch(() => guestDepartments.value, () => {
|
watch(
|
||||||
evaluateLocationDepartments(locations.location.value);
|
() => guestDepartments.value,
|
||||||
}, { deep: true });
|
() => {
|
||||||
|
|
||||||
watch(() => locations.location.value, (newValue) => {
|
|
||||||
evaluateLocationDepartments(newValue);
|
|
||||||
}, { deep: true });
|
|
||||||
|
|
||||||
watch(() => isSearchingDepartments.value, (isSearching) => {
|
|
||||||
if (!isSearching) {
|
|
||||||
evaluateLocationDepartments(locations.location.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(() => {
|
onUnmounted(() => {
|
||||||
stopAutoRefresh();
|
stopAutoRefresh();
|
||||||
@@ -216,6 +244,7 @@ export function useWashDepartments(options = {}) {
|
|||||||
forceNearestDepartmentEvaluationId,
|
forceNearestDepartmentEvaluationId,
|
||||||
isSearchingDepartments,
|
isSearchingDepartments,
|
||||||
lastDepartmentFetchTime,
|
lastDepartmentFetchTime,
|
||||||
|
departmentFetchError,
|
||||||
availableProductIds,
|
availableProductIds,
|
||||||
doesCurrentDepartmentSelectionHaveSelfServeEnabled,
|
doesCurrentDepartmentSelectionHaveSelfServeEnabled,
|
||||||
fetchDepartments,
|
fetchDepartments,
|
||||||
|
|||||||
@@ -170,10 +170,15 @@ export function useWashSessionActions(options) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return await executeSelfServeCommand(resolvedLaneId, command, {
|
return await executeSelfServeCommand(
|
||||||
customer_number: null,
|
resolvedLaneId,
|
||||||
license_plate: null,
|
command,
|
||||||
}, options);
|
{
|
||||||
|
customer_number: null,
|
||||||
|
license_plate: null,
|
||||||
|
},
|
||||||
|
options
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
loadingRef.value = false;
|
loadingRef.value = false;
|
||||||
}
|
}
|
||||||
@@ -185,6 +190,24 @@ export function useWashSessionActions(options) {
|
|||||||
const openPropertyExitGate = async (laneId = null, options = {}) =>
|
const openPropertyExitGate = async (laneId = null, options = {}) =>
|
||||||
executePropertyGateCommand("OPEN_PROPERTY_EXIT_GATE", laneId, openingPropertyExitGate, 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) => {
|
const onStartWash = async (laneId, licensePlate, customerNumber, targetStep = steps.WASH_IN_PROGRESS) => {
|
||||||
if (isStartingWash.value) {
|
if (isStartingWash.value) {
|
||||||
return false;
|
return false;
|
||||||
@@ -227,12 +250,31 @@ export function useWashSessionActions(options) {
|
|||||||
const startResponse = await executeSelfServeCommand(laneId, "START", {
|
const startResponse = await executeSelfServeCommand(laneId, "START", {
|
||||||
customer_number: parseInt(customerNumber),
|
customer_number: parseInt(customerNumber),
|
||||||
license_plate: licensePlate.trim().toUpperCase(),
|
license_plate: licensePlate.trim().toUpperCase(),
|
||||||
defer_relay_side_effects: true,
|
defer_relay_side_effects: false,
|
||||||
});
|
});
|
||||||
if (!startResponse) {
|
if (!startResponse) {
|
||||||
return false;
|
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;
|
washLaneId.value = laneId;
|
||||||
washStartTime.value = Date.now();
|
washStartTime.value = Date.now();
|
||||||
completedDurationMs.value = null;
|
completedDurationMs.value = null;
|
||||||
@@ -246,13 +288,6 @@ export function useWashSessionActions(options) {
|
|||||||
reg: licensePlate.trim().toUpperCase(),
|
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;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error starting self-serve wash:", 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_ENV = String(import.meta.env.VITE_RELEASE_SOURCE || "").trim().toLowerCase();
|
||||||
export const RELEASE_SOURCE = normalizeReleaseSource(RELEASE_SOURCE_ENV);
|
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_PUBLIC_GATEWAY_API_URL = "https://api-v2.truckwash.io";
|
||||||
export const DEFAULT_STABLE_API_URL = `${DEFAULT_PUBLIC_GATEWAY_API_URL}/master/api`;
|
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(",")
|
.split(",")
|
||||||
.map((url) => normalizeApiUrl(url.trim()))
|
.map((url) => normalizeApiUrl(url.trim()))
|
||||||
.filter(Boolean);
|
.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
|
// Allowed origins
|
||||||
export const ALLOWED_ORIGINS = [
|
export const ALLOWED_ORIGINS = [
|
||||||
|
|||||||
@@ -36,7 +36,73 @@ const entryContext = (entry = {}) => {
|
|||||||
return context && typeof context === "object" && !Array.isArray(context) ? context : {};
|
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 === "") {
|
if (value === null || typeof value === "undefined" || value === "") {
|
||||||
return "None";
|
return "None";
|
||||||
}
|
}
|
||||||
@@ -44,13 +110,14 @@ const formatDetailValue = (value) => {
|
|||||||
return value ? "yes" : "no";
|
return value ? "yes" : "no";
|
||||||
}
|
}
|
||||||
if (typeof value === "object") {
|
if (typeof value === "object") {
|
||||||
|
const displayValue = redactContext ? redactEdgeGatewayLogContext(value) : value;
|
||||||
try {
|
try {
|
||||||
return JSON.stringify(value, null, 2);
|
return JSON.stringify(displayValue, null, 2);
|
||||||
} catch {
|
} catch {
|
||||||
return String(value);
|
return String(displayValue);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return String(value);
|
return redactContext && typeof value === "string" ? redactSensitiveUrlParams(value) : String(value);
|
||||||
};
|
};
|
||||||
|
|
||||||
const detailRow = (label, value) => ({
|
const detailRow = (label, value) => ({
|
||||||
@@ -61,7 +128,7 @@ const detailRow = (label, value) => ({
|
|||||||
|
|
||||||
const contextDetailRow = (value) => ({
|
const contextDetailRow = (value) => ({
|
||||||
label: "Context",
|
label: "Context",
|
||||||
value: formatDetailValue(value),
|
value: formatDetailValue(value, { redactContext: true }),
|
||||||
multiline: true,
|
multiline: true,
|
||||||
actionsOnly: true,
|
actionsOnly: true,
|
||||||
});
|
});
|
||||||
@@ -208,7 +275,9 @@ const relayActorLabel = (entry = {}) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const normalizeRelayRole = (role = "") => {
|
const normalizeRelayRole = (role = "") => {
|
||||||
const normalized = String(role || "").trim().toUpperCase();
|
const normalized = String(role || "")
|
||||||
|
.trim()
|
||||||
|
.toUpperCase();
|
||||||
const aliases = {
|
const aliases = {
|
||||||
ENTRANCE: "ENTRY",
|
ENTRANCE: "ENTRY",
|
||||||
IN: "ENTRY",
|
IN: "ENTRY",
|
||||||
@@ -260,7 +329,8 @@ const firstRelayText = (values = []) => {
|
|||||||
|
|
||||||
const relayDisplayName = (entry = {}) => {
|
const relayDisplayName = (entry = {}) => {
|
||||||
const context = entryContext(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 binding = context.binding && typeof context.binding === "object" ? context.binding : {};
|
||||||
const request = relayRequestPayload(entry);
|
const request = relayRequestPayload(entry);
|
||||||
return firstRelayText([
|
return firstRelayText([
|
||||||
@@ -284,7 +354,8 @@ const relayIdentifier = (entry = {}) => {
|
|||||||
|
|
||||||
const relaySignalLabel = (entry = {}) => {
|
const relaySignalLabel = (entry = {}) => {
|
||||||
const context = entryContext(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 signal = context.signal && typeof context.signal === "object" ? context.signal : {};
|
||||||
const role = normalizeRelayRole(context.relay_role || actionContext.relay_role || actionContext.role);
|
const role = normalizeRelayRole(context.relay_role || actionContext.relay_role || actionContext.role);
|
||||||
const action = String(context.action || signal.command_type || "RELAY").toUpperCase();
|
const action = String(context.action || signal.command_type || "RELAY").toUpperCase();
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { computed, onMounted, onUnmounted, ref, watch } from "vue";
|
import { computed, onMounted, onUnmounted, ref, watch } from "vue";
|
||||||
import {
|
import {
|
||||||
cancelEdgeGatewayOperation,
|
cancelEdgeGatewayOperation,
|
||||||
|
clearEdgeGatewayWorkspaceCache,
|
||||||
createEdgeGatewayInstallToken,
|
createEdgeGatewayInstallToken,
|
||||||
createEdgeGatewayOperation,
|
createEdgeGatewayOperation,
|
||||||
deleteEdgeGateway,
|
deleteEdgeGateway,
|
||||||
@@ -12,16 +13,18 @@ import {
|
|||||||
getEdgeGatewayStatistics,
|
getEdgeGatewayStatistics,
|
||||||
getEdgeGatewayTasks,
|
getEdgeGatewayTasks,
|
||||||
listEdgeGatewayDepartments,
|
listEdgeGatewayDepartments,
|
||||||
|
isEdgeGatewayAuthorizationError,
|
||||||
listEdgeGateways,
|
listEdgeGateways,
|
||||||
peekCachedEdgeGateway,
|
peekCachedEdgeGateway,
|
||||||
peekCachedEdgeGatewayDepartments,
|
peekCachedEdgeGatewayDepartments,
|
||||||
peekCachedEdgeGatewayList,
|
peekCachedEdgeGatewayList,
|
||||||
|
removeEdgeGatewayCache,
|
||||||
rotateEdgeGatewayCredentials,
|
rotateEdgeGatewayCredentials,
|
||||||
saveEdgeGatewayBindings,
|
saveEdgeGatewayBindings,
|
||||||
setDepartmentGatewayCutover,
|
setDepartmentGatewayCutover,
|
||||||
updateEdgeGateway,
|
updateEdgeGateway,
|
||||||
} from "@/services/edgeGateways.js";
|
} 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 EdgeGatewayOverviewPage from "@/features/edgeGateways/EdgeGatewayOverviewPage.vue";
|
||||||
import EdgeGatewayInventoryPage from "@/features/edgeGateways/EdgeGatewayInventoryPage.vue";
|
import EdgeGatewayInventoryPage from "@/features/edgeGateways/EdgeGatewayInventoryPage.vue";
|
||||||
import EdgeGatewayTasksPage from "@/features/edgeGateways/EdgeGatewayTasksPage.vue";
|
import EdgeGatewayTasksPage from "@/features/edgeGateways/EdgeGatewayTasksPage.vue";
|
||||||
@@ -183,7 +186,7 @@ const terminalGatewayBrokerDiagnostics = (gateway = selectedGatewayView.value) =
|
|||||||
if (value === null || value === undefined || value === "") {
|
if (value === null || value === undefined || value === "") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
lines.push(`${label}: ${value}`);
|
lines.push(`${label}: ${redactEdgeGatewayDiagnostic(value)}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
push("Gateway ID", gateway?.id);
|
push("Gateway ID", gateway?.id);
|
||||||
@@ -1215,6 +1218,15 @@ const refreshSelected = async (gatewayId = activeGatewayId.value, { forceRefresh
|
|||||||
}
|
}
|
||||||
return gateway;
|
return gateway;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (isEdgeGatewayAuthorizationError(error)) {
|
||||||
|
removeEdgeGatewayCache(gatewayId);
|
||||||
|
unavailableGatewayId.value = String(gatewayId);
|
||||||
|
selectedGateway.value = null;
|
||||||
|
resetGatewayViewSnapshots();
|
||||||
|
fail(error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const fallbackGateway = findLocalGatewaySnapshot(gatewayId);
|
const fallbackGateway = findLocalGatewaySnapshot(gatewayId);
|
||||||
if (fallbackGateway) {
|
if (fallbackGateway) {
|
||||||
setSelectedGatewaySnapshot(fallbackGateway);
|
setSelectedGatewaySnapshot(fallbackGateway);
|
||||||
@@ -1561,6 +1573,16 @@ const load = async () => {
|
|||||||
departments.value = unwrap(departmentsResponse, []);
|
departments.value = unwrap(departmentsResponse, []);
|
||||||
await syncSelection();
|
await syncSelection();
|
||||||
} catch (error) {
|
} 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);
|
fail(error);
|
||||||
} finally {
|
} finally {
|
||||||
loading.value.init = false;
|
loading.value.init = false;
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
|
import { redactEdgeGatewayDiagnostic } from "@/features/edgeGateways/edgeGatewayErrors.js";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
status: {
|
status: {
|
||||||
type: String,
|
type: String,
|
||||||
@@ -44,7 +46,10 @@ const shortcuts = [
|
|||||||
{ id: "docker ps", label: "docker ps" },
|
{ 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 copyDiagnostics = async () => {
|
||||||
const text = diagnosticsText();
|
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.",
|
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) {
|
export function redactEdgeGatewayUrl(value) {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
return null;
|
return null;
|
||||||
@@ -62,17 +92,33 @@ export function redactEdgeGatewayUrl(value) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const url = new URL(String(value), typeof window !== "undefined" ? window.location.origin : "http://localhost");
|
const url = new URL(String(value), typeof window !== "undefined" ? window.location.origin : "http://localhost");
|
||||||
["token", "agentToken", "agent_token"].forEach((key) => {
|
if (url.username) {
|
||||||
if (url.searchParams.has(key)) {
|
url.username = "***";
|
||||||
|
}
|
||||||
|
if (url.password) {
|
||||||
|
url.password = "***";
|
||||||
|
}
|
||||||
|
Array.from(url.searchParams.keys()).forEach((key) => {
|
||||||
|
if (isSensitiveDiagnosticKey(key)) {
|
||||||
url.searchParams.set(key, "***");
|
url.searchParams.set(key, "***");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return url.toString();
|
return url.toString();
|
||||||
} catch (_error) {
|
} 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") => {
|
const formatDiagnosticLines = (diagnostics, prefix = "Diagnostic") => {
|
||||||
if (!diagnostics || typeof diagnostics !== "object" || Array.isArray(diagnostics)) {
|
if (!diagnostics || typeof diagnostics !== "object" || Array.isArray(diagnostics)) {
|
||||||
return [];
|
return [];
|
||||||
@@ -83,20 +129,23 @@ const formatDiagnosticLines = (diagnostics, prefix = "Diagnostic") => {
|
|||||||
if (value === null || value === undefined || value === "") {
|
if (value === null || value === undefined || value === "") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
lines.push(`${label}: ${value}`);
|
lines.push(`${label}: ${redactEdgeGatewayDiagnostic(value)}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
push(`${prefix} code`, diagnostics.reason_code || diagnostics.error_code);
|
push(`${prefix} code`, diagnostics.reason_code || diagnostics.error_code);
|
||||||
push("Broker URL", diagnostics.broker_url);
|
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);
|
push("Derived URL warning", diagnostics.derived_warning);
|
||||||
if (diagnostics.broker_presence && typeof diagnostics.broker_presence === "object") {
|
if (diagnostics.broker_presence && typeof diagnostics.broker_presence === "object") {
|
||||||
push("Broker connected", diagnostics.broker_presence.connected === true ? "yes" : "no");
|
push("Broker connected", diagnostics.broker_presence.connected === true ? "yes" : "no");
|
||||||
push("Broker connection", diagnostics.broker_presence.connection_id);
|
push("Broker connection", diagnostics.broker_presence.connection_id);
|
||||||
push("Broker last seen", diagnostics.broker_presence.last_seen_at);
|
push("Broker last seen", diagnostics.broker_presence.last_seen_at);
|
||||||
push("Broker age", Number.isFinite(Number(diagnostics.broker_presence.age_seconds))
|
push(
|
||||||
? `${Number(diagnostics.broker_presence.age_seconds)}s`
|
"Broker age",
|
||||||
: null);
|
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 last error", diagnostics.broker_presence.last_error);
|
||||||
push("Broker disconnect reason", diagnostics.broker_presence.disconnect_reason);
|
push("Broker disconnect reason", diagnostics.broker_presence.disconnect_reason);
|
||||||
}
|
}
|
||||||
@@ -180,7 +229,7 @@ export function normalizeGatewayWebSocketClose(event = {}, context = {}) {
|
|||||||
details.push(`WebSocket code: ${code}`);
|
details.push(`WebSocket code: ${code}`);
|
||||||
}
|
}
|
||||||
if (reason) {
|
if (reason) {
|
||||||
details.push(`Close reason: ${reason}`);
|
details.push(`Close reason: ${redactEdgeGatewayDiagnostic(reason)}`);
|
||||||
}
|
}
|
||||||
if (wasClean !== null) {
|
if (wasClean !== null) {
|
||||||
details.push(`Clean close: ${wasClean ? "yes" : "no"}`);
|
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 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 = {}) => {
|
const toNativeSocketUrl = (socketUrl, query = {}) => {
|
||||||
if (!socketUrl) {
|
if (!socketUrl) {
|
||||||
throw new Error("Missing websocket URL");
|
throw new Error("Missing websocket URL");
|
||||||
@@ -19,16 +79,48 @@ const toNativeSocketUrl = (socketUrl, query = {}) => {
|
|||||||
return socketUrl;
|
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]) => {
|
Object.entries(query || {}).forEach(([key, value]) => {
|
||||||
if (value === null || value === undefined || value === "") {
|
if (value === null || value === undefined || value === "") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (SENSITIVE_SOCKET_QUERY_KEYS.has(String(key).toLowerCase())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
url.searchParams.set(key, String(value));
|
url.searchParams.set(key, String(value));
|
||||||
});
|
});
|
||||||
return url.toString();
|
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) => {
|
const safeJsonParse = (payload) => {
|
||||||
if (payload === null || payload === undefined || payload === "") {
|
if (payload === null || payload === undefined || payload === "") {
|
||||||
return null;
|
return null;
|
||||||
@@ -292,10 +384,10 @@ export async function createGatewayStreamClient(gatewayId, scopes = [], handlers
|
|||||||
const session = unwrapEdgeGatewayResponse(response, {});
|
const session = unwrapEdgeGatewayResponse(response, {});
|
||||||
const socketUrl = toNativeSocketUrl(session.ws_url, {
|
const socketUrl = toNativeSocketUrl(session.ws_url, {
|
||||||
gatewayId,
|
gatewayId,
|
||||||
token: session.token,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const connection = createConnection(socketUrl, session, "stream", handlers, ({ send }) => {
|
const connection = createConnection(socketUrl, session, "stream", handlers, ({ send }) => {
|
||||||
|
sendSessionAuthentication(send, session, gatewayId, "stream");
|
||||||
send({
|
send({
|
||||||
type: "SUBSCRIBE",
|
type: "SUBSCRIBE",
|
||||||
gatewayId: String(gatewayId),
|
gatewayId: String(gatewayId),
|
||||||
@@ -318,9 +410,10 @@ export async function createGatewayShellClient(gatewayId, options = {}, handlers
|
|||||||
const session = unwrapEdgeGatewayResponse(response, {});
|
const session = unwrapEdgeGatewayResponse(response, {});
|
||||||
const socketUrl = toNativeSocketUrl(session.ws_url, {
|
const socketUrl = toNativeSocketUrl(session.ws_url, {
|
||||||
gatewayId,
|
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 {
|
return {
|
||||||
session,
|
session,
|
||||||
|
|||||||
@@ -4595,7 +4595,6 @@
|
|||||||
"loading_vehicle_types": "@.capitalize:{'words.generated.indlæser'} @:{'words.generated.tilgængelige'} @:{'words.generated.køretøjstyper'}...",
|
"loading_vehicle_types": "@.capitalize:{'words.generated.indlæser'} @:{'words.generated.tilgængelige'} @:{'words.generated.køretøjstyper'}...",
|
||||||
"lane_unavailable": "Vaskebanen er ikke tilgængelig",
|
"lane_unavailable": "Vaskebanen er ikke tilgængelig",
|
||||||
"machine": "@.capitalize:{'words.generated.maskine'}",
|
"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'}",
|
"no_departments_found": "@:{'templates.generated.compat.departments.no_departments'}",
|
||||||
"occupied": "Optaget",
|
"occupied": "Optaget",
|
||||||
"open_property_access_gate": "@:{'words.generated.abn'} adgangsport",
|
"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'}...",
|
"loading_vehicle_types": "@.capitalize:{'words.generated.verf'}?@:{'words.generated.gbare'} @:{'words.generated.fahrzeugtypen'} @:{'words.generated.werden'} @:{'words.generated.geladen'}...",
|
||||||
"lane_unavailable": "Waschspur nicht verfugbar",
|
"lane_unavailable": "Waschspur nicht verfugbar",
|
||||||
"machine": "@:{'words.generated.maschine'}",
|
"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'}",
|
"no_departments_found": "@:{'templates.generated.compat.departments.no_departments'}",
|
||||||
"occupied": "Belegt",
|
"occupied": "Belegt",
|
||||||
"open_property_access_gate": "Grundstueckszugangstor @:{'words.generated.oeffnen'}",
|
"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'}...",
|
"loading_vehicle_types": "@.capitalize:{'words.generated.loading'} @:{'words.generated.available'} @:{'words.generated.vehicle'} @:{'words.generated.types'}...",
|
||||||
"lane_unavailable": "Lane unavailable",
|
"lane_unavailable": "Lane unavailable",
|
||||||
"machine": "@.capitalize:{'words.generated.machine'}",
|
"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'}",
|
"no_departments_found": "@:{'templates.generated.compat.departments.no_departments'}",
|
||||||
"occupied": "Occupied",
|
"occupied": "Occupied",
|
||||||
"open_property_access_gate": "@.capitalize:{'words.generated.open'} @:{'words.generated.property'} @:{'words.generated.access'} @:{'words.generated.gate'}",
|
"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'}",
|
"loading_vehicle_types": "@:{'templates.generated.compat.self_wash.loading_vehicle_types'}",
|
||||||
"lane_unavailable": "@:{'templates.generated.compat.self_wash.lane_unavailable'}",
|
"lane_unavailable": "@:{'templates.generated.compat.self_wash.lane_unavailable'}",
|
||||||
"machine": "@:{'templates.generated.compat.self_wash.machine'}",
|
"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_departments_found": "@:{'templates.generated.compat.self_wash.no_departments_found'}",
|
||||||
"no_vehicles_found": "@:vehicles.no_vehicles",
|
"no_vehicles_found": "@:vehicles.no_vehicles",
|
||||||
"occupied": "@:{'templates.generated.compat.self_wash.occupied'}",
|
"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'}...",
|
"loading_vehicle_types": "@.capitalize:{'words.generated.laster'} @:{'words.generated.inn'} @:{'words.generated.tilgjengelige'} @:{'words.generated.kjøretøytyper'}...",
|
||||||
"lane_unavailable": "Vaskebanen er ikke tilgjengelig",
|
"lane_unavailable": "Vaskebanen er ikke tilgjengelig",
|
||||||
"machine": "@.capitalize:{'words.generated.maskin'}",
|
"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'}",
|
"no_departments_found": "@:{'templates.generated.compat.departments.no_departments'}",
|
||||||
"occupied": "Okkupert",
|
"occupied": "Okkupert",
|
||||||
"open_property_access_gate": "@:{'words.generated.aapne'} @:{'words.generated.eiendommens'} inngangsport",
|
"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'}...",
|
"loading_vehicle_types": "@.capitalize:{'words.generated.laser'} @:{'words.generated.in'} @:{'words.generated.tillgangliga'} @:{'words.generated.fordonstyper'}...",
|
||||||
"lane_unavailable": "Tvattbanan ar inte tillganglig",
|
"lane_unavailable": "Tvattbanan ar inte tillganglig",
|
||||||
"machine": "@.capitalize:{'words.generated.machine'}",
|
"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'}",
|
"no_departments_found": "@.capitalize:{'words.generated.inga'} @:{'words.generated.avdelningar'} @:{'words.generated.hittades'}",
|
||||||
"occupied": "Upptagen",
|
"occupied": "Upptagen",
|
||||||
"open_property_access_gate": "@:{'words.generated.oeppna'} @:{'words.generated.fastighetens'} ingangsgrind",
|
"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...",
|
"loading_vehicle_types": "Indlæser tilgængelige køretøjstyper...",
|
||||||
"lane_unavailable": "Vaskebanen er ikke tilgængelig",
|
"lane_unavailable": "Vaskebanen er ikke tilgængelig",
|
||||||
"machine": "Maskine",
|
"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_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_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.",
|
"vehicle_step_missing_registration": "Indtast registreringsnummer for at fortsætte.",
|
||||||
|
|||||||
@@ -3663,7 +3663,6 @@
|
|||||||
"loading_vehicle_types": "Verf?gbare Fahrzeugtypen werden geladen...",
|
"loading_vehicle_types": "Verf?gbare Fahrzeugtypen werden geladen...",
|
||||||
"lane_unavailable": "Waschspur nicht verfugbar",
|
"lane_unavailable": "Waschspur nicht verfugbar",
|
||||||
"machine": "Maschine",
|
"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_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_department": "Warten Sie, bis die nachste Abteilung geladen ist, bevor Sie fortfahren.",
|
||||||
"vehicle_step_missing_registration": "Geben Sie das Kennzeichen ein, um fortzufahren.",
|
"vehicle_step_missing_registration": "Geben Sie das Kennzeichen ein, um fortzufahren.",
|
||||||
|
|||||||
@@ -3662,7 +3662,6 @@
|
|||||||
"loading_vehicle_types": "Loading available vehicle types...",
|
"loading_vehicle_types": "Loading available vehicle types...",
|
||||||
"lane_unavailable": "Lane unavailable",
|
"lane_unavailable": "Lane unavailable",
|
||||||
"machine": "Machine",
|
"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_customer_number": "Enter your customer number to continue.",
|
||||||
"vehicle_step_missing_department": "Wait for the nearest department to load before continuing.",
|
"vehicle_step_missing_department": "Wait for the nearest department to load before continuing.",
|
||||||
"vehicle_step_missing_registration": "Enter your registration number to continue.",
|
"vehicle_step_missing_registration": "Enter your registration number to continue.",
|
||||||
|
|||||||
@@ -3663,7 +3663,6 @@
|
|||||||
"loading_vehicle_types": "Laster inn tilgjengelige kjøretøytyper...",
|
"loading_vehicle_types": "Laster inn tilgjengelige kjøretøytyper...",
|
||||||
"lane_unavailable": "Vaskebanen er ikke tilgjengelig",
|
"lane_unavailable": "Vaskebanen er ikke tilgjengelig",
|
||||||
"machine": "Maskin",
|
"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_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_department": "Vent til naermeste avdeling er lastet inn for du fortsetter.",
|
||||||
"vehicle_step_missing_registration": "Skriv inn registreringsnummer for a fortsette.",
|
"vehicle_step_missing_registration": "Skriv inn registreringsnummer for a fortsette.",
|
||||||
|
|||||||
@@ -3663,7 +3663,6 @@
|
|||||||
"loading_vehicle_types": "Läser in tillgängliga fordonstyper...",
|
"loading_vehicle_types": "Läser in tillgängliga fordonstyper...",
|
||||||
"lane_unavailable": "Tvattbanan ar inte tillganglig",
|
"lane_unavailable": "Tvattbanan ar inte tillganglig",
|
||||||
"machine": "Machine",
|
"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_customer_number": "Ange kundnummer for att fortsatta.",
|
||||||
"vehicle_step_missing_department": "Vanta tills narmaste avdelning har laddats innan du fortsatter.",
|
"vehicle_step_missing_department": "Vanta tills narmaste avdelning har laddats innan du fortsatter.",
|
||||||
"vehicle_step_missing_registration": "Ange registreringsnummer for att fortsatta.",
|
"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'}...",
|
"loading_vehicle_types": "@.capitalize:{'terms.glossary.indlæser'} @:{'terms.glossary.tilgængelige'} @:{'terms.glossary.køretøjstyper'}...",
|
||||||
"lane_unavailable": "Vaskebanen er ikke tilgængelig",
|
"lane_unavailable": "Vaskebanen er ikke tilgængelig",
|
||||||
"machine": "@.capitalize:{'terms.glossary.maskine'}",
|
"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'}",
|
"no_departments_found": "@:{'phrases.compat.departments.no_departments'}",
|
||||||
"occupied": "Optaget",
|
"occupied": "Optaget",
|
||||||
"open_property_access_gate": "@:{'terms.glossary.abn'} adgangsport",
|
"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'}...",
|
"loading_vehicle_types": "@.capitalize:{'terms.glossary.verf'}?@:{'terms.glossary.gbare'} @:{'terms.glossary.fahrzeugtypen'} @:{'terms.glossary.werden'} @:{'terms.glossary.geladen'}...",
|
||||||
"lane_unavailable": "Waschspur nicht verfugbar",
|
"lane_unavailable": "Waschspur nicht verfugbar",
|
||||||
"machine": "@:{'terms.glossary.maschine'}",
|
"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'}",
|
"no_departments_found": "@:{'phrases.compat.departments.no_departments'}",
|
||||||
"occupied": "Belegt",
|
"occupied": "Belegt",
|
||||||
"open_property_access_gate": "Grundstueckszugangstor @:{'terms.glossary.oeffnen'}",
|
"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'}...",
|
"loading_vehicle_types": "@.capitalize:{'terms.glossary.loading'} @:{'terms.glossary.available'} @:{'terms.glossary.vehicle'} @:{'terms.glossary.types'}...",
|
||||||
"lane_unavailable": "Lane unavailable",
|
"lane_unavailable": "Lane unavailable",
|
||||||
"machine": "@.capitalize:{'terms.glossary.machine'}",
|
"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'}",
|
"no_departments_found": "@:{'phrases.compat.departments.no_departments'}",
|
||||||
"occupied": "Occupied",
|
"occupied": "Occupied",
|
||||||
"open_property_access_gate": "@.capitalize:{'terms.glossary.open'} @:{'terms.glossary.property'} @:{'terms.glossary.access'} @:{'terms.glossary.gate'}",
|
"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'}",
|
"loading_vehicle_types": "@:{'phrases.compat.self_wash.loading_vehicle_types'}",
|
||||||
"lane_unavailable": "@:{'phrases.compat.self_wash.lane_unavailable'}",
|
"lane_unavailable": "@:{'phrases.compat.self_wash.lane_unavailable'}",
|
||||||
"machine": "@:{'phrases.compat.self_wash.machine'}",
|
"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_departments_found": "@:{'phrases.compat.self_wash.no_departments_found'}",
|
||||||
"no_vehicles_found": "@:vehicles.no_vehicles",
|
"no_vehicles_found": "@:vehicles.no_vehicles",
|
||||||
"occupied": "@:{'phrases.compat.self_wash.occupied'}",
|
"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'}...",
|
"loading_vehicle_types": "@.capitalize:{'terms.glossary.laster'} @:{'terms.glossary.inn'} @:{'terms.glossary.tilgjengelige'} @:{'terms.glossary.kjøretøytyper'}...",
|
||||||
"lane_unavailable": "Vaskebanen er ikke tilgjengelig",
|
"lane_unavailable": "Vaskebanen er ikke tilgjengelig",
|
||||||
"machine": "@.capitalize:{'terms.glossary.maskin'}",
|
"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'}",
|
"no_departments_found": "@:{'phrases.compat.departments.no_departments'}",
|
||||||
"occupied": "Okkupert",
|
"occupied": "Okkupert",
|
||||||
"open_property_access_gate": "@:{'terms.glossary.aapne'} @:{'terms.glossary.eiendommens'} inngangsport",
|
"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'}...",
|
"loading_vehicle_types": "@.capitalize:{'terms.glossary.laser'} @:{'terms.glossary.in'} @:{'terms.glossary.tillgangliga'} @:{'terms.glossary.fordonstyper'}...",
|
||||||
"lane_unavailable": "Tvattbanan ar inte tillganglig",
|
"lane_unavailable": "Tvattbanan ar inte tillganglig",
|
||||||
"machine": "@.capitalize:{'terms.glossary.machine'}",
|
"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'}",
|
"no_departments_found": "@.capitalize:{'terms.glossary.inga'} @:{'terms.glossary.avdelningar'} @:{'terms.glossary.hittades'}",
|
||||||
"occupied": "Upptagen",
|
"occupied": "Upptagen",
|
||||||
"open_property_access_gate": "@:{'terms.glossary.oeppna'} @:{'terms.glossary.fastighetens'} ingangsgrind",
|
"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 store from './store/user.vue'
|
||||||
import { createHead } from "@vueuse/head";
|
import { createHead } from "@vueuse/head";
|
||||||
import { createApp } from 'vue'
|
import { createApp } from 'vue'
|
||||||
|
import { createPinia } from 'pinia'
|
||||||
import Router from "@/router";
|
import Router from "@/router";
|
||||||
import App from "@/App.vue";
|
import App from "@/App.vue";
|
||||||
import Buefy from 'buefy'
|
import Buefy from 'buefy'
|
||||||
@@ -71,7 +72,6 @@ const logBuildBanner = () => {
|
|||||||
'',
|
'',
|
||||||
`Build: ${formatCommit(VITE_COMMIT_HASH)} @ ${formatDateTime(VITE_BUILD_DATE)} (${IS_DEV ? 'development' : 'production'})`,
|
`Build: ${formatCommit(VITE_COMMIT_HASH)} @ ${formatDateTime(VITE_BUILD_DATE)} (${IS_DEV ? 'development' : 'production'})`,
|
||||||
`Remote API: ${getReleaseRuntimeApiBaseUrl()}`,
|
`Remote API: ${getReleaseRuntimeApiBaseUrl()}`,
|
||||||
`Debug: Is running beta FE.`,
|
|
||||||
].join('\n'));
|
].join('\n'));
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -109,6 +109,7 @@ applyMiddleware(Router)
|
|||||||
const app = createApp(App)
|
const app = createApp(App)
|
||||||
.use(Router)
|
.use(Router)
|
||||||
.use(createHead())
|
.use(createHead())
|
||||||
|
.use(createPinia())
|
||||||
.use(store)
|
.use(store)
|
||||||
.use(ToastPlugin)
|
.use(ToastPlugin)
|
||||||
.use(Buefy)
|
.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";
|
import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue";
|
||||||
|
|
||||||
export const EDGE_GATEWAY_WORKSPACE_CACHE_KEY = "truckwash.edgeGatewayWorkspace.cache.v1";
|
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_BASE = "/edge-gateways";
|
||||||
const EDGE_GATEWAY_CONFIG_BASE = "/edgegateway/config";
|
const EDGE_GATEWAY_CONFIG_BASE = "/edgegateway/config";
|
||||||
@@ -9,8 +10,11 @@ const listRequestsInFlight = new Map();
|
|||||||
const detailRequestsInFlight = new Map();
|
const detailRequestsInFlight = new Map();
|
||||||
let departmentsRequestInFlight = null;
|
let departmentsRequestInFlight = null;
|
||||||
let edgeGatewayWorkspaceCache = null;
|
let edgeGatewayWorkspaceCache = null;
|
||||||
|
let edgeGatewayWorkspaceCacheStorageKey = null;
|
||||||
|
|
||||||
const cloneJson = (value) => (value === null || value === undefined ? value : JSON.parse(JSON.stringify(value)));
|
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) =>
|
const normalizeDepartmentId = (departmentId) =>
|
||||||
departmentId === null || departmentId === undefined || departmentId === "" ? null : Number(departmentId);
|
departmentId === null || departmentId === undefined || departmentId === "" ? null : Number(departmentId);
|
||||||
const normalizeGatewayId = (gatewayId) =>
|
const normalizeGatewayId = (gatewayId) =>
|
||||||
@@ -49,17 +53,21 @@ const normalizeRotateCredentialBundle = (bundle) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const config = parseJsonObject(bundle.config_json);
|
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 =
|
const stackServiceName =
|
||||||
bundle.stackServiceName ||
|
bundle.stackServiceName ||
|
||||||
bundle.stack_service_name ||
|
bundle.stack_service_name ||
|
||||||
config?.stackServiceName ||
|
(config && config.stackServiceName) ||
|
||||||
config?.stack_service_name ||
|
(config && config.stack_service_name) ||
|
||||||
config?.serviceName ||
|
(config && config.serviceName) ||
|
||||||
config?.service_name ||
|
(config && config.service_name) ||
|
||||||
null;
|
null;
|
||||||
const composeFileName =
|
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 {
|
return {
|
||||||
...bundle,
|
...bundle,
|
||||||
@@ -73,7 +81,7 @@ const normalizeRotateCredentialBundle = (bundle) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const normalizeRotateCredentialResponse = (response) => {
|
const normalizeRotateCredentialResponse = (response) => {
|
||||||
const bundle = response?.data?.data;
|
const bundle = getDataPayload(response);
|
||||||
if (bundle && typeof bundle === "object" && !Array.isArray(bundle)) {
|
if (bundle && typeof bundle === "object" && !Array.isArray(bundle)) {
|
||||||
response.data.data = normalizeRotateCredentialBundle(bundle);
|
response.data.data = normalizeRotateCredentialBundle(bundle);
|
||||||
}
|
}
|
||||||
@@ -91,55 +99,174 @@ const toConfigPayload = (entries) => {
|
|||||||
return payload;
|
return payload;
|
||||||
};
|
};
|
||||||
|
|
||||||
const createEmptyWorkspaceCache = () => ({
|
const createEmptyWorkspaceCache = (scope = null) => ({
|
||||||
|
scope,
|
||||||
|
cachedAt: Date.now(),
|
||||||
departments: null,
|
departments: null,
|
||||||
lists: {},
|
lists: {},
|
||||||
details: {},
|
details: {},
|
||||||
});
|
});
|
||||||
|
|
||||||
const storage = () => {
|
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) {
|
if (typeof window === "undefined" || !window.localStorage) {
|
||||||
return null;
|
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 = () => {
|
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;
|
return edgeGatewayWorkspaceCache;
|
||||||
}
|
}
|
||||||
|
|
||||||
const localStorageHandle = storage();
|
const storageHandle = storage();
|
||||||
if (!localStorageHandle) {
|
if (!storageHandle) {
|
||||||
edgeGatewayWorkspaceCache = createEmptyWorkspaceCache();
|
return resetWorkspaceCacheMemory(scope);
|
||||||
return edgeGatewayWorkspaceCache;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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 = {
|
edgeGatewayWorkspaceCache = {
|
||||||
...createEmptyWorkspaceCache(),
|
...createEmptyWorkspaceCache(scope),
|
||||||
...(decoded && typeof decoded === "object" ? decoded : {}),
|
cachedAt: Number(decoded.cachedAt || Date.now()),
|
||||||
lists: decoded?.lists && typeof decoded.lists === "object" ? decoded.lists : {},
|
departments: isFreshCacheEntry(decoded.departments) ? decoded.departments : null,
|
||||||
details: decoded?.details && typeof decoded.details === "object" ? decoded.details : {},
|
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) {
|
} catch (_error) {
|
||||||
edgeGatewayWorkspaceCache = createEmptyWorkspaceCache();
|
storageHandle.removeItem(EDGE_GATEWAY_WORKSPACE_CACHE_KEY);
|
||||||
|
resetWorkspaceCacheMemory(scope);
|
||||||
}
|
}
|
||||||
|
|
||||||
return edgeGatewayWorkspaceCache;
|
return edgeGatewayWorkspaceCache;
|
||||||
};
|
};
|
||||||
|
|
||||||
const persistWorkspaceCache = () => {
|
const persistWorkspaceCache = () => {
|
||||||
const localStorageHandle = storage();
|
const storageHandle = storage();
|
||||||
if (!localStorageHandle || edgeGatewayWorkspaceCache === null) {
|
const scope = currentWorkspaceCacheScope();
|
||||||
|
if (!storageHandle || edgeGatewayWorkspaceCache === null || !scope || edgeGatewayWorkspaceCache.scope !== scope) {
|
||||||
return;
|
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) => {
|
const mergeGatewaySnapshots = (currentGateway, nextGateway) => {
|
||||||
if (!currentGateway) {
|
if (!currentGateway) {
|
||||||
return cloneJson(nextGateway);
|
return cloneJson(nextGateway);
|
||||||
@@ -149,27 +276,27 @@ const mergeGatewaySnapshots = (currentGateway, nextGateway) => {
|
|||||||
...cloneJson(currentGateway),
|
...cloneJson(currentGateway),
|
||||||
...cloneJson(nextGateway),
|
...cloneJson(nextGateway),
|
||||||
metadata: {
|
metadata: {
|
||||||
...(currentGateway?.metadata || {}),
|
...((currentGateway && currentGateway.metadata) || {}),
|
||||||
...(nextGateway?.metadata || {}),
|
...((nextGateway && nextGateway.metadata) || {}),
|
||||||
},
|
},
|
||||||
inventory: Array.isArray(nextGateway?.inventory)
|
inventory: Array.isArray(nextGateway && nextGateway.inventory)
|
||||||
? cloneJson(nextGateway.inventory)
|
? cloneJson(nextGateway.inventory)
|
||||||
: Array.isArray(currentGateway?.inventory)
|
: Array.isArray(currentGateway && currentGateway.inventory)
|
||||||
? cloneJson(currentGateway.inventory)
|
? cloneJson(currentGateway.inventory)
|
||||||
: undefined,
|
: undefined,
|
||||||
bindings: Array.isArray(nextGateway?.bindings)
|
bindings: Array.isArray(nextGateway && nextGateway.bindings)
|
||||||
? cloneJson(nextGateway.bindings)
|
? cloneJson(nextGateway.bindings)
|
||||||
: Array.isArray(currentGateway?.bindings)
|
: Array.isArray(currentGateway && currentGateway.bindings)
|
||||||
? cloneJson(currentGateway.bindings)
|
? cloneJson(currentGateway.bindings)
|
||||||
: undefined,
|
: undefined,
|
||||||
operations: Array.isArray(nextGateway?.operations)
|
operations: Array.isArray(nextGateway && nextGateway.operations)
|
||||||
? cloneJson(nextGateway.operations)
|
? cloneJson(nextGateway.operations)
|
||||||
: Array.isArray(currentGateway?.operations)
|
: Array.isArray(currentGateway && currentGateway.operations)
|
||||||
? cloneJson(currentGateway.operations)
|
? cloneJson(currentGateway.operations)
|
||||||
: undefined,
|
: undefined,
|
||||||
audit_logs: Array.isArray(nextGateway?.audit_logs)
|
audit_logs: Array.isArray(nextGateway && nextGateway.audit_logs)
|
||||||
? cloneJson(nextGateway.audit_logs)
|
? cloneJson(nextGateway.audit_logs)
|
||||||
: Array.isArray(currentGateway?.audit_logs)
|
: Array.isArray(currentGateway && currentGateway.audit_logs)
|
||||||
? cloneJson(currentGateway.audit_logs)
|
? cloneJson(currentGateway.audit_logs)
|
||||||
: undefined,
|
: undefined,
|
||||||
};
|
};
|
||||||
@@ -181,8 +308,12 @@ const cacheGatewaySnapshot = (gateway) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const cache = loadWorkspaceCache();
|
const cache = loadWorkspaceCache();
|
||||||
const gatewayId = String(gateway.id);
|
const sanitizedGateway = sanitizeGatewayCachePayload(gateway);
|
||||||
const mergedGateway = mergeGatewaySnapshots(cache.details[gatewayId]?.data || null, gateway);
|
const gatewayId = String(sanitizedGateway.id);
|
||||||
|
const mergedGateway = mergeGatewaySnapshots(
|
||||||
|
(cache.details[gatewayId] && cache.details[gatewayId].data) || null,
|
||||||
|
sanitizedGateway
|
||||||
|
);
|
||||||
cache.details[gatewayId] = {
|
cache.details[gatewayId] = {
|
||||||
cachedAt: Date.now(),
|
cachedAt: Date.now(),
|
||||||
data: mergedGateway,
|
data: mergedGateway,
|
||||||
@@ -195,12 +326,12 @@ const cacheGatewaySnapshot = (gateway) => {
|
|||||||
const matchesDepartment =
|
const matchesDepartment =
|
||||||
normalizeDepartmentId(params.departmentId) === null ||
|
normalizeDepartmentId(params.departmentId) === null ||
|
||||||
Number(params.departmentId) === Number(gateway.department_id);
|
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) {
|
if (existingIndex >= 0) {
|
||||||
rows[existingIndex] = mergeGatewaySnapshots(rows[existingIndex], gateway);
|
rows[existingIndex] = mergeGatewaySnapshots(rows[existingIndex], sanitizedGateway);
|
||||||
} else if (matchesDepartment) {
|
} else if (matchesDepartment) {
|
||||||
rows.unshift(cloneJson(gateway));
|
rows.unshift(cloneJson(sanitizedGateway));
|
||||||
}
|
}
|
||||||
|
|
||||||
cache.lists[cacheKey] = {
|
cache.lists[cacheKey] = {
|
||||||
@@ -216,19 +347,20 @@ const cacheGatewaySnapshot = (gateway) => {
|
|||||||
|
|
||||||
const cacheFleetSnapshot = ({ departmentId = null, view = "summary", rows = [], meta = {} } = {}) => {
|
const cacheFleetSnapshot = ({ departmentId = null, view = "summary", rows = [], meta = {} } = {}) => {
|
||||||
const cache = loadWorkspaceCache();
|
const cache = loadWorkspaceCache();
|
||||||
|
const sanitizedRows = sanitizeGatewayCachePayload(Array.isArray(rows) ? rows : []);
|
||||||
const key = listCacheKey({ departmentId, view });
|
const key = listCacheKey({ departmentId, view });
|
||||||
cache.lists[key] = {
|
cache.lists[key] = {
|
||||||
params: {
|
params: {
|
||||||
departmentId: normalizeDepartmentId(departmentId),
|
departmentId: normalizeDepartmentId(departmentId),
|
||||||
view: String(view || "summary"),
|
view: String(view || "summary"),
|
||||||
},
|
},
|
||||||
rows: cloneJson(Array.isArray(rows) ? rows : []),
|
rows: sanitizedRows,
|
||||||
meta: cloneJson(meta || {}),
|
meta: sanitizeGatewayCachePayload(meta || {}),
|
||||||
cachedAt: Date.now(),
|
cachedAt: Date.now(),
|
||||||
};
|
};
|
||||||
|
|
||||||
rows.forEach((gateway) => {
|
sanitizedRows.forEach((gateway) => {
|
||||||
if (!gateway?.id) {
|
if (!gateway || !gateway.id) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -236,7 +368,7 @@ const cacheFleetSnapshot = ({ departmentId = null, view = "summary", rows = [],
|
|||||||
if (!cache.details[gatewayId]) {
|
if (!cache.details[gatewayId]) {
|
||||||
cache.details[gatewayId] = {
|
cache.details[gatewayId] = {
|
||||||
cachedAt: Date.now(),
|
cachedAt: Date.now(),
|
||||||
data: cloneJson(gateway),
|
data: sanitizeGatewayCachePayload(gateway),
|
||||||
};
|
};
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -254,22 +386,23 @@ const cacheDepartmentsSnapshot = (departments) => {
|
|||||||
const cache = loadWorkspaceCache();
|
const cache = loadWorkspaceCache();
|
||||||
cache.departments = {
|
cache.departments = {
|
||||||
cachedAt: Date.now(),
|
cachedAt: Date.now(),
|
||||||
data: cloneJson(Array.isArray(departments) ? departments : []),
|
data: sanitizeGatewayCachePayload(Array.isArray(departments) ? departments : []),
|
||||||
};
|
};
|
||||||
persistWorkspaceCache();
|
persistWorkspaceCache();
|
||||||
};
|
};
|
||||||
|
|
||||||
const cacheGatewayFromResponse = (response) => {
|
const cacheGatewayFromResponse = (response) => {
|
||||||
const gateway = response?.data?.data;
|
const gateway = getDataPayload(response);
|
||||||
if (gateway?.id) {
|
if (gateway && gateway.id) {
|
||||||
cacheGatewaySnapshot(gateway);
|
cacheGatewaySnapshot(gateway);
|
||||||
}
|
}
|
||||||
return response;
|
return response;
|
||||||
};
|
};
|
||||||
|
|
||||||
const cacheGatewayFromNestedResponse = (response) => {
|
const cacheGatewayFromNestedResponse = (response) => {
|
||||||
const gateway = response?.data?.data?.gateway;
|
const payload = getDataPayload(response);
|
||||||
if (gateway?.id) {
|
const gateway = payload && payload.gateway;
|
||||||
|
if (gateway && gateway.id) {
|
||||||
cacheGatewaySnapshot(gateway);
|
cacheGatewaySnapshot(gateway);
|
||||||
}
|
}
|
||||||
return response;
|
return response;
|
||||||
@@ -292,17 +425,31 @@ const buildOperationPayload = (typeOrPayload, request = {}) => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const unwrapEdgeGatewayResponse = (response, fallback = null) => response?.data?.data ?? fallback;
|
export const unwrapEdgeGatewayResponse = (response, fallback = null) => {
|
||||||
export const unwrapEdgeGatewayMeta = (response) => response?.data?.meta ?? {};
|
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 = () => {
|
export const peekCachedEdgeGatewayDepartments = () => {
|
||||||
const departments = loadWorkspaceCache().departments?.data;
|
const entry = loadWorkspaceCache().departments;
|
||||||
return Array.isArray(departments) ? cloneJson(departments) : null;
|
const departments = entry && entry.data;
|
||||||
|
return isFreshCacheEntry(entry) && Array.isArray(departments) ? cloneJson(departments) : null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const peekCachedEdgeGatewayList = ({ departmentId = null, view = "summary" } = {}) => {
|
export const peekCachedEdgeGatewayList = ({ departmentId = null, view = "summary" } = {}) => {
|
||||||
const entry = loadWorkspaceCache().lists[listCacheKey({ departmentId, view })];
|
const entry = loadWorkspaceCache().lists[listCacheKey({ departmentId, view })];
|
||||||
if (!entry || !Array.isArray(entry.rows)) {
|
if (!isFreshCacheEntry(entry) || !Array.isArray(entry.rows)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -320,7 +467,7 @@ export const peekCachedEdgeGateway = (gatewayId) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const entry = loadWorkspaceCache().details[normalizedGatewayId];
|
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) => {
|
export const patchEdgeGatewayCache = (gatewayId, patch) => {
|
||||||
@@ -341,8 +488,8 @@ export const removeEdgeGatewayCache = (gatewayId) => {
|
|||||||
const cache = loadWorkspaceCache();
|
const cache = loadWorkspaceCache();
|
||||||
delete cache.details[normalizedGatewayId];
|
delete cache.details[normalizedGatewayId];
|
||||||
Object.entries(cache.lists || {}).forEach(([cacheKey, entry]) => {
|
Object.entries(cache.lists || {}).forEach(([cacheKey, entry]) => {
|
||||||
const rows = Array.isArray(entry?.rows)
|
const rows = Array.isArray(entry && entry.rows)
|
||||||
? entry.rows.filter((item) => String(item?.id || "") !== normalizedGatewayId)
|
? entry.rows.filter((item) => String((item && item.id) || "") !== normalizedGatewayId)
|
||||||
: [];
|
: [];
|
||||||
cache.lists[cacheKey] = {
|
cache.lists[cacheKey] = {
|
||||||
...(entry || {}),
|
...(entry || {}),
|
||||||
@@ -361,7 +508,7 @@ export const updateDepartmentGatewayCutoverCache = (departmentId, transportMode)
|
|||||||
|
|
||||||
const cache = loadWorkspaceCache();
|
const cache = loadWorkspaceCache();
|
||||||
Object.entries(cache.details || {}).forEach(([gatewayId, entry]) => {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -374,9 +521,9 @@ export const updateDepartmentGatewayCutoverCache = (departmentId, transportMode)
|
|||||||
});
|
});
|
||||||
|
|
||||||
Object.entries(cache.lists || {}).forEach(([cacheKey, entry]) => {
|
Object.entries(cache.lists || {}).forEach(([cacheKey, entry]) => {
|
||||||
const rows = Array.isArray(entry?.rows)
|
const rows = Array.isArray(entry && entry.rows)
|
||||||
? entry.rows.map((gateway) =>
|
? entry.rows.map((gateway) =>
|
||||||
Number(gateway?.department_id || 0) === normalizedDepartmentId
|
Number((gateway && gateway.department_id) || 0) === normalizedDepartmentId
|
||||||
? { ...gateway, department_transport_mode: transportMode }
|
? { ...gateway, department_transport_mode: transportMode }
|
||||||
: gateway
|
: gateway
|
||||||
)
|
)
|
||||||
@@ -397,7 +544,7 @@ export const listEdgeGatewayDepartments = async ({ forceRefresh = false } = {})
|
|||||||
}
|
}
|
||||||
|
|
||||||
const request = authenticatedRequest("/departments", "GET", {}).then((response) => {
|
const request = authenticatedRequest("/departments", "GET", {}).then((response) => {
|
||||||
cacheDepartmentsSnapshot(response?.data?.data || []);
|
cacheDepartmentsSnapshot(getDataPayload(response) || []);
|
||||||
return response;
|
return response;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -433,8 +580,8 @@ export const listEdgeGateways = async ({ departmentId = null, view = "summary",
|
|||||||
cacheFleetSnapshot({
|
cacheFleetSnapshot({
|
||||||
departmentId,
|
departmentId,
|
||||||
view,
|
view,
|
||||||
rows: response?.data?.data || [],
|
rows: getDataPayload(response) || [],
|
||||||
meta: response?.data?.meta || {},
|
meta: getMetaPayload(response) || {},
|
||||||
});
|
});
|
||||||
return response;
|
return response;
|
||||||
});
|
});
|
||||||
@@ -460,8 +607,8 @@ export const getEdgeGateway = async (gatewayId, { forceRefresh = false } = {}) =
|
|||||||
|
|
||||||
const request = authenticatedRequest(`${EDGE_GATEWAY_BASE}/${encodeURIComponent(gatewayId)}`, "GET", {}).then(
|
const request = authenticatedRequest(`${EDGE_GATEWAY_BASE}/${encodeURIComponent(gatewayId)}`, "GET", {}).then(
|
||||||
(response) => {
|
(response) => {
|
||||||
const gateway = response?.data?.data;
|
const gateway = getDataPayload(response);
|
||||||
if (gateway?.id) {
|
if (gateway && gateway.id) {
|
||||||
cacheGatewaySnapshot(gateway);
|
cacheGatewaySnapshot(gateway);
|
||||||
}
|
}
|
||||||
return response;
|
return response;
|
||||||
@@ -602,7 +749,7 @@ const normalizeRelayTestTransport = (transport) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const normalizeRelayTestTimer = (seconds) => {
|
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;
|
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 { API_URL, IS_DEV, RELEASE_MANAGER_CONTROL_API_URL, RELEASE_SOURCE, RELEASE_SOURCE_ENV } from "@/config.js";
|
||||||
import { buildReleaseHeaders } from "@/services/releaseHeaders.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_RUNTIME_GLOBAL_KEY = "__TRUCKWASH_RELEASE_RUNTIME__";
|
||||||
export const RELEASE_CHANNEL_SELECTION_STORAGE_KEY = "release_channel_selected_slug";
|
export const RELEASE_CHANNEL_SELECTION_STORAGE_KEY = "release_channel_selected_slug";
|
||||||
@@ -172,7 +173,8 @@ export const fetchReleaseRuntime = async ({
|
|||||||
|
|
||||||
const runtimeFrontendBaseUrl = (runtime = {}) => {
|
const runtimeFrontendBaseUrl = (runtime = {}) => {
|
||||||
const urls = runtime?.urls && typeof runtime.urls === "object" ? runtime.urls : {};
|
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 || {};
|
const runtimeChannel = (runtime = {}) => runtime?.channel || {};
|
||||||
@@ -190,8 +192,14 @@ export const shouldLoadRemoteRelease = (runtime = {}) => {
|
|||||||
export const releaseEntryUrl = (frontendBaseUrl) =>
|
export const releaseEntryUrl = (frontendBaseUrl) =>
|
||||||
new URL(RELEASE_ENTRY_FILENAME, `${normalizeBaseUrl(frontendBaseUrl)}/`).href;
|
new URL(RELEASE_ENTRY_FILENAME, `${normalizeBaseUrl(frontendBaseUrl)}/`).href;
|
||||||
|
|
||||||
const resolveReleaseAssetUrl = (frontendBaseUrl, value) =>
|
const resolveReleaseAssetUrl = (frontendBaseUrl, value) => {
|
||||||
new URL(String(value || "").replace(/^\/+/, ""), `${normalizeBaseUrl(frontendBaseUrl)}/`).href;
|
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 ({
|
export const loadRemoteReleaseEntry = async ({
|
||||||
runtime,
|
runtime,
|
||||||
@@ -200,6 +208,9 @@ export const loadRemoteReleaseEntry = async ({
|
|||||||
importModule = (url) => import(/* @vite-ignore */ url),
|
importModule = (url) => import(/* @vite-ignore */ url),
|
||||||
} = {}) => {
|
} = {}) => {
|
||||||
const frontendBaseUrl = runtimeFrontendBaseUrl(runtime);
|
const frontendBaseUrl = runtimeFrontendBaseUrl(runtime);
|
||||||
|
if (!frontendBaseUrl) {
|
||||||
|
throw new Error("Release frontend URL is not trusted.");
|
||||||
|
}
|
||||||
const response = await fetchFn(releaseEntryUrl(frontendBaseUrl), {
|
const response = await fetchFn(releaseEntryUrl(frontendBaseUrl), {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
cache: "no-store",
|
cache: "no-store",
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { reactive, readonly } from "vue";
|
import { reactive, readonly } from "vue";
|
||||||
import { API_URL } from "@/config.js";
|
import { API_URL } from "@/config.js";
|
||||||
|
import { isTrustedReleaseUrl } from "@/services/releaseTrust.js";
|
||||||
import { buildReleaseHeaders, RELEASE_TRACE_STORAGE_KEY } from "@/services/releaseHeaders.js";
|
import { buildReleaseHeaders, RELEASE_TRACE_STORAGE_KEY } from "@/services/releaseHeaders.js";
|
||||||
|
|
||||||
const TRACE_STORAGE_KEY = RELEASE_TRACE_STORAGE_KEY;
|
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(/\/+$/, "");
|
const raw = String(value || "").trim().replace(/\/+$/, "");
|
||||||
if (!raw) {
|
if (!raw || !isTrustedReleaseUrl(raw, { allowRelative })) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (raw.startsWith("/") && !raw.startsWith("//")) {
|
if (raw.startsWith("/") && !raw.startsWith("//")) {
|
||||||
@@ -114,7 +115,9 @@ const normalizeRuntimeBaseUrl = (value) => {
|
|||||||
const runtimeUrls = (runtime = {}) => {
|
const runtimeUrls = (runtime = {}) => {
|
||||||
const urls = runtime?.urls && typeof runtime.urls === "object" ? runtime.urls : {};
|
const urls = runtime?.urls && typeof runtime.urls === "object" ? runtime.urls : {};
|
||||||
return {
|
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),
|
apiBaseUrl: normalizeRuntimeBaseUrl(runtime.api_base_url ?? urls.api_base_url),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -253,6 +256,47 @@ export const rewriteReleaseApiUrl = (url = "") => {
|
|||||||
return value;
|
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) => {
|
export const redactReleasePayload = (value, depth = 0) => {
|
||||||
if (depth > 8) {
|
if (depth > 8) {
|
||||||
return "[depth-limit]";
|
return "[depth-limit]";
|
||||||
@@ -267,9 +311,7 @@ export const redactReleasePayload = (value, depth = 0) => {
|
|||||||
Object.entries(value)
|
Object.entries(value)
|
||||||
.slice(0, 80)
|
.slice(0, 80)
|
||||||
.map(([key, item]) => {
|
.map(([key, item]) => {
|
||||||
if (
|
if (SENSITIVE_PAYLOAD_KEY_PATTERN.test(key)) {
|
||||||
/authorization|cookie|password|passwd|secret|token|api[_-]?key|session|credential|card|cpr|ssn/i.test(key)
|
|
||||||
) {
|
|
||||||
return [key, "[redacted]"];
|
return [key, "[redacted]"];
|
||||||
}
|
}
|
||||||
return [key, redactReleasePayload(item, depth + 1)];
|
return [key, redactReleasePayload(item, depth + 1)];
|
||||||
@@ -277,8 +319,8 @@ export const redactReleasePayload = (value, depth = 0) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof value === "string" && value.length > 4000) {
|
if (typeof value === "string") {
|
||||||
return `${value.slice(0, 4000)}\n... [truncated]`;
|
return redactSerializedReleasePayload(value, depth);
|
||||||
}
|
}
|
||||||
|
|
||||||
return value;
|
return value;
|
||||||
@@ -303,6 +345,30 @@ const shouldSendEvent = (type, severity) => {
|
|||||||
return releaseRuntimeStateMutable.capturePolicy.enabled === true;
|
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 = {}) => {
|
export const recordReleaseTimelineEvent = (type, payload = {}, options = {}) => {
|
||||||
const severity = options.severity || payload?.severity || "info";
|
const severity = options.severity || payload?.severity || "info";
|
||||||
if (!shouldSendEvent(type, severity)) {
|
if (!shouldSendEvent(type, severity)) {
|
||||||
@@ -317,7 +383,7 @@ export const recordReleaseTimelineEvent = (type, payload = {}, options = {}) =>
|
|||||||
component: options.component || payload?.component || null,
|
component: options.component || payload?.component || null,
|
||||||
request_id: options.requestId || payload?.request_id || null,
|
request_id: options.requestId || payload?.request_id || null,
|
||||||
occurred_at: new Date().toISOString(),
|
occurred_at: new Date().toISOString(),
|
||||||
payload: redactReleasePayload(payload),
|
payload: redactReleasePayload(sanitizeReleaseTimelinePayload(type, severity, payload)),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (FRONTEND_FAILURE_EVENT_TYPES.has(type)) {
|
if (FRONTEND_FAILURE_EVENT_TYPES.has(type)) {
|
||||||
@@ -421,7 +487,9 @@ const firstFilledString = (...values) => {
|
|||||||
const releaseRuntimeUrlsForDisplay = (runtime = {}) => {
|
const releaseRuntimeUrlsForDisplay = (runtime = {}) => {
|
||||||
const urls = isPlainRecord(runtime?.urls) ? runtime.urls : {};
|
const urls = isPlainRecord(runtime?.urls) ? runtime.urls : {};
|
||||||
return {
|
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),
|
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;
|
return isPlainRecord(service) ? service : null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const buildReleaseSessionSummary = (runtime = releaseRuntimeStateMutable) => {
|
export const buildReleaseSessionSummary = (runtime = releaseRuntimeStateMutable, options = {}) => {
|
||||||
const versions = isPlainRecord(runtime?.versions) ? runtime.versions : {};
|
const versions = isPlainRecord(runtime?.versions) ? runtime.versions : {};
|
||||||
const channel = isPlainRecord(runtime?.channel) ? runtime.channel : null;
|
const channel = isPlainRecord(runtime?.channel) ? runtime.channel : null;
|
||||||
const availability = isPlainRecord(runtime?.availability) ? runtime.availability : {};
|
const availability = isPlainRecord(runtime?.availability) ? runtime.availability : {};
|
||||||
@@ -595,16 +663,18 @@ export const buildReleaseSessionSummary = (runtime = releaseRuntimeStateMutable)
|
|||||||
channel?.default_channel === true
|
channel?.default_channel === true
|
||||||
|| channel?.default_channel === 1
|
|| channel?.default_channel === 1
|
||||||
|| String(channel?.slug || "").toLowerCase() === "stable";
|
|| 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 frontendVersion = isPlainRecord(versions.frontend) ? versions.frontend : null;
|
||||||
const apiVersion = isPlainRecord(versions.api) ? versions.api : null;
|
const apiVersion = isPlainRecord(versions.api) ? versions.api : null;
|
||||||
const bundle = isPlainRecord(versions.bundle) ? versions.bundle : null;
|
const bundle = isPlainRecord(versions.bundle) ? versions.bundle : null;
|
||||||
const bundleId = versions.bundle_id || bundle?.id || null;
|
const bundleId = versions.bundle_id || bundle?.id || null;
|
||||||
const serviceSet = isPlainRecord(versions.service_set)
|
const rawServiceSet = isPlainRecord(versions.service_set)
|
||||||
? versions.service_set
|
? versions.service_set
|
||||||
: isPlainRecord(bundle?.service_set)
|
: isPlainRecord(bundle?.service_set)
|
||||||
? bundle.service_set
|
? bundle.service_set
|
||||||
: null;
|
: null;
|
||||||
|
const serviceSet = includeInfrastructureDetails ? rawServiceSet : null;
|
||||||
const defaultSharedLabel = "Default/shared runtime";
|
const defaultSharedLabel = "Default/shared runtime";
|
||||||
const missingLabels = missing.map((key) => RELEASE_MISSING_LABELS[key] || key.replace(/_/g, " "));
|
const missingLabels = missing.map((key) => RELEASE_MISSING_LABELS[key] || key.replace(/_/g, " "));
|
||||||
|
|
||||||
@@ -623,15 +693,19 @@ export const buildReleaseSessionSummary = (runtime = releaseRuntimeStateMutable)
|
|||||||
? "shared"
|
? "shared"
|
||||||
: firstFilledString(version?.status, url ? "active" : "unknown");
|
: firstFilledString(version?.status, url ? "active" : "unknown");
|
||||||
|
|
||||||
|
const primaryText = releaseVersionPrimaryText(version, fallback);
|
||||||
|
const secondaryText = includeInfrastructureDetails ? releaseVersionSecondaryText(version) : "";
|
||||||
|
const displayUrl = includeInfrastructureDetails ? url || "" : "";
|
||||||
|
|
||||||
return {
|
return {
|
||||||
key,
|
key,
|
||||||
label,
|
label,
|
||||||
status,
|
status,
|
||||||
tone: missingKey ? "warning" : releaseStatusTone(status),
|
tone: missingKey ? "warning" : releaseStatusTone(status),
|
||||||
primaryText: releaseVersionPrimaryText(version, fallback),
|
primaryText,
|
||||||
secondaryText: releaseVersionSecondaryText(version),
|
secondaryText,
|
||||||
url: url || "",
|
url: displayUrl,
|
||||||
title: [releaseVersionPrimaryText(version, fallback), releaseVersionSecondaryText(version), url]
|
title: [primaryText, secondaryText, displayUrl]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(" - "),
|
.join(" - "),
|
||||||
missingLabel: missingKey ? RELEASE_MISSING_LABELS[missingKey] || missingKey : "",
|
missingLabel: missingKey ? RELEASE_MISSING_LABELS[missingKey] || missingKey : "",
|
||||||
@@ -687,13 +761,15 @@ export const buildReleaseSessionSummary = (runtime = releaseRuntimeStateMutable)
|
|||||||
bundleStatus: firstFilledString(bundle?.status, bundleId ? "active" : "shared"),
|
bundleStatus: firstFilledString(bundle?.status, bundleId ? "active" : "shared"),
|
||||||
serviceSetLabel: serviceSet
|
serviceSetLabel: serviceSet
|
||||||
? firstFilledString(serviceSet.name, serviceSet.slug, serviceSet.id ? `#${serviceSet.id}` : "Connected")
|
? firstFilledString(serviceSet.name, serviceSet.slug, serviceSet.id ? `#${serviceSet.id}` : "Connected")
|
||||||
: defaultSharedLabel,
|
: rawServiceSet
|
||||||
|
? "Restricted to release operators"
|
||||||
|
: defaultSharedLabel,
|
||||||
missingLabels,
|
missingLabels,
|
||||||
appRows: [
|
appRows: [
|
||||||
buildAppRow("frontend", "Frontend", frontendVersion, urls.frontend),
|
buildAppRow("frontend", "Frontend", frontendVersion, urls.frontend),
|
||||||
buildAppRow("api", "API", apiVersion, urls.api),
|
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 { API_URL, RELEASE_MANAGER_CONTROL_API_FALLBACK_URLS, RELEASE_MANAGER_CONTROL_API_URL } from "@/config.js";
|
||||||
import { enqueueRequest } from "@/services/requestQueue.js";
|
import { enqueueRequest } from "@/services/requestQueue.js";
|
||||||
import { getReleaseRuntimeApiBaseUrl, releaseRuntimeState } from "@/services/releaseTimeline.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";
|
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";
|
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;
|
return url;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isTrustedApiUrl = (value) => {
|
||||||
|
const url = normalizeApiUrl(value);
|
||||||
|
return Boolean(url && isTrustedReleaseUrl(url));
|
||||||
|
};
|
||||||
|
|
||||||
const isLocalApiUrl = (value) => {
|
const isLocalApiUrl = (value) => {
|
||||||
const url = normalizeApiUrl(value);
|
const url = normalizeApiUrl(value);
|
||||||
if (!url) {
|
if (!isTrustedApiUrl(url)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (url.startsWith("/") && !url.startsWith("//")) {
|
if (url.startsWith("/") && !url.startsWith("//")) {
|
||||||
@@ -91,14 +97,14 @@ export const releaseManagerControlApiCandidates = () => {
|
|||||||
storedUrl,
|
storedUrl,
|
||||||
...deploymentConfigUrls,
|
...deploymentConfigUrls,
|
||||||
...RELEASE_MANAGER_CONTROL_API_FALLBACK_URLS,
|
...RELEASE_MANAGER_CONTROL_API_FALLBACK_URLS,
|
||||||
]);
|
]).filter(isTrustedApiUrl);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getReleaseManagerControlApiUrl = () => releaseManagerControlApiCandidates()[0] || API_URL;
|
export const getReleaseManagerControlApiUrl = () => releaseManagerControlApiCandidates()[0] || API_URL;
|
||||||
|
|
||||||
export const setReleaseManagerControlApiUrl = (url) => {
|
export const setReleaseManagerControlApiUrl = (url) => {
|
||||||
const normalized = normalizeApiUrl(url);
|
const normalized = normalizeApiUrl(url);
|
||||||
if (!normalized) {
|
if (!isTrustedApiUrl(normalized)) {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
releaseManagerLocalStorage()?.setItem(RELEASE_MANAGER_CONTROL_API_STORAGE_KEY, normalized);
|
releaseManagerLocalStorage()?.setItem(RELEASE_MANAGER_CONTROL_API_STORAGE_KEY, normalized);
|
||||||
@@ -113,7 +119,7 @@ export const clearReleaseManagerControlApiUrl = () => {
|
|||||||
|
|
||||||
const rememberWorkingControlApiUrl = (url) => {
|
const rememberWorkingControlApiUrl = (url) => {
|
||||||
const normalized = normalizeApiUrl(url);
|
const normalized = normalizeApiUrl(url);
|
||||||
if (normalized) {
|
if (isTrustedApiUrl(normalized)) {
|
||||||
releaseManagerSessionStorage()?.setItem(RELEASE_MANAGER_LAST_WORKING_CONTROL_API_STORAGE_KEY, 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 { sounds } from "@/components/displays/department/pos/steps/mobile/objects/PosDepartmentStepMobileFlow.vue";
|
||||||
import PageLoader from "@/components/global/PageLoader.vue";
|
import PageLoader from "@/components/global/PageLoader.vue";
|
||||||
import Swal from "sweetalert2";
|
import Swal from "sweetalert2";
|
||||||
|
import { clearEdgeGatewayWorkspaceCache } from "@/services/edgeGateways.js";
|
||||||
|
|
||||||
const isProcessingQRCode = ref(false); // State to indicate if QR code is being processed
|
const isProcessingQRCode = ref(false); // State to indicate if QR code is being processed
|
||||||
const playCaptureSound = () => {
|
const playCaptureSound = () => {
|
||||||
@@ -20,6 +21,7 @@ const playCaptureSound = () => {
|
|||||||
|
|
||||||
const applyToken = (token: string) => {
|
const applyToken = (token: string) => {
|
||||||
isProcessingQRCode.value = true; // Start processing
|
isProcessingQRCode.value = true; // Start processing
|
||||||
|
clearEdgeGatewayWorkspaceCache();
|
||||||
localStorage.setItem("token", token);
|
localStorage.setItem("token", token);
|
||||||
window.location.href = "/";
|
window.location.href = "/";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, onUnmounted, ref, computed } from "vue";
|
import { onMounted, onUnmounted, ref, computed } from "vue";
|
||||||
import PageLoader from "@/components/global/PageLoader.vue";
|
import PageLoader from "@/components/global/PageLoader.vue";
|
||||||
|
import { clearEdgeGatewayWorkspaceCache } from "@/services/edgeGateways.js";
|
||||||
|
|
||||||
const isProcessing = ref(false);
|
const isProcessing = ref(false);
|
||||||
const hasToken = computed(() => localStorage.getItem("superuser_token") !== null);
|
const hasToken = computed(() => localStorage.getItem("superuser_token") !== null);
|
||||||
|
|
||||||
const applyToken = (token: string) => {
|
const applyToken = (token: string) => {
|
||||||
isProcessing.value = true; // Start processing
|
isProcessing.value = true; // Start processing
|
||||||
|
clearEdgeGatewayWorkspaceCache();
|
||||||
localStorage.setItem("token", token);
|
localStorage.setItem("token", token);
|
||||||
localStorage.removeItem("superuser_token");
|
localStorage.removeItem("superuser_token");
|
||||||
window.location.href = "/";
|
window.location.href = "/";
|
||||||
|
|||||||
+67
-6
@@ -95,6 +95,25 @@ const getDominantWeather = (entries) => {
|
|||||||
return dominant;
|
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 summarizeEntriesByDay = (entries) => {
|
||||||
const groupedByDate = new Map();
|
const groupedByDate = new Map();
|
||||||
|
|
||||||
@@ -120,14 +139,48 @@ const summarizeEntriesByDay = (entries) => {
|
|||||||
time: "00:00",
|
time: "00:00",
|
||||||
current: dayEntries.some((entry) => entry?.current === true),
|
current: dayEntries.some((entry) => entry?.current === true),
|
||||||
weather: getDominantWeather(dayEntries),
|
weather: getDominantWeather(dayEntries),
|
||||||
washes: Number.isInteger(washes) ? washes : Number(washes.toFixed(2)),
|
washes: formatMergedNumber(washes),
|
||||||
hours: Number(hours.toFixed(2)),
|
hours: Number(hours.toFixed(2)),
|
||||||
/**
|
/**
|
||||||
* Below 1.0 cars/workhour = unhealthy (red)
|
* Below 1.0 cars/workhour = unhealthy (red)
|
||||||
* 1.0-1.3 cars/workhour = degraded (yellow)
|
* 1.0-1.3 cars/workhour = degraded (yellow)
|
||||||
* Above 1.3 cars/workhour = healthy (green)
|
* 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));
|
return state.value.filter((entry) => hasOperationalData(entry));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const hourlyMergedDisplayedState = computed(() => {
|
||||||
|
return summarizeEntriesByHourOfDay(hourlyDisplayedState.value).filter((entry) => hasOperationalData(entry));
|
||||||
|
});
|
||||||
|
|
||||||
const dailyDisplayedState = computed(() => {
|
const dailyDisplayedState = computed(() => {
|
||||||
return summarizeEntriesByDay(state.value).filter((entry) => hasOperationalData(entry));
|
return summarizeEntriesByDay(state.value).filter((entry) => hasOperationalData(entry));
|
||||||
});
|
});
|
||||||
@@ -144,6 +201,9 @@ const displayedState = computed(() => {
|
|||||||
if (summarizeDaily.value && isMultiDaySelection.value) {
|
if (summarizeDaily.value && isMultiDaySelection.value) {
|
||||||
return dailyDisplayedState.value;
|
return dailyDisplayedState.value;
|
||||||
}
|
}
|
||||||
|
if (isMultiDaySelection.value) {
|
||||||
|
return hourlyMergedDisplayedState.value;
|
||||||
|
}
|
||||||
return hourlyDisplayedState.value;
|
return hourlyDisplayedState.value;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -167,7 +227,9 @@ const getEntryKey = (entry, index) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getEntryTestId = (prefix, entry) => {
|
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";
|
const time = typeof entry?.time === "string" && entry.time !== "" ? entry.time : "no-time";
|
||||||
return `department-weather-${prefix}-${date}-${time}`;
|
return `department-weather-${prefix}-${date}-${time}`;
|
||||||
};
|
};
|
||||||
@@ -177,9 +239,8 @@ const getHourDetailsSlotKey = (entry) => {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const timeValue = typeof entry?.time === "string" ? entry.time : "";
|
const normalizedTime = getNormalizedTime(entry);
|
||||||
const normalizedTime = timeValue.slice(0, 5);
|
if (!normalizedTime) {
|
||||||
if (!/^\d{2}:\d{2}$/.test(normalizedTime)) {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+136
-112
@@ -1,5 +1,6 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||||
|
import { storeToRefs } from "pinia";
|
||||||
import { useRoute, useRouter } from "vue-router";
|
import { useRoute, useRouter } from "vue-router";
|
||||||
import { Handle, Position, VueFlow, useVueFlow } from "@vue-flow/core";
|
import { Handle, Position, VueFlow, useVueFlow } from "@vue-flow/core";
|
||||||
import "@vue-flow/core/dist/style.css";
|
import "@vue-flow/core/dist/style.css";
|
||||||
@@ -18,6 +19,7 @@ import {
|
|||||||
isSearching as isSearchingCustomers,
|
isSearching as isSearchingCustomers,
|
||||||
} from "@/components/search/economic/customerSearch.vue";
|
} from "@/components/search/economic/customerSearch.vue";
|
||||||
import { resolveReleaseApiUrl } from "@/services/releaseTimeline.js";
|
import { resolveReleaseApiUrl } from "@/services/releaseTimeline.js";
|
||||||
|
import { safeAttachmentDownloadLink } from "@/services/attachmentDownloadLinks.js";
|
||||||
import {
|
import {
|
||||||
buildSelfServeDynamicImageUrl,
|
buildSelfServeDynamicImageUrl,
|
||||||
getSelfServeCompletedDynamicImageStep,
|
getSelfServeCompletedDynamicImageStep,
|
||||||
@@ -33,6 +35,7 @@ import {
|
|||||||
formatSelfServeTaskButtons,
|
formatSelfServeTaskButtons,
|
||||||
normalizeSelfServeTaskButtons,
|
normalizeSelfServeTaskButtons,
|
||||||
} from "@/components/session/token/SessionUser/Objects/selfServeTaskButtons.js";
|
} from "@/components/session/token/SessionUser/Objects/selfServeTaskButtons.js";
|
||||||
|
import { useSelfServeStudioStore } from "@/stores/selfServeStudioStore.js";
|
||||||
import { resolveAuditIssueNodeIds } from "./auditIssueNavigation.js";
|
import { resolveAuditIssueNodeIds } from "./auditIssueNavigation.js";
|
||||||
|
|
||||||
const currentRoute = useRoute();
|
const currentRoute = useRoute();
|
||||||
@@ -366,111 +369,88 @@ const AUTO_LAYOUT_RUNTIME_STAGE_ORDER = {
|
|||||||
finish: 2,
|
finish: 2,
|
||||||
};
|
};
|
||||||
|
|
||||||
const flowNodes = ref([]);
|
const studioStore = useSelfServeStudioStore();
|
||||||
const flowEdges = ref([]);
|
const {
|
||||||
const graphPayload = ref(null);
|
flowNodes,
|
||||||
const lookups = ref({});
|
flowEdges,
|
||||||
const validation = ref({ valid: true, items: [], errors: [], warnings: [] });
|
graphPayload,
|
||||||
const gatewayWorkspace = ref({ gateways: [], relays: [], lanes: [], issues: [], actions: [] });
|
lookups,
|
||||||
const versions = ref([]);
|
validation,
|
||||||
const activePanel = ref(panelFromRoute() || "flow");
|
gatewayWorkspace,
|
||||||
const selectedNodeId = ref(null);
|
versions,
|
||||||
const inspectorForm = ref({});
|
activePanel,
|
||||||
const simulatorForm = ref({
|
selectedNodeId,
|
||||||
lane_id: null,
|
inspectorForm,
|
||||||
reg: "TEST123",
|
simulatorForm,
|
||||||
customer_number: null,
|
simulatorResult,
|
||||||
config_source: "draft",
|
simulatorAnswerOverrides,
|
||||||
include_hardware: true,
|
simulatorShowJson,
|
||||||
hardware_mode: "studio",
|
simulatorDynamicImageHidden,
|
||||||
});
|
simulatorDynamicImageError,
|
||||||
const simulatorResult = ref(null);
|
simulatorCompletedTasks,
|
||||||
const simulatorAnswerOverrides = ref({});
|
isSimulatorRunning,
|
||||||
const simulatorShowJson = ref(false);
|
simulatorRunReason,
|
||||||
const simulatorDynamicImageHidden = ref(false);
|
simulatorRequestSequence,
|
||||||
const simulatorDynamicImageError = ref("");
|
simulatorLastRequestKey,
|
||||||
const simulatorCompletedTasks = ref({});
|
simulatorLastError,
|
||||||
const isSimulatorRunning = ref(false);
|
isRestoringSimulatorStorage,
|
||||||
const simulatorRunReason = ref("");
|
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 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 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 layoutSaveTimer = ref(null);
|
||||||
const suppressLayoutSave = ref(false);
|
|
||||||
const exportText = ref("");
|
|
||||||
const importText = ref("");
|
|
||||||
const draggedTemplateKind = ref(null);
|
|
||||||
const studioRoot = ref(null);
|
const studioRoot = ref(null);
|
||||||
const unfilteredNodePositions = ref(null);
|
|
||||||
|
|
||||||
const graphStats = computed(() => {
|
const graphStats = computed(() => {
|
||||||
const counts = {};
|
const counts = {};
|
||||||
@@ -482,6 +462,18 @@ const graphStats = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const permissions = computed(() => graphPayload.value?.permissions || {});
|
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 selectedNode = computed(() => flowNodes.value.find((node) => node.id === selectedNodeId.value) || null);
|
||||||
const selectedNodeKind = computed(() => selectedNode.value?.data?.kind || "");
|
const selectedNodeKind = computed(() => selectedNode.value?.data?.kind || "");
|
||||||
const editableNodeKinds = ["question", "condition", "task", "action", "lane"];
|
const editableNodeKinds = ["question", "condition", "task", "action", "lane"];
|
||||||
@@ -3149,14 +3141,14 @@ const inspectorTaskDynamicImageUnavailableReason = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const openTaskAttachment = async (attachment, taskId = null) => {
|
const openTaskAttachment = async (attachment, taskId = null) => {
|
||||||
let downloadLink = String(attachment?.download_link || "").trim();
|
let downloadLink = safeAttachmentDownloadLink(attachment?.download_link);
|
||||||
if (!downloadLink && taskId && attachment?.id) {
|
if (!downloadLink && taskId && attachment?.id) {
|
||||||
try {
|
try {
|
||||||
const payload = await requestGet("/department/selfserve/tasks/attachments/download", {
|
const payload = await requestGet("/department/selfserve/tasks/attachments/download", {
|
||||||
task_id: taskId,
|
task_id: taskId,
|
||||||
attachment_id: attachment.id,
|
attachment_id: attachment.id,
|
||||||
});
|
});
|
||||||
downloadLink = String(payload?.download_link || "").trim();
|
downloadLink = safeAttachmentDownloadLink(payload?.download_link);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
withErrorToast(error, "Attachment download could not be prepared.");
|
withErrorToast(error, "Attachment download could not be prepared.");
|
||||||
return;
|
return;
|
||||||
@@ -4677,6 +4669,11 @@ const deleteSelected = async () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const saveVehicleTypeProduct = async () => {
|
const saveVehicleTypeProduct = async () => {
|
||||||
|
if (!canEditVehicleTypeProduct.value) {
|
||||||
|
toast.error("Only superusers can update vehicle type product catalog fields.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const id = parseNullableInt(
|
const id = parseNullableInt(
|
||||||
inspectorForm.value.id || selectedRaw.value.product || selectedRaw.value.product_id || selectedRaw.value.id
|
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 {
|
try {
|
||||||
await requestPost("/department/selfserve/studio/gateway-action", {
|
await requestPost("/department/selfserve/studio/gateway-action", {
|
||||||
department: departmentId.value,
|
department: departmentId.value,
|
||||||
@@ -7026,7 +7042,7 @@ onBeforeUnmount(() => {
|
|||||||
class="studio-debug-attachment"
|
class="studio-debug-attachment"
|
||||||
:disabled="!attachment.download_link"
|
:disabled="!attachment.download_link"
|
||||||
:title="taskAttachmentLabel(attachment)"
|
: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 class="icon is-small"><i :class="taskAttachmentIcon(attachment)"></i></span>
|
||||||
<span>{{ taskAttachmentLabel(attachment) }}</span>
|
<span>{{ taskAttachmentLabel(attachment) }}</span>
|
||||||
@@ -7484,7 +7500,7 @@ onBeforeUnmount(() => {
|
|||||||
class="studio-node-attachment"
|
class="studio-node-attachment"
|
||||||
:disabled="!attachment.download_link"
|
:disabled="!attachment.download_link"
|
||||||
:title="taskAttachmentLabel(attachment)"
|
:title="taskAttachmentLabel(attachment)"
|
||||||
@click.stop="openTaskAttachment(attachment)"
|
@click.stop="openTaskAttachment(attachment, data.object_id)"
|
||||||
@mousedown.stop
|
@mousedown.stop
|
||||||
>
|
>
|
||||||
<span class="icon is-small"><i :class="taskAttachmentIcon(attachment)"></i></span>
|
<span class="icon is-small"><i :class="taskAttachmentIcon(attachment)"></i></span>
|
||||||
@@ -8025,7 +8041,7 @@ onBeforeUnmount(() => {
|
|||||||
/>
|
/>
|
||||||
<span>Wash product</span>
|
<span>Wash product</span>
|
||||||
</label>
|
</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 class="icon"><i class="fas fa-floppy-disk"></i></span>
|
||||||
<span>Save vehicle type</span>
|
<span>Save vehicle type</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -8102,6 +8118,7 @@ onBeforeUnmount(() => {
|
|||||||
type="button"
|
type="button"
|
||||||
title="Discover gateway"
|
title="Discover gateway"
|
||||||
@click="runGatewayAction(inspectorForm.key || selectedGatewayId, 'discovery')"
|
@click="runGatewayAction(inspectorForm.key || selectedGatewayId, 'discovery')"
|
||||||
|
:disabled="!canRunGatewayAction('discovery')"
|
||||||
>
|
>
|
||||||
<span class="icon"><i class="fas fa-radar"></i></span>
|
<span class="icon"><i class="fas fa-radar"></i></span>
|
||||||
<span>Discover</span>
|
<span>Discover</span>
|
||||||
@@ -8111,6 +8128,7 @@ onBeforeUnmount(() => {
|
|||||||
type="button"
|
type="button"
|
||||||
title="Update gateway"
|
title="Update gateway"
|
||||||
@click="runGatewayAction(inspectorForm.key || selectedGatewayId, 'update')"
|
@click="runGatewayAction(inspectorForm.key || selectedGatewayId, 'update')"
|
||||||
|
:disabled="!canRunGatewayAction('update')"
|
||||||
>
|
>
|
||||||
<span class="icon"><i class="fas fa-upload"></i></span>
|
<span class="icon"><i class="fas fa-upload"></i></span>
|
||||||
<span>Update</span>
|
<span>Update</span>
|
||||||
@@ -8119,7 +8137,8 @@ onBeforeUnmount(() => {
|
|||||||
class="button is-small is-warning is-light"
|
class="button is-small is-warning is-light"
|
||||||
type="button"
|
type="button"
|
||||||
title="Rotate gateway credentials"
|
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 class="icon"><i class="fas fa-key"></i></span>
|
||||||
<span>Rotate</span>
|
<span>Rotate</span>
|
||||||
@@ -8128,7 +8147,8 @@ onBeforeUnmount(() => {
|
|||||||
class="button is-small is-danger is-light"
|
class="button is-small is-danger is-light"
|
||||||
type="button"
|
type="button"
|
||||||
title="Uninstall gateway"
|
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 class="icon"><i class="fas fa-trash"></i></span>
|
||||||
<span>Uninstall</span>
|
<span>Uninstall</span>
|
||||||
@@ -9316,6 +9336,7 @@ onBeforeUnmount(() => {
|
|||||||
class="button is-small is-link is-light"
|
class="button is-small is-link is-light"
|
||||||
title="Discover gateway"
|
title="Discover gateway"
|
||||||
@click="runGatewayAction(gateway.id, 'discovery')"
|
@click="runGatewayAction(gateway.id, 'discovery')"
|
||||||
|
:disabled="!canRunGatewayAction('discovery')"
|
||||||
>
|
>
|
||||||
<span class="icon"><i class="fas fa-radar"></i></span>
|
<span class="icon"><i class="fas fa-radar"></i></span>
|
||||||
</button>
|
</button>
|
||||||
@@ -9323,20 +9344,23 @@ onBeforeUnmount(() => {
|
|||||||
class="button is-small is-light"
|
class="button is-small is-light"
|
||||||
title="Update gateway"
|
title="Update gateway"
|
||||||
@click="runGatewayAction(gateway.id, 'update')"
|
@click="runGatewayAction(gateway.id, 'update')"
|
||||||
|
:disabled="!canRunGatewayAction('update')"
|
||||||
>
|
>
|
||||||
<span class="icon"><i class="fas fa-upload"></i></span>
|
<span class="icon"><i class="fas fa-upload"></i></span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="button is-small is-warning is-light"
|
class="button is-small is-warning is-light"
|
||||||
title="Rotate gateway credentials"
|
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>
|
<span class="icon"><i class="fas fa-key"></i></span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="button is-small is-danger is-light"
|
class="button is-small is-danger is-light"
|
||||||
title="Uninstall gateway"
|
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>
|
<span class="icon"><i class="fas fa-trash"></i></span>
|
||||||
</button>
|
</button>
|
||||||
@@ -9802,7 +9826,7 @@ onBeforeUnmount(() => {
|
|||||||
class="studio-debug-attachment"
|
class="studio-debug-attachment"
|
||||||
:disabled="!attachment.download_link"
|
:disabled="!attachment.download_link"
|
||||||
:title="taskAttachmentLabel(attachment)"
|
: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 class="icon is-small"><i :class="taskAttachmentIcon(attachment)"></i></span>
|
||||||
<span>{{ taskAttachmentLabel(attachment) }}</span>
|
<span>{{ taskAttachmentLabel(attachment) }}</span>
|
||||||
|
|||||||
+20
-4
@@ -1,5 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import {ref, watch} from "vue";
|
import {computed, ref, watch} from "vue";
|
||||||
import { BLoading } from "buefy";
|
import { BLoading } from "buefy";
|
||||||
|
|
||||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||||
@@ -26,6 +26,12 @@ const washes = ref(0);
|
|||||||
const outsideHours = ref(createEmptyOutsideHours());
|
const outsideHours = ref(createEmptyOutsideHours());
|
||||||
const laneToggles = ref([]);
|
const laneToggles = ref([]);
|
||||||
const laneToggleError = 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) => {
|
const normalizeBoolean = (value, defaultValue = false) => {
|
||||||
if (typeof value === "boolean") {
|
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 laneDisplayName = (lane, index = 0) => lane.name || `Bane ${index + 1}`;
|
||||||
const isMachineToggleChecked = (lane) => Boolean(lane.machine_status_enabled);
|
const isMachineToggleChecked = (lane) => Boolean(lane.machine_status_enabled);
|
||||||
const dognvaskHasWarning = (lane) => !lane.dognvask_configured;
|
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 dognvaskWarnings = (lane) => Array.isArray(lane.dognvask_configuration_warnings) ? lane.dognvask_configuration_warnings : [];
|
||||||
const formatMachineStatusModifiedAt = (value) => {
|
const formatMachineStatusModifiedAt = (value) => {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
@@ -249,6 +255,11 @@ const applyLaneUpdate = (laneId, currentLane, update, savingPatch = {}) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const toggleMachineStatus = async (lane, event) => {
|
const toggleMachineStatus = async (lane, event) => {
|
||||||
|
if (!canManageMachineStatusToggles.value) {
|
||||||
|
event.target.checked = isMachineToggleChecked(lane);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const enabled = event.target.checked;
|
const enabled = event.target.checked;
|
||||||
const previousLane = { ...lane };
|
const previousLane = { ...lane };
|
||||||
|
|
||||||
@@ -278,6 +289,11 @@ const toggleMachineStatus = async (lane, event) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const toggleDognvask = async (lane, event) => {
|
const toggleDognvask = async (lane, event) => {
|
||||||
|
if (!canManageDognvaskToggles.value) {
|
||||||
|
event.target.checked = isDognvaskToggleChecked(lane);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const enabled = event.target.checked;
|
const enabled = event.target.checked;
|
||||||
const previousLane = { ...lane };
|
const previousLane = { ...lane };
|
||||||
|
|
||||||
@@ -335,7 +351,7 @@ watch([selected_date, selected_date_to], () => {
|
|||||||
type="checkbox"
|
type="checkbox"
|
||||||
:id="machineToggleId(lane)"
|
:id="machineToggleId(lane)"
|
||||||
:checked="isMachineToggleChecked(lane)"
|
:checked="isMachineToggleChecked(lane)"
|
||||||
:disabled="lane.isSavingMachineStatus"
|
:disabled="!canManageMachineStatusToggles || lane.isSavingMachineStatus"
|
||||||
@change="toggleMachineStatus(lane, $event)"
|
@change="toggleMachineStatus(lane, $event)"
|
||||||
>
|
>
|
||||||
<div class="status-toggle-top">
|
<div class="status-toggle-top">
|
||||||
@@ -371,7 +387,7 @@ watch([selected_date, selected_date_to], () => {
|
|||||||
type="checkbox"
|
type="checkbox"
|
||||||
:id="dognvaskToggleId(lane)"
|
:id="dognvaskToggleId(lane)"
|
||||||
:checked="isDognvaskToggleChecked(lane)"
|
:checked="isDognvaskToggleChecked(lane)"
|
||||||
:disabled="lane.isSavingDognvask"
|
:disabled="!canManageDognvaskToggles || lane.isSavingDognvask"
|
||||||
@change="toggleDognvask(lane, $event)"
|
@change="toggleDognvask(lane, $event)"
|
||||||
>
|
>
|
||||||
<div class="status-toggle-top">
|
<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 { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||||
import ConfigurationSubPageWrapper from "@/views/dashboards/superUserDashboard/configuration/ConfigurationSubPageWrapper.vue";
|
import ConfigurationSubPageWrapper from "@/views/dashboards/superUserDashboard/configuration/ConfigurationSubPageWrapper.vue";
|
||||||
import { normalizeEdgeGatewayError } from "@/features/edgeGateways/edgeGatewayErrors.js";
|
import { normalizeEdgeGatewayError } from "@/features/edgeGateways/edgeGatewayErrors.js";
|
||||||
|
import {
|
||||||
|
canUseStubBrokerAuthMode,
|
||||||
|
redactBrokerSharedSecret,
|
||||||
|
sanitizeBrokerAuthMode,
|
||||||
|
validatePublicBrokerUrl,
|
||||||
|
} from "@/features/edgeGateways/edgeGatewayBrokerConfigSecurity.js";
|
||||||
|
|
||||||
const moduleConfigEntries = ref([]);
|
const moduleConfigEntries = ref([]);
|
||||||
const moduleConfigMap = ref({
|
const moduleConfigMap = ref({
|
||||||
@@ -35,6 +41,15 @@ const brokerDiagnosticKeys = {
|
|||||||
secret: "broker_shared_secret",
|
secret: "broker_shared_secret",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const allowStubBrokerAuthMode = canUseStubBrokerAuthMode();
|
||||||
|
|
||||||
|
const setModuleError = (message) => {
|
||||||
|
moduleState.error = {
|
||||||
|
title: "Invalid edge gateway broker configuration",
|
||||||
|
message,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const loadModuleConfig = async () => {
|
const loadModuleConfig = async () => {
|
||||||
moduleState.loading = true;
|
moduleState.loading = true;
|
||||||
moduleState.error = null;
|
moduleState.error = null;
|
||||||
@@ -49,8 +64,8 @@ const loadModuleConfig = async () => {
|
|||||||
default_update_window: response?.data?.config?.default_update_window || "02:00-04:00",
|
default_update_window: response?.data?.config?.default_update_window || "02:00-04:00",
|
||||||
broker_url: response?.data?.config?.broker_url || "http://edge-broker:4300",
|
broker_url: response?.data?.config?.broker_url || "http://edge-broker:4300",
|
||||||
public_broker_url: response?.data?.config?.public_broker_url || "",
|
public_broker_url: response?.data?.config?.public_broker_url || "",
|
||||||
broker_auth_mode: response?.data?.config?.broker_auth_mode || "manager",
|
broker_auth_mode: sanitizeBrokerAuthMode(response?.data?.config?.broker_auth_mode),
|
||||||
broker_shared_secret: response?.data?.config?.broker_shared_secret || "",
|
broker_shared_secret: redactBrokerSharedSecret(),
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
moduleState.error = normalizeEdgeGatewayError(error);
|
moduleState.error = normalizeEdgeGatewayError(error);
|
||||||
@@ -65,15 +80,26 @@ const saveModuleConfig = async () => {
|
|||||||
moduleState.error = null;
|
moduleState.error = null;
|
||||||
|
|
||||||
try {
|
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),
|
enabled: Boolean(moduleConfigMap.value.enabled),
|
||||||
default_release_channel: moduleConfigMap.value.default_release_channel || "stable",
|
default_release_channel: moduleConfigMap.value.default_release_channel || "stable",
|
||||||
default_update_window: moduleConfigMap.value.default_update_window || "02:00-04:00",
|
default_update_window: moduleConfigMap.value.default_update_window || "02:00-04:00",
|
||||||
broker_url: moduleConfigMap.value.broker_url || "",
|
broker_url: moduleConfigMap.value.broker_url || "",
|
||||||
public_broker_url: moduleConfigMap.value.public_broker_url || "",
|
public_broker_url: publicBrokerUrl.value,
|
||||||
broker_auth_mode: moduleConfigMap.value.broker_auth_mode || "manager",
|
broker_auth_mode: sanitizeBrokerAuthMode(moduleConfigMap.value.broker_auth_mode),
|
||||||
broker_shared_secret: moduleConfigMap.value.broker_shared_secret || "",
|
};
|
||||||
});
|
|
||||||
|
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();
|
await loadModuleConfig();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
moduleState.error = normalizeEdgeGatewayError(error);
|
moduleState.error = normalizeEdgeGatewayError(error);
|
||||||
@@ -82,13 +108,20 @@ const saveModuleConfig = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const brokerDiagnosticPayload = (target) => ({
|
const brokerDiagnosticPayload = (target) => {
|
||||||
target,
|
const payload = {
|
||||||
broker_url: moduleConfigMap.value.broker_url || "",
|
target,
|
||||||
public_broker_url: moduleConfigMap.value.public_broker_url || "",
|
broker_url: moduleConfigMap.value.broker_url || "",
|
||||||
broker_auth_mode: moduleConfigMap.value.broker_auth_mode || "manager",
|
public_broker_url: moduleConfigMap.value.public_broker_url || "",
|
||||||
broker_shared_secret: moduleConfigMap.value.broker_shared_secret || "",
|
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 testBrokerConfig = async (target) => {
|
||||||
const resultKey = brokerDiagnosticKeys[target];
|
const resultKey = brokerDiagnosticKeys[target];
|
||||||
@@ -100,6 +133,15 @@ const testBrokerConfig = async (target) => {
|
|||||||
brokerDiagnostics.error = null;
|
brokerDiagnostics.error = null;
|
||||||
|
|
||||||
try {
|
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(
|
const response = await SessionUser.superUser.modules.edgegateway.config.test_broker(
|
||||||
brokerDiagnosticPayload(target)
|
brokerDiagnosticPayload(target)
|
||||||
);
|
);
|
||||||
@@ -275,7 +317,7 @@ onMounted(async () => {
|
|||||||
<div class="select is-fullwidth">
|
<div class="select is-fullwidth">
|
||||||
<select v-model="moduleConfigMap.broker_auth_mode" data-testid="gateway-module-broker-auth-mode">
|
<select v-model="moduleConfigMap.broker_auth_mode" data-testid="gateway-module-broker-auth-mode">
|
||||||
<option value="manager">manager</option>
|
<option value="manager">manager</option>
|
||||||
<option value="stub">stub</option>
|
<option v-if="allowStubBrokerAuthMode" value="stub">stub (development only)</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
@@ -288,7 +330,7 @@ onMounted(async () => {
|
|||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
class="input"
|
class="input"
|
||||||
data-testid="gateway-module-broker-shared-secret"
|
data-testid="gateway-module-broker-shared-secret"
|
||||||
placeholder="Shared secret"
|
placeholder="Leave blank to keep the existing secret"
|
||||||
type="password"
|
type="password"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
|
|||||||
+77
-16
@@ -48,7 +48,11 @@ export const periodPageCount = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const normalizeLimit = (value) => {
|
const normalizeLimit = (value) => {
|
||||||
if (String(value || "").trim().toLowerCase() === ALL_LIMIT) {
|
if (
|
||||||
|
String(value || "")
|
||||||
|
.trim()
|
||||||
|
.toLowerCase() === ALL_LIMIT
|
||||||
|
) {
|
||||||
return 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 memoryCache = new Map();
|
||||||
const selfWashCountsMemoryCache = new Map();
|
const selfWashCountsMemoryCache = new Map();
|
||||||
|
|
||||||
@@ -151,13 +194,18 @@ const isFreshCacheEntry = (entry) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const getCachedPeriodPage = (cacheKey) => {
|
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)) {
|
if (isFreshCacheEntry(memoryEntry)) {
|
||||||
return memoryEntry;
|
return memoryEntry;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (memoryEntry) {
|
if (memoryEntry) {
|
||||||
memoryCache.delete(cacheKey);
|
memoryCache.delete(scopedCacheKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
const storage = safeSessionStorage();
|
const storage = safeSessionStorage();
|
||||||
@@ -165,14 +213,14 @@ export const getCachedPeriodPage = (cacheKey) => {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const storageKey = `${CACHE_PREFIX}${cacheKey}`;
|
const storageKey = `${CACHE_PREFIX}${scopedCacheKey}`;
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(storage.getItem(storageKey) || "null");
|
const parsed = JSON.parse(storage.getItem(storageKey) || "null");
|
||||||
if (!isFreshCacheEntry(parsed)) {
|
if (!isFreshCacheEntry(parsed)) {
|
||||||
storage.removeItem(storageKey);
|
storage.removeItem(storageKey);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
memoryCache.set(cacheKey, parsed);
|
memoryCache.set(scopedCacheKey, parsed);
|
||||||
return parsed;
|
return parsed;
|
||||||
} catch {
|
} catch {
|
||||||
storage.removeItem(storageKey);
|
storage.removeItem(storageKey);
|
||||||
@@ -181,12 +229,17 @@ export const getCachedPeriodPage = (cacheKey) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const setCachedPeriodPage = (cacheKey, periodResult, paginationMeta) => {
|
export const setCachedPeriodPage = (cacheKey, periodResult, paginationMeta) => {
|
||||||
|
const scopedCacheKey = getScopedCacheKey(cacheKey);
|
||||||
|
if (!scopedCacheKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const entry = {
|
const entry = {
|
||||||
storedAt: Date.now(),
|
storedAt: Date.now(),
|
||||||
data: periodResult,
|
data: periodResult,
|
||||||
pagination: paginationMeta,
|
pagination: paginationMeta,
|
||||||
};
|
};
|
||||||
memoryCache.set(cacheKey, entry);
|
memoryCache.set(scopedCacheKey, entry);
|
||||||
|
|
||||||
const storage = safeSessionStorage();
|
const storage = safeSessionStorage();
|
||||||
if (!storage) {
|
if (!storage) {
|
||||||
@@ -194,7 +247,7 @@ export const setCachedPeriodPage = (cacheKey, periodResult, paginationMeta) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
storage.setItem(`${CACHE_PREFIX}${cacheKey}`, JSON.stringify(entry));
|
storage.setItem(`${CACHE_PREFIX}${scopedCacheKey}`, JSON.stringify(entry));
|
||||||
} catch {
|
} catch {
|
||||||
// Best-effort cache. Quota errors should not block the period view.
|
// Best-effort cache. Quota errors should not block the period view.
|
||||||
}
|
}
|
||||||
@@ -217,18 +270,21 @@ export const clearPeriodCache = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const buildSelfWashCountsCacheKey = ({ dateFrom, dateTo, filters }) =>
|
export const buildSelfWashCountsCacheKey = ({ dateFrom, dateTo, filters }) =>
|
||||||
[dateFrom, dateTo, filters]
|
[dateFrom, dateTo, filters].map((part) => encodeURIComponent(String(part ?? ""))).join("|");
|
||||||
.map((part) => encodeURIComponent(String(part ?? "")))
|
|
||||||
.join("|");
|
|
||||||
|
|
||||||
export const getCachedSelfWashCounts = (cacheKey) => {
|
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)) {
|
if (isFreshCacheEntry(memoryEntry)) {
|
||||||
return memoryEntry.data;
|
return memoryEntry.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (memoryEntry) {
|
if (memoryEntry) {
|
||||||
selfWashCountsMemoryCache.delete(cacheKey);
|
selfWashCountsMemoryCache.delete(scopedCacheKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
const storage = safeSessionStorage();
|
const storage = safeSessionStorage();
|
||||||
@@ -236,14 +292,14 @@ export const getCachedSelfWashCounts = (cacheKey) => {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const storageKey = `${SELF_WASH_COUNTS_CACHE_PREFIX}${cacheKey}`;
|
const storageKey = `${SELF_WASH_COUNTS_CACHE_PREFIX}${scopedCacheKey}`;
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(storage.getItem(storageKey) || "null");
|
const parsed = JSON.parse(storage.getItem(storageKey) || "null");
|
||||||
if (!isFreshCacheEntry(parsed)) {
|
if (!isFreshCacheEntry(parsed)) {
|
||||||
storage.removeItem(storageKey);
|
storage.removeItem(storageKey);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
selfWashCountsMemoryCache.set(cacheKey, parsed);
|
selfWashCountsMemoryCache.set(scopedCacheKey, parsed);
|
||||||
return parsed.data;
|
return parsed.data;
|
||||||
} catch {
|
} catch {
|
||||||
storage.removeItem(storageKey);
|
storage.removeItem(storageKey);
|
||||||
@@ -252,11 +308,16 @@ export const getCachedSelfWashCounts = (cacheKey) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const setCachedSelfWashCounts = (cacheKey, counts) => {
|
export const setCachedSelfWashCounts = (cacheKey, counts) => {
|
||||||
|
const scopedCacheKey = getScopedCacheKey(cacheKey);
|
||||||
|
if (!scopedCacheKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const entry = {
|
const entry = {
|
||||||
storedAt: Date.now(),
|
storedAt: Date.now(),
|
||||||
data: counts,
|
data: counts,
|
||||||
};
|
};
|
||||||
selfWashCountsMemoryCache.set(cacheKey, entry);
|
selfWashCountsMemoryCache.set(scopedCacheKey, entry);
|
||||||
|
|
||||||
const storage = safeSessionStorage();
|
const storage = safeSessionStorage();
|
||||||
if (!storage) {
|
if (!storage) {
|
||||||
@@ -264,7 +325,7 @@ export const setCachedSelfWashCounts = (cacheKey, counts) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
storage.setItem(`${SELF_WASH_COUNTS_CACHE_PREFIX}${cacheKey}`, JSON.stringify(entry));
|
storage.setItem(`${SELF_WASH_COUNTS_CACHE_PREFIX}${scopedCacheKey}`, JSON.stringify(entry));
|
||||||
} catch {
|
} catch {
|
||||||
// Best-effort cache. Quota errors should not block the period view.
|
// 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 isStripeTransferBusy = computed(() => stripeTransferQueue.disableSubmit.value);
|
||||||
const isStripeTransferQueuedOrProcessing = computed(() => stripeTransferQueue.isQueuedOrProcessing.value);
|
const isStripeTransferQueuedOrProcessing = computed(() => stripeTransferQueue.isQueuedOrProcessing.value);
|
||||||
const isStripeTransferCompleted = computed(() => stripeTransferQueue.isCompleted.value);
|
const isStripeTransferCompleted = computed(() => stripeTransferQueue.isCompleted.value);
|
||||||
|
const canBookStripeInvoice = computed(() => !isStripeTransferBusy.value && !isStripeTransferCompleted.value);
|
||||||
const isStripeTransferFailed = computed(() => stripeTransferQueue.isFailed.value);
|
const isStripeTransferFailed = computed(() => stripeTransferQueue.isFailed.value);
|
||||||
const stripeTransferProgressPercent = computed(() => stripeTransferQueue.progressPercent.value);
|
const stripeTransferProgressPercent = computed(() => stripeTransferQueue.progressPercent.value);
|
||||||
const stripeTransferProgressMessage = computed(() => stripeTransferQueue.progressMessage.value);
|
const stripeTransferProgressMessage = computed(() => stripeTransferQueue.progressMessage.value);
|
||||||
@@ -61,6 +62,10 @@ const stripeTransferResultMessage = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const onBookStripeInvoice = async () => {
|
const onBookStripeInvoice = async () => {
|
||||||
|
if (!canBookStripeInvoice.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await stripeTransferQueue.enqueue({});
|
await stripeTransferQueue.enqueue({});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -324,7 +329,7 @@ const stripePayment = computed(() => {
|
|||||||
<div class="buttons">
|
<div class="buttons">
|
||||||
<button
|
<button
|
||||||
class="button is-small is-light"
|
class="button is-small is-light"
|
||||||
:disabled="isStripeTransferBusy"
|
:disabled="!canBookStripeInvoice"
|
||||||
data-testid="collected-stripe-book-invoice"
|
data-testid="collected-stripe-book-invoice"
|
||||||
@click="onBookStripeInvoice"
|
@click="onBookStripeInvoice"
|
||||||
>
|
>
|
||||||
|
|||||||
+41
-44
@@ -1150,8 +1150,10 @@ function normalizeReleaseStatusService(service) {
|
|||||||
status: service?.status || service?.state || "ready",
|
status: service?.status || service?.state || "ready",
|
||||||
state: service?.state || service?.status || "ready",
|
state: service?.state || service?.status || "ready",
|
||||||
severity: service?.severity || "ok",
|
severity: service?.severity || "ok",
|
||||||
message: service?.message || trFallback("status.service_ready", "Release service is ready."),
|
message: redactSensitiveDiagnostics(
|
||||||
next_action: service?.next_action || "",
|
service?.message || trFallback("status.service_ready", "Release service is ready.")
|
||||||
|
),
|
||||||
|
next_action: redactSensitiveDiagnostics(service?.next_action || ""),
|
||||||
target_tab: service?.target_tab || "overview",
|
target_tab: service?.target_tab || "overview",
|
||||||
target_id: service?.target_id ?? null,
|
target_id: service?.target_id ?? null,
|
||||||
deployment_id: service?.deployment_id ?? null,
|
deployment_id: service?.deployment_id ?? null,
|
||||||
@@ -1175,8 +1177,8 @@ function normalizeReleaseStatusIssue(issue) {
|
|||||||
channel_slug: issue?.channel_slug || "",
|
channel_slug: issue?.channel_slug || "",
|
||||||
service_key: issue?.service_key || null,
|
service_key: issue?.service_key || null,
|
||||||
label: issue?.label || releaseStatusServiceLabel(issue?.service_key),
|
label: issue?.label || releaseStatusServiceLabel(issue?.service_key),
|
||||||
message: issue?.message || "",
|
message: redactSensitiveDiagnostics(issue?.message || ""),
|
||||||
next_action: issue?.next_action || "",
|
next_action: redactSensitiveDiagnostics(issue?.next_action || ""),
|
||||||
target_tab: issue?.target_tab || "overview",
|
target_tab: issue?.target_tab || "overview",
|
||||||
target_id: issue?.target_id ?? null,
|
target_id: issue?.target_id ?? null,
|
||||||
deployment_id: issue?.deployment_id ?? null,
|
deployment_id: issue?.deployment_id ?? null,
|
||||||
@@ -2888,26 +2890,6 @@ function isolatedStackRequestedName() {
|
|||||||
return String(bundleForm.service_set_name || `${channelSlug} isolated stack`).trim();
|
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) {
|
function isolatedStackServiceName(app) {
|
||||||
const channelSlug = selectedBundleChannel()?.slug || "release";
|
const channelSlug = selectedBundleChannel()?.slug || "release";
|
||||||
const stackName = safeStackSlug(bundleForm.service_set_name || `${channelSlug}-isolated-stack`);
|
const stackName = safeStackSlug(bundleForm.service_set_name || `${channelSlug}-isolated-stack`);
|
||||||
@@ -3150,19 +3132,6 @@ async function serviceSetIdForBundle() {
|
|||||||
return sourceId;
|
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
|
const isolatedTargets = isIsolatedStackMode.value
|
||||||
? {
|
? {
|
||||||
frontend: await createIsolatedStackDeploymentTarget("frontend"),
|
frontend: await createIsolatedStackDeploymentTarget("frontend"),
|
||||||
@@ -3310,7 +3279,7 @@ async function saveChannel() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function saveAssignment() {
|
async function saveAssignment() {
|
||||||
if (!assignmentForm.subject_type || !assignmentForm.subject_id) {
|
if (!canManage.value || !assignmentForm.subject_type || !assignmentForm.subject_id) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4109,12 +4078,35 @@ function deploymentFailureSummary(deployment) {
|
|||||||
return summary && typeof summary === "object" ? summary : null;
|
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) {
|
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) {
|
function deploymentFailureNextAction(deployment) {
|
||||||
return String(deploymentFailureSummary(deployment)?.next_action || "").trim();
|
return redactSensitiveDiagnostics(deploymentFailureSummary(deployment)?.next_action || "");
|
||||||
}
|
}
|
||||||
|
|
||||||
function canPromoteDeployment(deployment) {
|
function canPromoteDeployment(deployment) {
|
||||||
@@ -4134,9 +4126,9 @@ function promotionBlockedReason(deployment) {
|
|||||||
return tr("actions.promote");
|
return tr("actions.promote");
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return redactSensitiveDiagnostics(
|
||||||
deployment?.promotion_blocked_reason ||
|
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"
|
icon="fas fa-user-tag"
|
||||||
default-expanded
|
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
|
<b-button
|
||||||
v-for="channel in channels"
|
v-for="channel in channels"
|
||||||
:key="channel.id"
|
:key="channel.id"
|
||||||
@@ -5380,7 +5372,12 @@ onMounted(async () => {
|
|||||||
</b-button>
|
</b-button>
|
||||||
</div>
|
</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')">
|
<b-field :label="tr('assignments.subject')" :message="tr('assignments.subject_message')">
|
||||||
<ReleaseAssignmentSubjectAutocomplete
|
<ReleaseAssignmentSubjectAutocomplete
|
||||||
:model-value="assignmentSubjectSelection"
|
:model-value="assignmentSubjectSelection"
|
||||||
|
|||||||
@@ -143,11 +143,32 @@ const onItemsClick = (event, booking) => {
|
|||||||
showPopper(popperBox(t("bookings_table.services"), content), event.currentTarget || event.target);
|
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) => {
|
const canEditBooking = (bookingobj) => {
|
||||||
// Admins can always edit
|
// Admins can always edit
|
||||||
if (SessionUser.canAccessAdmin() && SessionUser.hasPermission("edit_bookings")) return true;
|
if (SessionUser.canAccessAdmin() && SessionUser.hasPermission("edit_bookings")) return true;
|
||||||
if (!!bookingobj.order_id) return false; // Only allow editing bookings without an order
|
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(() => {
|
const sortedList = computed(() => {
|
||||||
@@ -467,6 +488,7 @@ const getColspan = () => {
|
|||||||
:customer_number="booking.customer_number"
|
:customer_number="booking.customer_number"
|
||||||
:department_id="booking.department"
|
:department_id="booking.department"
|
||||||
:refresh-function="loadList"
|
:refresh-function="loadList"
|
||||||
|
:allow-booking-deletion="canDeleteBooking(booking)"
|
||||||
:reg_1="booking.reg_1"
|
:reg_1="booking.reg_1"
|
||||||
:reg_2="booking.reg_2"
|
:reg_2="booking.reg_2"
|
||||||
:icon="'fas fa-ellipsis-v'"
|
:icon="'fas fa-ellipsis-v'"
|
||||||
@@ -532,6 +554,7 @@ const getColspan = () => {
|
|||||||
<span>{{ getDepartmentName(parseInt(booking.department)) }}</span>
|
<span>{{ getDepartmentName(parseInt(booking.department)) }}</span>
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
|
v-if="canDeleteBooking(booking)"
|
||||||
class="button is-light is-danger is-small order-booking-card-header__delete"
|
class="button is-light is-danger is-small order-booking-card-header__delete"
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="Slet booking"
|
aria-label="Slet booking"
|
||||||
@@ -598,6 +621,7 @@ const getColspan = () => {
|
|||||||
:customer_number="booking.customer_number"
|
:customer_number="booking.customer_number"
|
||||||
:department_id="booking.department"
|
:department_id="booking.department"
|
||||||
:refresh-function="loadList"
|
:refresh-function="loadList"
|
||||||
|
:allow-booking-deletion="canDeleteBooking(booking)"
|
||||||
:reg_1="booking.reg_1"
|
:reg_1="booking.reg_1"
|
||||||
:reg_2="booking.reg_2"
|
:reg_2="booking.reg_2"
|
||||||
:displayActionsDirectly="true"
|
:displayActionsDirectly="true"
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ const hasStartFormUserInput = ref(false);
|
|||||||
const shouldRestoreServerActiveWash = ref(false);
|
const shouldRestoreServerActiveWash = ref(false);
|
||||||
const isRestoringServerActiveWash = ref(false);
|
const isRestoringServerActiveWash = ref(false);
|
||||||
const isSyncingActiveWash = ref(false);
|
const isSyncingActiveWash = ref(false);
|
||||||
|
const hasConfirmedActiveWashOnServer = ref(false);
|
||||||
const MIN_FINISHING_SCREEN_MS = 250;
|
const MIN_FINISHING_SCREEN_MS = 250;
|
||||||
const RECENT_COMPLETED_WASH_KEY = "mywash_recent_completed_v1";
|
const RECENT_COMPLETED_WASH_KEY = "mywash_recent_completed_v1";
|
||||||
const RECENT_COMPLETED_WASH_SUPPRESSION_MS = 10 * 60 * 1000;
|
const RECENT_COMPLETED_WASH_SUPPRESSION_MS = 10 * 60 * 1000;
|
||||||
@@ -83,6 +84,7 @@ const {
|
|||||||
isForcingNearestDepartment,
|
isForcingNearestDepartment,
|
||||||
forceNearestDepartmentEvaluationId,
|
forceNearestDepartmentEvaluationId,
|
||||||
isSearchingDepartments,
|
isSearchingDepartments,
|
||||||
|
departmentFetchError,
|
||||||
availableProductIds,
|
availableProductIds,
|
||||||
doesCurrentDepartmentSelectionHaveSelfServeEnabled,
|
doesCurrentDepartmentSelectionHaveSelfServeEnabled,
|
||||||
fetchDepartments,
|
fetchDepartments,
|
||||||
@@ -436,6 +438,8 @@ const extractErrorMessage = (error: any, fallback: string) => {
|
|||||||
return fallback;
|
return fallback;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getRequestStatus = (error: any) => Number(error?.response?.status ?? error?.status ?? 0);
|
||||||
|
|
||||||
watch(dynamicImageUrl, () => {
|
watch(dynamicImageUrl, () => {
|
||||||
hideDynamicImage.value = false;
|
hideDynamicImage.value = false;
|
||||||
});
|
});
|
||||||
@@ -654,18 +658,28 @@ const fetchSelfServeData = async () => {
|
|||||||
await fetchSelfServeDataInternal(departmentId, vehicleTypeSelect.value || null, laneId, normalizedReg);
|
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 : [];
|
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 : [];
|
const lanes = Array.isArray(department?.lanes) ? department.lanes : [];
|
||||||
return lanes
|
const lane = lanes.find((candidateLane: any) => normalizeLaneId(candidateLane?.id) === normalizedLaneId);
|
||||||
.map((lane: any) => ({
|
if (lane) {
|
||||||
|
return {
|
||||||
department,
|
department,
|
||||||
lane,
|
lane,
|
||||||
laneId: normalizeLaneId(lane?.id),
|
laneId: normalizedLaneId,
|
||||||
}))
|
};
|
||||||
.filter((candidate: any) => candidate.laneId);
|
}
|
||||||
});
|
}
|
||||||
|
|
||||||
|
return { laneId: normalizedLaneId };
|
||||||
};
|
};
|
||||||
|
|
||||||
const isAuthenticatedCustomerActiveWash = (details: any, customerNumber: number) => {
|
const isAuthenticatedCustomerActiveWash = (details: any, customerNumber: number) => {
|
||||||
@@ -769,31 +783,34 @@ const fetchServerActiveWash = async () => {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const candidates = getServerActiveWashCandidates();
|
let response = null;
|
||||||
if (candidates.length === 0) {
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const responses = await Promise.allSettled(
|
const laneId = normalizeLaneId(details?.lane_id ?? details?.session?.lane_id);
|
||||||
candidates.map(async (candidate: any) => {
|
const activeWashCandidate = findServerActiveWashCandidate(laneId);
|
||||||
const response = await SessionUser.request("/modules/self-serve/lane/wash/in-progress", "GET", {
|
const activeWash = {
|
||||||
lane_id: candidate.laneId,
|
...(activeWashCandidate || {}),
|
||||||
});
|
details,
|
||||||
return {
|
};
|
||||||
...candidate,
|
|
||||||
details: unwrapApiData(response),
|
|
||||||
};
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
const activeMatches = responses
|
if (isRecentlyCompletedActiveWash(activeWash)) {
|
||||||
.filter((entry): entry is PromiseFulfilledResult<any> => entry.status === "fulfilled")
|
return null;
|
||||||
.map((entry) => entry.value)
|
}
|
||||||
.filter((entry) => isAuthenticatedCustomerActiveWash(entry.details, customerNumber))
|
|
||||||
.filter((entry) => !isRecentlyCompletedActiveWash(entry))
|
|
||||||
.sort((left, right) => activeWashTimestamp(right.details) - activeWashTimestamp(left.details));
|
|
||||||
|
|
||||||
return activeMatches[0] || null;
|
return activeWash;
|
||||||
};
|
};
|
||||||
|
|
||||||
const applyServerActiveWash = async (activeWash: any) => {
|
const applyServerActiveWash = async (activeWash: any) => {
|
||||||
@@ -817,6 +834,7 @@ const applyServerActiveWash = async (activeWash: any) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
washInProgress.value = true;
|
washInProgress.value = true;
|
||||||
|
hasConfirmedActiveWashOnServer.value = true;
|
||||||
washLaneId.value = laneId;
|
washLaneId.value = laneId;
|
||||||
radioLaneOption.value = laneId;
|
radioLaneOption.value = laneId;
|
||||||
licensePlateInput.value = reg;
|
licensePlateInput.value = reg;
|
||||||
@@ -845,7 +863,10 @@ const applyServerActiveWash = async (activeWash: any) => {
|
|||||||
|
|
||||||
const summary = await fetchWashSummary(summaryParams, false);
|
const summary = await fetchWashSummary(summaryParams, false);
|
||||||
if (!summary) {
|
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");
|
saveProgress("serverActiveWash");
|
||||||
@@ -898,6 +919,7 @@ const clearLocalActiveWashFromServer = () => {
|
|||||||
completedDurationMs.value = washStartTime.value ? now.value - washStartTime.value : 0;
|
completedDurationMs.value = washStartTime.value ? now.value - washStartTime.value : 0;
|
||||||
currentStep.value = steps.COMPLETED;
|
currentStep.value = steps.COMPLETED;
|
||||||
washInProgress.value = false;
|
washInProgress.value = false;
|
||||||
|
hasConfirmedActiveWashOnServer.value = false;
|
||||||
washLaneId.value = null;
|
washLaneId.value = null;
|
||||||
washStartTime.value = null;
|
washStartTime.value = null;
|
||||||
shouldRestoreServerActiveWash.value = false;
|
shouldRestoreServerActiveWash.value = false;
|
||||||
@@ -932,9 +954,15 @@ const syncActiveWashWithServer = async () => {
|
|||||||
details?.session?.customer_number ?? details?.customer?.customer_number
|
details?.session?.customer_number ?? details?.customer?.customer_number
|
||||||
);
|
);
|
||||||
const serverStillMatchesCurrentWash =
|
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 (!serverStillMatchesCurrentWash || isRecentlyCompletedActiveWash({ details, laneId })) {
|
||||||
|
if (shouldPreserveUnconfirmedLocalWash) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const isWithinStartGracePeriod =
|
const isWithinStartGracePeriod =
|
||||||
washStartTime.value && Date.now() - washStartTime.value < WASH_START_SERVER_SYNC_GRACE_MS;
|
washStartTime.value && Date.now() - washStartTime.value < WASH_START_SERVER_SYNC_GRACE_MS;
|
||||||
if (isWithinStartGracePeriod && !isRecentlyCompletedActiveWash({ details, laneId })) {
|
if (isWithinStartGracePeriod && !isRecentlyCompletedActiveWash({ details, laneId })) {
|
||||||
@@ -945,6 +973,7 @@ const syncActiveWashWithServer = async () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
hasConfirmedActiveWashOnServer.value = true;
|
||||||
const session = details?.session || {};
|
const session = details?.session || {};
|
||||||
const vehicle = details?.vehicle || {};
|
const vehicle = details?.vehicle || {};
|
||||||
const serverReg = normalizeLicensePlate(session?.reg ?? vehicle?.reg);
|
const serverReg = normalizeLicensePlate(session?.reg ?? vehicle?.reg);
|
||||||
@@ -1158,6 +1187,7 @@ const onConfirmNext = async () => {
|
|||||||
const onCloseCompleted = () => {
|
const onCloseCompleted = () => {
|
||||||
stopElapsedTimer();
|
stopElapsedTimer();
|
||||||
washInProgress.value = false;
|
washInProgress.value = false;
|
||||||
|
hasConfirmedActiveWashOnServer.value = false;
|
||||||
washLaneId.value = null;
|
washLaneId.value = null;
|
||||||
washStartTime.value = null;
|
washStartTime.value = null;
|
||||||
completedDurationMs.value = null;
|
completedDurationMs.value = null;
|
||||||
@@ -1328,10 +1358,15 @@ watch(
|
|||||||
|
|
||||||
watch([() => washInProgress.value, () => washLaneId.value], ([inProgress]) => {
|
watch([() => washInProgress.value, () => washLaneId.value], ([inProgress]) => {
|
||||||
if (inProgress) {
|
if (inProgress) {
|
||||||
|
if (isRestoring.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
hasConfirmedActiveWashOnServer.value = true;
|
||||||
startActiveWashRefresh();
|
startActiveWashRefresh();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
hasConfirmedActiveWashOnServer.value = false;
|
||||||
stopActiveWashRefresh();
|
stopActiveWashRefresh();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1380,6 +1415,9 @@ watch(
|
|||||||
if (!restoring) {
|
if (!restoring) {
|
||||||
ensureEffectiveLaneSelection();
|
ensureEffectiveLaneSelection();
|
||||||
fetchSelfServeData();
|
fetchSelfServeData();
|
||||||
|
if (washInProgress.value) {
|
||||||
|
startActiveWashRefresh();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -1436,10 +1474,31 @@ watch(
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</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
|
<b-message
|
||||||
v-if="selfServeDataError"
|
v-if="selfServeDataError"
|
||||||
type="is-danger"
|
type="is-danger"
|
||||||
has-icon
|
|
||||||
:closable="false"
|
:closable="false"
|
||||||
data-testid="self-serve-runtime-error"
|
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 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("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-multi-day-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-multi-day-19:00")).toHaveText("19");
|
||||||
await expect(page.getByTestId("department-weather-hours-2026-03-23-20:00")).toHaveText("21");
|
await expect(page.getByTestId("department-weather-hours-multi-day-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-multi-day-06:00")).toHaveClass(/red/);
|
||||||
await expect(page.getByTestId("department-weather-productivity-2026-03-24-08:00")).toHaveClass(/gray/);
|
await expect(page.getByTestId("department-weather-productivity-multi-day-08:00")).toHaveClass(/gray/);
|
||||||
|
|
||||||
await page.getByTestId("department-weather-summary-toggle").click();
|
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 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("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-multi-day-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-multi-day-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-14:00")).toHaveCount(0);
|
||||||
|
|
||||||
await page.getByTestId("department-weather-summary-toggle").click();
|
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(machineStatusTooltip).toContainText("2026");
|
||||||
}
|
}
|
||||||
await expect(page.locator("#dognvask-1-101")).toBeChecked();
|
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(
|
await expect(page.getByTestId("overview-department-2-lane-202-dognvask").locator(".switch")).toHaveCSS(
|
||||||
"background-color",
|
"background-color",
|
||||||
"rgb(238, 75, 43)"
|
"rgb(146, 208, 80)"
|
||||||
);
|
);
|
||||||
await expect(page.getByTestId("overview-department-3-night-washes")).not.toContainText(
|
await expect(page.getByTestId("overview-department-3-night-washes")).not.toContainText(
|
||||||
/mangler|Opening hours missing|Missing Hours/
|
/mangler|Opening hours missing|Missing Hours/
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ test.describe("Edge gateway routing and fleet navigation", () => {
|
|||||||
await saveRequest;
|
await saveRequest;
|
||||||
|
|
||||||
await expect(page.getByTestId("gateway-module-broker-url")).toHaveValue("http://edge-broker:4301");
|
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 }) => {
|
test("tests broker module configuration values", async ({ page }) => {
|
||||||
|
|||||||
@@ -31,8 +31,6 @@ const expectedFuldfValues = {
|
|||||||
|
|
||||||
const expectedSelfWashValues = {
|
const expectedSelfWashValues = {
|
||||||
"self_wash.lane_unavailable": "Vaskebanen er ikke tilgængelig",
|
"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_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_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.",
|
"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 selectPrimaryProduct(page, 53);
|
||||||
|
|
||||||
await waitForMobileNextStepCooldown(page);
|
await waitForMobileNextStepCooldown(page);
|
||||||
|
const orderId = Number(new URL(page.url()).searchParams.get("id"));
|
||||||
|
|
||||||
await page.getByTestId("pos-mobile-next-step").click();
|
await page.getByTestId("pos-mobile-next-step").click();
|
||||||
await expect.poll(() => fixture.requestCounters.orderItemsPost, { timeout: 10_000 }).toBe(1);
|
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);
|
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.bookingSetOrderId, { timeout: 10_000 }).toBe(1);
|
||||||
await expect.poll(() => fixture.requestCounters.bookingComplete, { 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 expect.poll(() => fixture.bookingsById[DEFAULT_BOOKING_ID]?.status ?? "").toBe("completed");
|
||||||
await waitForStepReset(page);
|
await waitForStepReset(page);
|
||||||
});
|
});
|
||||||
@@ -3757,6 +3763,8 @@ test.describe("POS mobile order flow", () => {
|
|||||||
await page.getByTestId("pos-mobile-booking-complete-with-certificate").click();
|
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.bookingComplete, { timeout: 10_000 }).toBe(1);
|
||||||
|
await expect.poll(() => fixture.requestCounters.markAsCompleted, { timeout: 10_000 }).toBe(1);
|
||||||
|
expect(fixture.markCompletedOrderIds).toContain(orderId);
|
||||||
await expect
|
await expect
|
||||||
.poll(() => fixture.requestLog.bookingCompletions[0]?.safety_seal ?? null, { timeout: 10_000 })
|
.poll(() => fixture.requestLog.bookingCompletions[0]?.safety_seal ?? null, { timeout: 10_000 })
|
||||||
.toBe(9090);
|
.toBe(9090);
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ const json = (body, status = 200) => ({
|
|||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
});
|
});
|
||||||
const GUARD_TIMEOUT_MS = 30_000;
|
const GUARD_TIMEOUT_MS = 30_000;
|
||||||
|
const isDesktopProject = (projectName) => projectName.includes("desktop");
|
||||||
|
|
||||||
const unavailableRuntime = {
|
const unavailableRuntime = {
|
||||||
generated_at: "2026-05-19T09:00:00.000Z",
|
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("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 stableRuntime = runtimeWithFailingBetaSwitch();
|
||||||
const betaRuntime = runtimeWithReadyBetaSelected();
|
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("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();
|
const runtime = runtimeWithFailingBetaSwitch();
|
||||||
await boot(page, runtime);
|
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 page.evaluate(() => window.localStorage.setItem("release_channel_selected_slug", "beta"));
|
||||||
await useLocal.click();
|
await useLocal.click();
|
||||||
|
|
||||||
await expect.poll(() => page.evaluate(() => window.localStorage.getItem("release_source_override"))).toBe("local");
|
await expect
|
||||||
await expect.poll(() => page.evaluate(() => window.localStorage.getItem("release_channel_selected_slug"))).toBeNull();
|
.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("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();
|
const runtime = runtimeWithFailingBetaSwitch();
|
||||||
await boot(page, runtime);
|
await boot(page, runtime);
|
||||||
|
|||||||
@@ -930,13 +930,13 @@ function completeIsolatedDataServices(state, serviceSet) {
|
|||||||
return serviceSet;
|
return serviceSet;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function boot(page, state = createReleaseState()) {
|
async function boot(page, state = createReleaseState(), options = {}) {
|
||||||
await page.addInitScript(() => {
|
await page.addInitScript(() => {
|
||||||
window.localStorage.setItem("locale", "en");
|
window.localStorage.setItem("locale", "en");
|
||||||
});
|
});
|
||||||
await mockApi(page, {
|
await mockApi(page, {
|
||||||
authenticated: true,
|
authenticated: true,
|
||||||
permissions,
|
permissions: options.permissions || permissions,
|
||||||
sessionData: {
|
sessionData: {
|
||||||
runtime_config: {
|
runtime_config: {
|
||||||
release: {
|
release: {
|
||||||
@@ -992,6 +992,8 @@ async function installReleaseMocks(page, state) {
|
|||||||
|
|
||||||
if (pathname.endsWith("/superuser/releases/assignment-subjects") && method === "GET") {
|
if (pathname.endsWith("/superuser/releases/assignment-subjects") && method === "GET") {
|
||||||
const query = (url.searchParams.get("search") || "").toLowerCase();
|
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 limit = Math.max(1, Math.min(10, Number(url.searchParams.get("limit") || 5)));
|
||||||
const subjects = [
|
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");
|
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 }) => {
|
test("superusers manage release settings, assignments, integrations, and sync operations", async ({ page }) => {
|
||||||
const state = await boot(page);
|
const state = await boot(page);
|
||||||
await page.goto("/superuser/configuration/releases/overview?channel=canary&app=api&branch=canary", {
|
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;
|
const isolatedTargetCount = state.targets.filter((target) => target.deploy_context?.isolated_stack).length;
|
||||||
await page.getByTestId("release-bundle-deploy-submit").click();
|
await page.getByTestId("release-bundle-deploy-submit").click();
|
||||||
await expect(page.getByTestId("release-created-bundle")).toContainText("Bundle deployed");
|
await expect(page.getByTestId("release-created-bundle")).toContainText("Bundle deployed");
|
||||||
expect(state.serviceSets.filter((set) => set.mode === "isolated_stack")).toHaveLength(isolatedServiceSetCount);
|
expect(state.serviceSets.filter((set) => set.mode === "isolated_stack")).toHaveLength(isolatedServiceSetCount + 1);
|
||||||
expect(state.targets.filter((target) => target.deploy_context?.isolated_stack)).toHaveLength(isolatedTargetCount);
|
expect(state.targets.filter((target) => target.deploy_context?.isolated_stack)).toHaveLength(isolatedTargetCount + 2);
|
||||||
|
|
||||||
page.once("dialog", (dialog) => {
|
page.once("dialog", (dialog) => {
|
||||||
expect(dialog.message()).toContain("soft-deleted");
|
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();
|
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 }) => {
|
test("existing isolated stacks can add missing data services safely", async ({ page }) => {
|
||||||
const state = createReleaseState();
|
const state = createReleaseState();
|
||||||
state.serviceSets.unshift({
|
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 ({
|
test("@smoke landing route sorts departments, loads vehicle selection, and restores direct wash progress", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
await mockApi(page, {
|
const api = await mockApi(page, {
|
||||||
authenticated: true,
|
authenticated: true,
|
||||||
permissions: ["user"],
|
permissions: ["user"],
|
||||||
|
sessionData: {
|
||||||
|
customer_number: 12345679,
|
||||||
|
},
|
||||||
selfServe: true,
|
selfServe: true,
|
||||||
});
|
});
|
||||||
await primeSession(page, {
|
await primeSession(page, {
|
||||||
@@ -188,21 +191,39 @@ test.describe("Self-serve wash", () => {
|
|||||||
|
|
||||||
await page.goto("/user/wash");
|
await page.goto("/user/wash");
|
||||||
|
|
||||||
await expect(page.getByTestId("self-serve-wash-home")).toBeVisible();
|
await expect(page.getByRole("link", { name: "Start vask" })).toBeVisible({ timeout: 15_000 });
|
||||||
await expect(page.getByTestId("self-serve-home-nearest-name")).toContainText("Roskilde");
|
await expect(page.getByText("Roskilde").first()).toBeVisible();
|
||||||
|
|
||||||
const orderedDepartmentIds = await page
|
const orderedDepartmentNames = await page
|
||||||
.locator('[data-testid^="self-serve-home-department-"]')
|
.locator(".card-header-title.has-text-grey")
|
||||||
.evaluateAll((elements) => elements.map((element) => element.getAttribute("data-testid")));
|
.evaluateAll((elements) => elements.map((element) => element.textContent?.trim()));
|
||||||
expect(orderedDepartmentIds).toEqual(["self-serve-home-department-2", "self-serve-home-department-3"]);
|
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).toHaveURL(/\/user\/wash\/start$/);
|
||||||
await expect(page.getByTestId("self-serve-department-name")).toContainText("Roskilde");
|
await expect(page.getByTestId("self-serve-department-name")).toContainText("Roskilde");
|
||||||
|
|
||||||
await fillRegistration(page, "ab12345");
|
await fillRegistration(page, "ab12345");
|
||||||
await selectVehicleType(page, 2);
|
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, {
|
await seedSavedProgress(page, {
|
||||||
washInProgress: true,
|
washInProgress: true,
|
||||||
washLaneId: 7,
|
washLaneId: 7,
|
||||||
@@ -705,7 +726,7 @@ test.describe("Self-serve wash", () => {
|
|||||||
expect(startCommandRequest.postDataJSON?.()).toMatchObject({
|
expect(startCommandRequest.postDataJSON?.()).toMatchObject({
|
||||||
command: "START",
|
command: "START",
|
||||||
customer_number: 12345679,
|
customer_number: 12345679,
|
||||||
defer_relay_side_effects: true,
|
defer_relay_side_effects: false,
|
||||||
});
|
});
|
||||||
await page.waitForTimeout(250);
|
await page.waitForTimeout(250);
|
||||||
expect(orderFilters.some((filters) => filters.includes("customer_id:777"))).toBe(false);
|
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") {
|
if (target === "secret" || target === "all") {
|
||||||
const validSecret =
|
const submittedSecret = Object.prototype.hasOwnProperty.call(body, "broker_shared_secret")
|
||||||
String(body.broker_shared_secret || "") === String(edgeGatewayFixture.config.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 = {
|
payload.broker_shared_secret = {
|
||||||
ok: validSecret,
|
ok: validSecret,
|
||||||
status: validSecret ? "validated" : "secret_rejected",
|
status: validSecret ? "validated" : "secret_rejected",
|
||||||
@@ -6506,6 +6508,26 @@ export async function mockApi(page, options = {}) {
|
|||||||
return;
|
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") {
|
if (pathname.endsWith("/modules/self-serve/lane/wash/in-progress") && method === "GET") {
|
||||||
const laneId = parsedUrl.searchParams.get("lane_id");
|
const laneId = parsedUrl.searchParams.get("lane_id");
|
||||||
const details = selfServe.inProgressByLaneId?.[String(laneId || "")] || null;
|
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 { authenticatedRequest } from "@/components/session/authenticatedRequest.vue";
|
||||||
|
import { __resetReleaseTimelineForTests, configureReleaseRuntime } from "@/services/releaseTimeline.js";
|
||||||
import {
|
import {
|
||||||
__configureRequestQueueForTests,
|
__configureRequestQueueForTests,
|
||||||
__resetRequestQueueForTests,
|
__resetRequestQueueForTests,
|
||||||
@@ -43,6 +44,7 @@ describe("authenticatedRequest", () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
axiosMock.mockReset();
|
axiosMock.mockReset();
|
||||||
__resetRequestQueueForTests();
|
__resetRequestQueueForTests();
|
||||||
|
__resetReleaseTimelineForTests();
|
||||||
__configureRequestQueueForTests({
|
__configureRequestQueueForTests({
|
||||||
maxConcurrentGet: 1,
|
maxConcurrentGet: 1,
|
||||||
maxConcurrentOther: 1,
|
maxConcurrentOther: 1,
|
||||||
@@ -102,6 +104,26 @@ describe("authenticatedRequest", () => {
|
|||||||
expect(catchCallable).toHaveBeenCalledWith(error);
|
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 () => {
|
it("queues requests and executes them sequentially", async () => {
|
||||||
const first = createDeferred();
|
const first = createDeferred();
|
||||||
const second = createDeferred();
|
const second = createDeferred();
|
||||||
|
|||||||
@@ -10,7 +10,12 @@ import {
|
|||||||
__resetAxiosRequestQueueInstallerForTests,
|
__resetAxiosRequestQueueInstallerForTests,
|
||||||
installAxiosRequestQueue,
|
installAxiosRequestQueue,
|
||||||
} from "@/services/installAxiosRequestQueue.js";
|
} from "@/services/installAxiosRequestQueue.js";
|
||||||
import { __resetReleaseTimelineForTests, configureReleaseRuntime } from "@/services/releaseTimeline.js";
|
import {
|
||||||
|
__resetReleaseTimelineForTests,
|
||||||
|
__setReleaseTimelineTransportForTests,
|
||||||
|
configureReleaseRuntime,
|
||||||
|
flushReleaseTimelineEvents,
|
||||||
|
} from "@/services/releaseTimeline.js";
|
||||||
|
|
||||||
const flushMicrotasks = async () => {
|
const flushMicrotasks = async () => {
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
@@ -100,6 +105,47 @@ describe("axios request queue interceptor", () => {
|
|||||||
expect(requestQueueState.batchFailed).toBe(0);
|
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 () => {
|
it("rewrites default API requests to the active release API before dispatch", async () => {
|
||||||
configureReleaseRuntime({
|
configureReleaseRuntime({
|
||||||
channel: { slug: "canary" },
|
channel: { slug: "canary" },
|
||||||
|
|||||||
@@ -24,8 +24,17 @@ describe("collected invoice stripe queue wiring", () => {
|
|||||||
it("renders progress and retry states for queued transfers", () => {
|
it("renders progress and retry states for queued transfers", () => {
|
||||||
expect(stripeManageSource).toContain("isStripeTransferQueuedOrProcessing");
|
expect(stripeManageSource).toContain("isStripeTransferQueuedOrProcessing");
|
||||||
expect(stripeManageSource).toContain("isStripeTransferFailed");
|
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-progress"');
|
||||||
expect(stripeManageSource).toContain('data-testid="collected-stripe-retry"');
|
expect(stripeManageSource).toContain('data-testid="collected-stripe-retry"');
|
||||||
expect(stripeManageSource).toContain('data-testid="collected-stripe-book-invoice"');
|
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 () => {
|
vi.mock("@/views/dashboards/departmentDashboard/modules/daily-report/DepartmentDailyReportObject.vue", async () => {
|
||||||
const { ref } = await import("vue");
|
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";
|
import DepartmentDailyReport from "@/views/dashboards/departmentDashboard/modules/daily-report/DepartmentDailyReport.vue";
|
||||||
|
|
||||||
const flushAll = async () => {
|
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