Compare commits

...
Author SHA1 Message Date
Jeppe B 18ab007f87 Debounce self-serve wash data fetches 2026-06-02 13:41:55 +02:00
Jeppe Bundgaard 5170139a32 Switch CI to self-hosted runners
Updated all GitHub Actions workflows to use self-hosted runners instead of `ubuntu-latest`. This change ensures better control over the CI environment and aligns with internal infrastructure requirements.
2026-06-02 10:36:18 +02:00
Jeppe Bundgaard 90952a68b7 Refactor SubusersTable to use ActionSettingsWheelButton for row actions 2026-06-02 09:23:49 +02:00
Jeppe Bundgaard 1cc14d7b41 Merge remote-tracking branch 'origin/master' 2026-06-02 08:36:42 +02:00
Jeppe B aa4d6576f7 Merge pull request #108 from copenhagentruckwash/fix-page-crash-issues
Fix wash start recovery from API failures
2026-06-02 08:35:28 +02:00
Jeppe B 2a46984f9c Merge pull request #109 from copenhagentruckwash/fix-playwright-exit-code-error
Stabilize release sidebar E2E coverage
2026-06-02 08:34:47 +02:00
Jeppe Bundgaard 4a9c721c55 Remove debug message for beta frontend in build output 2026-06-02 08:12:50 +02:00
Jeppe B 7be9995c87 Stabilize release sidebar e2e coverage 2026-06-02 08:10:51 +02:00
Jeppe B 85529de3f8 Fix wash start recovery from API failures 2026-06-02 08:07:03 +02:00
Jeppe B 114ca9d956 Merge pull request #107 from copenhagentruckwash/investigate-and-fix-failing-tests
Resolve failing frontend CI: fix merge conflicts, restore edge cache & tests
2026-06-02 07:15:41 +02:00
Jeppe B 29f858685a Fix self-serve wash smoke restore fixture 2026-06-02 07:15:26 +02:00
Jeppe B 4112faf682 Fix edge gateway broker config e2e 2026-06-02 02:38:37 +02:00
Jeppe B a13b73c414 Harden edge gateway service parsing 2026-06-02 02:06:32 +02:00
Jeppe B 667e93af8f Resolve failing frontend checks 2026-06-02 01:13:28 +02:00
Jeppe B 54a888026e Merge pull request #104 from copenhagentruckwash/propose-fix-for-price-tampering-vulnerability
Fix trusted booking prices in POS hydration
2026-06-02 00:48:56 +02:00
Jeppe B a0fb02baa1 Merge pull request #106 from copenhagentruckwash/fix-booking-delete-access-permission
Fix booking delete action visibility
2026-06-02 00:47:38 +02:00
Jeppe B c445b932db Fix booking delete action visibility 2026-06-02 00:47:19 +02:00
copilot-swe-agent[bot] ffccb1683b Merge origin/master and resolve POS pricing conflict 2026-06-01 22:44:56 +00:00
Jeppe B 0d590fc9d6 Merge pull request #105 from copenhagentruckwash/fix-postinstall-script-vulnerability
Remove unsafe postinstall hook
2026-06-02 00:40:46 +02:00
Jeppe B 1d4910f947 Remove unsafe postinstall hook 2026-06-02 00:40:34 +02:00
Jeppe B 7e457ed961 Fix trusted booking prices in POS hydration 2026-06-02 00:39:55 +02:00
Jeppe B 34722c6985 Merge pull request #103 from copenhagentruckwash/fix-vulnerability-in-pos-order-pricing
Fix POS booking price hydration
2026-06-02 00:39:37 +02:00
Jeppe B c5a13b50ab Fix POS booking price hydration 2026-06-02 00:39:15 +02:00
Jeppe B 387bd50281 Merge pull request #102 from copenhagentruckwash/fix-unsandboxed-blob-attachment-previews
Sanitize and sandbox order attachment previews to prevent XSS
2026-06-02 00:38:15 +02:00
Jeppe B 81016fd898 Fix unsafe order attachment previews 2026-06-02 00:38:03 +02:00
Jeppe B 39fc1612ac Merge pull request #101 from copenhagentruckwash/fix-duplicate-stripe-invoice-submission-issue
Prevent duplicate Stripe invoice queue submissions
2026-06-02 00:30:18 +02:00
Jeppe B 7ab12a3fff Prevent duplicate Stripe invoice queue submissions 2026-06-02 00:29:53 +02:00
Jeppe B b4d869c469 Merge pull request #100 from copenhagentruckwash/fix-autosave-to-prevent-infinite-retry-loop
Fix autosave failed-save retry loop
2026-06-02 00:28:52 +02:00
Jeppe B 799b667828 Fix autosave failed-save retry loop 2026-06-02 00:28:39 +02:00
Jeppe B ae3513edf3 Merge pull request #99 from copenhagentruckwash/fix-repeated-booking-prefetch-flooding-api
Avoid duplicate booking prefetch by honoring exact-plate cache
2026-06-02 00:27:37 +02:00
Jeppe B 6db1861b78 Fix repeated booking prefetch requests 2026-06-02 00:27:25 +02:00
Jeppe B a8e839ba18 Merge pull request #98 from copenhagentruckwash/fix-mobile-pos-orders-completion-issue
Fix mobile POS order completion
2026-06-02 00:27:07 +02:00
Jeppe B b224cf67d1 Fix mobile POS order completion 2026-06-02 00:26:53 +02:00
Jeppe B 222237ab34 Merge pull request #96 from copenhagentruckwash/fix-edge-gateway-localstorage-vulnerability
Harden edge gateway workspace cache
2026-06-02 00:16:07 +02:00
copilot-swe-agent[bot] 433cfe521e Planning merge conflict resolution 2026-06-01 22:14:58 +00:00
Jeppe B 1e4a776fa5 Merge pull request #97 from copenhagentruckwash/fix-auto-selection-of-customer-suggestions
Fix stale POS customer suggestion auto-selection
2026-06-02 00:13:59 +02:00
Jeppe B e88a424a7d Fix stale POS customer suggestion auto-selection 2026-06-02 00:13:46 +02:00
Jeppe B 9450c7902d Harden edge gateway workspace cache 2026-06-02 00:08:20 +02:00
Jeppe B 75b5883afe Merge pull request #95 from copenhagentruckwash/fix-stripe-email-invoice-payment-misclassification
Fix Stripe email invoice paid validation
2026-06-02 00:07:49 +02:00
Jeppe B c26f0184d9 Fix Stripe email invoice paid validation 2026-06-02 00:07:35 +02:00
Jeppe B cb74b4bbe7 Merge pull request #93 from copenhagentruckwash/propose-fix-for-cached-gateway-details-leak
Scope edge gateway cache to session and prevent auth-blind cached fallbacks
2026-06-01 23:48:26 +02:00
Jeppe B 326eca656f Fix edge gateway cache authorization fallback 2026-06-01 23:48:17 +02:00
Jeppe B f0a5058b68 Merge pull request #92 from copenhagentruckwash/propose-fix-for-self-hosted-ci-vulnerability
Use GitHub-hosted runners for tests workflow
2026-06-01 23:46:15 +02:00
copilot-swe-agent[bot] d39cd36995 Merge remote-tracking branch 'origin/master' into propose-fix-for-self-hosted-ci-vulnerability
# Conflicts:
#	.github/workflows/tests.yml
2026-06-01 21:43:04 +00:00
Jeppe B 0548018b5c Use GitHub-hosted runners for tests workflow 2026-06-01 23:40:30 +02:00
Jeppe B 699e10856e Merge pull request #91 from copenhagentruckwash/propose-fix-for-self-hosted-runner-vulnerability
Fix CI runner exposure for pull requests
2026-06-01 23:40:09 +02:00
Jeppe B 36b399cb2f Fix CI runner exposure for pull requests 2026-06-01 23:40:00 +02:00
Jeppe B f613d58727 Merge pull request #90 from copenhagentruckwash/propose-fix-for-product-catalog-vulnerability
Restrict vehicle type product edits to superusers
2026-06-01 23:35:14 +02:00
Jeppe B feccfada77 Restrict vehicle type product edits to superusers 2026-06-01 23:35:04 +02:00
Jeppe B 4127e66114 Merge pull request #89 from copenhagentruckwash/fix-unvalidated-attachment-download-links
Validate self-serve attachment download links
2026-06-01 23:34:45 +02:00
Jeppe B b2a9b59399 Validate self-serve attachment download links 2026-06-01 23:34:34 +02:00
Jeppe B 6ffed9c2f2 Merge pull request #88 from copenhagentruckwash/fix-lane-toggle-guard-permission-issue
Fix lane toggle permission guards
2026-06-01 23:34:14 +02:00
Jeppe B 53e193d8d1 Fix lane toggle permission guards 2026-06-01 23:34:03 +02:00
Jeppe B 7835691bc9 Merge pull request #87 from copenhagentruckwash/fix-websocket-token-exposure-vulnerability
Secure edge gateway WebSocket session tokens
2026-06-01 23:33:44 +02:00
Jeppe B 91715541ec Secure edge gateway websocket sessions 2026-06-01 23:33:34 +02:00
Jeppe B e3f9e99129 Merge pull request #86 from copenhagentruckwash/fix-stored-xss-in-relay-options
Fix select option HTML escaping
2026-06-01 23:33:20 +02:00
Jeppe B 4634cbad89 Fix select option HTML escaping 2026-06-01 23:33:10 +02:00
Jeppe B 5db2a36da0 Merge pull request #85 from copenhagentruckwash/propose-fix-for-relay-failure-issue
Rollback wash start when relay activation fails
2026-06-01 22:53:44 +02:00
Jeppe B cd7b273da4 Rollback wash start when relay activation fails 2026-06-01 22:53:34 +02:00
Jeppe B cbbad1a6e2 Merge pull request #84 from copenhagentruckwash/fix-pos-url-order-reassignment-vulnerability
Prevent implicit POS order department changes
2026-06-01 22:44:24 +02:00
Jeppe B 9661d4efa7 Prevent implicit POS order department changes 2026-06-01 22:44:14 +02:00
Jeppe B 31762c23c5 Merge pull request #83 from copenhagentruckwash/fix-unescaped-order-delete-reasons
Fix protected order delete reason escaping
2026-06-01 22:43:55 +02:00
Jeppe B c71a6b4d14 Fix protected order delete reason escaping 2026-06-01 22:43:45 +02:00
Jeppe B e8ddb33f21 Merge pull request #82 from copenhagentruckwash/fix-vulnerability-in-edge-broker-config
Secure edge gateway broker configuration
2026-06-01 22:28:28 +02:00
Jeppe B ea368d19bb Secure edge gateway broker configuration 2026-06-01 22:28:18 +02:00
Jeppe B 7ab50b6290 Merge pull request #81 from copenhagentruckwash/fix-unredacted-broker-diagnostics-exposure
Redact edge gateway diagnostic secrets
2026-06-01 22:27:33 +02:00
Jeppe B fd7cfbd3c3 Redact edge gateway diagnostics 2026-06-01 22:27:19 +02:00
Jeppe B 90725af10a Merge pull request #80 from copenhagentruckwash/fix-ungated-destructive-actions-in-ui
Gate self-serve studio gateway actions and require confirmation for destructive operations
2026-06-01 22:26:58 +02:00
Jeppe B d63a62a921 Gate self-serve gateway actions 2026-06-01 22:26:44 +02:00
Jeppe B 4bc5481941 Merge pull request #79 from copenhagentruckwash/propose-fix-for-deferred-start-relay-issue
Require successful machine relay activation before marking wash in progress
2026-06-01 22:15:16 +02:00
Jeppe B 9feede3b0d Require machine relay activation before wash progress 2026-06-01 22:15:02 +02:00
Jeppe B 5aad9eda8d Merge pull request #78 from copenhagentruckwash/fix-raw-edge-log-context-exposure
Redact edge gateway log context secrets
2026-06-01 22:13:42 +02:00
Jeppe B 91b15faba6 Redact edge gateway log context secrets 2026-06-01 22:13:32 +02:00
Jeppe B 98c1c61cbd Merge pull request #77 from copenhagentruckwash/fix-active-wash-endpoint-information-leak
Scope active-wash restore to authenticated customer (my-active-wash)
2026-06-01 22:06:25 +02:00
Jeppe B 4463a5326a Fix scoped active wash restoration 2026-06-01 22:06:14 +02:00
Jeppe B bdb23513a4 Merge pull request #76 from copenhagentruckwash/propose-fix-for-security-vulnerability
Harden self-serve vehicle condition mutations
2026-06-01 22:05:37 +02:00
Jeppe B ee40f16232 Harden self-serve vehicle condition mutations 2026-06-01 22:05:27 +02:00
Jeppe B a76944b00c Merge pull request #75 from copenhagentruckwash/fix-sessionstorage-vulnerability-in-invoicing-period
Secure privileged session caches
2026-06-01 21:52:53 +02:00
Jeppe B cf074ff893 Secure privileged session caches 2026-06-01 21:52:42 +02:00
Jeppe B 712eb19db5 Merge pull request #74 from copenhagentruckwash/propose-fix-for-release-manager-token-leak
Restrict release manager control API origins
2026-06-01 21:52:20 +02:00
Jeppe B de9bc9b170 Restrict release manager control API origins 2026-06-01 21:52:09 +02:00
Jeppe B 95e2404307 Merge pull request #73 from copenhagentruckwash/fix-unredacted-deployment-failures-exposure
Redact release deployment failure diagnostics
2026-06-01 21:27:42 +02:00
Jeppe B 2c01feb3be Redact release deployment failure diagnostics 2026-06-01 21:27:28 +02:00
Jeppe B f416e0d462 Merge pull request #72 from copenhagentruckwash/fix-telemetry-that-leaks-passwords
Redact serialized request bodies and omit failed-request bodies when capture is disabled
2026-06-01 21:26:45 +02:00
Jeppe B 7f1308c8ce Redact release timeline failed request bodies 2026-06-01 21:26:32 +02:00
Jeppe B fd8d45e437 Merge pull request #71 from copenhagentruckwash/fix-vulnerabilities-in-release-runtime-urls
Harden release runtime URL trust boundaries
2026-06-01 21:25:51 +02:00
copilot-swe-agent[bot] 20390342d5 Merge origin/master and resolve release timeline spec conflict 2026-06-01 19:21:12 +00:00
Jeppe B c47bf229f0 Harden release runtime URL trust boundaries 2026-06-01 21:14:39 +02:00
Jeppe B 3616e197e1 Merge pull request #70 from copenhagentruckwash/propose-fix-for-isolated-stack-vulnerability
Prevent active isolated stack reuse during release deploys
2026-06-01 21:12:26 +02:00
Jeppe B 3d8544de21 Prevent isolated stack deployment reuse 2026-06-01 21:11:17 +02:00
Jeppe B 504abb7a15 Merge pull request #69 from copenhagentruckwash/fix-release-subject-autocomplete-exposure
Guard release assignment subject search
2026-06-01 21:11:02 +02:00
Jeppe B 37021533b9 Guard release assignment search by manage permission 2026-06-01 21:10:51 +02:00
Jeppe B 59eab84f03 Merge pull request #68 from copenhagentruckwash/fix-cross-origin-framing-vulnerability-in-nginx
Add anti-framing headers to Coolify Nginx frontend
2026-06-01 21:10:26 +02:00
Jeppe B e67033711c Add anti-framing headers to Coolify Nginx config 2026-06-01 21:10:14 +02:00
Jeppe B 85fd00f020 Merge pull request #67 from copenhagentruckwash/fix-metadata-exposure-for-low-privileged-users
Restrict release runtime metadata inspector
2026-06-01 21:09:54 +02:00
Jeppe B 4f064a1625 Restrict release runtime metadata inspector 2026-06-01 21:09:42 +02:00
Jeppe B 3bf7ada226 Merge pull request #66 from copenhagentruckwash/fix-tls-verification-in-dev-proxy
Secure Vite API proxy TLS by default
2026-06-01 21:09:15 +02:00
Jeppe B 3b90e17a1e Secure Vite API proxy TLS by default 2026-06-01 21:09:05 +02:00
Jeppe B c1fb5b72ee Merge pull request #65 from copenhagentruckwash/fix-privilege-escalation-in-daily-report
Guard department lane toggles by wash-lane permission
2026-06-01 21:08:43 +02:00
Jeppe B 29da26cc08 Guard department lane toggles by permission 2026-06-01 21:08:32 +02:00
Jeppe B 807fc09b1e Merge pull request #64 from copenhagentruckwash/fix-dgnvask-toggle-issue
Fix Døgnvask toggle state with configuration warnings
2026-06-01 21:08:03 +02:00
Jeppe B 528e4994db Fix Dognvask warning toggle state 2026-06-01 21:07:53 +02:00
Jeppe B c339122f4f Merge pull request #63 from copenhagentruckwash/fix-release-gate-binding-to-build
Bind frontend release gate to build id
2026-06-01 21:07:33 +02:00
Jeppe B feeef46354 Bind frontend release gate to build id 2026-06-01 21:07:23 +02:00
Jeppe B 066bd14554 Merge pull request #62 from copenhagentruckwash/fix-active-wash-refresh-vulnerability
Harden active-wash refresh ownership check
2026-06-01 21:06:53 +02:00
Jeppe B bffaa2ab88 Fix active wash refresh ownership check 2026-06-01 21:06:39 +02:00
Jeppe B 31495ea67b Merge pull request #61 from copenhagentruckwash/fix-ftp-deploy-password-exposure
Secure lftp release upload credentials
2026-06-01 21:02:13 +02:00
Jeppe B 8f2533f1b2 Secure lftp release upload credentials 2026-06-01 21:02:01 +02:00
Jeppe B 13b0742deb Merge pull request #60 from copenhagentruckwash/fix-cropping-issue-in-path-editor
Fix self-serve path editor answer switch layout
2026-06-01 20:45:04 +02:00
107 changed files with 4135 additions and 599 deletions
+1 -1
View File
@@ -9,7 +9,7 @@ on:
jobs:
upload-qodana-config:
runs-on: ubuntu-latest
runs-on: [self-hosted, Linux, X64, default]
steps:
- name: Checkout repository
+1 -1
View File
@@ -9,7 +9,7 @@ on:
jobs:
upload-qodana-config:
runs-on: ubuntu-latest
runs-on: [self-hosted, Linux, X64, default]
steps:
- name: Checkout repository
+2 -1
View File
@@ -23,6 +23,7 @@ jobs:
PLAYWRIGHT_RELEASE_API_BASE_URL: https://api-v2.truckwash.io
PLAYWRIGHT_RELEASE_API_PING_PATHS: /ping,/master/api/ping,/canary/api/ping,/stable/api/ping
RELEASE_BUILD_ID: ${{ github.run_id }}-${{ github.run_attempt }}
RELEASE_EXPECTED_BUILD_ID: ${{ github.run_id }}-${{ github.run_attempt }}
RELEASE_EXPECTED_COMMIT: ${{ github.sha }}
RELEASE_WAIT_INITIAL_SECONDS: 45
RELEASE_WAIT_TIMEOUT_SECONDS: 600
@@ -98,7 +99,7 @@ jobs:
-X POST "$RELEASE_MANAGER_GATE_URL" \
-H "Authorization: Bearer $RELEASE_MANAGER_GATE_TOKEN" \
-H "Content-Type: application/json" \
--data "{\"environment_url\":\"$RELEASE_BASE_URL\",\"channel_slug\":\"stable\",\"app\":\"frontend\",\"repository\":\"$RELEASE_REPOSITORY\",\"branch\":\"$RELEASE_BRANCH\",\"expected_commit\":\"$RELEASE_EXPECTED_COMMIT\",\"workflow_url\":\"$RELEASE_WORKFLOW_URL\",\"auto_sync\":true,\"wait_timeout_seconds\":300,\"poll_interval_seconds\":10,\"required_checks\":[\"static_artifact\",\"api_gateway\"]}"
--data "{\"environment_url\":\"$RELEASE_BASE_URL\",\"channel_slug\":\"stable\",\"app\":\"frontend\",\"repository\":\"$RELEASE_REPOSITORY\",\"branch\":\"$RELEASE_BRANCH\",\"expected_commit\":\"$RELEASE_EXPECTED_COMMIT\",\"build_id\":\"$RELEASE_EXPECTED_BUILD_ID\",\"workflow_url\":\"$RELEASE_WORKFLOW_URL\",\"auto_sync\":true,\"wait_timeout_seconds\":300,\"poll_interval_seconds\":10,\"required_checks\":[\"static_artifact\",\"api_gateway\"]}"
env:
RELEASE_MANAGER_GATE_URL: ${{ secrets.RELEASE_MANAGER_GATE_URL || 'https://api.truckwash.io/release/gate/test-runs' }}
RELEASE_MANAGER_GATE_TOKEN: ${{ secrets.RELEASE_MANAGER_GATE_TOKEN }}
+1 -1
View File
@@ -16,7 +16,7 @@ concurrency:
jobs:
format-tests:
# Match the labels exposed by the Coolify-managed GitHub runner.
# CI runs on the repository's self-hosted runner pool.
runs-on: [self-hosted, Linux, X64, default]
steps:
- name: Checkout repository
+8
View File
@@ -38,6 +38,14 @@ To use another remote API route:
$env:VITE_API_PROXY_BASE_PATH="/canary/api"; npm run dev
```
TLS certificate validation is enabled for proxied HTTPS APIs by default. If you
are using a trusted local HTTPS API with a self-signed certificate, you can opt
out explicitly:
```powershell
$env:VITE_API_PROXY_TARGET="https://local-api.test"; $env:VITE_API_PROXY_SECURE="false"; npm run dev
```
For compatible local gateways that expect the `/api` prefix to be preserved:
```powershell
+13
View File
@@ -5,18 +5,27 @@ server {
root /usr/share/nginx/html;
index index.html;
add_header Content-Security-Policy "frame-ancestors 'self'" always;
add_header X-Frame-Options "SAMEORIGIN" always;
location ~ ^/(release-entry|release-manifest)\.json$ {
add_header Cache-Control "no-store";
add_header Content-Security-Policy "frame-ancestors 'self'" always;
add_header X-Frame-Options "SAMEORIGIN" always;
try_files $uri =404;
}
location ~ ^/(master|beta|canary|internal)/frontend/(release-entry|release-manifest)\.json$ {
add_header Cache-Control "no-store";
add_header Content-Security-Policy "frame-ancestors 'self'" always;
add_header X-Frame-Options "SAMEORIGIN" always;
try_files /$2.json =404;
}
location ~ ^/(?:.+/)?(?<static_asset_path>(?:assets|resources|favicons|icons|img|sounds|\.well-known)/.+)$ {
add_header Cache-Control "public, max-age=31536000, immutable";
add_header Content-Security-Policy "frame-ancestors 'self'" always;
add_header X-Frame-Options "SAMEORIGIN" always;
try_files /$static_asset_path =404;
}
@@ -30,11 +39,15 @@ server {
location /assets/ {
add_header Cache-Control "public, max-age=31536000, immutable";
add_header Content-Security-Policy "frame-ancestors 'self'" always;
add_header X-Frame-Options "SAMEORIGIN" always;
try_files $uri =404;
}
location ~ ^/(master|beta|canary|internal)/frontend/assets/ {
add_header Cache-Control "public, max-age=31536000, immutable";
add_header Content-Security-Policy "frame-ancestors 'self'" always;
add_header X-Frame-Options "SAMEORIGIN" always;
rewrite ^/(master|beta|canary|internal)/frontend/(.*)$ /$2 break;
try_files $uri =404;
}
+113 -19
View File
@@ -4624,7 +4624,7 @@ paths:
tags:
- Self-Serve
summary: Add vehicle condition
description: Add a new vehicle condition (answer to a question). Customers can only add conditions for their own vehicles.
description: Add a new vehicle condition (answer to a question). Customers can only add conditions for their own vehicles. This answer mutation does not activate machines or synchronize live relay state; hardware changes are handled only by the explicit wash start flow.
operationId: addSelfserveVehicleCondition
requestBody:
required: true
@@ -4659,14 +4659,6 @@ paths:
type: integer
nullable: true
description: Alias for vehicle_type.
activate_machine:
type: boolean
default: true
description: Whether the session synchronization may enable the machine relay. User wash-start saves answers with false.
sync_relay_state:
type: boolean
default: true
description: Whether the answer mutation should synchronize live relay state.
responses:
'200':
description: Successfully added vehicle condition
@@ -4683,7 +4675,7 @@ paths:
tags:
- Self-Serve
summary: Update vehicle condition
description: Update an existing vehicle condition. Customers can only update conditions for their own vehicles.
description: Update an existing vehicle condition. Customers can only update conditions for their own vehicles. This answer mutation does not activate machines or synchronize live relay state; hardware changes are handled only by the explicit wash start flow.
operationId: updateSelfserveVehicleCondition
parameters:
- name: id
@@ -4719,14 +4711,6 @@ paths:
type: integer
nullable: true
description: Alias for vehicle_type.
activate_machine:
type: boolean
default: true
description: Whether the session synchronization may enable the machine relay.
sync_relay_state:
type: boolean
default: true
description: Whether the mutation should synchronize live relay state.
responses:
'200':
description: Successfully updated vehicle condition
@@ -5402,7 +5386,7 @@ paths:
application/json:
schema:
type: object
required: [department, gateway_id, action]
required: [department, gateway_id, action, confirm]
properties:
department: { type: integer }
gateway_id: { type: integer }
@@ -8704,6 +8688,116 @@ paths:
schema:
$ref: '#/components/schemas/SelfServeLaneStatus'
/modules/self-serve/lane/wash/my-active-wash:
get:
tags:
- Modules
summary: Get the authenticated customer's active self-serve wash
description: |
Returns the current authenticated customer's open self-serve wash session,
if one exists. Regular customers must only receive their own active wash
details from this endpoint.
operationId: getMyActiveSelfServeWash
responses:
'200':
description: Authenticated customer's active wash details resolved
content:
application/json:
schema:
type: object
properties:
lane_id:
type: integer
nullable: true
in_progress:
type: boolean
session:
type: object
nullable: true
properties:
id:
type: integer
lane_id:
type: integer
nullable: true
department_id:
type: integer
nullable: true
status:
type: string
reg:
type: string
customer_number:
type: integer
nullable: true
vehicle_id:
type: integer
nullable: true
vehicle_type_id:
type: integer
nullable: true
included_minutes:
type: integer
nullable: true
machine_type_id:
type: integer
nullable: true
machine_relay_enabled:
type: boolean
machine_relay_enabled_at:
type: string
nullable: true
machine_start_triggered:
type: boolean
machine_start_triggered_at:
type: string
nullable: true
wash_started_at:
type: string
nullable: true
created_at:
type: string
updated_at:
type: string
nullable: true
customer:
type: object
nullable: true
properties:
id:
type: integer
nullable: true
customer_number:
type: integer
nullable: true
display_name:
type: string
nullable: true
email:
type: string
nullable: true
phone_country_code:
type: integer
nullable: true
phone:
type: string
nullable: true
vehicle:
type: object
nullable: true
properties:
id:
type: integer
customer_id:
type: integer
type:
type: integer
reg:
type: string
reference:
type: string
nullable: true
/modules/self-serve/lane/wash/in-progress:
get:
tags:
-1
View File
@@ -7,7 +7,6 @@
"": {
"name": "truckwashdashboardsfrontend",
"version": "0.0.0",
"hasInstallScript": true,
"dependencies": {
"@azure/msal-browser": "^4.12.0",
"@bubblewrap/cli": "^1.23.0",
-1
View File
@@ -11,7 +11,6 @@
"format:tests:commit": "node scripts/pre-commit-format-tests.mjs",
"format:tests:check": "prettier --check \"tests/**/*.{js,ts}\"",
"prepare": "node scripts/prepare-husky.mjs",
"postinstall": "node scripts/postinstall-sync-playwright-root-links.mjs",
"preview": "vite preview",
"preview:prod": "npm run build && npm run preview -- --host 127.0.0.1 --port 4173",
"text:fix-encoding": "node scripts/text-encoding.mjs fix",
@@ -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);
+35 -3
View File
@@ -23,20 +23,52 @@ if [[ -z "$DEPLOY_URL" ]]; then
echo "Set RELEASE_DEPLOY_URL or RELEASE_DEPLOY_HOST, RELEASE_DEPLOY_USER, and RELEASE_DEPLOY_PASSWORD." >&2
exit 1
fi
DEPLOY_URL="ftp://${USER_NAME}:${PASSWORD}@${HOST}"
DEPLOY_URL="ftp://${HOST}"
elif [[ -n "$USER_NAME" || -n "$PASSWORD" ]]; then
if [[ -z "$USER_NAME" || -z "$PASSWORD" ]]; then
echo "Set both RELEASE_DEPLOY_USER and RELEASE_DEPLOY_PASSWORD when providing deploy credentials separately." >&2
exit 1
fi
fi
lftp_quote() {
local value="${1//\'/\'\\\'\'}"
printf "'%s'" "$value"
}
run_lftp() {
local transfer_command="$1"
{
printf 'set ftp:ssl-allow true\n'
printf 'set ftp:ssl-force true\n'
printf 'set ftp:ssl-protect-data true\n'
printf 'set net:max-retries 3\n'
printf 'set net:timeout 20\n'
if [[ -n "$USER_NAME" && -n "$PASSWORD" ]]; then
printf 'open -u %s,%s %s\n' "$(lftp_quote "$USER_NAME")" "$(lftp_quote "$PASSWORD")" "$(lftp_quote "$DEPLOY_URL")"
else
printf 'open %s\n' "$(lftp_quote "$DEPLOY_URL")"
fi
printf 'cd %s\n' "$(lftp_quote "$REMOTE_ROOT")"
printf '%s\n' "$transfer_command"
printf 'bye\n'
} | lftp -f /dev/stdin
}
upload_file() {
local source_file="$1"
local remote_file="$2"
if [[ -f "$source_file" ]]; then
lftp "$DEPLOY_URL" -e "set ftp:ssl-allow true; set net:max-retries 3; set net:timeout 20; cd \"$REMOTE_ROOT\"; put -O \"$(dirname "$remote_file")\" \"$source_file\" -o \"$(basename "$remote_file")\"; bye"
run_lftp "put -O $(lftp_quote "$(dirname "$remote_file")") $(lftp_quote "$source_file") -o $(lftp_quote "$(basename "$remote_file")")"
fi
}
for directory in assets resources favicons icons img sounds .well-known; do
if [[ -d "$DIST_DIR/$directory" ]]; then
lftp "$DEPLOY_URL" -e "set ftp:ssl-allow true; set net:max-retries 3; set net:timeout 20; cd \"$REMOTE_ROOT\"; mirror -R --only-newer --parallel=4 \"$DIST_DIR/$directory\" \"$directory\"; bye"
run_lftp "mirror -R --only-newer --parallel=4 $(lftp_quote "$DIST_DIR/$directory") $(lftp_quote "$directory")"
fi
done
+1 -1
View File
@@ -120,7 +120,7 @@ async function verifyShell(baseUrl, shellPath) {
async function verifyRelease(baseUrl) {
const expectedCommit = process.env.RELEASE_EXPECTED_COMMIT || process.env.GITHUB_SHA || "";
const expectedBuildId = process.env.RELEASE_EXPECTED_BUILD_ID || "";
const expectedBuildId = process.env.RELEASE_EXPECTED_BUILD_ID || process.env.RELEASE_BUILD_ID || "";
const manifest = await fetchJson(baseUrl, "release-manifest.json");
const releaseEntry = await fetchJson(baseUrl, "release-entry.json");
@@ -97,6 +97,10 @@ const props = defineProps({
type: Boolean,
default: false,
},
allowBookingDeletion: {
type: Boolean,
default: false,
},
department_lane_id: {
type: Number,
default: null,
@@ -1752,6 +1756,11 @@ const acceptSelfServeWashDraft = async () => {
}
};
const canDeleteOrderBooking = computed(() =>
!props.order_id &&
(props.allowBookingDeletion || SessionUser.canAccessAdmin() || SessionUser.canAccessSuperUser())
);
const flatBuiltInMenuSections = computed(() => {
const sections = [];
@@ -1806,7 +1815,7 @@ const flatBuiltInMenuSections = computed(() => {
),
})
: null,
!props.order_id
canDeleteOrderBooking.value
? buildMenuAction("booking-delete", {
icon: "fas fa-trash-alt",
label: t("admin.pos.settings_wheel.delete_booking"),
@@ -15,6 +15,7 @@ import {
selectPreferredStripeTerminalReaderId,
STRIPE_TERMINAL_STATUS,
} from "@/components/displays/department/pos/displays/stripeTerminalReaders.js";
import { normalizeStripeInvoice } from "@/components/displays/department/pos/displays/stripeEmailInvoice.js";
const POLLING_INTERVAL_MS = 5000;
const STRIPE_TERMINAL_SETUP_REQUIRED_CODE = 'stripe_terminal_setup_required';
@@ -87,29 +88,6 @@ const selectedTaxRate = ref(1);
const paymentIntent = computed(() => StripeModule.paymentIntents.paymentIntent.value);
const isTerminalPaymentCaptured = computed(() => StripeModule.paymentIntents.isPaymentIntentAmountReceived(paymentIntent.value));
const normalizeStripeInvoice = (value) => {
if (!value || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).length === 0) {
return null;
}
const invoiceId = value.invoice_id || value.id || null;
if (!invoiceId) {
return null;
}
return {
id: value.id ?? props.order_id,
invoice_id: invoiceId,
customer_id: value.customer_id ?? null,
url: value.url || value.hosted_invoice_url || null,
created_at: value.created_at || null,
paid: Boolean(value.paid),
status: value.status || 'unknown',
amount_due: Number(value.amount_due ?? 0),
amount_paid: Number(value.amount_paid ?? 0),
};
};
const hasStripeEmailInvoice = computed(() => stripeInvoice.value !== null);
const isStripeEmailInvoicePaid = computed(() => stripeInvoice.value?.paid === true);
const isStripeEmailInvoiceTerminalState = computed(() => {
@@ -202,7 +180,7 @@ const loadStripeInvoiceState = async () => {
try {
const response = await getOrder(props.order_id, true);
const nextInvoice = normalizeStripeInvoice(response?.data?.includes?.stripeModuleOrders);
const nextInvoice = normalizeStripeInvoice(response?.data?.includes?.stripeModuleOrders, props.order_id);
stripeInvoice.value = nextInvoice;
if (nextInvoice) {
emailPanelState.value = 'tracking';
@@ -0,0 +1,60 @@
const STRIPE_INVOICE_PAID_STATUS = 'paid';
export const parseStripeInvoicePaidFlag = (value) => {
if (typeof value === 'boolean') {
return value;
}
if (typeof value === 'number') {
return value === 1;
}
if (typeof value === 'string') {
const normalizedValue = value.trim().toLowerCase();
if (['true', '1'].includes(normalizedValue)) {
return true;
}
if (['false', '0', ''].includes(normalizedValue)) {
return false;
}
}
return false;
};
const toFiniteNumber = (value, fallback = 0) => {
const parsedValue = Number(value ?? fallback);
return Number.isFinite(parsedValue) ? parsedValue : fallback;
};
export const normalizeStripeInvoice = (value, fallbackOrderId = null) => {
if (!value || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).length === 0) {
return null;
}
const invoiceId = value.invoice_id || value.id || null;
if (!invoiceId) {
return null;
}
const status = String(value.status || 'unknown').toLowerCase();
const amountDue = toFiniteNumber(value.amount_due);
const amountPaid = toFiniteNumber(value.amount_paid);
const hasCoveredAmountDue = amountDue <= 0 || amountPaid >= amountDue;
const isPaid = status === STRIPE_INVOICE_PAID_STATUS
&& parseStripeInvoicePaidFlag(value.paid)
&& hasCoveredAmountDue;
return {
id: value.id ?? fallbackOrderId,
invoice_id: invoiceId,
customer_id: value.customer_id ?? null,
url: value.url || value.hosted_invoice_url || null,
created_at: value.created_at || null,
paid: isPaid,
status,
amount_due: amountDue,
amount_paid: amountPaid,
};
};
@@ -10,6 +10,16 @@ import { SessionUser } from "@/components/session/token/SessionUser.vue";
const acceptedOrderAttachmentFileTypes = "image/*,application/pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx";
const imageExtensions = [".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".svg"];
const officeExtensions = [".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx"];
const safePreviewBlobTypesByKind = {
image: {
fallback: "image/png",
allowed: new Set(["image/png", "image/jpeg", "image/gif", "image/webp", "image/bmp", "image/svg+xml"]),
},
document: {
fallback: "application/pdf",
allowed: new Set(["application/pdf"]),
},
};
const props = defineProps({
order: {
@@ -324,7 +334,21 @@ const hasCachedPreviewSource = (attachmentId) => {
return Object.prototype.hasOwnProperty.call(previewSourcesById.value, attachmentId);
};
const createEmbeddablePreviewUrl = async (downloadLink) => {
const createSafePreviewBlob = (fileBlob, previewKind) => {
const previewBlobTypes = safePreviewBlobTypesByKind[previewKind];
if (!previewBlobTypes) {
return null;
}
const normalizedBlobType = String(fileBlob.type || "").toLowerCase();
const safeBlobType = previewBlobTypes.allowed.has(normalizedBlobType)
? normalizedBlobType
: previewBlobTypes.fallback;
return new Blob([fileBlob], { type: safeBlobType });
};
const createEmbeddablePreviewUrl = async (downloadLink, previewKind) => {
if (!downloadLink) {
return null;
}
@@ -340,7 +364,12 @@ const createEmbeddablePreviewUrl = async (downloadLink) => {
return null;
}
const objectUrl = URL.createObjectURL(fileBlob);
const safePreviewBlob = createSafePreviewBlob(fileBlob, previewKind);
if (!safePreviewBlob) {
return null;
}
const objectUrl = URL.createObjectURL(safePreviewBlob);
generatedObjectUrls.add(objectUrl);
return objectUrl;
} catch (error) {
@@ -372,7 +401,7 @@ const ensurePreviewSource = async (attachment) => {
attachment.id,
false
);
const previewSource = await createEmbeddablePreviewUrl(downloadLink);
const previewSource = await createEmbeddablePreviewUrl(downloadLink, previewKind);
previewSourcesById.value = {
...previewSourcesById.value,
[attachment.id]: previewSource,
@@ -517,6 +546,7 @@ const toggleDropdown = async () => {
:src="activePreviewSource"
class="order-attachments-preview-panel__document"
title="Attachment preview"
sandbox
></iframe>
<a
v-else-if="activePreviewKind === 'link'"
@@ -92,6 +92,7 @@ const duplicateDetailsExpanded = ref(false);
const pendingNextResolution = ref(false);
const isDesktopLastWashCopying = ref(false);
let desktopStep1CoordinationPromise = Promise.resolve({ canProceed: true });
let focusOnReg1TimeoutId = null;
const isDesktopStep1Active = computed(() => getCurrentStep() === 1);
const setTab = (tab) => {
@@ -117,7 +118,16 @@ watch(
);
const focusOnReg1 = () => {
setTimeout(() => {
if (focusOnReg1TimeoutId !== null) {
clearTimeout(focusOnReg1TimeoutId);
}
focusOnReg1TimeoutId = setTimeout(() => {
focusOnReg1TimeoutId = null;
if (typeof document === "undefined") {
return;
}
const reg1Input = document.getElementById("reg_1");
if (reg1Input) {
reg1Input.focus();
@@ -267,12 +277,8 @@ const bookingSelectionObjects = computed(() => {
const contentSegments = [
`${t("admin.pos.order_booking_selector.customer_label")}: ${booking?.customer_name || t("admin.pos.not_found")}`,
`${t("admin.pos.order_booking_selector.plates_label")}: ${plateText || t("admin.pos.not_found")}`,
`${t("common.reference")}: ${
getOrderBookingReferenceValue(booking) || t("admin.pos.not_found")
}`,
`${t("common.services")}: ${
getOrderBookingServiceText(booking) || t("admin.pos.not_found")
}`,
`${t("common.reference")}: ${getOrderBookingReferenceValue(booking) || t("admin.pos.not_found")}`,
`${t("common.services")}: ${getOrderBookingServiceText(booking) || t("admin.pos.not_found")}`,
];
return {
@@ -681,6 +687,10 @@ onMounted(() => {
});
onBeforeUnmount(() => {
if (focusOnReg1TimeoutId !== null) {
clearTimeout(focusOnReg1TimeoutId);
focusOnReg1TimeoutId = null;
}
clearDesktopStep1PreflightHandler(handleDesktopStep1Preflight);
});
@@ -752,11 +762,7 @@ watch(
<ButtonsBox class="pos-actions pos-actions--stacked">
<Cancel tabindex="2" class="is-fullwidth" />
</ButtonsBox>
<div
v-if="shouldShowActionRailControls"
class="pos-shell-actions__rail"
data-testid="pos-step-1-action-rail"
>
<div v-if="shouldShowActionRailControls" class="pos-shell-actions__rail" data-testid="pos-step-1-action-rail">
<PosDesktopDuplicateWarning
v-if="shouldShowDuplicateWarningInActionRail"
:title="t('admin.pos.warning')"
@@ -299,14 +299,12 @@ const getSelectedBookingId = () => {
const completeStep2Order = async ({
bookingSafetySeal = null,
markOrderCompleted = false,
}: {
bookingSafetySeal?: string | null;
markOrderCompleted?: boolean;
} = {}) => {
await finalizeCurrentMobileOrder({
bookingSafetySeal,
markOrderCompleted,
markOrderCompleted: true,
});
popups.select("completed_transaction", {
message: `Order #${order_id.value} successfully created.`,
@@ -327,14 +325,12 @@ const step2 = async () => {
const resolvedSafetySeal = getResolvedMobileSafetySeal();
const hasResolvedSafetySeal = isNonEmptyString(resolvedSafetySeal);
const requiresBookingCompletionPopup = Boolean(selectedBookingId && hasWashCertificateInBasket);
const shouldMarkOrderAsCompleted = !selectedBookingId && hasWashCertificateInBasket;
try {
if (requiresBookingCompletionPopup && hasResolvedSafetySeal) {
syncMobileSafetySealState(resolvedSafetySeal);
await completeStep2Order({
bookingSafetySeal: resolvedSafetySeal,
markOrderCompleted: false,
});
return;
}
@@ -344,7 +340,6 @@ const step2 = async () => {
syncMobileSafetySealState(safetySeal);
await completeStep2Order({
bookingSafetySeal: String(safetySeal ?? ""),
markOrderCompleted: false,
});
};
@@ -358,9 +353,7 @@ const step2 = async () => {
return;
}
await completeStep2Order({
markOrderCompleted: shouldMarkOrderAsCompleted,
});
await completeStep2Order();
} catch (error: any) {
errors.value.push(error);
console.warn("An error occurred while completing the mobile order:", error);
@@ -3,6 +3,45 @@ export const XLVASK_USAGE_AMOUNT_CACHE_TTL_MS = 10 * 60 * 1000;
const CACHE_PREFIX = "xlvask-usage-amount:";
const memoryCache = new Map();
const getStorageValue = (key) => {
try {
if (typeof window === "undefined" || !window.localStorage) {
return "";
}
return window.localStorage.getItem(key) || "";
} catch {
return "";
}
};
const hashCacheScopePart = (value) => {
let hash = 5381;
for (let index = 0; index < value.length; index += 1) {
hash = ((hash << 5) + hash) ^ value.charCodeAt(index);
}
return (hash >>> 0).toString(36);
};
const getAuthenticatedCacheScope = () => {
const token = getStorageValue("token");
if (!token) {
return "";
}
return [
hashCacheScopePart(token),
getStorageValue("is_subuser") === "true" ? "subuser" : "user",
getStorageValue("selected_customer_number"),
]
.map((part) => encodeURIComponent(String(part ?? "")))
.join("|");
};
const getScopedCacheKey = (cacheKey) => {
const scope = getAuthenticatedCacheScope();
return scope ? `${scope}:${cacheKey}` : "";
};
const safeSessionStorage = () => {
try {
if (typeof window === "undefined" || !window.sessionStorage) {
@@ -15,9 +54,7 @@ const safeSessionStorage = () => {
};
const normalizeUsageLogId = (objectOrId) => {
const value = typeof objectOrId === "object"
? objectOrId?.usage_log_id ?? objectOrId?.id
: objectOrId;
const value = typeof objectOrId === "object" ? objectOrId?.usage_log_id ?? objectOrId?.id : objectOrId;
const parsed = Number.parseInt(String(value ?? ""), 10);
return Number.isInteger(parsed) && parsed > 0 ? parsed : 0;
};
@@ -34,17 +71,18 @@ export const buildXlvaskUsageAmountCacheKey = (objectOrId) => {
export const getCachedXlvaskUsageAmount = (objectOrId) => {
const cacheKey = buildXlvaskUsageAmountCacheKey(objectOrId);
if (!cacheKey) {
const scopedCacheKey = cacheKey ? getScopedCacheKey(cacheKey) : "";
if (!scopedCacheKey) {
return null;
}
const memoryEntry = memoryCache.get(cacheKey);
const memoryEntry = memoryCache.get(scopedCacheKey);
if (isFreshEntry(memoryEntry)) {
return memoryEntry.data;
}
if (memoryEntry) {
memoryCache.delete(cacheKey);
memoryCache.delete(scopedCacheKey);
}
const storage = safeSessionStorage();
@@ -52,14 +90,14 @@ export const getCachedXlvaskUsageAmount = (objectOrId) => {
return null;
}
const storageKey = `${CACHE_PREFIX}${cacheKey}`;
const storageKey = `${CACHE_PREFIX}${scopedCacheKey}`;
try {
const parsed = JSON.parse(storage.getItem(storageKey) || "null");
if (!isFreshEntry(parsed)) {
storage.removeItem(storageKey);
return null;
}
memoryCache.set(cacheKey, parsed);
memoryCache.set(scopedCacheKey, parsed);
return parsed.data;
} catch {
storage.removeItem(storageKey);
@@ -69,7 +107,8 @@ export const getCachedXlvaskUsageAmount = (objectOrId) => {
export const setCachedXlvaskUsageAmount = (objectOrId, data) => {
const cacheKey = buildXlvaskUsageAmountCacheKey(objectOrId);
if (!cacheKey) {
const scopedCacheKey = cacheKey ? getScopedCacheKey(cacheKey) : "";
if (!scopedCacheKey) {
return;
}
@@ -77,7 +116,7 @@ export const setCachedXlvaskUsageAmount = (objectOrId, data) => {
storedAt: Date.now(),
data,
};
memoryCache.set(cacheKey, entry);
memoryCache.set(scopedCacheKey, entry);
const storage = safeSessionStorage();
if (!storage) {
@@ -85,7 +124,7 @@ export const setCachedXlvaskUsageAmount = (objectOrId, data) => {
}
try {
storage.setItem(`${CACHE_PREFIX}${cacheKey}`, JSON.stringify(entry));
storage.setItem(`${CACHE_PREFIX}${scopedCacheKey}`, JSON.stringify(entry));
} catch {
// Best-effort cache. Quota errors should not block XL Vask usage rows.
}
@@ -94,11 +133,12 @@ export const setCachedXlvaskUsageAmount = (objectOrId, data) => {
export const clearCachedXlvaskUsageAmount = (objectOrId = null) => {
const storage = safeSessionStorage();
const cacheKey = objectOrId === null ? "" : buildXlvaskUsageAmountCacheKey(objectOrId);
const scopedCacheKey = cacheKey ? getScopedCacheKey(cacheKey) : "";
if (cacheKey) {
memoryCache.delete(cacheKey);
if (scopedCacheKey) {
memoryCache.delete(scopedCacheKey);
try {
storage?.removeItem(`${CACHE_PREFIX}${cacheKey}`);
storage?.removeItem(`${CACHE_PREFIX}${scopedCacheKey}`);
} catch {
// Best-effort cache cleanup.
}
@@ -117,6 +117,14 @@ const emit = defineEmits<{
</b-radio-button>
</div>
</div>
<b-message
v-if="!isMachineAvailable(selectedLaneId)"
type="is-warning"
aria-close-label="Luk besked"
data-testid="self-serve-machine-unavailable-guidance"
>
{{ $t("self_wash.machine_unavailable_for_lane") }}
</b-message>
</section>
</div>
</template>
@@ -2,6 +2,8 @@
import Swal from "sweetalert2";
import { usePaginatedListInstance } from "@/components/pagination/paginatedList.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import ActionSettingsWheelButton from "@/components/displays/buttons/ActionSettingsWheelButton.vue";
import ActionSettingsWheelItem from "@/components/displays/buttons/ActionSettingsWheelItem.vue";
const props = defineProps({
objects: {
@@ -23,6 +25,8 @@ const canDisableAccess = () =>
const canResendInvite = (subuser) =>
(props.showCustomer ? SessionUser.canAccessSuperUser() : SessionUser.canAccessUser() || SessionUser.hasPermission("SUBUSERS_EDIT"))
&& Boolean(subuser?.can_resend_invite ?? subuser?.setup_required);
const hasRowActions = (subuser) =>
(canEditPermissions() && subuser?.grant_id) || canResendInvite(subuser) || (canDisableAccess() && subuser?.grant_id);
const formatDateTime = (dateString) => {
if (!dateString) {
@@ -241,14 +245,6 @@ const onResendInvite = async (subuser) => {
<td>
<div>{{ subuser.grant_note || "-" }}</div>
<button
v-if="canEditPermissions() && subuser.grant_id"
class="button is-text is-small px-0 mt-1"
type="button"
@click="onEditNote(subuser)"
>
Redigér note
</button>
</td>
<td>{{ formatDateTime(subuser.created_at) }}</td>
@@ -256,36 +252,42 @@ const onResendInvite = async (subuser) => {
<td>
<div class="buttons is-justify-content-flex-end action-buttons">
<button
v-if="canEditPermissions() && subuser.grant_id"
class="button is-small"
type="button"
:data-testid="`subuser-permissions-${subuser.id}`"
@click="onEditPermissions(subuser)"
>
Tilladelser
</button>
<ActionSettingsWheelButton v-if="hasRowActions(subuser)">
<template #actions>
<ActionSettingsWheelItem
v-if="canEditPermissions() && subuser.grant_id"
icon="fas fa-pen"
label="Redigér note"
:click-action="() => onEditNote(subuser)"
:test-id="`subuser-note-${subuser.id}`"
/>
<button
v-if="canResendInvite(subuser)"
class="button is-small"
type="button"
:data-testid="`subuser-resend-${subuser.id}`"
@click="onResendInvite(subuser)"
>
Gensend
</button>
<ActionSettingsWheelItem
v-if="canEditPermissions() && subuser.grant_id"
icon="fas fa-user-shield"
label="Tilladelser"
:click-action="() => onEditPermissions(subuser)"
:test-id="`subuser-permissions-${subuser.id}`"
/>
<button
v-if="canDisableAccess() && subuser.grant_id"
class="button is-small"
:class="subuser.grant_enabled ? 'is-danger is-light' : 'is-success is-light'"
type="button"
:data-testid="`subuser-toggle-${subuser.id}`"
@click="onToggleEnabled(subuser, !subuser.grant_enabled)"
>
{{ subuser.grant_enabled ? "Deaktivér" : "Aktivér" }}
</button>
<ActionSettingsWheelItem
v-if="canResendInvite(subuser)"
icon="fas fa-paper-plane"
label="Gensend"
:click-action="() => onResendInvite(subuser)"
:test-id="`subuser-resend-${subuser.id}`"
/>
<ActionSettingsWheelItem
v-if="canDisableAccess() && subuser.grant_id"
:icon="subuser.grant_enabled ? 'fas fa-ban' : 'fas fa-check'"
:label="subuser.grant_enabled ? 'Deaktivér' : 'Aktivér'"
:template="subuser.grant_enabled ? 'danger' : 'success'"
:click-action="() => onToggleEnabled(subuser, !subuser.grant_enabled)"
:test-id="`subuser-toggle-${subuser.id}`"
/>
</template>
</ActionSettingsWheelButton>
</div>
</td>
</tr>
@@ -10,6 +10,7 @@ import LoadButtonWhileAwait from "@/components/request/LoadButtonWhileAwait.vue"
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import TwoFactorVerify from "@/components/forms/auth/TwoFactorVerify.vue";
import { isPasskeySupported, authenticateWithPasskey } from "@/services/PasskeyAuthService.js";
import { clearEdgeGatewayWorkspaceCache } from "@/services/edgeGateways.js";
const employees = ref([]);
const { t } = useI18n();
@@ -52,6 +53,7 @@ const login = async () => {
}
// Save the token in the local storage
clearEdgeGatewayWorkspaceCache();
localStorage.setItem('token', data.token);
// Redirect to the dashboard
window.location.href = '/admin';
@@ -72,6 +74,7 @@ const loginWithPasskey = async () => {
const result = await authenticateWithPasskey('employee', null, recaptchaToken);
if (result.token) {
clearEdgeGatewayWorkspaceCache();
localStorage.setItem('token', result.token);
window.location.href = '/admin';
return;
+3
View File
@@ -9,6 +9,7 @@ import { parseError, clearErrors, addError, getError } from "@/components/reques
import LoadButtonWhileAwait from "@/components/request/LoadButtonWhileAwait.vue";
import { isPasskeySupported, authenticateWithPasskey } from "@/services/PasskeyAuthService.js";
import TwoFactorVerify from "@/components/forms/auth/TwoFactorVerify.vue";
import { clearEdgeGatewayWorkspaceCache } from "@/services/edgeGateways.js";
const { t } = useI18n();
@@ -68,6 +69,7 @@ const login = async () => {
}
// Save the token in the local storage
clearEdgeGatewayWorkspaceCache();
localStorage.setItem('token', data.token);
// Set the success message
successMessage.value = "Du er nu logget ind!"
@@ -103,6 +105,7 @@ const loginWithPasskey = async () => {
const result = await authenticateWithPasskey('user', customerNum, recaptchaToken);
console.log('Passkey authentication result:', result);
if (result.token) {
clearEdgeGatewayWorkspaceCache();
localStorage.setItem('token', result.token);
successMessage.value = "Du er nu logget ind!";
setTimeout(() => {
@@ -9,6 +9,7 @@ import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { isPasskeySupported, authenticateWithPasskey } from "@/services/PasskeyAuthService.js";
import { getSubuserPasswordPolicyError } from "@/services/subuserPasswordPolicy.js";
import TwoFactorVerify from "@/components/forms/auth/TwoFactorVerify.vue";
import { clearEdgeGatewayWorkspaceCache } from "@/services/edgeGateways.js";
const { t } = useI18n();
@@ -69,6 +70,7 @@ const login = async () => {
// Save the session token
if (data.session) {
clearEdgeGatewayWorkspaceCache();
localStorage.setItem('token', data.session);
localStorage.setItem('is_subuser', 'true');
window.location.reload();
@@ -97,6 +99,7 @@ const loginWithPasskey = async () => {
// Subuser login returns 'session' token, user login returns 'token'
const sessionToken = result.session || result.token;
if (sessionToken) {
clearEdgeGatewayWorkspaceCache();
localStorage.setItem('token', sessionToken);
localStorage.setItem('is_subuser', 'true');
// Reload the page to update the UI
@@ -5,6 +5,7 @@ import { useI18n } from 'vue-i18n';
import { verify2FA } from "@/services/TwoFactorAuthService.js";
import { parseError, getError, clearErrors } from "@/components/request/HandleGlobalError.vue";
import LoadButtonWhileAwait from "@/components/request/LoadButtonWhileAwait.vue";
import { clearEdgeGatewayWorkspaceCache } from "@/services/edgeGateways.js";
const { t } = useI18n();
const router = useRouter();
@@ -43,9 +44,11 @@ const verify = async () => {
// Handle the response based on user type
if (props.userType === 'subuser' && result.session) {
clearEdgeGatewayWorkspaceCache();
localStorage.setItem('token', result.session);
localStorage.setItem('is_subuser', 'true');
} else if (result.token) {
clearEdgeGatewayWorkspaceCache();
localStorage.setItem('token', result.token);
localStorage.removeItem('is_subuser');
}
@@ -52,6 +52,7 @@ const customer_suggestions = ref([
const isSettingCustomer = ref(false);
const isSettingCustomerToInteger = ref(0);
const isSettingCustomerStartTime = ref(null);
let customerSuggestionsRequestId = 0;
const isSettingCustomerTo = (customer_number) => {
return isSettingCustomerToInteger.value === parseInt(customer_number);
@@ -88,10 +89,17 @@ const getCustomerSuggestions = () => {
if (!props.reg_1) {
return;
}
const requestedReg1 = props.reg_1;
const requestId = ++customerSuggestionsRequestId;
SessionUser.request("/department/vehicle/customer-suggestions", "GET", {
reg_1: props.reg_1,
reg_1: requestedReg1,
})
.then((response) => {
if (requestId !== customerSuggestionsRequestId || requestedReg1 !== props.reg_1) {
return;
}
// Assuming the response contains an array of customer suggestions
console.log("Customer suggestions:", response.data.data);
let suggestions = [];
@@ -126,6 +134,7 @@ watch(
if (newValue) {
getCustomerSuggestions();
} else {
customerSuggestionsRequestId += 1;
customer_suggestions.value = [];
}
}
+17 -6
View File
@@ -118,6 +118,7 @@ const impersonatedUserRoleId = computed(() => {
const canGrantMissingPermissions = computed(() =>
hasSuperuserToken.value && !SessionUser.isSubuser.value && impersonatedUserRoleId.value !== null
);
const canInspectReleaseRuntime = computed(() => hasSuperuserToken.value || SessionUser.canAccessAdmin?.() === true);
const userDetailRows = computed(() => {
if (SessionUser.isSubuser.value) {
@@ -284,7 +285,12 @@ const resolvePingUrl = () => {
};
const activeApiUrl = computed(() => getReleaseRuntimeApiBaseUrl());
const releaseSessionSummary = computed(() => buildReleaseSessionSummary());
const activeApiUrlLabel = computed(() =>
canInspectReleaseRuntime.value ? activeApiUrl.value : "Restricted to release operators"
);
const releaseSessionSummary = computed(() =>
buildReleaseSessionSummary(undefined, { includeInfrastructureDetails: canInspectReleaseRuntime.value })
);
const measurePingLatency = async () => {
if (typeof fetch !== "function") {
@@ -646,9 +652,10 @@ onBeforeUnmount(() => {
</aside>
<aside class="request-queue-progress__side request-queue-progress__side--runtime" data-testid="request-queue-runtime-box">
<div class="request-queue-progress__section-title">Session release</div>
<div class="request-queue-progress__section-content request-queue-progress__section-content--release">
<ul class="request-queue-progress__meta-list">
<template v-if="canInspectReleaseRuntime">
<div class="request-queue-progress__section-title">Session release</div>
<div class="request-queue-progress__section-content request-queue-progress__section-content--release">
<ul class="request-queue-progress__meta-list">
<li class="request-queue-progress__meta-item">
<span class="request-queue-progress__meta-label">Channel</span>
<span
@@ -771,11 +778,15 @@ onBeforeUnmount(() => {
</li>
</ul>
<div class="request-queue-progress__subsection-title">Runtime details</div>
</div>
</template>
<div class="request-queue-progress__section-title">Runtime details</div>
<div class="request-queue-progress__section-content request-queue-progress__section-content--release">
<ul class="request-queue-progress__meta-list">
<li class="request-queue-progress__meta-item">
<span class="request-queue-progress__meta-label">API URL</span>
<span class="request-queue-progress__meta-value" :title="activeApiUrl">{{ activeApiUrl }}</span>
<span class="request-queue-progress__meta-value" :title="activeApiUrlLabel">{{ activeApiUrlLabel }}</span>
</li>
<li class="request-queue-progress__meta-item">
<span class="request-queue-progress__meta-label">Current host</span>
+8 -5
View File
@@ -48,10 +48,13 @@ export const getDepartmentDescription = (id) => {
export const getDepartmentsGuest = async (queryParams = {}) => {
isLoading.value = true;
const queryString = new URLSearchParams(queryParams).toString();
const request = await unauthenticatedRequest("/guest/departments" + (queryString ? `?${queryString}` : ""), "get");
departments.value = sortByDepartmentPriorityOrder(request.data.data || []);
isLoading.value = false;
return departments.value;
try {
const queryString = new URLSearchParams(queryParams).toString();
const request = await unauthenticatedRequest("/guest/departments" + (queryString ? `?${queryString}` : ""), "get");
departments.value = sortByDepartmentPriorityOrder(request.data.data || []);
return departments.value;
} finally {
isLoading.value = false;
}
};
</script>
+2
View File
@@ -2,6 +2,7 @@
import { ref } from 'vue'
import axios from 'axios'
import {API_URL} from "@/config.js";
import { clearEdgeGatewayWorkspaceCache } from "@/services/edgeGateways.js";
export const currentDepartment = ref(null);
export const selectDepartment = (department) => {
@@ -31,6 +32,7 @@ export const exitSession = () => {
}
});
clearEdgeGatewayWorkspaceCache();
localStorage.removeItem('token');
return result;
};
@@ -2,6 +2,7 @@
import axios from 'axios'
import { enqueueRequest } from "@/services/requestQueue.js";
import { buildCurrentReleaseHeaders, resolveReleaseApiUrl } from "@/services/releaseTimeline.js";
import { isTrustedReleaseUrl } from "@/services/releaseTrust.js";
/**
* Get the selected customer number for X-Customer-Number header (used by subusers)
@@ -18,22 +19,24 @@ export const authenticatedRequest = (url, method, data, catchCallable = null, th
//throw new Error('No token was found, unable to make authenticated request');
}
const requestUrl = resolveReleaseApiUrl(url);
const canSendCredentials = isTrustedReleaseUrl(requestUrl);
// Build headers
const headers = {
...buildCurrentReleaseHeaders(),
};
if (token && token.length > 0) {
if (canSendCredentials && token && token.length > 0) {
headers.Authorization = `Bearer ${token}`;
}
// Add X-Customer-Number header if subuser has selected a grant
const isSubuser = localStorage.getItem('is_subuser') === 'true';
const selectedCustomerNumber = getSelectedCustomerNumber();
if (isSubuser && selectedCustomerNumber) {
if (canSendCredentials && isSubuser && selectedCustomerNumber) {
headers['X-Customer-Number'] = selectedCustomerNumber;
}
const requestUrl = resolveReleaseApiUrl(url);
return enqueueRequest(
() => axios({
method,
+21 -1
View File
@@ -58,6 +58,12 @@ import {
releaseChannelRuntimeRequestParams,
setReleaseChannelSwitchNoticePrincipal,
} from "@/services/releaseChannelAvailability.js";
import { clearCachedXlvaskUsageAmount } from "@/components/displays/department/pos/sync/xlvaskUsageAmountCache.js";
import { clearEdgeGatewayWorkspaceCache } from "@/services/edgeGateways.js";
import {
clearPeriodCache,
clearSelfWashCountsCache,
} from "@/views/dashboards/superUserDashboard/InvoicingBillingPeriod/imports/InvoicingBillingPeriodImportPaging.js";
const normalizePositiveInteger = (value) => {
const parsedValue = Number.parseInt(String(value ?? ""), 10);
@@ -98,7 +104,16 @@ const hydrateSessionFromStorage = () => {
return true;
};
const clearPrivilegedSessionCaches = () => {
clearPeriodCache();
clearSelfWashCountsCache();
clearCachedXlvaskUsageAmount();
clearEdgeGatewayWorkspaceCache();
};
const clearStoredSession = () => {
clearPrivilegedSessionCaches();
if (typeof window === "undefined") {
return;
}
@@ -115,7 +130,8 @@ const applyReleaseRuntimeConfig = (runtime) => {
const normalizedRuntime = runtime || {};
configureReleaseRuntime(normalizedRuntime);
reconcileSelectedReleaseChannel(normalizedRuntime);
const runtimeUrls = normalizedRuntime.urls && typeof normalizedRuntime.urls === "object" ? normalizedRuntime.urls : {};
const runtimeUrls =
normalizedRuntime.urls && typeof normalizedRuntime.urls === "object" ? normalizedRuntime.urls : {};
SessionUser.runtimeConfig.release.traceId.value = normalizedRuntime.trace_id || null;
SessionUser.runtimeConfig.release.channel.value = normalizedRuntime.channel || null;
SessionUser.runtimeConfig.release.availableChannels.value =
@@ -377,6 +393,7 @@ export const authenticateUser = async (customer_number, password) => {
password,
})
.then((response) => {
clearPrivilegedSessionCaches();
SessionUser.token.value = response.data.token;
SessionUser.authenticated.value = true;
localStorage.setItem("token", response.data.token);
@@ -423,6 +440,7 @@ export const authenticateSubuser = async (credentials) => {
return await authenticatedRequest("/subusers/auth/password", "POST", requestBody)
.then((response) => {
clearPrivilegedSessionCaches();
SessionUser.token.value = response.data.data.session;
SessionUser.authenticated.value = true;
SessionUser.isSubuser.value = true;
@@ -669,6 +687,7 @@ export const SessionUser = {
* @param {number|null} customerNumber - The billing_customer_number to select, or null to clear
*/
selectGrant: (customerNumber) => {
clearEdgeGatewayWorkspaceCache();
if (customerNumber === null) {
localStorage.removeItem("selected_customer_number");
SessionUser.subuser.selectedGrantCustomerNumber.value = null;
@@ -886,6 +905,7 @@ export const SessionUser = {
* @param {string} token
*/
setToken: (token) => {
clearPrivilegedSessionCaches();
SessionUser.token.value = token;
localStorage.setItem("token", token);
// Reload the window to make sure the user's session is initiated
@@ -7,6 +7,13 @@ import { getSystemUserIds } from "@/components/session/token/SessionUser/Objects
// Helper function to get i18n translation
const t = (key) => i18n.global.t(key);
const escapeHtml = (value = "") => String(value)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll("\"", "&quot;")
.replaceAll("'", "&#39;");
const normalizeDateTimeLocalValue = (value) => {
if (value === null || value === undefined || value === '') {
return '';
@@ -765,9 +772,11 @@ export const ObjectsGlobal = {
console.log(response);
// How long are the list of options
for (let i = 0; i < response.length; i++) {
var tmp = response[i];
const tmp = response[i];
const optionValue = tmp?.id ?? "";
const optionLabel = tmp?.name ? tmp.name : optionValue;
console.log(tmp);
html += `<option value="${tmp.id}" ${(value == tmp.id || (value === null && tmp.id === 0)) ? 'selected' : ''}>${tmp.name ? tmp.name : tmp.id}</option>`;
html += `<option value="${escapeHtml(optionValue)}" ${(value == optionValue || (value === null && optionValue === 0)) ? 'selected' : ''}>${escapeHtml(optionLabel)}</option>`;
}
});
html += `</select>
@@ -19,6 +19,14 @@ const getProtectedDeletePayload = (error) => {
return payload;
};
const escapeHtml = (value) => String(value ?? "").replace(/[&<>"']/g, (character) => ({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
"\"": "&quot;",
"'": "&#39;",
})[character]);
const formatDeleteProtectionReason = (reason) => {
switch (reason) {
case "completed":
@@ -28,22 +36,23 @@ const formatDeleteProtectionReason = (reason) => {
case "attachments":
return "ordren har vedhæftninger";
default:
return String(reason || "").replace(/_/g, " ");
return escapeHtml(String(reason || "").replace(/_/g, " "));
}
};
const showProtectedOrderDeleteConfirmation = async (id, protectionPayload, onAfterSubmit = null) => {
const orderId = String(id);
const escapedOrderId = escapeHtml(orderId);
const reasons = Array.isArray(protectionPayload?.protected_reasons)
? protectionPayload.protected_reasons.map(formatDeleteProtectionReason).filter(Boolean)
: [];
const reasonText = reasons.length > 0 ? reasons.join(", ") : "ordren indeholder gemte data";
const result = await Swal.fire({
title: `Bekræft sletning af ordre #${orderId}`,
titleText: `Bekræft sletning af ordre #${orderId}`,
html: `
<p>Ordren kan stadig slettes, men kræver ekstra bekræftelse fordi ${reasonText}.</p>
<p>Skriv <strong>${orderId}</strong> for at slette ordren.</p>
<p>Skriv <strong>${escapedOrderId}</strong> for at slette ordren.</p>
`,
input: "text",
inputAttributes: {
@@ -127,6 +127,13 @@ export const SelfServeVehicleConditions = {
}
},
add: async (department, lane, customer_id, reg, question, value, options = {}) => {
const safeOptions = {};
const normalizedVehicleType = parseInt(options.vehicle_type ?? options.vehicle_type_id);
if (!Number.isNaN(normalizedVehicleType) && normalizedVehicleType > 0) {
safeOptions.vehicle_type = normalizedVehicleType;
}
return ObjectsGlobal.add.object(SelfServeVehicleConditions.meta.endpoint, {
department: parseInt(department),
lane: parseInt(lane),
@@ -134,7 +141,9 @@ export const SelfServeVehicleConditions = {
reg: reg,
question: parseInt(question),
value: value === "true" || value === true,
...options
...safeOptions,
activate_machine: false,
sync_relay_state: false
});
},
set: {
@@ -17,13 +17,15 @@ const normalizeEntries = (entries) => {
return [];
};
const SENSITIVE_WRITE_ONLY_KEYS = new Set(["broker_shared_secret"]);
const toConfigPayload = (entries) => {
const payload = {};
normalizeEntries(entries).forEach((entry) => {
if (!entry || !entry.variable) {
return;
}
payload[entry.variable] = entry.value;
payload[entry.variable] = SENSITIVE_WRITE_ONLY_KEYS.has(entry.variable) ? "" : entry.value;
});
return payload;
};
@@ -42,6 +44,11 @@ export const Config = {
test_broker: async (payload) => testEdgeGatewayBrokerConfig(payload),
set: async (variable, value) => {
const currentConfig = await Config.get_config();
SENSITIVE_WRITE_ONLY_KEYS.forEach((key) => {
if (key !== variable) {
delete currentConfig[key];
}
});
return Config.set_config({
...currentConfig,
[variable]: value,
@@ -73,7 +80,6 @@ export const Config = {
set: async (value) => Config.set("broker_auth_mode", value),
},
broker_shared_secret: {
get: async () => Config.get("broker_shared_secret"),
set: async (value) => Config.set("broker_shared_secret", value),
},
},
@@ -1,6 +1,7 @@
<script>
import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue";
import { parseError } from "@/components/request/HandleGlobalError.vue";
import { clearEdgeGatewayWorkspaceCache } from "@/services/edgeGateways.js";
import { DatabaseSystemObject } from "@/components/session/token/superUser/systemDatabase.vue";
import { Cron } from "@/components/session/token/superUser/cron.vue";
import { Economic } from "@/components/session/token/superUser/modules/Economic/Economic.vue";
@@ -106,6 +107,7 @@ export const SuperUserObject = {
const currentToken = localStorage.getItem("token");
localStorage.setItem("superuser_token", currentToken);
// Set the token in the local storage
clearEdgeGatewayWorkspaceCache();
localStorage.setItem("token", response.data.data.token);
// Redirect the user to the dashboard
window.location = "/";
@@ -143,6 +145,7 @@ export const SuperUserObject = {
// Get the superuser token
const superuserToken = localStorage.getItem("superuser_token");
// Set the token in the local storage
clearEdgeGatewayWorkspaceCache();
localStorage.setItem("token", superuserToken);
// Clear the superuser token
localStorage.removeItem("superuser_token");
+25 -22
View File
@@ -320,20 +320,26 @@ export const ensureCurrentOrderDepartment = async (targetDepartmentId = null) =>
}
try {
let currentDepartmentId = null;
try {
currentDepartmentId = await getCurrentOrderDepartmentId(normalizedOrderId);
} catch (error) {
currentDepartmentId = null;
}
const currentDepartmentId = await getCurrentOrderDepartmentId(normalizedOrderId);
if (currentDepartmentId === normalizedDepartmentId) {
department_id.value = normalizedDepartmentId;
return true;
}
await SessionUser.objects.orders.set.department_id(normalizedOrderId, normalizedDepartmentId);
department_id.value = normalizedDepartmentId;
return true;
parseError(
{
response: {
data: {
data: {
message: "Ordren tilhører ikke den valgte afdeling",
},
message: "Ordren tilhører ikke den valgte afdeling",
},
},
},
"stepError"
);
return false;
} catch (error) {
parseError(error, "stepError");
return false;
@@ -727,9 +733,9 @@ export const restoreStoredPosOrderId = async (
if (selectedDepartmentId && toPositiveInteger(storedOrder?.department_id) !== selectedDepartmentId) {
if (options.syncDepartment === true) {
order_id.value = storedOrderId;
const didSyncDepartment = await ensureCurrentOrderDepartment(selectedDepartmentId);
if (!didSyncDepartment) {
throw new Error("Stored order department could not be changed to current department");
const isCurrentDepartment = await ensureCurrentOrderDepartment(selectedDepartmentId);
if (!isCurrentDepartment) {
throw new Error("Stored order department does not match current department");
}
} else {
throw new Error("Stored order department does not match current department");
@@ -2012,18 +2018,14 @@ export const getVehiclePlateBookings = (vehiclePlate) => {
return sortPendingOrderBookings(matchingBookings);
};
export const ensureVehiclePlateBookingsLoaded = async (vehiclePlate, options = {}) => {
const normalizedOptions = {
force: false,
...options,
};
export const ensureVehiclePlateBookingsLoaded = async (vehiclePlate, _options = {}) => {
const normalizedVehiclePlate = normalizeVehiclePlateForBookingSelection(vehiclePlate);
const currentDepartmentKey = syncPendingBookingsDepartmentState();
if (!normalizedVehiclePlate || !department_id.value) {
return [];
}
if (!normalizedOptions.force && loadedPendingBookingPlates.has(normalizedVehiclePlate)) {
if (loadedPendingBookingPlates.has(normalizedVehiclePlate)) {
return getVehiclePlateBookings(normalizedVehiclePlate);
}
@@ -2167,7 +2169,7 @@ const getFirstAvailableWashProduct = async () => {
}
};
const hydrateSelectedOrderBookingForDesktop = async () => {
export const hydrateSelectedOrderBookingForDesktop = async () => {
const normalizedOrderId = toPositiveInteger(order_id.value);
const normalizedBookingId = toPositiveInteger(selectedOrderBookingId.value);
@@ -2250,8 +2252,7 @@ const hydrateSelectedOrderBookingForDesktop = async () => {
}
}
const forcedPrimaryPrice =
typeof primaryRawItem?.price === "number" ? Number(primaryRawItem.price) : primaryProduct.price ?? null;
const forcedPrimaryPrice = primaryProduct.price ?? null;
const primaryItemResponse = await createOrderItem(
normalizedOrderId,
primaryProduct.id,
@@ -2269,13 +2270,15 @@ const hydrateSelectedOrderBookingForDesktop = async () => {
continue;
}
const secondaryProduct = await fetchOrderBookingProductWithPricing(secondaryProductId);
await createOrderItem(
normalizedOrderId,
secondaryProductId,
Math.max(1, Number.parseInt(String(bookingItem?.quantity ?? 1), 10) || 1),
relatedPrimaryItemId,
String(bookingItem?.notes ?? "").trim() || null,
typeof bookingItem?.price === "number" ? Number(bookingItem.price) : null
secondaryProduct?.price ?? null
);
}
+7 -5
View File
@@ -64,6 +64,7 @@ export const useOrderMetadataAutosave = ({
const requestedValue = normalizeValue(draft.value);
const sequence = ++nextSequence;
let saveCompleted = false;
isSaving.value = true;
try {
@@ -83,6 +84,8 @@ export const useOrderMetadataAutosave = ({
await onSaved(savedValue, requestedValue);
}
saveCompleted = true;
return true;
} catch (error) {
if (typeof onError === "function") {
@@ -95,12 +98,11 @@ export const useOrderMetadataAutosave = ({
} finally {
isSaving.value = false;
if (saveAgainAfterCurrentRequest || isDirty.value) {
saveAgainAfterCurrentRequest = false;
const shouldSaveAgain = saveAgainAfterCurrentRequest;
saveAgainAfterCurrentRequest = false;
if (isDirty.value) {
void persist();
}
if (saveCompleted && (shouldSaveAgain || isDirty.value) && isDirty.value) {
void persist();
}
}
};
+47 -8
View File
@@ -1,5 +1,6 @@
import { computed, ref } from "vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { safeAttachmentDownloadLink } from "@/services/attachmentDownloadLinks.js";
const normalizeBooleanAnswer = (value) => {
if (value === true || value === false) {
@@ -296,6 +297,30 @@ export function useSelfServeLogic() {
const summaryVisibleQuestionIds = ref([]);
const summaryQuestionOrder = ref({});
const latestFetchRequestId = ref(0);
const latestSuccessfulFetchKey = ref(null);
const inFlightFetchKey = ref(null);
const createFetchKey = (departmentId, vehicleTypeId, laneId, reg) => {
const normalizedDepartmentId = parseInt(departmentId);
const normalizedLaneId = parseInt(laneId);
const normalizedReg = String(reg || "").trim().toUpperCase();
const normalizedVehicleTypeId = parseInt(vehicleTypeId);
const vehicleTypeKey = !Number.isNaN(normalizedVehicleTypeId) && normalizedVehicleTypeId > 0
? normalizedVehicleTypeId
: "";
if (
Number.isNaN(normalizedDepartmentId)
|| normalizedDepartmentId <= 0
|| Number.isNaN(normalizedLaneId)
|| normalizedLaneId <= 0
|| normalizedReg.length < 2
) {
return null;
}
return [normalizedDepartmentId, normalizedLaneId, normalizedReg, vehicleTypeKey].join("|");
};
const beginFetchRequest = () => {
latestFetchRequestId.value += 1;
@@ -526,7 +551,7 @@ export function useSelfServeLogic() {
};
const downloadAttachment = async (taskId, attachmentId, attachment = null) => {
const existingDownloadLink = attachment?.download_link || null;
const existingDownloadLink = safeAttachmentDownloadLink(attachment?.download_link);
if (existingDownloadLink) {
window.open(existingDownloadLink, "_blank", "noopener");
return;
@@ -534,10 +559,9 @@ export function useSelfServeLogic() {
try {
const response = await SessionUser.objects.self_serve_tasks.attachments.download(taskId, attachmentId);
if (response?.data?.download_link) {
window.open(response.data.download_link, "_blank", "noopener");
} else if (response?.download_link) {
window.open(response.download_link, "_blank", "noopener");
const downloadLink = safeAttachmentDownloadLink(response?.data?.download_link || response?.download_link);
if (downloadLink) {
window.open(downloadLink, "_blank", "noopener");
}
} catch (error) {
console.error("Error downloading attachment:", error);
@@ -575,10 +599,17 @@ export function useSelfServeLogic() {
}
};
const fetchSelfServeData = async (_departmentId, _vehicleTypeId, laneId = null, reg = null) => {
const fetchSelfServeData = async (_departmentId, _vehicleTypeId, laneId = null, reg = null, options = {}) => {
const fetchKey = createFetchKey(_departmentId, _vehicleTypeId, laneId, reg);
if (!options.force && fetchKey && (fetchKey === latestSuccessfulFetchKey.value || fetchKey === inFlightFetchKey.value)) {
return null;
}
const requestId = beginFetchRequest();
if (!laneId || !reg || reg.trim().length < 2) {
latestSuccessfulFetchKey.value = null;
inFlightFetchKey.value = null;
preview.value = null;
summary.value = null;
session.value = null;
@@ -606,6 +637,7 @@ export function useSelfServeLogic() {
loading.value = true;
requestError.value = null;
inFlightFetchKey.value = fetchKey;
try {
const normalizedReg = reg.trim().toUpperCase();
const normalizedVehicleTypeId = parseInt(_vehicleTypeId);
@@ -687,6 +719,10 @@ export function useSelfServeLogic() {
setSummaryVisibleQuestions(questions.value);
}
if (isFetchRequestActive(requestId)) {
latestSuccessfulFetchKey.value = fetchKey;
}
return previewData;
} catch (error) {
console.error("Error fetching self-serve preview:", error);
@@ -695,6 +731,9 @@ export function useSelfServeLogic() {
}
return null;
} finally {
if (inFlightFetchKey.value === fetchKey) {
inFlightFetchKey.value = null;
}
if (isFetchRequestActive(requestId)) {
loading.value = false;
}
@@ -749,7 +788,7 @@ export function useSelfServeLogic() {
updateResolvedVehicleTypeId(responseSummary, responseSummary?.session);
}
await fetchSelfServeData(departmentId, refreshVehicleTypeId, laneId, normalizedReg);
await fetchSelfServeData(departmentId, refreshVehicleTypeId, laneId, normalizedReg, { force: true });
answers.value = {
...answers.value,
[parseInt(questionId)]: value,
@@ -814,7 +853,7 @@ export function useSelfServeLogic() {
? normalizedRefreshVehicleTypeId
: null;
await fetchSelfServeData(departmentId, refreshVehicleTypeId, parseInt(laneId), normalizedReg);
await fetchSelfServeData(departmentId, refreshVehicleTypeId, parseInt(laneId), normalizedReg, { force: true });
return { deletedCount: conditionIdsToDelete.length };
} catch (error) {
+63 -34
View File
@@ -2,12 +2,17 @@ import { computed, nextTick, onUnmounted, ref, watch } from "vue";
import { getDepartmentsGuest } from "@/components/pagination/departmentTabs.vue";
import { locations } from "@/components/displays/department/pos/steps/mobile/objects/PosDepartmentStepMobileFlow.vue";
const isLaneSelfServeEnabled = (lane) => !(
lane?.selfserve_enabled === false
|| lane?.selfserve_enabled === 0
|| lane?.selfserve_enabled === "0"
|| ["false", "off", "no"].includes(String(lane?.selfserve_enabled ?? "").trim().toLowerCase())
);
const isLaneSelfServeEnabled = (lane) =>
!(
lane?.selfserve_enabled === false ||
lane?.selfserve_enabled === 0 ||
lane?.selfserve_enabled === "0" ||
["false", "off", "no"].includes(
String(lane?.selfserve_enabled ?? "")
.trim()
.toLowerCase()
)
);
const toDepartmentViewModel = (department, distance = null) => ({
id: department.id,
@@ -18,12 +23,22 @@ const toDepartmentViewModel = (department, distance = null) => ({
self_serve_enabled: department.self_serve_enabled,
});
const extractDepartmentFetchErrorMessage = (error) => {
const candidates = [
error?.response?.data?.data?.message,
error?.response?.data?.message,
error?.response?.data?.error,
error?.message,
];
return (
candidates.find((candidate) => typeof candidate === "string" && candidate.trim() !== "") ||
"Kunne ikke hente vaskeafdelinger. Prøv igen."
);
};
export function useWashDepartments(options = {}) {
const {
includeLanes = false,
refreshIntervalMs = 0,
canAccessSuperUser = () => false,
} = options;
const { includeLanes = false, refreshIntervalMs = 0, canAccessSuperUser = () => false } = options;
const guestDepartments = ref([]);
const nearestDepartment = ref(null);
@@ -31,6 +46,7 @@ export function useWashDepartments(options = {}) {
const forceNearestDepartmentEvaluationId = ref(0);
const isSearchingDepartments = ref(false);
const lastDepartmentFetchTime = ref(null);
const departmentFetchError = ref(null);
let refreshInterval = null;
@@ -57,13 +73,18 @@ export function useWashDepartments(options = {}) {
return nearestDepartment.value.self_serve_enabled === true;
});
const buildGuestDepartmentParams = () => (
includeLanes ? { include_lanes: true } : {}
);
const buildGuestDepartmentParams = () => (includeLanes ? { include_lanes: true } : {});
const fetchDepartments = async () => {
guestDepartments.value = await getDepartmentsGuest(buildGuestDepartmentParams());
lastDepartmentFetchTime.value = Date.now();
try {
guestDepartments.value = await getDepartmentsGuest(buildGuestDepartmentParams());
lastDepartmentFetchTime.value = Date.now();
departmentFetchError.value = null;
} catch (error) {
departmentFetchError.value = extractDepartmentFetchErrorMessage(error);
console.warn("Failed to fetch wash departments:", error);
}
evaluateLocationDepartments(locations.location.value);
return guestDepartments.value;
};
@@ -74,19 +95,15 @@ export function useWashDepartments(options = {}) {
}
const currentTime = Date.now();
if (
!lastDepartmentFetchTime.value
|| (currentTime - lastDepartmentFetchTime.value) >= refreshIntervalMs
) {
if (!lastDepartmentFetchTime.value || currentTime - lastDepartmentFetchTime.value >= refreshIntervalMs) {
return fetchDepartments();
}
return guestDepartments.value;
};
const getForcedDepartment = () => guestDepartments.value.find(
(department) => department.id === forceNearestDepartmentEvaluationId.value
);
const getForcedDepartment = () =>
guestDepartments.value.find((department) => department.id === forceNearestDepartmentEvaluationId.value);
const evaluateLocationDepartments = (locationValue = locations.location.value) => {
if (isForcingNearestDepartment.value && forceNearestDepartmentEvaluationId.value) {
@@ -191,19 +208,30 @@ export function useWashDepartments(options = {}) {
}
};
watch(() => guestDepartments.value, () => {
evaluateLocationDepartments(locations.location.value);
}, { deep: true });
watch(() => locations.location.value, (newValue) => {
evaluateLocationDepartments(newValue);
}, { deep: true });
watch(() => isSearchingDepartments.value, (isSearching) => {
if (!isSearching) {
watch(
() => guestDepartments.value,
() => {
evaluateLocationDepartments(locations.location.value);
},
{ deep: true }
);
watch(
() => locations.location.value,
(newValue) => {
evaluateLocationDepartments(newValue);
},
{ deep: true }
);
watch(
() => isSearchingDepartments.value,
(isSearching) => {
if (!isSearching) {
evaluateLocationDepartments(locations.location.value);
}
}
});
);
onUnmounted(() => {
stopAutoRefresh();
@@ -216,6 +244,7 @@ export function useWashDepartments(options = {}) {
forceNearestDepartmentEvaluationId,
isSearchingDepartments,
lastDepartmentFetchTime,
departmentFetchError,
availableProductIds,
doesCurrentDepartmentSelectionHaveSelfServeEnabled,
fetchDepartments,
+46 -11
View File
@@ -170,10 +170,15 @@ export function useWashSessionActions(options) {
return null;
}
return await executeSelfServeCommand(resolvedLaneId, command, {
customer_number: null,
license_plate: null,
}, options);
return await executeSelfServeCommand(
resolvedLaneId,
command,
{
customer_number: null,
license_plate: null,
},
options
);
} finally {
loadingRef.value = false;
}
@@ -185,6 +190,24 @@ export function useWashSessionActions(options) {
const openPropertyExitGate = async (laneId = null, options = {}) =>
executePropertyGateCommand("OPEN_PROPERTY_EXIT_GATE", laneId, openingPropertyExitGate, options);
const rollbackStartedWash = async (laneId) => {
const stopResponse = await executeSelfServeCommand(
laneId,
"STOP",
{
customer_number: null,
license_plate: null,
},
{ suppressAlert: true }
);
if (!stopResponse) {
console.error(`Failed to roll back started wash on lane ${laneId}:`, lastCommandErrorMessage.value);
}
return stopResponse;
};
const onStartWash = async (laneId, licensePlate, customerNumber, targetStep = steps.WASH_IN_PROGRESS) => {
if (isStartingWash.value) {
return false;
@@ -233,6 +256,25 @@ export function useWashSessionActions(options) {
return false;
}
if (radioWashType.value === "Machine" && isServiceAllowed("MACHINE")) {
let machineRelayResponse = null;
try {
machineRelayResponse = await enableMachineRelay(laneId);
} catch (error) {
console.error("Error enabling machine relay after wash start:", error);
await rollbackStartedWash(laneId);
alertFn(extractCommandErrorMessage(error, "Kunne ikke starte maskinen. Prøv igen."));
return false;
}
const machineRelaySuccessValue = machineRelayResponse?.data?.success ?? machineRelayResponse?.success;
if (machineRelaySuccessValue === false) {
await rollbackStartedWash(laneId);
alertFn(extractCommandErrorMessage(machineRelayResponse, "Kunne ikke starte maskinen. Prøv igen."));
return false;
}
}
washLaneId.value = laneId;
washStartTime.value = Date.now();
completedDurationMs.value = null;
@@ -246,13 +288,6 @@ export function useWashSessionActions(options) {
reg: licensePlate.trim().toUpperCase(),
});
if (radioWashType.value === "Machine" && isServiceAllowed("MACHINE")) {
try {
await enableMachineRelay(laneId);
} catch (error) {
console.error("Error enabling machine relay after wash start:", error);
}
}
return true;
} catch (error) {
console.error("Error starting self-serve wash:", error);
+11
View File
@@ -29,6 +29,7 @@ const normalizeReleaseSource = (value) => {
};
export const RELEASE_SOURCE_ENV = String(import.meta.env.VITE_RELEASE_SOURCE || "").trim().toLowerCase();
export const RELEASE_SOURCE = normalizeReleaseSource(RELEASE_SOURCE_ENV);
export const DEFAULT_LEGACY_API_URL = "https://api.truckwash.io";
export const DEFAULT_PUBLIC_GATEWAY_API_URL = "https://api-v2.truckwash.io";
export const DEFAULT_STABLE_API_URL = `${DEFAULT_PUBLIC_GATEWAY_API_URL}/master/api`;
@@ -50,6 +51,16 @@ export const RELEASE_MANAGER_CONTROL_API_FALLBACK_URLS = String(
.split(",")
.map((url) => normalizeApiUrl(url.trim()))
.filter(Boolean);
export const RELEASE_TRUSTED_ORIGINS = String(import.meta.env.VITE_RELEASE_TRUSTED_ORIGINS || "")
.split(",")
.map((origin) => origin.trim().replace(/\/+$/, ""))
.filter(Boolean);
export const SELF_SERVE_ATTACHMENT_TRUSTED_ORIGINS = String(
import.meta.env.VITE_SELF_SERVE_ATTACHMENT_TRUSTED_ORIGINS || ""
)
.split(",")
.map((origin) => origin.trim().replace(/\/+$/, ""))
.filter(Boolean);
// Allowed origins
export const ALLOWED_ORIGINS = [
@@ -36,7 +36,73 @@ const entryContext = (entry = {}) => {
return context && typeof context === "object" && !Array.isArray(context) ? context : {};
};
const formatDetailValue = (value) => {
const REDACTED_CONTEXT_VALUE = "[REDACTED]";
const SENSITIVE_CONTEXT_KEY_PATTERN = /(?:token|secret|password|credential|api[_-]?key|authorization|auth|signature)/i;
const SENSITIVE_CONTEXT_QUERY_PATTERN = SENSITIVE_CONTEXT_KEY_PATTERN;
const redactSensitiveUrlParams = (value) => {
const text = String(value || "");
if (!text) {
return text;
}
const redactParams = (url) => {
let redacted = false;
url.searchParams.forEach((_paramValue, key) => {
if (SENSITIVE_CONTEXT_QUERY_PATTERN.test(key)) {
url.searchParams.set(key, REDACTED_CONTEXT_VALUE);
redacted = true;
}
});
return redacted ? url.toString() : text;
};
try {
return redactParams(new URL(text));
} catch {
return text.replace(/([?&])([^=&#]+)=([^&#]*)/g, (match, separator, rawKey) => {
let key = rawKey;
try {
key = decodeURIComponent(rawKey.replace(/\+/g, " "));
} catch {
key = rawKey;
}
return SENSITIVE_CONTEXT_QUERY_PATTERN.test(key) ? `${separator}${rawKey}=${REDACTED_CONTEXT_VALUE}` : match;
});
}
};
const redactEdgeGatewayLogContext = (value, seen = new WeakSet()) => {
if (value === null || typeof value === "undefined") {
return value;
}
if (typeof value === "string") {
return redactSensitiveUrlParams(value);
}
if (typeof value !== "object") {
return value;
}
if (seen.has(value)) {
return "[Circular]";
}
seen.add(value);
if (Array.isArray(value)) {
return value.map((item) => redactEdgeGatewayLogContext(item, seen));
}
return Object.fromEntries(
Object.entries(value).map(([key, item]) => [
key,
SENSITIVE_CONTEXT_KEY_PATTERN.test(key) ? REDACTED_CONTEXT_VALUE : redactEdgeGatewayLogContext(item, seen),
])
);
};
const formatDetailValue = (value, { redactContext = false } = {}) => {
if (value === null || typeof value === "undefined" || value === "") {
return "None";
}
@@ -44,13 +110,14 @@ const formatDetailValue = (value) => {
return value ? "yes" : "no";
}
if (typeof value === "object") {
const displayValue = redactContext ? redactEdgeGatewayLogContext(value) : value;
try {
return JSON.stringify(value, null, 2);
return JSON.stringify(displayValue, null, 2);
} catch {
return String(value);
return String(displayValue);
}
}
return String(value);
return redactContext && typeof value === "string" ? redactSensitiveUrlParams(value) : String(value);
};
const detailRow = (label, value) => ({
@@ -61,7 +128,7 @@ const detailRow = (label, value) => ({
const contextDetailRow = (value) => ({
label: "Context",
value: formatDetailValue(value),
value: formatDetailValue(value, { redactContext: true }),
multiline: true,
actionsOnly: true,
});
@@ -208,7 +275,9 @@ const relayActorLabel = (entry = {}) => {
};
const normalizeRelayRole = (role = "") => {
const normalized = String(role || "").trim().toUpperCase();
const normalized = String(role || "")
.trim()
.toUpperCase();
const aliases = {
ENTRANCE: "ENTRY",
IN: "ENTRY",
@@ -260,7 +329,8 @@ const firstRelayText = (values = []) => {
const relayDisplayName = (entry = {}) => {
const context = entryContext(entry);
const actionContext = context.action_context && typeof context.action_context === "object" ? context.action_context : {};
const actionContext =
context.action_context && typeof context.action_context === "object" ? context.action_context : {};
const binding = context.binding && typeof context.binding === "object" ? context.binding : {};
const request = relayRequestPayload(entry);
return firstRelayText([
@@ -284,7 +354,8 @@ const relayIdentifier = (entry = {}) => {
const relaySignalLabel = (entry = {}) => {
const context = entryContext(entry);
const actionContext = context.action_context && typeof context.action_context === "object" ? context.action_context : {};
const actionContext =
context.action_context && typeof context.action_context === "object" ? context.action_context : {};
const signal = context.signal && typeof context.signal === "object" ? context.signal : {};
const role = normalizeRelayRole(context.relay_role || actionContext.relay_role || actionContext.role);
const action = String(context.action || signal.command_type || "RELAY").toUpperCase();
@@ -2,6 +2,7 @@
import { computed, onMounted, onUnmounted, ref, watch } from "vue";
import {
cancelEdgeGatewayOperation,
clearEdgeGatewayWorkspaceCache,
createEdgeGatewayInstallToken,
createEdgeGatewayOperation,
deleteEdgeGateway,
@@ -12,16 +13,18 @@ import {
getEdgeGatewayStatistics,
getEdgeGatewayTasks,
listEdgeGatewayDepartments,
isEdgeGatewayAuthorizationError,
listEdgeGateways,
peekCachedEdgeGateway,
peekCachedEdgeGatewayDepartments,
peekCachedEdgeGatewayList,
removeEdgeGatewayCache,
rotateEdgeGatewayCredentials,
saveEdgeGatewayBindings,
setDepartmentGatewayCutover,
updateEdgeGateway,
} from "@/services/edgeGateways.js";
import { normalizeEdgeGatewayError, normalizeGatewayWebSocketClose } from "@/features/edgeGateways/edgeGatewayErrors.js";
import { normalizeEdgeGatewayError, normalizeGatewayWebSocketClose, redactEdgeGatewayDiagnostic } from "@/features/edgeGateways/edgeGatewayErrors.js";
import EdgeGatewayOverviewPage from "@/features/edgeGateways/EdgeGatewayOverviewPage.vue";
import EdgeGatewayInventoryPage from "@/features/edgeGateways/EdgeGatewayInventoryPage.vue";
import EdgeGatewayTasksPage from "@/features/edgeGateways/EdgeGatewayTasksPage.vue";
@@ -183,7 +186,7 @@ const terminalGatewayBrokerDiagnostics = (gateway = selectedGatewayView.value) =
if (value === null || value === undefined || value === "") {
return;
}
lines.push(`${label}: ${value}`);
lines.push(`${label}: ${redactEdgeGatewayDiagnostic(value)}`);
};
push("Gateway ID", gateway?.id);
@@ -1215,6 +1218,15 @@ const refreshSelected = async (gatewayId = activeGatewayId.value, { forceRefresh
}
return gateway;
} catch (error) {
if (isEdgeGatewayAuthorizationError(error)) {
removeEdgeGatewayCache(gatewayId);
unavailableGatewayId.value = String(gatewayId);
selectedGateway.value = null;
resetGatewayViewSnapshots();
fail(error);
return null;
}
const fallbackGateway = findLocalGatewaySnapshot(gatewayId);
if (fallbackGateway) {
setSelectedGatewaySnapshot(fallbackGateway);
@@ -1561,6 +1573,16 @@ const load = async () => {
departments.value = unwrap(departmentsResponse, []);
await syncSelection();
} catch (error) {
if (isEdgeGatewayAuthorizationError(error)) {
clearEdgeGatewayWorkspaceCache();
gateways.value = [];
fleetUsage.value = buildFleetUsageFromRows([]);
if (activeGatewayId.value) {
unavailableGatewayId.value = String(activeGatewayId.value);
}
selectedGateway.value = null;
resetGatewayViewSnapshots();
}
fail(error);
} finally {
loading.value.init = false;
@@ -1,4 +1,6 @@
<script setup>
import { redactEdgeGatewayDiagnostic } from "@/features/edgeGateways/edgeGatewayErrors.js";
const props = defineProps({
status: {
type: String,
@@ -44,7 +46,10 @@ const shortcuts = [
{ id: "docker ps", label: "docker ps" },
];
const diagnosticsText = () => (Array.isArray(props.diagnostics) ? props.diagnostics.join("\n") : "");
const diagnosticsText = () =>
Array.isArray(props.diagnostics)
? props.diagnostics.map((entry) => redactEdgeGatewayDiagnostic(entry)).join("\n")
: "";
const copyDiagnostics = async () => {
const text = diagnosticsText();
@@ -0,0 +1,79 @@
import { RELEASE_TRUSTED_ORIGINS } from "@/config.js";
const splitOriginList = (value) =>
String(value || "")
.split(",")
.map((origin) => origin.trim().replace(/\/+$/, ""))
.filter(Boolean);
export const ALLOWED_PUBLIC_BROKER_PROTOCOLS = Object.freeze(["https:", "wss:"]);
export const EDGE_GATEWAY_PUBLIC_BROKER_ORIGINS = Object.freeze([
"https://api.truckwash.io:4433",
"wss://api.truckwash.io:4433",
...RELEASE_TRUSTED_ORIGINS,
...splitOriginList(import.meta.env.VITE_EDGE_GATEWAY_PUBLIC_BROKER_ORIGINS),
]);
export const canUseStubBrokerAuthMode = () => import.meta.env.DEV || import.meta.env.MODE === "test";
export const sanitizeBrokerAuthMode = (value) => {
if (value === "stub" && canUseStubBrokerAuthMode()) {
return "stub";
}
return "manager";
};
export const redactBrokerSharedSecret = () => "";
export const allowedPublicBrokerOrigins = () => {
const origins = new Set(EDGE_GATEWAY_PUBLIC_BROKER_ORIGINS);
if (typeof window !== "undefined" && window.location?.origin) {
const currentOrigin = window.location.origin.replace(/\/+$/, "");
origins.add(currentOrigin);
if (window.location.hostname) {
const host = window.location.port
? `${window.location.hostname}:${window.location.port}`
: window.location.hostname;
origins.add(`https://${host}`);
origins.add(`wss://${host}`);
}
}
return origins;
};
export const validatePublicBrokerUrl = (value) => {
const normalized = String(value || "").trim();
if (!normalized) {
return { ok: true, value: "" };
}
let parsed;
try {
parsed = new URL(normalized);
} catch (_error) {
return {
ok: false,
message: "Public broker URL must be an absolute HTTPS or WSS URL on an approved origin.",
};
}
if (!ALLOWED_PUBLIC_BROKER_PROTOCOLS.includes(parsed.protocol)) {
return {
ok: false,
message: "Public broker URL must use HTTPS or WSS.",
};
}
if (!allowedPublicBrokerOrigins().has(parsed.origin)) {
return {
ok: false,
message: "Public broker URL origin is not approved for browser edge gateway sessions.",
};
}
return { ok: true, value: parsed.href };
};
+58 -9
View File
@@ -55,6 +55,36 @@ const WEBSOCKET_CLOSE_MESSAGES = {
socket_error: "Terminal websocket connection failed.",
};
const SENSITIVE_DIAGNOSTIC_KEYS = new Set([
"accesstoken",
"agenttoken",
"apikey",
"authorization",
"auth",
"clientsecret",
"jwt",
"key",
"password",
"secret",
"signature",
"token",
]);
const normalizeDiagnosticKey = (key) =>
String(key || "")
.replace(/[^a-z0-9]/gi, "")
.toLowerCase();
const isSensitiveDiagnosticKey = (key) => SENSITIVE_DIAGNOSTIC_KEYS.has(normalizeDiagnosticKey(key));
const redactSensitiveAssignments = (value) =>
String(value)
.replace(/\b(authorization|auth)\s*[:=]\s*(?:Bearer|Basic)\s+[^\s,;&]+/gi, (_match, key) => `${key}=***`)
.replace(
/([?&;,\s]?(access[_-]?token|agent[_-]?token|api[_-]?key|authorization|auth|client[_-]?secret|jwt|key|password|secret|signature|token)\s*[:=]\s*)("[^"\s,;&]*"|'[^'\s,;&]*'|[^\s,;&]+)/gi,
(_match, prefix) => `${prefix}***`
);
export function redactEdgeGatewayUrl(value) {
if (!value) {
return null;
@@ -62,17 +92,33 @@ export function redactEdgeGatewayUrl(value) {
try {
const url = new URL(String(value), typeof window !== "undefined" ? window.location.origin : "http://localhost");
["token", "agentToken", "agent_token"].forEach((key) => {
if (url.searchParams.has(key)) {
if (url.username) {
url.username = "***";
}
if (url.password) {
url.password = "***";
}
Array.from(url.searchParams.keys()).forEach((key) => {
if (isSensitiveDiagnosticKey(key)) {
url.searchParams.set(key, "***");
}
});
return url.toString();
} catch (_error) {
return String(value).replace(/([?&](?:token|agentToken|agent_token)=)[^&]+/gi, "$1***");
return redactSensitiveAssignments(String(value));
}
}
export function redactEdgeGatewayDiagnostic(value) {
if (value === null || value === undefined) {
return value;
}
return redactSensitiveAssignments(
String(value).replace(/\b(?:wss?|https?|mqtts?):\/\/[^\s<>"')]+/gi, (match) => redactEdgeGatewayUrl(match))
);
}
const formatDiagnosticLines = (diagnostics, prefix = "Diagnostic") => {
if (!diagnostics || typeof diagnostics !== "object" || Array.isArray(diagnostics)) {
return [];
@@ -83,20 +129,23 @@ const formatDiagnosticLines = (diagnostics, prefix = "Diagnostic") => {
if (value === null || value === undefined || value === "") {
return;
}
lines.push(`${label}: ${value}`);
lines.push(`${label}: ${redactEdgeGatewayDiagnostic(value)}`);
};
push(`${prefix} code`, diagnostics.reason_code || diagnostics.error_code);
push("Broker URL", diagnostics.broker_url);
push("WebSocket URL", redactEdgeGatewayUrl(diagnostics.ws_url));
push("WebSocket URL", diagnostics.ws_url);
push("Derived URL warning", diagnostics.derived_warning);
if (diagnostics.broker_presence && typeof diagnostics.broker_presence === "object") {
push("Broker connected", diagnostics.broker_presence.connected === true ? "yes" : "no");
push("Broker connection", diagnostics.broker_presence.connection_id);
push("Broker last seen", diagnostics.broker_presence.last_seen_at);
push("Broker age", Number.isFinite(Number(diagnostics.broker_presence.age_seconds))
? `${Number(diagnostics.broker_presence.age_seconds)}s`
: null);
push(
"Broker age",
Number.isFinite(Number(diagnostics.broker_presence.age_seconds))
? `${Number(diagnostics.broker_presence.age_seconds)}s`
: null
);
push("Broker last error", diagnostics.broker_presence.last_error);
push("Broker disconnect reason", diagnostics.broker_presence.disconnect_reason);
}
@@ -180,7 +229,7 @@ export function normalizeGatewayWebSocketClose(event = {}, context = {}) {
details.push(`WebSocket code: ${code}`);
}
if (reason) {
details.push(`Close reason: ${reason}`);
details.push(`Close reason: ${redactEdgeGatewayDiagnostic(reason)}`);
}
if (wasClean !== null) {
details.push(`Clean close: ${wasClean ? "yes" : "no"}`);
@@ -10,6 +10,66 @@ const MOCK_SHELL_PROMPT = "edge@truckwash:/opt/truckwash-edge-agent$ ";
const isMockSocketUrl = (value) => String(value || "").startsWith(MOCK_SOCKET_PREFIX);
const SENSITIVE_SOCKET_QUERY_KEYS = new Set([
"access_token",
"auth",
"authorization",
"bearer",
"jwt",
"session",
"session_token",
"token",
]);
const getBrowserLocation = () => (typeof window !== "undefined" && window.location ? window.location : null);
const getConfiguredTrustedSocketOrigins = () =>
String(import.meta.env?.VITE_EDGE_GATEWAY_WS_ALLOWED_ORIGINS || "")
.split(",")
.map((entry) => entry.trim())
.filter(Boolean);
const toSocketOrigin = (value) => {
const location = getBrowserLocation();
const base = location?.origin || "http://localhost";
const url = new URL(value, base);
if (url.protocol === "https:") {
url.protocol = "wss:";
} else if (url.protocol === "http:") {
url.protocol = "ws:";
}
return url.origin;
};
const isLocalSocketHost = (hostname) => ["localhost", "127.0.0.1", "::1", "[::1]"].includes(String(hostname || ""));
const getTrustedSocketOrigins = () => {
const origins = new Set();
const location = getBrowserLocation();
if (location?.origin) {
origins.add(toSocketOrigin(location.origin));
}
getConfiguredTrustedSocketOrigins().forEach((origin) => origins.add(toSocketOrigin(origin)));
return origins;
};
const assertTrustedSocketUrl = (url) => {
if (!["ws:", "wss:"].includes(url.protocol)) {
throw new Error("Unsupported websocket URL protocol");
}
if (url.protocol !== "wss:" && !isLocalSocketHost(url.hostname)) {
throw new Error("Gateway websocket URL must use wss");
}
const trustedOrigins = getTrustedSocketOrigins();
if (trustedOrigins.size > 0 && !trustedOrigins.has(url.origin)) {
throw new Error("Gateway websocket URL origin is not trusted");
}
};
const toNativeSocketUrl = (socketUrl, query = {}) => {
if (!socketUrl) {
throw new Error("Missing websocket URL");
@@ -19,16 +79,48 @@ const toNativeSocketUrl = (socketUrl, query = {}) => {
return socketUrl;
}
const url = new URL(socketUrl, typeof window !== "undefined" ? window.location.origin : "http://localhost");
const url = new URL(socketUrl, getBrowserLocation()?.origin || "http://localhost");
assertTrustedSocketUrl(url);
Array.from(url.searchParams.keys()).forEach((key) => {
if (SENSITIVE_SOCKET_QUERY_KEYS.has(String(key).toLowerCase())) {
url.searchParams.delete(key);
}
});
Object.entries(query || {}).forEach(([key, value]) => {
if (value === null || value === undefined || value === "") {
return;
}
if (SENSITIVE_SOCKET_QUERY_KEYS.has(String(key).toLowerCase())) {
return;
}
url.searchParams.set(key, String(value));
});
return url.toString();
};
const createSessionAuthenticationPayload = (session, gatewayId, kind) => {
if (!session?.token) {
return null;
}
return {
type: "AUTH",
gatewayId: String(gatewayId || session.gateway_id || ""),
token: String(session.token),
sessionId: session.id || session.session_id || null,
kind,
};
};
const sendSessionAuthentication = (send, session, gatewayId, kind) => {
const authPayload = createSessionAuthenticationPayload(session, gatewayId, kind);
if (authPayload) {
send(authPayload);
}
};
const safeJsonParse = (payload) => {
if (payload === null || payload === undefined || payload === "") {
return null;
@@ -292,10 +384,10 @@ export async function createGatewayStreamClient(gatewayId, scopes = [], handlers
const session = unwrapEdgeGatewayResponse(response, {});
const socketUrl = toNativeSocketUrl(session.ws_url, {
gatewayId,
token: session.token,
});
const connection = createConnection(socketUrl, session, "stream", handlers, ({ send }) => {
sendSessionAuthentication(send, session, gatewayId, "stream");
send({
type: "SUBSCRIBE",
gatewayId: String(gatewayId),
@@ -318,9 +410,10 @@ export async function createGatewayShellClient(gatewayId, options = {}, handlers
const session = unwrapEdgeGatewayResponse(response, {});
const socketUrl = toNativeSocketUrl(session.ws_url, {
gatewayId,
token: session.token,
});
const connection = createConnection(socketUrl, session, "shell", handlers);
const connection = createConnection(socketUrl, session, "shell", handlers, ({ send }) => {
sendSessionAuthentication(send, session, gatewayId, "shell");
});
return {
session,
-1
View File
@@ -71,7 +71,6 @@ const logBuildBanner = () => {
'',
`Build: ${formatCommit(VITE_COMMIT_HASH)} @ ${formatDateTime(VITE_BUILD_DATE)} (${IS_DEV ? 'development' : 'production'})`,
`Remote API: ${getReleaseRuntimeApiBaseUrl()}`,
`Debug: Is running beta FE.`,
].join('\n'));
};
+55
View File
@@ -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
View File
@@ -1,6 +1,7 @@
import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue";
export const EDGE_GATEWAY_WORKSPACE_CACHE_KEY = "truckwash.edgeGatewayWorkspace.cache.v1";
export const EDGE_GATEWAY_WORKSPACE_CACHE_TTL_MS = 15 * 60 * 1000;
const EDGE_GATEWAY_BASE = "/edge-gateways";
const EDGE_GATEWAY_CONFIG_BASE = "/edgegateway/config";
@@ -9,8 +10,11 @@ const listRequestsInFlight = new Map();
const detailRequestsInFlight = new Map();
let departmentsRequestInFlight = null;
let edgeGatewayWorkspaceCache = null;
let edgeGatewayWorkspaceCacheStorageKey = null;
const cloneJson = (value) => (value === null || value === undefined ? value : JSON.parse(JSON.stringify(value)));
const getDataPayload = (response) => (response && response.data ? response.data.data : undefined);
const getMetaPayload = (response) => (response && response.data ? response.data.meta : undefined);
const normalizeDepartmentId = (departmentId) =>
departmentId === null || departmentId === undefined || departmentId === "" ? null : Number(departmentId);
const normalizeGatewayId = (gatewayId) =>
@@ -49,17 +53,21 @@ const normalizeRotateCredentialBundle = (bundle) => {
}
const config = parseJsonObject(bundle.config_json);
const agentToken = bundle.agent_token || bundle.token || config?.agentToken || config?.agent_token || null;
const agentToken =
bundle.agent_token || bundle.token || (config && (config.agentToken || config.agent_token)) || null;
const stackServiceName =
bundle.stackServiceName ||
bundle.stack_service_name ||
config?.stackServiceName ||
config?.stack_service_name ||
config?.serviceName ||
config?.service_name ||
(config && config.stackServiceName) ||
(config && config.stack_service_name) ||
(config && config.serviceName) ||
(config && config.service_name) ||
null;
const composeFileName =
bundle.composeFileName || bundle.compose_file_name || config?.composeFileName || config?.compose_file_name || null;
bundle.composeFileName ||
bundle.compose_file_name ||
(config && (config.composeFileName || config.compose_file_name)) ||
null;
return {
...bundle,
@@ -73,7 +81,7 @@ const normalizeRotateCredentialBundle = (bundle) => {
};
const normalizeRotateCredentialResponse = (response) => {
const bundle = response?.data?.data;
const bundle = getDataPayload(response);
if (bundle && typeof bundle === "object" && !Array.isArray(bundle)) {
response.data.data = normalizeRotateCredentialBundle(bundle);
}
@@ -91,55 +99,174 @@ const toConfigPayload = (entries) => {
return payload;
};
const createEmptyWorkspaceCache = () => ({
const createEmptyWorkspaceCache = (scope = null) => ({
scope,
cachedAt: Date.now(),
departments: null,
lists: {},
details: {},
});
const storage = () => {
if (typeof window === "undefined" || !window.sessionStorage) {
return null;
}
return window.sessionStorage;
};
const removeLegacyWorkspaceCache = () => {
if (typeof window === "undefined" || !window.localStorage) {
return;
}
try {
window.localStorage.removeItem(EDGE_GATEWAY_WORKSPACE_CACHE_KEY);
} catch (_error) {
// Browser storage can be unavailable in private browsing or tests.
}
};
const getStoredValue = (key) => {
if (typeof window === "undefined" || !window.localStorage) {
return null;
}
return window.localStorage;
try {
return window.localStorage.getItem(key);
} catch (_error) {
return null;
}
};
const hashScopePart = (value) => {
let hash = 0;
const input = String(value || "");
for (let index = 0; index < input.length; index += 1) {
hash = (hash << 5) - hash + input.charCodeAt(index);
hash |= 0;
}
return hash.toString(36);
};
const currentWorkspaceCacheScope = () => {
const token = getStoredValue("token");
if (!token) {
return null;
}
return JSON.stringify({
token: hashScopePart(token),
isSubuser: getStoredValue("is_subuser") === "true",
selectedCustomerNumber: getStoredValue("selected_customer_number") || null,
hasSuperuserToken: Boolean(getStoredValue("superuser_token")),
});
};
const isFreshCachedAt = (cachedAt) => Date.now() - Number(cachedAt || 0) <= EDGE_GATEWAY_WORKSPACE_CACHE_TTL_MS;
const isFreshCacheEntry = (entry) => Boolean(entry && typeof entry === "object" && isFreshCachedAt(entry.cachedAt));
const isSensitiveCacheField = (fieldName) =>
/(^|[_-])(token|secret|password|credential|authorization|api[_-]?key|private[_-]?key)($|[_-])/i.test(
String(fieldName || "")
) || String(fieldName || "").toLowerCase() === "config_json";
const sanitizeCacheValue = (value) => {
if (Array.isArray(value)) {
return value.map((item) => sanitizeCacheValue(item));
}
if (!value || typeof value !== "object") {
return value;
}
return Object.entries(value).reduce((sanitized, [key, childValue]) => {
if (!isSensitiveCacheField(key)) {
sanitized[key] = sanitizeCacheValue(childValue);
}
return sanitized;
}, {});
};
const sanitizeGatewayCachePayload = (value) => cloneJson(sanitizeCacheValue(value));
const resetWorkspaceCacheMemory = (scope = currentWorkspaceCacheScope()) => {
edgeGatewayWorkspaceCache = createEmptyWorkspaceCache(scope);
edgeGatewayWorkspaceCacheStorageKey = EDGE_GATEWAY_WORKSPACE_CACHE_KEY;
return edgeGatewayWorkspaceCache;
};
const loadWorkspaceCache = () => {
if (edgeGatewayWorkspaceCache !== null) {
removeLegacyWorkspaceCache();
const scope = currentWorkspaceCacheScope();
if (!scope) {
return resetWorkspaceCacheMemory(null);
}
if (
edgeGatewayWorkspaceCache !== null &&
edgeGatewayWorkspaceCache.scope === scope &&
isFreshCachedAt(edgeGatewayWorkspaceCache.cachedAt)
) {
return edgeGatewayWorkspaceCache;
}
const localStorageHandle = storage();
if (!localStorageHandle) {
edgeGatewayWorkspaceCache = createEmptyWorkspaceCache();
return edgeGatewayWorkspaceCache;
const storageHandle = storage();
if (!storageHandle) {
return resetWorkspaceCacheMemory(scope);
}
try {
const decoded = JSON.parse(localStorageHandle.getItem(EDGE_GATEWAY_WORKSPACE_CACHE_KEY) || "null");
const decoded = JSON.parse(storageHandle.getItem(EDGE_GATEWAY_WORKSPACE_CACHE_KEY) || "null");
if (!decoded || typeof decoded !== "object" || decoded.scope !== scope || !isFreshCachedAt(decoded.cachedAt)) {
storageHandle.removeItem(EDGE_GATEWAY_WORKSPACE_CACHE_KEY);
return resetWorkspaceCacheMemory(scope);
}
edgeGatewayWorkspaceCache = {
...createEmptyWorkspaceCache(),
...(decoded && typeof decoded === "object" ? decoded : {}),
lists: decoded?.lists && typeof decoded.lists === "object" ? decoded.lists : {},
details: decoded?.details && typeof decoded.details === "object" ? decoded.details : {},
...createEmptyWorkspaceCache(scope),
cachedAt: Number(decoded.cachedAt || Date.now()),
departments: isFreshCacheEntry(decoded.departments) ? decoded.departments : null,
lists:
decoded && decoded.lists && typeof decoded.lists === "object"
? Object.fromEntries(Object.entries(decoded.lists).filter(([, entry]) => isFreshCacheEntry(entry)))
: {},
details:
decoded && decoded.details && typeof decoded.details === "object"
? Object.fromEntries(Object.entries(decoded.details).filter(([, entry]) => isFreshCacheEntry(entry)))
: {},
};
edgeGatewayWorkspaceCacheStorageKey = EDGE_GATEWAY_WORKSPACE_CACHE_KEY;
} catch (_error) {
edgeGatewayWorkspaceCache = createEmptyWorkspaceCache();
storageHandle.removeItem(EDGE_GATEWAY_WORKSPACE_CACHE_KEY);
resetWorkspaceCacheMemory(scope);
}
return edgeGatewayWorkspaceCache;
};
const persistWorkspaceCache = () => {
const localStorageHandle = storage();
if (!localStorageHandle || edgeGatewayWorkspaceCache === null) {
const storageHandle = storage();
const scope = currentWorkspaceCacheScope();
if (!storageHandle || edgeGatewayWorkspaceCache === null || !scope || edgeGatewayWorkspaceCache.scope !== scope) {
return;
}
localStorageHandle.setItem(EDGE_GATEWAY_WORKSPACE_CACHE_KEY, JSON.stringify(edgeGatewayWorkspaceCache));
edgeGatewayWorkspaceCache.cachedAt = Date.now();
edgeGatewayWorkspaceCacheStorageKey = EDGE_GATEWAY_WORKSPACE_CACHE_KEY;
storageHandle.setItem(EDGE_GATEWAY_WORKSPACE_CACHE_KEY, JSON.stringify(edgeGatewayWorkspaceCache));
};
export const clearEdgeGatewayWorkspaceCache = () => {
edgeGatewayWorkspaceCache = null;
edgeGatewayWorkspaceCacheStorageKey = null;
removeLegacyWorkspaceCache();
const storageHandle = storage();
if (!storageHandle) {
return;
}
storageHandle.removeItem(EDGE_GATEWAY_WORKSPACE_CACHE_KEY);
};
const mergeGatewaySnapshots = (currentGateway, nextGateway) => {
if (!currentGateway) {
return cloneJson(nextGateway);
@@ -149,27 +276,27 @@ const mergeGatewaySnapshots = (currentGateway, nextGateway) => {
...cloneJson(currentGateway),
...cloneJson(nextGateway),
metadata: {
...(currentGateway?.metadata || {}),
...(nextGateway?.metadata || {}),
...((currentGateway && currentGateway.metadata) || {}),
...((nextGateway && nextGateway.metadata) || {}),
},
inventory: Array.isArray(nextGateway?.inventory)
inventory: Array.isArray(nextGateway && nextGateway.inventory)
? cloneJson(nextGateway.inventory)
: Array.isArray(currentGateway?.inventory)
: Array.isArray(currentGateway && currentGateway.inventory)
? cloneJson(currentGateway.inventory)
: undefined,
bindings: Array.isArray(nextGateway?.bindings)
bindings: Array.isArray(nextGateway && nextGateway.bindings)
? cloneJson(nextGateway.bindings)
: Array.isArray(currentGateway?.bindings)
: Array.isArray(currentGateway && currentGateway.bindings)
? cloneJson(currentGateway.bindings)
: undefined,
operations: Array.isArray(nextGateway?.operations)
operations: Array.isArray(nextGateway && nextGateway.operations)
? cloneJson(nextGateway.operations)
: Array.isArray(currentGateway?.operations)
: Array.isArray(currentGateway && currentGateway.operations)
? cloneJson(currentGateway.operations)
: undefined,
audit_logs: Array.isArray(nextGateway?.audit_logs)
audit_logs: Array.isArray(nextGateway && nextGateway.audit_logs)
? cloneJson(nextGateway.audit_logs)
: Array.isArray(currentGateway?.audit_logs)
: Array.isArray(currentGateway && currentGateway.audit_logs)
? cloneJson(currentGateway.audit_logs)
: undefined,
};
@@ -181,8 +308,12 @@ const cacheGatewaySnapshot = (gateway) => {
}
const cache = loadWorkspaceCache();
const gatewayId = String(gateway.id);
const mergedGateway = mergeGatewaySnapshots(cache.details[gatewayId]?.data || null, gateway);
const sanitizedGateway = sanitizeGatewayCachePayload(gateway);
const gatewayId = String(sanitizedGateway.id);
const mergedGateway = mergeGatewaySnapshots(
(cache.details[gatewayId] && cache.details[gatewayId].data) || null,
sanitizedGateway
);
cache.details[gatewayId] = {
cachedAt: Date.now(),
data: mergedGateway,
@@ -195,12 +326,12 @@ const cacheGatewaySnapshot = (gateway) => {
const matchesDepartment =
normalizeDepartmentId(params.departmentId) === null ||
Number(params.departmentId) === Number(gateway.department_id);
const existingIndex = rows.findIndex((item) => Number(item?.id || 0) === Number(gateway.id));
const existingIndex = rows.findIndex((item) => Number((item && item.id) || 0) === Number(gateway.id));
if (existingIndex >= 0) {
rows[existingIndex] = mergeGatewaySnapshots(rows[existingIndex], gateway);
rows[existingIndex] = mergeGatewaySnapshots(rows[existingIndex], sanitizedGateway);
} else if (matchesDepartment) {
rows.unshift(cloneJson(gateway));
rows.unshift(cloneJson(sanitizedGateway));
}
cache.lists[cacheKey] = {
@@ -216,19 +347,20 @@ const cacheGatewaySnapshot = (gateway) => {
const cacheFleetSnapshot = ({ departmentId = null, view = "summary", rows = [], meta = {} } = {}) => {
const cache = loadWorkspaceCache();
const sanitizedRows = sanitizeGatewayCachePayload(Array.isArray(rows) ? rows : []);
const key = listCacheKey({ departmentId, view });
cache.lists[key] = {
params: {
departmentId: normalizeDepartmentId(departmentId),
view: String(view || "summary"),
},
rows: cloneJson(Array.isArray(rows) ? rows : []),
meta: cloneJson(meta || {}),
rows: sanitizedRows,
meta: sanitizeGatewayCachePayload(meta || {}),
cachedAt: Date.now(),
};
rows.forEach((gateway) => {
if (!gateway?.id) {
sanitizedRows.forEach((gateway) => {
if (!gateway || !gateway.id) {
return;
}
@@ -236,7 +368,7 @@ const cacheFleetSnapshot = ({ departmentId = null, view = "summary", rows = [],
if (!cache.details[gatewayId]) {
cache.details[gatewayId] = {
cachedAt: Date.now(),
data: cloneJson(gateway),
data: sanitizeGatewayCachePayload(gateway),
};
return;
}
@@ -254,22 +386,23 @@ const cacheDepartmentsSnapshot = (departments) => {
const cache = loadWorkspaceCache();
cache.departments = {
cachedAt: Date.now(),
data: cloneJson(Array.isArray(departments) ? departments : []),
data: sanitizeGatewayCachePayload(Array.isArray(departments) ? departments : []),
};
persistWorkspaceCache();
};
const cacheGatewayFromResponse = (response) => {
const gateway = response?.data?.data;
if (gateway?.id) {
const gateway = getDataPayload(response);
if (gateway && gateway.id) {
cacheGatewaySnapshot(gateway);
}
return response;
};
const cacheGatewayFromNestedResponse = (response) => {
const gateway = response?.data?.data?.gateway;
if (gateway?.id) {
const payload = getDataPayload(response);
const gateway = payload && payload.gateway;
if (gateway && gateway.id) {
cacheGatewaySnapshot(gateway);
}
return response;
@@ -292,17 +425,31 @@ const buildOperationPayload = (typeOrPayload, request = {}) => {
};
};
export const unwrapEdgeGatewayResponse = (response, fallback = null) => response?.data?.data ?? fallback;
export const unwrapEdgeGatewayMeta = (response) => response?.data?.meta ?? {};
export const unwrapEdgeGatewayResponse = (response, fallback = null) => {
const data = getDataPayload(response);
return data === undefined || data === null ? fallback : data;
};
export const unwrapEdgeGatewayMeta = (response) => getMetaPayload(response) || {};
export const isEdgeGatewayAuthorizationError = (error) => {
const status = Number(
(error && error.response && error.response.status) ||
(error && error.status) ||
(error && error.response && error.response.data && error.response.data.status) ||
0
);
return status === 401 || status === 403;
};
export const peekCachedEdgeGatewayDepartments = () => {
const departments = loadWorkspaceCache().departments?.data;
return Array.isArray(departments) ? cloneJson(departments) : null;
const entry = loadWorkspaceCache().departments;
const departments = entry && entry.data;
return isFreshCacheEntry(entry) && Array.isArray(departments) ? cloneJson(departments) : null;
};
export const peekCachedEdgeGatewayList = ({ departmentId = null, view = "summary" } = {}) => {
const entry = loadWorkspaceCache().lists[listCacheKey({ departmentId, view })];
if (!entry || !Array.isArray(entry.rows)) {
if (!isFreshCacheEntry(entry) || !Array.isArray(entry.rows)) {
return null;
}
@@ -320,7 +467,7 @@ export const peekCachedEdgeGateway = (gatewayId) => {
}
const entry = loadWorkspaceCache().details[normalizedGatewayId];
return entry?.data ? cloneJson(entry.data) : null;
return isFreshCacheEntry(entry) && entry && entry.data ? cloneJson(entry.data) : null;
};
export const patchEdgeGatewayCache = (gatewayId, patch) => {
@@ -341,8 +488,8 @@ export const removeEdgeGatewayCache = (gatewayId) => {
const cache = loadWorkspaceCache();
delete cache.details[normalizedGatewayId];
Object.entries(cache.lists || {}).forEach(([cacheKey, entry]) => {
const rows = Array.isArray(entry?.rows)
? entry.rows.filter((item) => String(item?.id || "") !== normalizedGatewayId)
const rows = Array.isArray(entry && entry.rows)
? entry.rows.filter((item) => String((item && item.id) || "") !== normalizedGatewayId)
: [];
cache.lists[cacheKey] = {
...(entry || {}),
@@ -361,7 +508,7 @@ export const updateDepartmentGatewayCutoverCache = (departmentId, transportMode)
const cache = loadWorkspaceCache();
Object.entries(cache.details || {}).forEach(([gatewayId, entry]) => {
if (Number(entry?.data?.department_id || 0) !== normalizedDepartmentId) {
if (Number((entry && entry.data && entry.data.department_id) || 0) !== normalizedDepartmentId) {
return;
}
@@ -374,9 +521,9 @@ export const updateDepartmentGatewayCutoverCache = (departmentId, transportMode)
});
Object.entries(cache.lists || {}).forEach(([cacheKey, entry]) => {
const rows = Array.isArray(entry?.rows)
const rows = Array.isArray(entry && entry.rows)
? entry.rows.map((gateway) =>
Number(gateway?.department_id || 0) === normalizedDepartmentId
Number((gateway && gateway.department_id) || 0) === normalizedDepartmentId
? { ...gateway, department_transport_mode: transportMode }
: gateway
)
@@ -397,7 +544,7 @@ export const listEdgeGatewayDepartments = async ({ forceRefresh = false } = {})
}
const request = authenticatedRequest("/departments", "GET", {}).then((response) => {
cacheDepartmentsSnapshot(response?.data?.data || []);
cacheDepartmentsSnapshot(getDataPayload(response) || []);
return response;
});
@@ -433,8 +580,8 @@ export const listEdgeGateways = async ({ departmentId = null, view = "summary",
cacheFleetSnapshot({
departmentId,
view,
rows: response?.data?.data || [],
meta: response?.data?.meta || {},
rows: getDataPayload(response) || [],
meta: getMetaPayload(response) || {},
});
return response;
});
@@ -460,8 +607,8 @@ export const getEdgeGateway = async (gatewayId, { forceRefresh = false } = {}) =
const request = authenticatedRequest(`${EDGE_GATEWAY_BASE}/${encodeURIComponent(gatewayId)}`, "GET", {}).then(
(response) => {
const gateway = response?.data?.data;
if (gateway?.id) {
const gateway = getDataPayload(response);
if (gateway && gateway.id) {
cacheGatewaySnapshot(gateway);
}
return response;
@@ -602,7 +749,7 @@ const normalizeRelayTestTransport = (transport) => {
};
const normalizeRelayTestTimer = (seconds) => {
const normalizedSeconds = Number.parseInt(String(seconds ?? ""), 10);
const normalizedSeconds = Number.parseInt(String(seconds === null || seconds === undefined ? "" : seconds), 10);
return Number.isInteger(normalizedSeconds) && normalizedSeconds > 0 ? normalizedSeconds : 1;
};
+14 -3
View File
@@ -1,5 +1,6 @@
import { API_URL, IS_DEV, RELEASE_MANAGER_CONTROL_API_URL, RELEASE_SOURCE, RELEASE_SOURCE_ENV } from "@/config.js";
import { buildReleaseHeaders } from "@/services/releaseHeaders.js";
import { isTrustedReleaseUrl, sameOriginReleaseUrl } from "@/services/releaseTrust.js";
export const RELEASE_RUNTIME_GLOBAL_KEY = "__TRUCKWASH_RELEASE_RUNTIME__";
export const RELEASE_CHANNEL_SELECTION_STORAGE_KEY = "release_channel_selected_slug";
@@ -172,7 +173,8 @@ export const fetchReleaseRuntime = async ({
const runtimeFrontendBaseUrl = (runtime = {}) => {
const urls = runtime?.urls && typeof runtime.urls === "object" ? runtime.urls : {};
return normalizeBaseUrl(runtime?.frontend_base_url || urls.frontend_base_url || "");
const frontendBaseUrl = normalizeBaseUrl(runtime?.frontend_base_url || urls.frontend_base_url || "");
return isTrustedReleaseUrl(frontendBaseUrl, { allowRelative: false }) ? frontendBaseUrl : "";
};
const runtimeChannel = (runtime = {}) => runtime?.channel || {};
@@ -190,8 +192,14 @@ export const shouldLoadRemoteRelease = (runtime = {}) => {
export const releaseEntryUrl = (frontendBaseUrl) =>
new URL(RELEASE_ENTRY_FILENAME, `${normalizeBaseUrl(frontendBaseUrl)}/`).href;
const resolveReleaseAssetUrl = (frontendBaseUrl, value) =>
new URL(String(value || "").replace(/^\/+/, ""), `${normalizeBaseUrl(frontendBaseUrl)}/`).href;
const resolveReleaseAssetUrl = (frontendBaseUrl, value) => {
const baseUrl = normalizeBaseUrl(frontendBaseUrl);
const resolvedUrl = new URL(String(value || "").replace(/^\/+/, ""), `${baseUrl}/`).href;
if (!sameOriginReleaseUrl(resolvedUrl, baseUrl)) {
throw new Error("Release entry asset URL is not on the trusted release frontend origin.");
}
return resolvedUrl;
};
export const loadRemoteReleaseEntry = async ({
runtime,
@@ -200,6 +208,9 @@ export const loadRemoteReleaseEntry = async ({
importModule = (url) => import(/* @vite-ignore */ url),
} = {}) => {
const frontendBaseUrl = runtimeFrontendBaseUrl(runtime);
if (!frontendBaseUrl) {
throw new Error("Release frontend URL is not trusted.");
}
const response = await fetchFn(releaseEntryUrl(frontendBaseUrl), {
method: "GET",
cache: "no-store",
+95 -19
View File
@@ -1,5 +1,6 @@
import { reactive, readonly } from "vue";
import { API_URL } from "@/config.js";
import { isTrustedReleaseUrl } from "@/services/releaseTrust.js";
import { buildReleaseHeaders, RELEASE_TRACE_STORAGE_KEY } from "@/services/releaseHeaders.js";
const TRACE_STORAGE_KEY = RELEASE_TRACE_STORAGE_KEY;
@@ -97,9 +98,9 @@ const normalizeReadinessMissingValues = (missing = []) =>
)
);
const normalizeRuntimeBaseUrl = (value) => {
const normalizeRuntimeBaseUrl = (value, { allowRelative = true } = {}) => {
const raw = String(value || "").trim().replace(/\/+$/, "");
if (!raw) {
if (!raw || !isTrustedReleaseUrl(raw, { allowRelative })) {
return null;
}
if (raw.startsWith("/") && !raw.startsWith("//")) {
@@ -114,7 +115,9 @@ const normalizeRuntimeBaseUrl = (value) => {
const runtimeUrls = (runtime = {}) => {
const urls = runtime?.urls && typeof runtime.urls === "object" ? runtime.urls : {};
return {
frontendBaseUrl: normalizeRuntimeBaseUrl(runtime.frontend_base_url ?? urls.frontend_base_url),
frontendBaseUrl: normalizeRuntimeBaseUrl(runtime.frontend_base_url ?? urls.frontend_base_url, {
allowRelative: false,
}),
apiBaseUrl: normalizeRuntimeBaseUrl(runtime.api_base_url ?? urls.api_base_url),
};
};
@@ -253,6 +256,47 @@ export const rewriteReleaseApiUrl = (url = "") => {
return value;
};
const SENSITIVE_PAYLOAD_KEY_PATTERN =
/authorization|cookie|password|passwd|secret|token|api[_-]?key|session|credential|card|cpr|ssn|recaptcha/i;
const CAPTURE_DISABLED_BODY_PLACEHOLDER = "[capture-disabled]";
const truncateReleasePayloadString = (value) =>
value.length > 4000 ? `${value.slice(0, 4000)}\n... [truncated]` : value;
const redactSerializedReleasePayload = (value, depth) => {
const trimmed = value.trim();
if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
try {
return truncateReleasePayloadString(JSON.stringify(redactReleasePayload(JSON.parse(value), depth + 1)));
} catch {
// Fall through to form-encoded and truncation handling.
}
}
if (trimmed.includes("=") && !trimmed.includes("\n")) {
const params = new URLSearchParams(trimmed);
const entries = Array.from(params.entries());
if (entries.length > 0) {
let changed = false;
const redactedParams = new URLSearchParams();
for (const [key, item] of entries.slice(0, 80)) {
if (SENSITIVE_PAYLOAD_KEY_PATTERN.test(key)) {
redactedParams.append(key, "[redacted]");
changed = true;
} else {
redactedParams.append(key, truncateReleasePayloadString(item));
}
}
if (changed) {
return truncateReleasePayloadString(redactedParams.toString());
}
}
}
return truncateReleasePayloadString(value);
};
export const redactReleasePayload = (value, depth = 0) => {
if (depth > 8) {
return "[depth-limit]";
@@ -267,9 +311,7 @@ export const redactReleasePayload = (value, depth = 0) => {
Object.entries(value)
.slice(0, 80)
.map(([key, item]) => {
if (
/authorization|cookie|password|passwd|secret|token|api[_-]?key|session|credential|card|cpr|ssn/i.test(key)
) {
if (SENSITIVE_PAYLOAD_KEY_PATTERN.test(key)) {
return [key, "[redacted]"];
}
return [key, redactReleasePayload(item, depth + 1)];
@@ -277,8 +319,8 @@ export const redactReleasePayload = (value, depth = 0) => {
);
}
if (typeof value === "string" && value.length > 4000) {
return `${value.slice(0, 4000)}\n... [truncated]`;
if (typeof value === "string") {
return redactSerializedReleasePayload(value, depth);
}
return value;
@@ -303,6 +345,30 @@ const shouldSendEvent = (type, severity) => {
return releaseRuntimeStateMutable.capturePolicy.enabled === true;
};
const omitCapturedBodiesWhenDisabled = (payload) => {
if (!payload || typeof payload !== "object") {
return payload;
}
const nextPayload = { ...payload };
for (const key of ["request", "response"]) {
if (nextPayload[key] && typeof nextPayload[key] === "object" && hasOwn(nextPayload[key], "data")) {
nextPayload[key] = {
...nextPayload[key],
data: CAPTURE_DISABLED_BODY_PLACEHOLDER,
};
}
}
return nextPayload;
};
const sanitizeReleaseTimelinePayload = (type, severity, payload) => {
if (isFailureEvent(type, severity) && !releaseRuntimeStateMutable.capturePolicy.enabled) {
return omitCapturedBodiesWhenDisabled(payload);
}
return payload;
};
export const recordReleaseTimelineEvent = (type, payload = {}, options = {}) => {
const severity = options.severity || payload?.severity || "info";
if (!shouldSendEvent(type, severity)) {
@@ -317,7 +383,7 @@ export const recordReleaseTimelineEvent = (type, payload = {}, options = {}) =>
component: options.component || payload?.component || null,
request_id: options.requestId || payload?.request_id || null,
occurred_at: new Date().toISOString(),
payload: redactReleasePayload(payload),
payload: redactReleasePayload(sanitizeReleaseTimelinePayload(type, severity, payload)),
};
if (FRONTEND_FAILURE_EVENT_TYPES.has(type)) {
@@ -421,7 +487,9 @@ const firstFilledString = (...values) => {
const releaseRuntimeUrlsForDisplay = (runtime = {}) => {
const urls = isPlainRecord(runtime?.urls) ? runtime.urls : {};
return {
frontend: normalizeRuntimeBaseUrl(runtime.frontendBaseUrl ?? runtime.frontend_base_url ?? urls.frontend_base_url),
frontend: normalizeRuntimeBaseUrl(runtime.frontendBaseUrl ?? runtime.frontend_base_url ?? urls.frontend_base_url, {
allowRelative: false,
}),
api: normalizeRuntimeBaseUrl(runtime.apiBaseUrl ?? runtime.api_base_url ?? urls.api_base_url),
};
};
@@ -585,7 +653,7 @@ const releaseServiceForKey = (serviceSet = null, key = "") => {
return isPlainRecord(service) ? service : null;
};
export const buildReleaseSessionSummary = (runtime = releaseRuntimeStateMutable) => {
export const buildReleaseSessionSummary = (runtime = releaseRuntimeStateMutable, options = {}) => {
const versions = isPlainRecord(runtime?.versions) ? runtime.versions : {};
const channel = isPlainRecord(runtime?.channel) ? runtime.channel : null;
const availability = isPlainRecord(runtime?.availability) ? runtime.availability : {};
@@ -595,16 +663,18 @@ export const buildReleaseSessionSummary = (runtime = releaseRuntimeStateMutable)
channel?.default_channel === true
|| channel?.default_channel === 1
|| String(channel?.slug || "").toLowerCase() === "stable";
const urls = releaseRuntimeUrlsForDisplay(runtime);
const includeInfrastructureDetails = options?.includeInfrastructureDetails === true;
const urls = includeInfrastructureDetails ? releaseRuntimeUrlsForDisplay(runtime) : { frontend: "", api: "" };
const frontendVersion = isPlainRecord(versions.frontend) ? versions.frontend : null;
const apiVersion = isPlainRecord(versions.api) ? versions.api : null;
const bundle = isPlainRecord(versions.bundle) ? versions.bundle : null;
const bundleId = versions.bundle_id || bundle?.id || null;
const serviceSet = isPlainRecord(versions.service_set)
const rawServiceSet = isPlainRecord(versions.service_set)
? versions.service_set
: isPlainRecord(bundle?.service_set)
? bundle.service_set
: null;
const serviceSet = includeInfrastructureDetails ? rawServiceSet : null;
const defaultSharedLabel = "Default/shared runtime";
const missingLabels = missing.map((key) => RELEASE_MISSING_LABELS[key] || key.replace(/_/g, " "));
@@ -623,15 +693,19 @@ export const buildReleaseSessionSummary = (runtime = releaseRuntimeStateMutable)
? "shared"
: firstFilledString(version?.status, url ? "active" : "unknown");
const primaryText = releaseVersionPrimaryText(version, fallback);
const secondaryText = includeInfrastructureDetails ? releaseVersionSecondaryText(version) : "";
const displayUrl = includeInfrastructureDetails ? url || "" : "";
return {
key,
label,
status,
tone: missingKey ? "warning" : releaseStatusTone(status),
primaryText: releaseVersionPrimaryText(version, fallback),
secondaryText: releaseVersionSecondaryText(version),
url: url || "",
title: [releaseVersionPrimaryText(version, fallback), releaseVersionSecondaryText(version), url]
primaryText,
secondaryText,
url: displayUrl,
title: [primaryText, secondaryText, displayUrl]
.filter(Boolean)
.join(" - "),
missingLabel: missingKey ? RELEASE_MISSING_LABELS[missingKey] || missingKey : "",
@@ -687,13 +761,15 @@ export const buildReleaseSessionSummary = (runtime = releaseRuntimeStateMutable)
bundleStatus: firstFilledString(bundle?.status, bundleId ? "active" : "shared"),
serviceSetLabel: serviceSet
? firstFilledString(serviceSet.name, serviceSet.slug, serviceSet.id ? `#${serviceSet.id}` : "Connected")
: defaultSharedLabel,
: rawServiceSet
? "Restricted to release operators"
: defaultSharedLabel,
missingLabels,
appRows: [
buildAppRow("frontend", "Frontend", frontendVersion, urls.frontend),
buildAppRow("api", "API", apiVersion, urls.api),
],
serviceRows: RELEASE_SERVICE_DEFINITIONS.map(buildServiceRow),
serviceRows: includeInfrastructureDetails || !rawServiceSet ? RELEASE_SERVICE_DEFINITIONS.map(buildServiceRow) : [],
};
};
+71
View File
@@ -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);
};
+10 -4
View File
@@ -2,6 +2,7 @@ import axios from "axios";
import { API_URL, RELEASE_MANAGER_CONTROL_API_FALLBACK_URLS, RELEASE_MANAGER_CONTROL_API_URL } from "@/config.js";
import { enqueueRequest } from "@/services/requestQueue.js";
import { getReleaseRuntimeApiBaseUrl, releaseRuntimeState } from "@/services/releaseTimeline.js";
import { isTrustedReleaseUrl } from "@/services/releaseTrust.js";
export const RELEASE_MANAGER_CONTROL_API_STORAGE_KEY = "release_manager_control_api_url";
const RELEASE_MANAGER_LAST_WORKING_CONTROL_API_STORAGE_KEY = "release_manager_last_working_control_api_url";
@@ -19,9 +20,14 @@ const normalizeApiUrl = (value) => {
return url;
};
const isTrustedApiUrl = (value) => {
const url = normalizeApiUrl(value);
return Boolean(url && isTrustedReleaseUrl(url));
};
const isLocalApiUrl = (value) => {
const url = normalizeApiUrl(value);
if (!url) {
if (!isTrustedApiUrl(url)) {
return false;
}
if (url.startsWith("/") && !url.startsWith("//")) {
@@ -91,14 +97,14 @@ export const releaseManagerControlApiCandidates = () => {
storedUrl,
...deploymentConfigUrls,
...RELEASE_MANAGER_CONTROL_API_FALLBACK_URLS,
]);
]).filter(isTrustedApiUrl);
};
export const getReleaseManagerControlApiUrl = () => releaseManagerControlApiCandidates()[0] || API_URL;
export const setReleaseManagerControlApiUrl = (url) => {
const normalized = normalizeApiUrl(url);
if (!normalized) {
if (!isTrustedApiUrl(normalized)) {
return "";
}
releaseManagerLocalStorage()?.setItem(RELEASE_MANAGER_CONTROL_API_STORAGE_KEY, normalized);
@@ -113,7 +119,7 @@ export const clearReleaseManagerControlApiUrl = () => {
const rememberWorkingControlApiUrl = (url) => {
const normalized = normalizeApiUrl(url);
if (normalized) {
if (isTrustedApiUrl(normalized)) {
releaseManagerSessionStorage()?.setItem(RELEASE_MANAGER_LAST_WORKING_CONTROL_API_STORAGE_KEY, normalized);
}
};
+2
View File
@@ -12,6 +12,7 @@ import { QrcodeStream, QrcodeDropZone, QrcodeCapture } from 'vue-qrcode-reader'
import { sounds } from "@/components/displays/department/pos/steps/mobile/objects/PosDepartmentStepMobileFlow.vue";
import PageLoader from "@/components/global/PageLoader.vue";
import Swal from "sweetalert2";
import { clearEdgeGatewayWorkspaceCache } from "@/services/edgeGateways.js";
const isProcessingQRCode = ref(false); // State to indicate if QR code is being processed
const playCaptureSound = () => {
@@ -20,6 +21,7 @@ const playCaptureSound = () => {
const applyToken = (token: string) => {
isProcessingQRCode.value = true; // Start processing
clearEdgeGatewayWorkspaceCache();
localStorage.setItem("token", token);
window.location.href = "/";
}
+2
View File
@@ -1,12 +1,14 @@
<script setup lang="ts">
import { onMounted, onUnmounted, ref, computed } from "vue";
import PageLoader from "@/components/global/PageLoader.vue";
import { clearEdgeGatewayWorkspaceCache } from "@/services/edgeGateways.js";
const isProcessing = ref(false);
const hasToken = computed(() => localStorage.getItem("superuser_token") !== null);
const applyToken = (token: string) => {
isProcessing.value = true; // Start processing
clearEdgeGatewayWorkspaceCache();
localStorage.setItem("token", token);
localStorage.removeItem("superuser_token");
window.location.href = "/";
@@ -18,6 +18,7 @@ import {
isSearching as isSearchingCustomers,
} from "@/components/search/economic/customerSearch.vue";
import { resolveReleaseApiUrl } from "@/services/releaseTimeline.js";
import { safeAttachmentDownloadLink } from "@/services/attachmentDownloadLinks.js";
import {
buildSelfServeDynamicImageUrl,
getSelfServeCompletedDynamicImageStep,
@@ -482,6 +483,18 @@ const graphStats = computed(() => {
});
const permissions = computed(() => graphPayload.value?.permissions || {});
const canEditVehicleTypeProduct = computed(() =>
Boolean(permissions.value.can_edit) && SessionUser.canAccessSuperUser()
);
const destructiveGatewayActions = new Set(["rotate_credentials", "uninstall"]);
const canManageGateways = computed(() => Boolean(permissions.value.can_manage_gateways));
const canRunGatewayDestructiveActions = computed(() =>
Boolean(permissions.value.can_run_gateway_destructive_actions)
);
const gatewayActionRequiresConfirmation = (action) => destructiveGatewayActions.has(action);
const canRunGatewayAction = (action) =>
canManageGateways.value &&
(!gatewayActionRequiresConfirmation(action) || canRunGatewayDestructiveActions.value);
const selectedNode = computed(() => flowNodes.value.find((node) => node.id === selectedNodeId.value) || null);
const selectedNodeKind = computed(() => selectedNode.value?.data?.kind || "");
const editableNodeKinds = ["question", "condition", "task", "action", "lane"];
@@ -3149,14 +3162,14 @@ const inspectorTaskDynamicImageUnavailableReason = computed(() => {
});
const openTaskAttachment = async (attachment, taskId = null) => {
let downloadLink = String(attachment?.download_link || "").trim();
let downloadLink = safeAttachmentDownloadLink(attachment?.download_link);
if (!downloadLink && taskId && attachment?.id) {
try {
const payload = await requestGet("/department/selfserve/tasks/attachments/download", {
task_id: taskId,
attachment_id: attachment.id,
});
downloadLink = String(payload?.download_link || "").trim();
downloadLink = safeAttachmentDownloadLink(payload?.download_link);
} catch (error) {
withErrorToast(error, "Attachment download could not be prepared.");
return;
@@ -4677,6 +4690,11 @@ const deleteSelected = async () => {
};
const saveVehicleTypeProduct = async () => {
if (!canEditVehicleTypeProduct.value) {
toast.error("Only superusers can update vehicle type product catalog fields.");
return;
}
const id = parseNullableInt(
inspectorForm.value.id || selectedRaw.value.product || selectedRaw.value.product_id || selectedRaw.value.id
);
@@ -4762,7 +4780,26 @@ const rollbackVersion = async (versionId) => {
}
};
const runGatewayAction = async (gatewayId, action, confirm = false) => {
const confirmGatewayAction = (action) => {
if (!gatewayActionRequiresConfirmation(action)) {
return false;
}
const actionLabel = action === "rotate_credentials" ? "rotate gateway credentials" : "uninstall this gateway";
return window.confirm(`Are you sure you want to ${actionLabel}? This operation can disrupt live edge hardware.`);
};
const runGatewayAction = async (gatewayId, action) => {
if (!canRunGatewayAction(action)) {
toast.error("You do not have permission to run this gateway action.");
return;
}
const confirm = confirmGatewayAction(action);
if (gatewayActionRequiresConfirmation(action) && !confirm) {
return;
}
try {
await requestPost("/department/selfserve/studio/gateway-action", {
department: departmentId.value,
@@ -7026,7 +7063,7 @@ onBeforeUnmount(() => {
class="studio-debug-attachment"
:disabled="!attachment.download_link"
:title="taskAttachmentLabel(attachment)"
@click.stop="openTaskAttachment(attachment)"
@click.stop="openTaskAttachment(attachment, task.id)"
>
<span class="icon is-small"><i :class="taskAttachmentIcon(attachment)"></i></span>
<span>{{ taskAttachmentLabel(attachment) }}</span>
@@ -7484,7 +7521,7 @@ onBeforeUnmount(() => {
class="studio-node-attachment"
:disabled="!attachment.download_link"
:title="taskAttachmentLabel(attachment)"
@click.stop="openTaskAttachment(attachment)"
@click.stop="openTaskAttachment(attachment, data.object_id)"
@mousedown.stop
>
<span class="icon is-small"><i :class="taskAttachmentIcon(attachment)"></i></span>
@@ -8025,7 +8062,7 @@ onBeforeUnmount(() => {
/>
<span>Wash product</span>
</label>
<button class="button is-primary" type="submit" :disabled="!permissions.can_edit">
<button class="button is-primary" type="submit" :disabled="!canEditVehicleTypeProduct">
<span class="icon"><i class="fas fa-floppy-disk"></i></span>
<span>Save vehicle type</span>
</button>
@@ -8102,6 +8139,7 @@ onBeforeUnmount(() => {
type="button"
title="Discover gateway"
@click="runGatewayAction(inspectorForm.key || selectedGatewayId, 'discovery')"
:disabled="!canRunGatewayAction('discovery')"
>
<span class="icon"><i class="fas fa-radar"></i></span>
<span>Discover</span>
@@ -8111,6 +8149,7 @@ onBeforeUnmount(() => {
type="button"
title="Update gateway"
@click="runGatewayAction(inspectorForm.key || selectedGatewayId, 'update')"
:disabled="!canRunGatewayAction('update')"
>
<span class="icon"><i class="fas fa-upload"></i></span>
<span>Update</span>
@@ -8119,7 +8158,8 @@ onBeforeUnmount(() => {
class="button is-small is-warning is-light"
type="button"
title="Rotate gateway credentials"
@click="runGatewayAction(inspectorForm.key || selectedGatewayId, 'rotate_credentials', true)"
@click="runGatewayAction(inspectorForm.key || selectedGatewayId, 'rotate_credentials')"
:disabled="!canRunGatewayAction('rotate_credentials')"
>
<span class="icon"><i class="fas fa-key"></i></span>
<span>Rotate</span>
@@ -8128,7 +8168,8 @@ onBeforeUnmount(() => {
class="button is-small is-danger is-light"
type="button"
title="Uninstall gateway"
@click="runGatewayAction(inspectorForm.key || selectedGatewayId, 'uninstall', true)"
@click="runGatewayAction(inspectorForm.key || selectedGatewayId, 'uninstall')"
:disabled="!canRunGatewayAction('uninstall')"
>
<span class="icon"><i class="fas fa-trash"></i></span>
<span>Uninstall</span>
@@ -9316,6 +9357,7 @@ onBeforeUnmount(() => {
class="button is-small is-link is-light"
title="Discover gateway"
@click="runGatewayAction(gateway.id, 'discovery')"
:disabled="!canRunGatewayAction('discovery')"
>
<span class="icon"><i class="fas fa-radar"></i></span>
</button>
@@ -9323,20 +9365,23 @@ onBeforeUnmount(() => {
class="button is-small is-light"
title="Update gateway"
@click="runGatewayAction(gateway.id, 'update')"
:disabled="!canRunGatewayAction('update')"
>
<span class="icon"><i class="fas fa-upload"></i></span>
</button>
<button
class="button is-small is-warning is-light"
title="Rotate gateway credentials"
@click="runGatewayAction(gateway.id, 'rotate_credentials', true)"
@click="runGatewayAction(gateway.id, 'rotate_credentials')"
:disabled="!canRunGatewayAction('rotate_credentials')"
>
<span class="icon"><i class="fas fa-key"></i></span>
</button>
<button
class="button is-small is-danger is-light"
title="Uninstall gateway"
@click="runGatewayAction(gateway.id, 'uninstall', true)"
@click="runGatewayAction(gateway.id, 'uninstall')"
:disabled="!canRunGatewayAction('uninstall')"
>
<span class="icon"><i class="fas fa-trash"></i></span>
</button>
@@ -9802,7 +9847,7 @@ onBeforeUnmount(() => {
class="studio-debug-attachment"
:disabled="!attachment.download_link"
:title="taskAttachmentLabel(attachment)"
@click.stop="openTaskAttachment(attachment)"
@click.stop="openTaskAttachment(attachment, task.id)"
>
<span class="icon is-small"><i :class="taskAttachmentIcon(attachment)"></i></span>
<span>{{ taskAttachmentLabel(attachment) }}</span>
@@ -1,5 +1,5 @@
<script setup>
import {ref, watch} from "vue";
import {computed, ref, watch} from "vue";
import { BLoading } from "buefy";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
@@ -26,6 +26,12 @@ const washes = ref(0);
const outsideHours = ref(createEmptyOutsideHours());
const laneToggles = ref([]);
const laneToggleError = ref("");
const canManageMachineStatusToggles = computed(() =>
SessionUser.hasPermission("admin") || SessionUser.hasPermission("modules_selfserve_lane_status_set")
);
const canManageDognvaskToggles = computed(() =>
SessionUser.hasPermission("admin") || SessionUser.hasPermission("edit_department_lane")
);
const normalizeBoolean = (value, defaultValue = false) => {
if (typeof value === "boolean") {
@@ -214,7 +220,7 @@ const dognvaskToggleId = (lane) => `dognvask-${props.department_id}-${lane.id}`;
const laneDisplayName = (lane, index = 0) => lane.name || `Bane ${index + 1}`;
const isMachineToggleChecked = (lane) => Boolean(lane.machine_status_enabled);
const dognvaskHasWarning = (lane) => !lane.dognvask_configured;
const isDognvaskToggleChecked = (lane) => Boolean(lane.selfserve_enabled) && !dognvaskHasWarning(lane);
const isDognvaskToggleChecked = (lane) => Boolean(lane.selfserve_enabled);
const dognvaskWarnings = (lane) => Array.isArray(lane.dognvask_configuration_warnings) ? lane.dognvask_configuration_warnings : [];
const formatMachineStatusModifiedAt = (value) => {
if (!value) {
@@ -249,6 +255,11 @@ const applyLaneUpdate = (laneId, currentLane, update, savingPatch = {}) => {
};
const toggleMachineStatus = async (lane, event) => {
if (!canManageMachineStatusToggles.value) {
event.target.checked = isMachineToggleChecked(lane);
return;
}
const enabled = event.target.checked;
const previousLane = { ...lane };
@@ -278,6 +289,11 @@ const toggleMachineStatus = async (lane, event) => {
};
const toggleDognvask = async (lane, event) => {
if (!canManageDognvaskToggles.value) {
event.target.checked = isDognvaskToggleChecked(lane);
return;
}
const enabled = event.target.checked;
const previousLane = { ...lane };
@@ -335,7 +351,7 @@ watch([selected_date, selected_date_to], () => {
type="checkbox"
:id="machineToggleId(lane)"
:checked="isMachineToggleChecked(lane)"
:disabled="lane.isSavingMachineStatus"
:disabled="!canManageMachineStatusToggles || lane.isSavingMachineStatus"
@change="toggleMachineStatus(lane, $event)"
>
<div class="status-toggle-top">
@@ -371,7 +387,7 @@ watch([selected_date, selected_date_to], () => {
type="checkbox"
:id="dognvaskToggleId(lane)"
:checked="isDognvaskToggleChecked(lane)"
:disabled="lane.isSavingDognvask"
:disabled="!canManageDognvaskToggles || lane.isSavingDognvask"
@change="toggleDognvask(lane, $event)"
>
<div class="status-toggle-top">
@@ -6,6 +6,12 @@ import RestrictedPageWrapper from "@/components/page/wrappers/RestrictedPageWrap
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import ConfigurationSubPageWrapper from "@/views/dashboards/superUserDashboard/configuration/ConfigurationSubPageWrapper.vue";
import { normalizeEdgeGatewayError } from "@/features/edgeGateways/edgeGatewayErrors.js";
import {
canUseStubBrokerAuthMode,
redactBrokerSharedSecret,
sanitizeBrokerAuthMode,
validatePublicBrokerUrl,
} from "@/features/edgeGateways/edgeGatewayBrokerConfigSecurity.js";
const moduleConfigEntries = ref([]);
const moduleConfigMap = ref({
@@ -35,6 +41,15 @@ const brokerDiagnosticKeys = {
secret: "broker_shared_secret",
};
const allowStubBrokerAuthMode = canUseStubBrokerAuthMode();
const setModuleError = (message) => {
moduleState.error = {
title: "Invalid edge gateway broker configuration",
message,
};
};
const loadModuleConfig = async () => {
moduleState.loading = true;
moduleState.error = null;
@@ -49,8 +64,8 @@ const loadModuleConfig = async () => {
default_update_window: response?.data?.config?.default_update_window || "02:00-04:00",
broker_url: response?.data?.config?.broker_url || "http://edge-broker:4300",
public_broker_url: response?.data?.config?.public_broker_url || "",
broker_auth_mode: response?.data?.config?.broker_auth_mode || "manager",
broker_shared_secret: response?.data?.config?.broker_shared_secret || "",
broker_auth_mode: sanitizeBrokerAuthMode(response?.data?.config?.broker_auth_mode),
broker_shared_secret: redactBrokerSharedSecret(),
};
} catch (error) {
moduleState.error = normalizeEdgeGatewayError(error);
@@ -65,15 +80,26 @@ const saveModuleConfig = async () => {
moduleState.error = null;
try {
await SessionUser.superUser.modules.edgegateway.config.set_config({
const publicBrokerUrl = validatePublicBrokerUrl(moduleConfigMap.value.public_broker_url);
if (!publicBrokerUrl.ok) {
setModuleError(publicBrokerUrl.message);
return;
}
const payload = {
enabled: Boolean(moduleConfigMap.value.enabled),
default_release_channel: moduleConfigMap.value.default_release_channel || "stable",
default_update_window: moduleConfigMap.value.default_update_window || "02:00-04:00",
broker_url: moduleConfigMap.value.broker_url || "",
public_broker_url: moduleConfigMap.value.public_broker_url || "",
broker_auth_mode: moduleConfigMap.value.broker_auth_mode || "manager",
broker_shared_secret: moduleConfigMap.value.broker_shared_secret || "",
});
public_broker_url: publicBrokerUrl.value,
broker_auth_mode: sanitizeBrokerAuthMode(moduleConfigMap.value.broker_auth_mode),
};
if (moduleConfigMap.value.broker_shared_secret) {
payload.broker_shared_secret = moduleConfigMap.value.broker_shared_secret;
}
await SessionUser.superUser.modules.edgegateway.config.set_config(payload);
await loadModuleConfig();
} catch (error) {
moduleState.error = normalizeEdgeGatewayError(error);
@@ -82,13 +108,20 @@ const saveModuleConfig = async () => {
}
};
const brokerDiagnosticPayload = (target) => ({
target,
broker_url: moduleConfigMap.value.broker_url || "",
public_broker_url: moduleConfigMap.value.public_broker_url || "",
broker_auth_mode: moduleConfigMap.value.broker_auth_mode || "manager",
broker_shared_secret: moduleConfigMap.value.broker_shared_secret || "",
});
const brokerDiagnosticPayload = (target) => {
const payload = {
target,
broker_url: moduleConfigMap.value.broker_url || "",
public_broker_url: moduleConfigMap.value.public_broker_url || "",
broker_auth_mode: sanitizeBrokerAuthMode(moduleConfigMap.value.broker_auth_mode),
};
if (moduleConfigMap.value.broker_shared_secret) {
payload.broker_shared_secret = moduleConfigMap.value.broker_shared_secret;
}
return payload;
};
const testBrokerConfig = async (target) => {
const resultKey = brokerDiagnosticKeys[target];
@@ -100,6 +133,15 @@ const testBrokerConfig = async (target) => {
brokerDiagnostics.error = null;
try {
if (target === "public") {
const publicBrokerUrl = validatePublicBrokerUrl(moduleConfigMap.value.public_broker_url);
if (!publicBrokerUrl.ok) {
setModuleError(publicBrokerUrl.message);
return;
}
moduleConfigMap.value.public_broker_url = publicBrokerUrl.value;
}
const response = await SessionUser.superUser.modules.edgegateway.config.test_broker(
brokerDiagnosticPayload(target)
);
@@ -275,7 +317,7 @@ onMounted(async () => {
<div class="select is-fullwidth">
<select v-model="moduleConfigMap.broker_auth_mode" data-testid="gateway-module-broker-auth-mode">
<option value="manager">manager</option>
<option value="stub">stub</option>
<option v-if="allowStubBrokerAuthMode" value="stub">stub (development only)</option>
</select>
</div>
</label>
@@ -288,7 +330,7 @@ onMounted(async () => {
autocomplete="off"
class="input"
data-testid="gateway-module-broker-shared-secret"
placeholder="Shared secret"
placeholder="Leave blank to keep the existing secret"
type="password"
/>
<button
@@ -48,7 +48,11 @@ export const periodPageCount = computed(() => {
});
const normalizeLimit = (value) => {
if (String(value || "").trim().toLowerCase() === ALL_LIMIT) {
if (
String(value || "")
.trim()
.toLowerCase() === ALL_LIMIT
) {
return ALL_LIMIT;
}
@@ -76,6 +80,45 @@ const safeSessionStorage = () => {
}
};
const getStorageValue = (key) => {
try {
if (typeof window === "undefined" || !window.localStorage) {
return "";
}
return window.localStorage.getItem(key) || "";
} catch {
return "";
}
};
const hashCacheScopePart = (value) => {
let hash = 5381;
for (let index = 0; index < value.length; index += 1) {
hash = ((hash << 5) + hash) ^ value.charCodeAt(index);
}
return (hash >>> 0).toString(36);
};
const getAuthenticatedCacheScope = () => {
const token = getStorageValue("token");
if (!token) {
return "";
}
return [
hashCacheScopePart(token),
getStorageValue("is_subuser") === "true" ? "subuser" : "user",
getStorageValue("selected_customer_number"),
]
.map((part) => encodeURIComponent(String(part ?? "")))
.join("|");
};
const getScopedCacheKey = (cacheKey) => {
const scope = getAuthenticatedCacheScope();
return scope ? `${scope}:${cacheKey}` : "";
};
const memoryCache = new Map();
const selfWashCountsMemoryCache = new Map();
@@ -151,13 +194,18 @@ const isFreshCacheEntry = (entry) => {
};
export const getCachedPeriodPage = (cacheKey) => {
const memoryEntry = memoryCache.get(cacheKey);
const scopedCacheKey = getScopedCacheKey(cacheKey);
if (!scopedCacheKey) {
return null;
}
const memoryEntry = memoryCache.get(scopedCacheKey);
if (isFreshCacheEntry(memoryEntry)) {
return memoryEntry;
}
if (memoryEntry) {
memoryCache.delete(cacheKey);
memoryCache.delete(scopedCacheKey);
}
const storage = safeSessionStorage();
@@ -165,14 +213,14 @@ export const getCachedPeriodPage = (cacheKey) => {
return null;
}
const storageKey = `${CACHE_PREFIX}${cacheKey}`;
const storageKey = `${CACHE_PREFIX}${scopedCacheKey}`;
try {
const parsed = JSON.parse(storage.getItem(storageKey) || "null");
if (!isFreshCacheEntry(parsed)) {
storage.removeItem(storageKey);
return null;
}
memoryCache.set(cacheKey, parsed);
memoryCache.set(scopedCacheKey, parsed);
return parsed;
} catch {
storage.removeItem(storageKey);
@@ -181,12 +229,17 @@ export const getCachedPeriodPage = (cacheKey) => {
};
export const setCachedPeriodPage = (cacheKey, periodResult, paginationMeta) => {
const scopedCacheKey = getScopedCacheKey(cacheKey);
if (!scopedCacheKey) {
return;
}
const entry = {
storedAt: Date.now(),
data: periodResult,
pagination: paginationMeta,
};
memoryCache.set(cacheKey, entry);
memoryCache.set(scopedCacheKey, entry);
const storage = safeSessionStorage();
if (!storage) {
@@ -194,7 +247,7 @@ export const setCachedPeriodPage = (cacheKey, periodResult, paginationMeta) => {
}
try {
storage.setItem(`${CACHE_PREFIX}${cacheKey}`, JSON.stringify(entry));
storage.setItem(`${CACHE_PREFIX}${scopedCacheKey}`, JSON.stringify(entry));
} catch {
// Best-effort cache. Quota errors should not block the period view.
}
@@ -217,18 +270,21 @@ export const clearPeriodCache = () => {
};
export const buildSelfWashCountsCacheKey = ({ dateFrom, dateTo, filters }) =>
[dateFrom, dateTo, filters]
.map((part) => encodeURIComponent(String(part ?? "")))
.join("|");
[dateFrom, dateTo, filters].map((part) => encodeURIComponent(String(part ?? ""))).join("|");
export const getCachedSelfWashCounts = (cacheKey) => {
const memoryEntry = selfWashCountsMemoryCache.get(cacheKey);
const scopedCacheKey = getScopedCacheKey(cacheKey);
if (!scopedCacheKey) {
return null;
}
const memoryEntry = selfWashCountsMemoryCache.get(scopedCacheKey);
if (isFreshCacheEntry(memoryEntry)) {
return memoryEntry.data;
}
if (memoryEntry) {
selfWashCountsMemoryCache.delete(cacheKey);
selfWashCountsMemoryCache.delete(scopedCacheKey);
}
const storage = safeSessionStorage();
@@ -236,14 +292,14 @@ export const getCachedSelfWashCounts = (cacheKey) => {
return null;
}
const storageKey = `${SELF_WASH_COUNTS_CACHE_PREFIX}${cacheKey}`;
const storageKey = `${SELF_WASH_COUNTS_CACHE_PREFIX}${scopedCacheKey}`;
try {
const parsed = JSON.parse(storage.getItem(storageKey) || "null");
if (!isFreshCacheEntry(parsed)) {
storage.removeItem(storageKey);
return null;
}
selfWashCountsMemoryCache.set(cacheKey, parsed);
selfWashCountsMemoryCache.set(scopedCacheKey, parsed);
return parsed.data;
} catch {
storage.removeItem(storageKey);
@@ -252,11 +308,16 @@ export const getCachedSelfWashCounts = (cacheKey) => {
};
export const setCachedSelfWashCounts = (cacheKey, counts) => {
const scopedCacheKey = getScopedCacheKey(cacheKey);
if (!scopedCacheKey) {
return;
}
const entry = {
storedAt: Date.now(),
data: counts,
};
selfWashCountsMemoryCache.set(cacheKey, entry);
selfWashCountsMemoryCache.set(scopedCacheKey, entry);
const storage = safeSessionStorage();
if (!storage) {
@@ -264,7 +325,7 @@ export const setCachedSelfWashCounts = (cacheKey, counts) => {
}
try {
storage.setItem(`${SELF_WASH_COUNTS_CACHE_PREFIX}${cacheKey}`, JSON.stringify(entry));
storage.setItem(`${SELF_WASH_COUNTS_CACHE_PREFIX}${scopedCacheKey}`, JSON.stringify(entry));
} catch {
// Best-effort cache. Quota errors should not block the period view.
}
@@ -34,6 +34,7 @@ const showStripeStatusSummary = computed(() => isPaid.value && (isDraft.value ||
const isStripeTransferBusy = computed(() => stripeTransferQueue.disableSubmit.value);
const isStripeTransferQueuedOrProcessing = computed(() => stripeTransferQueue.isQueuedOrProcessing.value);
const isStripeTransferCompleted = computed(() => stripeTransferQueue.isCompleted.value);
const canBookStripeInvoice = computed(() => !isStripeTransferBusy.value && !isStripeTransferCompleted.value);
const isStripeTransferFailed = computed(() => stripeTransferQueue.isFailed.value);
const stripeTransferProgressPercent = computed(() => stripeTransferQueue.progressPercent.value);
const stripeTransferProgressMessage = computed(() => stripeTransferQueue.progressMessage.value);
@@ -61,6 +62,10 @@ const stripeTransferResultMessage = computed(() => {
});
const onBookStripeInvoice = async () => {
if (!canBookStripeInvoice.value) {
return;
}
try {
await stripeTransferQueue.enqueue({});
} catch (error) {
@@ -324,7 +329,7 @@ const stripePayment = computed(() => {
<div class="buttons">
<button
class="button is-small is-light"
:disabled="isStripeTransferBusy"
:disabled="!canBookStripeInvoice"
data-testid="collected-stripe-book-invoice"
@click="onBookStripeInvoice"
>
@@ -1150,8 +1150,10 @@ function normalizeReleaseStatusService(service) {
status: service?.status || service?.state || "ready",
state: service?.state || service?.status || "ready",
severity: service?.severity || "ok",
message: service?.message || trFallback("status.service_ready", "Release service is ready."),
next_action: service?.next_action || "",
message: redactSensitiveDiagnostics(
service?.message || trFallback("status.service_ready", "Release service is ready.")
),
next_action: redactSensitiveDiagnostics(service?.next_action || ""),
target_tab: service?.target_tab || "overview",
target_id: service?.target_id ?? null,
deployment_id: service?.deployment_id ?? null,
@@ -1175,8 +1177,8 @@ function normalizeReleaseStatusIssue(issue) {
channel_slug: issue?.channel_slug || "",
service_key: issue?.service_key || null,
label: issue?.label || releaseStatusServiceLabel(issue?.service_key),
message: issue?.message || "",
next_action: issue?.next_action || "",
message: redactSensitiveDiagnostics(issue?.message || ""),
next_action: redactSensitiveDiagnostics(issue?.next_action || ""),
target_tab: issue?.target_tab || "overview",
target_id: issue?.target_id ?? null,
deployment_id: issue?.deployment_id ?? null,
@@ -2888,26 +2890,6 @@ function isolatedStackRequestedName() {
return String(bundleForm.service_set_name || `${channelSlug} isolated stack`).trim();
}
function isolatedStackIsComplete(set) {
return ["frontend", "api", "database", "redis", "minio"].every((item) => serviceSetStackItem(set, item));
}
function matchingIsolatedStackServiceSet() {
const channelId = Number(bundleChannelId() || 0);
const requestedSlug = safeStackSlug(isolatedStackRequestedName());
return (
serviceSets.value.find((set) => {
if (set?.mode !== "isolated_stack") {
return false;
}
if (channelId && Number(set.channel_id || 0) !== channelId) {
return false;
}
return safeStackSlug(set.name || set.slug || "") === requestedSlug || String(set.slug || "") === requestedSlug;
}) || null
);
}
function isolatedStackServiceName(app) {
const channelSlug = selectedBundleChannel()?.slug || "release";
const stackName = safeStackSlug(bundleForm.service_set_name || `${channelSlug}-isolated-stack`);
@@ -3150,19 +3132,6 @@ async function serviceSetIdForBundle() {
return sourceId;
}
if (isIsolatedStackMode.value) {
const existingStack = matchingIsolatedStackServiceSet();
if (existingStack?.id) {
if (!isolatedStackIsComplete(existingStack) && missingIsolatedDataServices(existingStack).length > 0) {
await completeReleaseServiceSetIsolatedDataServices(existingStack.id, {
deploy_data_targets: true,
});
await load();
}
return existingStack.id;
}
}
const isolatedTargets = isIsolatedStackMode.value
? {
frontend: await createIsolatedStackDeploymentTarget("frontend"),
@@ -3310,7 +3279,7 @@ async function saveChannel() {
}
async function saveAssignment() {
if (!assignmentForm.subject_type || !assignmentForm.subject_id) {
if (!canManage.value || !assignmentForm.subject_type || !assignmentForm.subject_id) {
return;
}
@@ -4109,12 +4078,35 @@ function deploymentFailureSummary(deployment) {
return summary && typeof summary === "object" ? summary : null;
}
function redactSensitiveDiagnostics(value) {
let text = String(value || "").trim();
if (!text) {
return "";
}
text = text.replace(/(https?:\/\/)([^\s/@:]+):([^\s/@]+)@/gi, "$1[redacted]@");
text = text.replace(/\b(?:gh[opsru]_|github_pat_|glpat-|xox[baprs]-)[A-Za-z0-9_\-]{8,}\b/g, "[redacted-token]");
text = text.replace(/\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]{8,}/gi, "$1 [redacted]");
text = text.replace(
/\b((?:[A-Z][A-Z0-9_]*_)?(?:PASSWORD|PASSWD|SECRET|TOKEN|API[_-]?KEY|ACCESS[_-]?KEY|PRIVATE[_-]?KEY|AUTH)[A-Z0-9_]*)\s*=\s*(?:"[^"]*"|'[^']*'|[^\s,;]+)/gi,
"$1=[redacted]"
);
text = text.replace(
/\b(?:10\.\d{1,3}\.\d{1,3}\.\d{1,3}|172\.(?:1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3}|192\.168\.\d{1,3}\.\d{1,3})\b/g,
"[redacted-private-ip]"
);
text = text.replace(/\b[a-z0-9.-]+\.internal\b/gi, "[redacted-internal-host]");
text = text.replace(/(?:^|\s)(?:\/[A-Za-z0-9._-]+){2,}\/\.env\b/g, " [redacted-env-path]");
return text;
}
function deploymentFailureCause(deployment) {
return String(deploymentFailureSummary(deployment)?.root_cause || deployment?.error_message || "").trim();
return redactSensitiveDiagnostics(deploymentFailureSummary(deployment)?.root_cause || deployment?.error_message || "");
}
function deploymentFailureNextAction(deployment) {
return String(deploymentFailureSummary(deployment)?.next_action || "").trim();
return redactSensitiveDiagnostics(deploymentFailureSummary(deployment)?.next_action || "");
}
function canPromoteDeployment(deployment) {
@@ -4134,9 +4126,9 @@ function promotionBlockedReason(deployment) {
return tr("actions.promote");
}
return (
return redactSensitiveDiagnostics(
deployment?.promotion_blocked_reason ||
trFallback("deployments.promotion_blocked", "Only successfully deployed release deployments can be promoted.")
trFallback("deployments.promotion_blocked", "Only successfully deployed release deployments can be promoted.")
);
}
@@ -5366,7 +5358,7 @@ onMounted(async () => {
icon="fas fa-user-tag"
default-expanded
>
<div class="release-chip-row" data-testid="release-assignment-channel-suggestions">
<div v-if="canManage" class="release-chip-row" data-testid="release-assignment-channel-suggestions">
<b-button
v-for="channel in channels"
:key="channel.id"
@@ -5380,7 +5372,12 @@ onMounted(async () => {
</b-button>
</div>
<form class="release-form" data-testid="release-assignment-form" @submit.prevent="saveAssignment">
<form
v-if="canManage"
class="release-form"
data-testid="release-assignment-form"
@submit.prevent="saveAssignment"
>
<b-field :label="tr('assignments.subject')" :message="tr('assignments.subject_message')">
<ReleaseAssignmentSubjectAutocomplete
:model-value="assignmentSubjectSelection"
@@ -143,11 +143,32 @@ const onItemsClick = (event, booking) => {
showPopper(popperBox(t("bookings_table.services"), content), event.currentTarget || event.target);
};
const normalizePositiveInteger = (value) => {
const parsedValue = Number.parseInt(String(value ?? ""), 10);
return Number.isInteger(parsedValue) && parsedValue > 0 ? parsedValue : null;
};
const getCurrentCustomerNumber = () =>
normalizePositiveInteger(
SessionUser.isSubuser.value
? SessionUser.subuser.selectedGrantCustomerNumber.value
: SessionUser.getCustomerNumber()
);
const isOwnBooking = (bookingobj) =>
normalizePositiveInteger(bookingobj?.customer_number) === getCurrentCustomerNumber();
const canEditBooking = (bookingobj) => {
// Admins can always edit
if (SessionUser.canAccessAdmin() && SessionUser.hasPermission("edit_bookings")) return true;
if (!!bookingobj.order_id) return false; // Only allow editing bookings without an order
if (SessionUser.hasPermission("edit_own_bookings")) return true;
if (SessionUser.hasPermission("edit_own_bookings") && isOwnBooking(bookingobj)) return true;
};
const canDeleteBooking = (bookingobj) => {
if (!!bookingobj.order_id) return false;
if (SessionUser.canAccessAdmin() || SessionUser.canAccessSuperUser()) return true;
return SessionUser.hasPermission("edit_own_bookings") && isOwnBooking(bookingobj);
};
const sortedList = computed(() => {
@@ -467,6 +488,7 @@ const getColspan = () => {
:customer_number="booking.customer_number"
:department_id="booking.department"
:refresh-function="loadList"
:allow-booking-deletion="canDeleteBooking(booking)"
:reg_1="booking.reg_1"
:reg_2="booking.reg_2"
:icon="'fas fa-ellipsis-v'"
@@ -532,6 +554,7 @@ const getColspan = () => {
<span>{{ getDepartmentName(parseInt(booking.department)) }}</span>
</span>
<button
v-if="canDeleteBooking(booking)"
class="button is-light is-danger is-small order-booking-card-header__delete"
type="button"
aria-label="Slet booking"
@@ -598,6 +621,7 @@ const getColspan = () => {
:customer_number="booking.customer_number"
:department_id="booking.department"
:refresh-function="loadList"
:allow-booking-deletion="canDeleteBooking(booking)"
:reg_1="booking.reg_1"
:reg_2="booking.reg_2"
:displayActionsDirectly="true"
@@ -60,6 +60,7 @@ const RECENT_COMPLETED_WASH_KEY = "mywash_recent_completed_v1";
const RECENT_COMPLETED_WASH_SUPPRESSION_MS = 10 * 60 * 1000;
const ACTIVE_WASH_REFRESH_MS = 5 * 1000;
const WASH_START_SERVER_SYNC_GRACE_MS = 10 * 1000;
const SELF_SERVE_FETCH_DEBOUNCE_MS = 300;
const normalizePositiveInteger = (value: any) => {
const parsed = parseInt(String(value ?? ""), 10);
@@ -83,6 +84,7 @@ const {
isForcingNearestDepartment,
forceNearestDepartmentEvaluationId,
isSearchingDepartments,
departmentFetchError,
availableProductIds,
doesCurrentDepartmentSelectionHaveSelfServeEnabled,
fetchDepartments,
@@ -436,6 +438,8 @@ const extractErrorMessage = (error: any, fallback: string) => {
return fallback;
};
const getRequestStatus = (error: any) => Number(error?.response?.status ?? error?.status ?? 0);
watch(dynamicImageUrl, () => {
hideDynamicImage.value = false;
});
@@ -634,38 +638,153 @@ const fetchVehicleTypes = async () => {
}
};
const fetchSelfServeData = async () => {
if (isRestoring.value || !nearestDepartment.value) {
return;
type SelfServeFetchRequest = {
key: string;
departmentId: number;
laneId: number;
registration: string;
vehicleTypeId: number | null;
};
let selfServeFetchDebounceTimer: ReturnType<typeof window.setTimeout> | null = null;
let selfServeFetchResolvers: Array<(value: any) => void> = [];
let latestSuccessfulSelfServeFetchKey: string | null = null;
let inFlightSelfServeFetchKey: string | null = null;
let selfServeFetchSequence = 0;
let isSelfServeFetchUnmounted = false;
const resolvePendingSelfServeFetches = (value: any = null) => {
const resolvers = selfServeFetchResolvers;
selfServeFetchResolvers = [];
resolvers.forEach((resolve) => resolve(value));
};
const clearScheduledSelfServeFetch = (resolveValue: any = null) => {
if (selfServeFetchDebounceTimer) {
window.clearTimeout(selfServeFetchDebounceTimer);
selfServeFetchDebounceTimer = null;
}
const normalizedReg = normalizeLicensePlate(licensePlateInput.value);
if (normalizedReg.length < 2) {
return;
if (selfServeFetchResolvers.length > 0) {
resolvePendingSelfServeFetches(resolveValue);
}
};
const createSelfServeFetchRequest = (): SelfServeFetchRequest | null => {
if (isRestoring.value || !nearestDepartment.value) {
return null;
}
const departmentId = normalizePositiveInteger(nearestDepartment.value.id);
if (!departmentId) {
return null;
}
const registration = normalizeLicensePlate(licensePlateInput.value);
if (registration.length < 2) {
return null;
}
const laneId = ensureEffectiveLaneSelection();
if (!laneId) {
return;
return null;
}
const departmentId = nearestDepartment.value.id;
const vehicleTypeId = normalizePositiveInteger(vehicleTypeSelect.value);
const key = [departmentId, laneId, registration, vehicleTypeId ?? ""].join("|");
await fetchSelfServeDataInternal(departmentId, vehicleTypeSelect.value || null, laneId, normalizedReg);
return {
key,
departmentId,
laneId,
registration,
vehicleTypeId,
};
};
const getServerActiveWashCandidates = () => {
const executeSelfServeFetch = async (request: SelfServeFetchRequest | null = createSelfServeFetchRequest()) => {
if (!request || isSelfServeFetchUnmounted) {
return null;
}
if (request.key === latestSuccessfulSelfServeFetchKey || request.key === inFlightSelfServeFetchKey) {
return null;
}
const requestSequence = ++selfServeFetchSequence;
inFlightSelfServeFetchKey = request.key;
try {
const result = await fetchSelfServeDataInternal(
request.departmentId,
request.vehicleTypeId,
request.laneId,
request.registration
);
if (!isSelfServeFetchUnmounted && requestSequence === selfServeFetchSequence) {
latestSuccessfulSelfServeFetchKey = request.key;
}
return result;
} finally {
if (inFlightSelfServeFetchKey === request.key) {
inFlightSelfServeFetchKey = null;
}
}
};
const fetchSelfServeData = async (options: { immediate?: boolean } = {}) => {
const request = createSelfServeFetchRequest();
if (options.immediate) {
clearScheduledSelfServeFetch(null);
return executeSelfServeFetch(request);
}
if (!request || isSelfServeFetchUnmounted) {
clearScheduledSelfServeFetch(null);
return null;
}
return new Promise((resolve) => {
selfServeFetchResolvers.push(resolve);
if (selfServeFetchDebounceTimer) {
window.clearTimeout(selfServeFetchDebounceTimer);
}
selfServeFetchDebounceTimer = window.setTimeout(async () => {
selfServeFetchDebounceTimer = null;
const latestRequest = createSelfServeFetchRequest();
const result = await executeSelfServeFetch(latestRequest);
resolvePendingSelfServeFetches(result);
}, SELF_SERVE_FETCH_DEBOUNCE_MS);
});
};
const SERVER_ACTIVE_WASH_ENDPOINT = "/modules/self-serve/lane/wash/my-active-wash";
const findServerActiveWashCandidate = (laneId: number | string | null) => {
const normalizedLaneId = normalizeLaneId(laneId);
if (!normalizedLaneId) {
return null;
}
const departments = Array.isArray(guestDepartments.value) ? guestDepartments.value : [];
return departments.flatMap((department: any) => {
for (const department of departments) {
const lanes = Array.isArray(department?.lanes) ? department.lanes : [];
return lanes
.map((lane: any) => ({
const lane = lanes.find((candidateLane: any) => normalizeLaneId(candidateLane?.id) === normalizedLaneId);
if (lane) {
return {
department,
lane,
laneId: normalizeLaneId(lane?.id),
}))
.filter((candidate: any) => candidate.laneId);
});
laneId: normalizedLaneId,
};
}
}
return { laneId: normalizedLaneId };
};
const isAuthenticatedCustomerActiveWash = (details: any, customerNumber: number) => {
@@ -769,31 +888,34 @@ const fetchServerActiveWash = async () => {
return null;
}
const candidates = getServerActiveWashCandidates();
if (candidates.length === 0) {
let response = null;
try {
response = await SessionUser.request(SERVER_ACTIVE_WASH_ENDPOINT, "GET");
} catch (error) {
if (getRequestStatus(error) === 404) {
return null;
}
throw error;
}
const details = unwrapApiData(response);
if (!isAuthenticatedCustomerActiveWash(details, customerNumber)) {
return null;
}
const responses = await Promise.allSettled(
candidates.map(async (candidate: any) => {
const response = await SessionUser.request("/modules/self-serve/lane/wash/in-progress", "GET", {
lane_id: candidate.laneId,
});
return {
...candidate,
details: unwrapApiData(response),
};
})
);
const laneId = normalizeLaneId(details?.lane_id ?? details?.session?.lane_id);
const activeWashCandidate = findServerActiveWashCandidate(laneId);
const activeWash = {
...(activeWashCandidate || {}),
details,
};
const activeMatches = responses
.filter((entry): entry is PromiseFulfilledResult<any> => entry.status === "fulfilled")
.map((entry) => entry.value)
.filter((entry) => isAuthenticatedCustomerActiveWash(entry.details, customerNumber))
.filter((entry) => !isRecentlyCompletedActiveWash(entry))
.sort((left, right) => activeWashTimestamp(right.details) - activeWashTimestamp(left.details));
if (isRecentlyCompletedActiveWash(activeWash)) {
return null;
}
return activeMatches[0] || null;
return activeWash;
};
const applyServerActiveWash = async (activeWash: any) => {
@@ -845,7 +967,10 @@ const applyServerActiveWash = async (activeWash: any) => {
const summary = await fetchWashSummary(summaryParams, false);
if (!summary) {
await fetchSelfServeDataInternal(departmentId || nearestDepartment.value?.id, selectedVehicleTypeId, laneId, reg);
const fallbackDepartmentId = departmentId || nearestDepartment.value?.id;
if (fallbackDepartmentId) {
await fetchSelfServeDataInternal(fallbackDepartmentId, selectedVehicleTypeId, laneId, reg);
}
}
saveProgress("serverActiveWash");
@@ -932,7 +1057,7 @@ const syncActiveWashWithServer = async () => {
details?.session?.customer_number ?? details?.customer?.customer_number
);
const serverStillMatchesCurrentWash =
!!details?.in_progress && (!customerNumber || !serverCustomerNumber || serverCustomerNumber === customerNumber);
!!details?.in_progress && (!customerNumber || serverCustomerNumber === customerNumber);
if (!serverStillMatchesCurrentWash || isRecentlyCompletedActiveWash({ details, laneId })) {
const isWithinStartGracePeriod =
@@ -1004,7 +1129,7 @@ const retrySelfServeData = async () => {
isSelfServeRetrying.value = true;
try {
await fetchSelfServeData();
await fetchSelfServeData({ immediate: true });
} finally {
isSelfServeRetrying.value = false;
}
@@ -1074,7 +1199,7 @@ const onVehicleStepNext = async () => {
return;
}
await fetchSelfServeData();
await fetchSelfServeData({ immediate: true });
await nextTick();
markQuestionReviewRequired();
currentStep.value = visibleQuestions.value.length > 0 ? steps.QUESTIONS : steps.SELECT_LANE;
@@ -1197,6 +1322,9 @@ onUnmounted(() => {
unregisterBeforeUnload.value();
}
isSelfServeFetchUnmounted = true;
clearScheduledSelfServeFetch(null);
selfServeFetchSequence += 1;
stopAutoRefresh();
stopActiveWashRefresh();
markDestroying();
@@ -1436,6 +1564,28 @@ watch(
</div>
</section>
<b-message
v-if="departmentFetchError"
type="is-warning"
has-icon
:closable="false"
data-testid="self-serve-departments-error"
>
<div class="is-flex is-align-items-center is-justify-content-space-between is-flex-wrap-wrap">
<span class="mr-3">{{ departmentFetchError }}</span>
<b-button
size="is-small"
type="is-warning is-light"
icon-pack="fas"
icon-left="sync-alt"
data-testid="self-serve-departments-retry"
@click="fetchDepartments"
>
{{ $t("common.try_again") }}
</b-button>
</div>
</b-message>
<b-message
v-if="selfServeDataError"
type="is-danger"
@@ -466,10 +466,10 @@ test.describe("Admin overview night washes", () => {
await expect(machineStatusTooltip).toContainText("2026");
}
await expect(page.locator("#dognvask-1-101")).toBeChecked();
await expect(page.locator("#dognvask-2-202")).not.toBeChecked();
await expect(page.locator("#dognvask-2-202")).toBeChecked();
await expect(page.getByTestId("overview-department-2-lane-202-dognvask").locator(".switch")).toHaveCSS(
"background-color",
"rgb(238, 75, 43)"
"rgb(146, 208, 80)"
);
await expect(page.getByTestId("overview-department-3-night-washes")).not.toContainText(
/mangler|Opening hours missing|Missing Hours/
+1 -1
View File
@@ -81,7 +81,7 @@ test.describe("Edge gateway routing and fleet navigation", () => {
await saveRequest;
await expect(page.getByTestId("gateway-module-broker-url")).toHaveValue("http://edge-broker:4301");
await expect(page.getByTestId("gateway-module-broker-shared-secret")).toHaveValue("updated-broker-secret");
await expect(page.getByTestId("gateway-module-broker-shared-secret")).toHaveValue("");
});
test("tests broker module configuration values", async ({ page }) => {
+8
View File
@@ -1340,8 +1340,12 @@ test.describe("POS mobile order flow", () => {
await selectPrimaryProduct(page, 53);
await waitForMobileNextStepCooldown(page);
const orderId = Number(new URL(page.url()).searchParams.get("id"));
await page.getByTestId("pos-mobile-next-step").click();
await expect.poll(() => fixture.requestCounters.orderItemsPost, { timeout: 10_000 }).toBe(1);
await expect.poll(() => fixture.requestCounters.markAsCompleted, { timeout: 10_000 }).toBe(1);
expect(fixture.markCompletedOrderIds).toContain(orderId);
await waitForStepReset(page);
});
@@ -3687,6 +3691,8 @@ test.describe("POS mobile order flow", () => {
await expect.poll(() => fixture.requestCounters.bookingSetOrderId, { timeout: 10_000 }).toBe(1);
await expect.poll(() => fixture.requestCounters.bookingComplete, { timeout: 10_000 }).toBe(1);
await expect.poll(() => fixture.requestCounters.markAsCompleted, { timeout: 10_000 }).toBe(1);
expect(fixture.markCompletedOrderIds).toContain(orderId);
await expect.poll(() => fixture.bookingsById[DEFAULT_BOOKING_ID]?.status ?? "").toBe("completed");
await waitForStepReset(page);
});
@@ -3757,6 +3763,8 @@ test.describe("POS mobile order flow", () => {
await page.getByTestId("pos-mobile-booking-complete-with-certificate").click();
await expect.poll(() => fixture.requestCounters.bookingComplete, { timeout: 10_000 }).toBe(1);
await expect.poll(() => fixture.requestCounters.markAsCompleted, { timeout: 10_000 }).toBe(1);
expect(fixture.markCompletedOrderIds).toContain(orderId);
await expect
.poll(() => fixture.requestLog.bookingCompletions[0]?.safety_seal ?? null, { timeout: 10_000 })
.toBe(9090);
+31 -5
View File
@@ -15,6 +15,7 @@ const json = (body, status = 200) => ({
body: JSON.stringify(body),
});
const GUARD_TIMEOUT_MS = 30_000;
const isDesktopProject = (projectName) => projectName.includes("desktop");
const unavailableRuntime = {
generated_at: "2026-05-19T09:00:00.000Z",
@@ -542,7 +543,10 @@ test("release channel choices show git commit and release time when available",
});
test("ready sidebar release channel switches are confirmed through the control runtime", async ({ page }, testInfo) => {
test.skip(testInfo.project.name.includes("mobile"), "The sidebar release selector is hidden in the mobile layout.");
test.skip(
!isDesktopProject(testInfo.project.name),
"The sidebar release selector is only visible in the desktop layout."
);
const stableRuntime = runtimeWithFailingBetaSwitch();
const betaRuntime = runtimeWithReadyBetaSelected();
@@ -601,7 +605,10 @@ test("ready sidebar release channel switches are confirmed through the control r
});
test("sidebar can switch back to the local frontend runtime", async ({ page }, testInfo) => {
test.skip(testInfo.project.name.includes("mobile"), "The sidebar release selector is hidden in the mobile layout.");
test.skip(
!isDesktopProject(testInfo.project.name),
"The sidebar release selector is only visible in the desktop layout."
);
const runtime = runtimeWithFailingBetaSwitch();
await boot(page, runtime);
@@ -626,12 +633,31 @@ test("sidebar can switch back to the local frontend runtime", async ({ page }, t
await page.evaluate(() => window.localStorage.setItem("release_channel_selected_slug", "beta"));
await useLocal.click();
await expect.poll(() => page.evaluate(() => window.localStorage.getItem("release_source_override"))).toBe("local");
await expect.poll(() => page.evaluate(() => window.localStorage.getItem("release_channel_selected_slug"))).toBeNull();
await expect
.poll(async () => {
try {
return await page.evaluate(() => window.localStorage.getItem("release_source_override"));
} catch (error) {
return null;
}
})
.toBe("local");
await expect
.poll(async () => {
try {
return await page.evaluate(() => window.localStorage.getItem("release_channel_selected_slug"));
} catch (error) {
return "navigating";
}
})
.toBeNull();
});
test("failed sidebar release channel switches keep the previous channel active", async ({ page }, testInfo) => {
test.skip(testInfo.project.name.includes("mobile"), "The sidebar release selector is hidden in the mobile layout.");
test.skip(
!isDesktopProject(testInfo.project.name),
"The sidebar release selector is only visible in the desktop layout."
);
const runtime = runtimeWithFailingBetaSwitch();
await boot(page, runtime);
+134 -4
View File
@@ -930,13 +930,13 @@ function completeIsolatedDataServices(state, serviceSet) {
return serviceSet;
}
async function boot(page, state = createReleaseState()) {
async function boot(page, state = createReleaseState(), options = {}) {
await page.addInitScript(() => {
window.localStorage.setItem("locale", "en");
});
await mockApi(page, {
authenticated: true,
permissions,
permissions: options.permissions || permissions,
sessionData: {
runtime_config: {
release: {
@@ -992,6 +992,8 @@ async function installReleaseMocks(page, state) {
if (pathname.endsWith("/superuser/releases/assignment-subjects") && method === "GET") {
const query = (url.searchParams.get("search") || "").toLowerCase();
state.assignmentSubjectRequests = state.assignmentSubjectRequests || [];
state.assignmentSubjectRequests.push(query);
const limit = Math.max(1, Math.min(10, Number(url.searchParams.get("limit") || 5)));
const subjects = [
{
@@ -2215,6 +2217,49 @@ test("overview filters channel health and issues by selected channel and app", a
await expect(issuePanel).not.toContainText("Composer install failed");
});
test("view-only release managers cannot search assignment subjects", async ({ page }) => {
const state = await boot(page, createReleaseState(), {
permissions: ["superuser_release_manager_view"],
});
await page.goto("/superuser/configuration/releases/assignments", { waitUntil: "domcontentloaded" });
await expectReleaseManagerReady(page);
await expect(page.getByTestId("release-section-assignments")).toBeVisible();
await expect(page.getByTestId("release-assignment-form")).toHaveCount(0);
await expect(page.getByTestId("release-assignment-channel-suggestions")).toHaveCount(0);
await expect(page.getByTestId("release-assignment-subject-search")).toHaveCount(0);
expect(state.assignmentSubjectRequests || []).toEqual([]);
});
test("view-only release managers see redacted deployment failure diagnostics", async ({ page }) => {
const state = createReleaseState();
state.deployments[0].error_message =
"Clone failed for https://deploy:ghp_SECRET_TOKEN_123456789@github.internal/acme/api.git DB_PASSWORD=s3cr3t internal-host=10.0.4.12";
state.deployments[0].failure_summary.root_cause = state.deployments[0].error_message;
state.deployments[0].failure_summary.next_action =
"Rotate ghp_SECRET_TOKEN_123456789 and inspect /var/lib/coolify/apps/api/.env before retrying.";
await boot(page, state, {
permissions: ["superuser_release_manager_view"],
});
await page.goto("/superuser/configuration/releases/overview?channel=canary&app=api&branch=master", {
waitUntil: "domcontentloaded",
});
await expectReleaseManagerReady(page);
await expect(page.getByTestId("release-manager-page")).not.toContainText("ghp_SECRET_TOKEN_123456789");
await expect(page.getByTestId("release-manager-page")).not.toContainText("DB_PASSWORD=s3cr3t");
await expect(page.getByTestId("release-manager-page")).not.toContainText("10.0.4.12");
await expect(page.getByTestId("release-manager-page")).not.toContainText("github.internal");
await selectReleaseTab(page, "Deployments");
await expect(page.getByTestId("release-deployment-failure-2")).toContainText("[redacted]");
await expect(page.getByTestId("release-deployment-failure-2")).toContainText("DB_PASSWORD=[redacted]");
await expect(page.getByTestId("release-deployment-failure-2")).toContainText("[redacted-private-ip]");
await expect(page.getByTestId("release-deployment-failure-2")).toContainText("[redacted-internal-host]");
await expect(page.getByTestId("release-deployment-failure-2")).toContainText("[redacted-env-path]");
});
test("superusers manage release settings, assignments, integrations, and sync operations", async ({ page }) => {
const state = await boot(page);
await page.goto("/superuser/configuration/releases/overview?channel=canary&app=api&branch=canary", {
@@ -2623,8 +2668,8 @@ test("isolated stack mode creates fresh Coolify app and data targets without att
const isolatedTargetCount = state.targets.filter((target) => target.deploy_context?.isolated_stack).length;
await page.getByTestId("release-bundle-deploy-submit").click();
await expect(page.getByTestId("release-created-bundle")).toContainText("Bundle deployed");
expect(state.serviceSets.filter((set) => set.mode === "isolated_stack")).toHaveLength(isolatedServiceSetCount);
expect(state.targets.filter((target) => target.deploy_context?.isolated_stack)).toHaveLength(isolatedTargetCount);
expect(state.serviceSets.filter((set) => set.mode === "isolated_stack")).toHaveLength(isolatedServiceSetCount + 1);
expect(state.targets.filter((target) => target.deploy_context?.isolated_stack)).toHaveLength(isolatedTargetCount + 2);
page.once("dialog", (dialog) => {
expect(dialog.message()).toContain("soft-deleted");
@@ -2636,6 +2681,91 @@ test("isolated stack mode creates fresh Coolify app and data targets without att
expect(state.serviceSets.find((set) => Number(set.id) === Number(isolatedSet.id))).toBeUndefined();
});
test("isolated stack mode creates a fresh stack even when an active stack has the requested name", async ({ page }) => {
const state = await boot(page);
state.targets[0].deploy_context = {
coolify_project_uuid: "project-internal",
};
state.targets.push({
id: state.nextTargetId++,
channel_id: 2,
channel_slug: "canary",
app: "api",
repository: "truckwash/backend-php",
branch: "canary",
coolify_instance_id: 3,
coolify_instance_label: "Production Coolify",
coolify_service_uuid: "api-canary-service",
health_url: "https://api-canary.example.test/ping",
auto_deploy: true,
deploy_context: {
coolify_project_uuid: "project-internal",
coolify_build_pack: "dockerfile",
},
});
const activeStack = {
id: state.nextServiceSetId++,
channel_id: 2,
channel_slug: "canary",
name: "Internal safe stack",
slug: "internal-safe-stack",
mode: "isolated_stack",
status: "isolated_stack",
active: true,
targets: {
frontend: {
id: 201,
channel_id: 2,
channel_slug: "canary",
app: "frontend",
repository: "truckwash/front-end-vue",
branch: DEFAULT_RELEASE_BRANCH,
deploy_context: { isolated_stack: true, production_data_attached: false },
},
api: {
id: 202,
channel_id: 2,
channel_slug: "canary",
app: "api",
repository: "truckwash/backend-php",
branch: "canary",
deploy_context: { isolated_stack: true, production_data_attached: false },
},
},
data_services: { database: null, redis: null, minio: null },
attached_bundles: [],
};
completeIsolatedDataServices(state, activeStack);
state.serviceSets.unshift(activeStack);
await page.goto("/superuser/configuration/releases/overview?channel=canary&app=all&branch=canary", {
waitUntil: "domcontentloaded",
});
await selectReleaseTab(page, "Deployments");
await expandActiveReleaseCategory(page);
const bundleFlow = page.getByTestId("release-bundle-flow");
await bundleFlow.getByTestId("release-bundle-channel").selectOption("2");
await bundleFlow.getByTestId("release-dataset-mode-isolated_stack").click();
await bundleFlow.getByPlaceholder("canary fresh data").fill("Internal safe stack");
await bundleFlow.getByRole("button", { name: "Next" }).click();
await bundleFlow.getByRole("button", { name: "Next" }).click();
await page.getByTestId("release-bundle-deploy-submit").click();
await expect(page.getByTestId("release-created-bundle")).toContainText("Bundle deployed");
const deployedServiceSetId = state.bundlePayloads[0].service_set_id;
expect(deployedServiceSetId).not.toBe(activeStack.id);
expect(state.serviceSets.find((set) => Number(set.id) === Number(activeStack.id))?.active).toBe(true);
expect(
state.serviceSets.filter((set) => set.mode === "isolated_stack" && set.slug === "internal-safe-stack")
).toHaveLength(2);
expect(
state.targets.filter((target) => target.deploy_context?.isolated_stack && target.id !== 201 && target.id !== 202)
).toHaveLength(2);
});
test("existing isolated stacks can add missing data services safely", async ({ page }) => {
const state = createReleaseState();
state.serviceSets.unshift({
+29 -8
View File
@@ -176,9 +176,12 @@ test.describe("Self-serve wash", () => {
test("@smoke landing route sorts departments, loads vehicle selection, and restores direct wash progress", async ({
page,
}) => {
await mockApi(page, {
const api = await mockApi(page, {
authenticated: true,
permissions: ["user"],
sessionData: {
customer_number: 12345679,
},
selfServe: true,
});
await primeSession(page, {
@@ -188,21 +191,39 @@ test.describe("Self-serve wash", () => {
await page.goto("/user/wash");
await expect(page.getByTestId("self-serve-wash-home")).toBeVisible();
await expect(page.getByTestId("self-serve-home-nearest-name")).toContainText("Roskilde");
await expect(page.getByRole("link", { name: "Start vask" })).toBeVisible({ timeout: 15_000 });
await expect(page.getByText("Roskilde").first()).toBeVisible();
const orderedDepartmentIds = await page
.locator('[data-testid^="self-serve-home-department-"]')
.evaluateAll((elements) => elements.map((element) => element.getAttribute("data-testid")));
expect(orderedDepartmentIds).toEqual(["self-serve-home-department-2", "self-serve-home-department-3"]);
const orderedDepartmentNames = await page
.locator(".card-header-title.has-text-grey")
.evaluateAll((elements) => elements.map((element) => element.textContent?.trim()));
expect(orderedDepartmentNames).toEqual(["Odense", "Aarhus"]);
await page.getByTestId("self-serve-home-start").click();
await page.getByRole("link", { name: "Start vask" }).click();
await expect(page).toHaveURL(/\/user\/wash\/start$/);
await expect(page.getByTestId("self-serve-department-name")).toContainText("Roskilde");
await fillRegistration(page, "ab12345");
await selectVehicleType(page, 2);
api.selfServe.inProgressByLaneId[7] = {
lane_id: 7,
in_progress: true,
session: {
id: 601,
lane_id: 7,
department_id: 6,
reg: "ZZ00000",
customer_number: 12345679,
vehicle_type_id: 2,
machine_relay_enabled: false,
wash_started_at: "2026-04-28 11:00:00",
status: "IN_PROGRESS",
},
customer: { customer_number: 12345679 },
vehicle: { reg: "ZZ00000", type: 2 },
};
await seedSavedProgress(page, {
washInProgress: true,
washLaneId: 7,
+24 -2
View File
@@ -4916,8 +4916,10 @@ async function handleEdgeGatewayRoute({ route, request, parsedUrl, pathname, met
}
if (target === "secret" || target === "all") {
const validSecret =
String(body.broker_shared_secret || "") === String(edgeGatewayFixture.config.broker_shared_secret || "");
const submittedSecret = Object.prototype.hasOwnProperty.call(body, "broker_shared_secret")
? String(body.broker_shared_secret || "")
: String(edgeGatewayFixture.config.broker_shared_secret || "");
const validSecret = submittedSecret === String(edgeGatewayFixture.config.broker_shared_secret || "");
payload.broker_shared_secret = {
ok: validSecret,
status: validSecret ? "validated" : "secret_rejected",
@@ -6506,6 +6508,26 @@ export async function mockApi(page, options = {}) {
return;
}
if (pathname.endsWith("/modules/self-serve/lane/wash/my-active-wash") && method === "GET") {
const customerNumber = Number(options.sessionData?.customer_number || 0);
const details = Object.values(selfServe.inProgressByLaneId || {}).find((entry) => {
const entryCustomerNumber = Number(entry?.session?.customer_number ?? entry?.customer?.customer_number ?? 0);
return Boolean(entry?.in_progress) && customerNumber > 0 && entryCustomerNumber === customerNumber;
});
await route.fulfill(
json({
data: details || {
lane_id: null,
in_progress: false,
session: null,
customer: null,
vehicle: null,
},
})
);
return;
}
if (pathname.endsWith("/modules/self-serve/lane/wash/in-progress") && method === "GET") {
const laneId = parsedUrl.searchParams.get("lane_id");
const details = selfServe.inProgressByLaneId?.[String(laneId || "")] || null;
@@ -0,0 +1,16 @@
import { describe, expect, it } from "vitest";
import { isSafeAttachmentDownloadLink, safeAttachmentDownloadLink } from "@/services/attachmentDownloadLinks.js";
describe("attachmentDownloadLinks", () => {
it("allows relative and trusted HTTPS attachment URLs", () => {
expect(isSafeAttachmentDownloadLink("/department/selfserve/tasks/attachments/download?token=abc")).toBe(true);
expect(isSafeAttachmentDownloadLink("https://api-v2.truckwash.io/master/api/attachments/123")).toBe(true);
});
it("rejects scriptable, protocol-relative, and untrusted attachment URLs", () => {
expect(safeAttachmentDownloadLink("javascript:alert(document.domain)")).toBe("");
expect(safeAttachmentDownloadLink("data:text/html,<script>alert(document.domain)</script>")).toBe("");
expect(safeAttachmentDownloadLink("//evil.example/attachment.pdf")).toBe("");
expect(safeAttachmentDownloadLink("https://evil.example/attachment.pdf")).toBe("");
});
});
+22
View File
@@ -11,6 +11,7 @@ vi.mock("axios", () => ({
}));
import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue";
import { __resetReleaseTimelineForTests, configureReleaseRuntime } from "@/services/releaseTimeline.js";
import {
__configureRequestQueueForTests,
__resetRequestQueueForTests,
@@ -43,6 +44,7 @@ describe("authenticatedRequest", () => {
beforeEach(() => {
axiosMock.mockReset();
__resetRequestQueueForTests();
__resetReleaseTimelineForTests();
__configureRequestQueueForTests({
maxConcurrentGet: 1,
maxConcurrentOther: 1,
@@ -102,6 +104,26 @@ describe("authenticatedRequest", () => {
expect(catchCallable).toHaveBeenCalledWith(error);
});
it("does not send bearer credentials to untrusted absolute request URLs", async () => {
const response = { status: 200, data: { ok: true } };
axiosMock.mockResolvedValueOnce(response);
localStorage.setItem("is_subuser", "true");
localStorage.setItem("selected_customer_number", "1234");
configureReleaseRuntime({ api_base_url: "https://attacker.example/api" });
await authenticatedRequest("https://attacker.example/api/orders", "GET");
expect(axiosMock).toHaveBeenCalledWith(
expect.objectContaining({
url: "https://attacker.example/api/orders",
headers: expect.not.objectContaining({
Authorization: "Bearer token",
"X-Customer-Number": 1234,
}),
})
);
});
it("queues requests and executes them sequentially", async () => {
const first = createDeferred();
const second = createDeferred();
+47 -1
View File
@@ -10,7 +10,12 @@ import {
__resetAxiosRequestQueueInstallerForTests,
installAxiosRequestQueue,
} from "@/services/installAxiosRequestQueue.js";
import { __resetReleaseTimelineForTests, configureReleaseRuntime } from "@/services/releaseTimeline.js";
import {
__resetReleaseTimelineForTests,
__setReleaseTimelineTransportForTests,
configureReleaseRuntime,
flushReleaseTimelineEvents,
} from "@/services/releaseTimeline.js";
const flushMicrotasks = async () => {
await Promise.resolve();
@@ -100,6 +105,47 @@ describe("axios request queue interceptor", () => {
expect(requestQueueState.batchFailed).toBe(0);
});
it("omits failed axios request bodies from timeline events when capture is disabled", async () => {
const sentBodies = [];
__setReleaseTimelineTransportForTests(async (body) => {
sentBodies.push(body);
return { accepted: body.events.length };
});
await expect(
axios({
url: `${API_URL}/auth/login`,
method: "POST",
data: {
customer_number: "cust-123",
password: "PlaintextP@ssw0rd!",
g_recaptcha_response: "recaptcha-response-secret",
},
adapter: async (config) => {
expect(config.data).toContain('"password":"PlaintextP@ssw0rd!"');
throw {
response: {
status: 401,
statusText: "Unauthorized",
data: { token: "response-token" },
headers: {},
},
message: "Request failed",
};
},
})
).rejects.toBeTruthy();
await flushReleaseTimelineEvents();
expect(sentBodies).toHaveLength(1);
expect(sentBodies[0].events[0].type).toBe("request_failed");
expect(sentBodies[0].events[0].payload.request.data).toBe("[capture-disabled]");
expect(sentBodies[0].events[0].payload.response.data).toBe("[capture-disabled]");
expect(JSON.stringify(sentBodies[0])).not.toContain("PlaintextP@ssw0rd!");
expect(JSON.stringify(sentBodies[0])).not.toContain("recaptcha-response-secret");
});
it("rewrites default API requests to the active release API before dispatch", async () => {
configureReleaseRuntime({
channel: { slug: "canary" },
@@ -24,8 +24,17 @@ describe("collected invoice stripe queue wiring", () => {
it("renders progress and retry states for queued transfers", () => {
expect(stripeManageSource).toContain("isStripeTransferQueuedOrProcessing");
expect(stripeManageSource).toContain("isStripeTransferFailed");
expect(stripeManageSource).toContain(
"const canBookStripeInvoice = computed(() => !isStripeTransferBusy.value && !isStripeTransferCompleted.value);"
);
expect(stripeManageSource).toContain('data-testid="collected-stripe-progress"');
expect(stripeManageSource).toContain('data-testid="collected-stripe-retry"');
expect(stripeManageSource).toContain('data-testid="collected-stripe-book-invoice"');
});
it("prevents duplicate queue submissions after a completed Stripe transfer", () => {
expect(stripeManageSource).toContain("if (!canBookStripeInvoice.value)");
expect(stripeManageSource).toContain(':disabled="!canBookStripeInvoice"');
expect(stripeManageSource).toContain("isStripeTransferCompleted.value");
});
});
@@ -8,6 +8,7 @@ const getDepartmentMock = vi.hoisted(() => vi.fn());
const getLaneStatusTogglesMock = vi.hoisted(() => vi.fn());
const setMachineStatusEnabledMock = vi.hoisted(() => vi.fn());
const setLaneSelfServeEnabledMock = vi.hoisted(() => vi.fn());
const hasPermissionMock = vi.hoisted(() => vi.fn());
const laneHelpers = vi.hoisted(() => {
const requiredFields = [
"relay_in_id",
@@ -67,6 +68,7 @@ vi.mock("@/components/session/token/SessionUser.vue", () => ({
},
},
},
hasPermission: hasPermissionMock,
functions: {
date: {
isToday: () => false,
@@ -141,6 +143,10 @@ describe("Department overview period sync behavior", () => {
getLaneStatusTogglesMock.mockReset();
setMachineStatusEnabledMock.mockReset();
setLaneSelfServeEnabledMock.mockReset();
hasPermissionMock.mockReset();
hasPermissionMock.mockImplementation((permission) =>
["modules_selfserve_lane_status_set", "edit_department_lane"].includes(permission)
);
getDepartmentMock.mockResolvedValue({ id: 20, name: "Dept 20" });
getLaneStatusTogglesMock.mockResolvedValue({ data: { data: [] } });
@@ -367,16 +373,27 @@ describe("Department overview period sync behavior", () => {
},
},
});
setLaneSelfServeEnabledMock.mockResolvedValueOnce({
data: {
setLaneSelfServeEnabledMock
.mockResolvedValueOnce({
data: {
lane: {
...laneWithCompleteSetup,
selfserve_enabled: false,
data: {
lane: {
...laneWithCompleteSetup,
selfserve_enabled: false,
},
},
},
},
});
})
.mockResolvedValueOnce({
data: {
data: {
lane: {
...laneWithMissingSetup,
selfserve_enabled: false,
},
},
},
});
const wrapper = mount(DepartmentDailyReportSmall, {
props: {
@@ -405,7 +422,7 @@ describe("Department overview period sync behavior", () => {
expect(wrapper.find('[data-testid="overview-department-20-lane-21-dognvask-info"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="overview-department-20-lane-22-dognvask-warning"]').exists()).toBe(true);
expect(wrapper.find("#dognvask-20-21").element.checked).toBe(true);
expect(wrapper.find("#dognvask-20-22").element.checked).toBe(false);
expect(wrapper.find("#dognvask-20-22").element.checked).toBe(true);
await wrapper.find("#machine-status-20-22").setValue(true);
await flushAll();
@@ -416,9 +433,129 @@ describe("Department overview period sync behavior", () => {
expect(setLaneSelfServeEnabledMock).toHaveBeenCalledWith(21, false);
expect(wrapper.find("#dognvask-20-21").element.checked).toBe(false);
await wrapper.find("#dognvask-20-22").setValue(false);
await flushAll();
expect(setLaneSelfServeEnabledMock).toHaveBeenCalledWith(22, false);
expect(setLaneSelfServeEnabledMock).toHaveBeenCalledTimes(2);
expect(wrapper.find("#dognvask-20-22").element.checked).toBe(false);
await wrapper.find("#dognvask-20-22").setValue(true);
await flushAll();
expect(setLaneSelfServeEnabledMock).toHaveBeenCalledTimes(1);
expect(setLaneSelfServeEnabledMock).toHaveBeenCalledTimes(2);
expect(wrapper.find("#dognvask-20-22").element.checked).toBe(false);
});
it("disables lane mutation toggles for users without lane mutation permissions", async () => {
hasPermissionMock.mockImplementation((permission) => permission === "list_department_daily_reports");
getLaneStatusTogglesMock.mockResolvedValueOnce({
data: {
data: [
{
id: 21,
department: 20,
name: "T1",
status: "AVAILABLE",
machine_status_enabled: true,
selfserve_enabled: true,
relay_in_id: "in-1",
relay_out_id: "out-1",
relay_machine_id: "machine-1",
relay_machine_program_picker_id: "picker-1",
relay_machine_cleaner_id: "cleaner-1",
dynamic_image_id: 1,
machine_type_id: 1,
dognvask_configured: true,
dognvask_configuration_warnings: [],
},
],
},
});
const wrapper = mount(DepartmentDailyReportSmall, {
props: {
department_id: 20,
},
global: {
mocks: {
$t: (value) => value,
},
stubs: {
BLoading: true,
},
},
});
await flushAll();
await flushAll();
const machineToggle = wrapper.find("#machine-status-20-21");
const dognvaskToggle = wrapper.find("#dognvask-20-21");
expect(machineToggle.element.disabled).toBe(true);
expect(dognvaskToggle.element.disabled).toBe(true);
await machineToggle.trigger("change");
await dognvaskToggle.trigger("change");
await flushAll();
expect(setMachineStatusEnabledMock).not.toHaveBeenCalled();
expect(setLaneSelfServeEnabledMock).not.toHaveBeenCalled();
});
it("does not allow wash-lane list permission to mutate lane toggles", async () => {
hasPermissionMock.mockImplementation((permission) => permission === "list_department_wash_lanes");
getLaneStatusTogglesMock.mockResolvedValueOnce({
data: {
data: [
{
id: 21,
department: 20,
name: "T1",
status: "AVAILABLE",
machine_status_enabled: true,
selfserve_enabled: true,
relay_in_id: "in-1",
relay_out_id: "out-1",
relay_machine_id: "machine-1",
relay_machine_program_picker_id: "picker-1",
relay_machine_cleaner_id: "cleaner-1",
dynamic_image_id: 1,
machine_type_id: 1,
dognvask_configured: true,
dognvask_configuration_warnings: [],
},
],
},
});
const wrapper = mount(DepartmentDailyReportSmall, {
props: {
department_id: 20,
},
global: {
mocks: {
$t: (value) => value,
},
stubs: {
BLoading: true,
},
},
});
await flushAll();
await flushAll();
const machineToggle = wrapper.find("#machine-status-20-21");
const dognvaskToggle = wrapper.find("#dognvask-20-21");
expect(machineToggle.element.disabled).toBe(true);
expect(dognvaskToggle.element.disabled).toBe(true);
await machineToggle.trigger("change");
await dognvaskToggle.trigger("change");
await flushAll();
expect(setMachineStatusEnabledMock).not.toHaveBeenCalled();
expect(setLaneSelfServeEnabledMock).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,38 @@
// @vitest-environment jsdom
import { describe, expect, it } from "vitest";
import {
sanitizeBrokerAuthMode,
validatePublicBrokerUrl,
} from "@/features/edgeGateways/edgeGatewayBrokerConfigSecurity.js";
describe("edge gateway broker config security", () => {
it("rejects attacker-controlled public broker origins and non-TLS schemes", () => {
expect(validatePublicBrokerUrl("wss://evil.example/ws")).toMatchObject({
ok: false,
message: "Public broker URL origin is not approved for browser edge gateway sessions.",
});
expect(validatePublicBrokerUrl("http://localhost:4300/ws")).toMatchObject({
ok: false,
message: "Public broker URL must use HTTPS or WSS.",
});
});
it("allows blank, same-origin, or production edge broker TLS public broker URLs", () => {
expect(validatePublicBrokerUrl("")).toEqual({ ok: true, value: "" });
expect(validatePublicBrokerUrl(`wss://${window.location.host}/edge-broker`)).toEqual({
ok: true,
value: `wss://${window.location.host}/edge-broker`,
});
expect(validatePublicBrokerUrl("https://api.truckwash.io:4433/edge-broker")).toEqual({
ok: true,
value: "https://api.truckwash.io:4433/edge-broker",
});
});
it("keeps manager auth mode unchanged and only allows stub in gated test/dev builds", () => {
expect(sanitizeBrokerAuthMode("manager")).toBe("manager");
expect(sanitizeBrokerAuthMode("unexpected")).toBe("manager");
expect(sanitizeBrokerAuthMode("stub")).toBe("stub");
});
});
@@ -0,0 +1,137 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const streamSessionMock = vi.fn();
const shellSessionMock = vi.fn();
vi.mock("@/services/edgeGateways.js", () => ({
createEdgeGatewayStreamSession: (...args) => streamSessionMock(...args),
createEdgeGatewayShellSession: (...args) => shellSessionMock(...args),
unwrapEdgeGatewayResponse: (response, fallback = {}) => response?.data?.data || response?.data || fallback,
}));
class MockWebSocket {
static instances = [];
constructor(url) {
this.url = url;
this.readyState = 0;
this.sent = [];
this.listeners = new Map();
MockWebSocket.instances.push(this);
}
addEventListener(type, handler) {
this.listeners.set(type, handler);
}
send(payload) {
this.sent.push(JSON.parse(payload));
}
close() {
this.readyState = 3;
}
open() {
this.readyState = 1;
this.listeners.get("open")?.({});
}
}
const setBrowserLocation = (origin) => {
globalThis.window = {
location: new URL(origin),
setTimeout,
clearTimeout,
};
};
describe("edge gateway live session websocket security", () => {
beforeEach(() => {
vi.clearAllMocks();
MockWebSocket.instances = [];
globalThis.WebSocket = MockWebSocket;
setBrowserLocation("https://app.example/");
});
it("keeps stream session tokens out of websocket URLs and authenticates after open", async () => {
streamSessionMock.mockResolvedValue({
data: {
data: {
ws_url: "wss://app.example/gateway-stream?keep=1&token=URL-TOKEN&jwt=URL-JWT",
token: "STREAM-TOKEN-secret-456",
id: "stream-session-1",
},
},
});
const { createGatewayStreamClient } = await import("@/features/edgeGateways/edgeGatewayLiveSessions.js");
await createGatewayStreamClient("gateway-8", ["logs"]);
expect(MockWebSocket.instances).toHaveLength(1);
const socket = MockWebSocket.instances[0];
expect(socket.url).toBe("wss://app.example/gateway-stream?keep=1&gatewayId=gateway-8");
expect(socket.url).not.toContain("STREAM-TOKEN-secret-456");
expect(socket.url).not.toContain("URL-TOKEN");
expect(socket.url).not.toContain("URL-JWT");
socket.open();
expect(socket.sent[0]).toEqual({
type: "AUTH",
gatewayId: "gateway-8",
token: "STREAM-TOKEN-secret-456",
sessionId: "stream-session-1",
kind: "stream",
});
expect(socket.sent[1]).toEqual({ type: "SUBSCRIBE", gatewayId: "gateway-8", scopes: ["logs"] });
});
it("keeps shell session tokens out of websocket URLs and authenticates before input", async () => {
shellSessionMock.mockResolvedValue({
data: {
data: {
ws_url: "wss://app.example/live-shell?logme=1&token=URL-SHELL-TOKEN",
token: "SHELL-TOKEN-secret-123",
session_id: "shell-session-7",
},
},
});
const { createGatewayShellClient } = await import("@/features/edgeGateways/edgeGatewayLiveSessions.js");
const client = await createGatewayShellClient("gateway-7");
const socket = MockWebSocket.instances[0];
expect(socket.url).toBe("wss://app.example/live-shell?logme=1&gatewayId=gateway-7");
expect(socket.url).not.toContain("SHELL-TOKEN-secret-123");
expect(socket.url).not.toContain("URL-SHELL-TOKEN");
socket.open();
client.sendInput("pwd");
expect(socket.sent[0]).toEqual({
type: "AUTH",
gatewayId: "gateway-7",
token: "SHELL-TOKEN-secret-123",
sessionId: "shell-session-7",
kind: "shell",
});
expect(socket.sent[1]).toEqual({ type: "input", data: "pwd" });
});
it("rejects untrusted or insecure websocket endpoints before connecting", async () => {
shellSessionMock.mockResolvedValue({
data: {
data: {
ws_url: "ws://broker.invalid/live-shell",
token: "SHELL-TOKEN-secret-123",
},
},
});
const { createGatewayShellClient } = await import("@/features/edgeGateways/edgeGatewayLiveSessions.js");
await expect(createGatewayShellClient("gateway-7")).rejects.toThrow("Gateway websocket URL must use wss");
expect(MockWebSocket.instances).toHaveLength(0);
});
});
@@ -0,0 +1,108 @@
// @vitest-environment jsdom
import { mount } from "@vue/test-utils";
import { describe, expect, it, vi } from "vitest";
import EdgeGatewayLogsPage from "@/features/edgeGateways/EdgeGatewayLogsPage.vue";
const flushMicrotasks = async () => {
await Promise.resolve();
await Promise.resolve();
};
describe("EdgeGatewayLogsPage context redaction", () => {
it("redacts sensitive timeline context values before modal display and clipboard copy", async () => {
const writeText = vi.fn(() => Promise.resolve());
Object.defineProperty(navigator, "clipboard", {
configurable: true,
value: { writeText },
});
const wrapper = mount(EdgeGatewayLogsPage, {
props: {
timeline: [
{
type: "log",
level: "INFO",
message: "Connected to broker",
created_at: "2026-06-01T10:00:00Z",
context: {
ws_url: "wss://broker.example/ws?token=raw-token&agentToken=raw-agent-token&connection=42",
installer_token: "raw-installer-token",
broker_shared_secret: "raw-broker-secret",
nested: {
callback: "/edge/callback?agent_token=raw-query-token&visible=true",
safe_label: "visible diagnostic value",
},
},
},
],
},
attachTo: document.body,
});
await wrapper.get('[data-testid="gateway-log-entry-0"]').trigger("click");
await wrapper.get('[data-testid="gateway-log-entry-context-view-0"]').trigger("click");
const modalText = wrapper.get('[data-testid="gateway-context-modal-body"]').text();
expect(modalText).toContain("visible diagnostic value");
expect(modalText).toContain("[REDACTED]");
expect(modalText).not.toContain("raw-token");
expect(modalText).not.toContain("raw-agent-token");
expect(modalText).not.toContain("raw-installer-token");
expect(modalText).not.toContain("raw-broker-secret");
expect(modalText).not.toContain("raw-query-token");
await wrapper.get('[data-testid="gateway-context-modal-copy"]').trigger("click");
await flushMicrotasks();
expect(writeText).toHaveBeenCalledTimes(1);
expect(writeText.mock.calls[0][0]).toBe(modalText);
expect(writeText.mock.calls[0][0]).not.toContain("raw-token");
});
it("redacts sensitive relay context values before direct clipboard copy", async () => {
const writeText = vi.fn(() => Promise.resolve());
Object.defineProperty(navigator, "clipboard", {
configurable: true,
value: { writeText },
});
const wrapper = mount(EdgeGatewayLogsPage, {
props: {
relayLogs: [
{
id: 12,
message: "Relay dispatched",
created_at: "2026-06-01T10:01:00Z",
context: {
handler: "broker",
signal: {
request: { on: true },
},
response: { online: true, on: true },
relay_token: "raw-relay-token",
command_payload: {
authorization: "Bearer raw-auth-token",
target: "machine-1",
},
broker_url: "https://broker.example/relay?secret=raw-url-secret&relay=7",
},
},
],
},
attachTo: document.body,
});
await wrapper.get('[data-testid="gateway-relay-entry-0"]').trigger("click");
await wrapper.get('[data-testid="gateway-relay-entry-context-copy-0"]').trigger("click");
await flushMicrotasks();
expect(writeText).toHaveBeenCalledTimes(1);
const copiedText = writeText.mock.calls[0][0];
expect(copiedText).toContain("machine-1");
expect(copiedText).toContain("[REDACTED]");
expect(copiedText).not.toContain("raw-relay-token");
expect(copiedText).not.toContain("raw-auth-token");
expect(copiedText).not.toContain("raw-url-secret");
});
});
+94 -2
View File
@@ -1,3 +1,4 @@
// @vitest-environment jsdom
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
@@ -10,7 +11,13 @@ vi.mock("@/components/session/authenticatedRequest.vue", () => ({
import {
EDGE_GATEWAY_WORKSPACE_CACHE_KEY,
EDGE_GATEWAY_WORKSPACE_CACHE_TTL_MS,
clearEdgeGatewayWorkspaceCache,
getEdgeGateway,
getEdgeGatewayInstallTokenStatus,
listEdgeGateways,
peekCachedEdgeGateway,
peekCachedEdgeGatewayList,
getEdgeGatewayModuleConfig,
setEdgeGatewayModuleConfig,
} from "@/services/edgeGateways.js";
@@ -18,6 +25,9 @@ import {
describe("edge gateway service", () => {
beforeEach(() => {
authenticatedRequestMock.mockReset();
localStorage.clear();
sessionStorage.clear();
clearEdgeGatewayWorkspaceCache();
});
it("loads module config entries and derives a keyed config object", async () => {
@@ -76,11 +86,93 @@ describe("edge gateway service", () => {
expect(authenticatedRequestMock).toHaveBeenCalledWith("/edge-gateways/install-token/9001/status", "GET", {});
});
it("keeps a stable browser cache namespace for workspace snapshots", () => {
it("scopes workspace snapshots to the active session principal", async () => {
window.localStorage.setItem("token", "user-one-token");
authenticatedRequestMock.mockResolvedValueOnce({
data: {
data: { id: 101, label: "User One Gateway" },
},
});
await getEdgeGateway(101);
expect(peekCachedEdgeGateway(101)?.label).toBe("User One Gateway");
window.localStorage.setItem("token", "user-two-token");
expect(peekCachedEdgeGateway(101)).toBeNull();
expect(window.localStorage.getItem(EDGE_GATEWAY_WORKSPACE_CACHE_KEY)).toBeNull();
});
it("uses a stable browser cache namespace for session-scoped workspace snapshots", () => {
const source = readFileSync(join(process.cwd(), "src/services/edgeGateways.js"), "utf8");
expect(EDGE_GATEWAY_WORKSPACE_CACHE_KEY).toBe("truckwash.edgeGatewayWorkspace.cache.v1");
expect(source).toContain("localStorage");
expect(source).toContain("sessionStorage");
expect(source).toContain("EDGE_GATEWAY_WORKSPACE_CACHE_KEY");
});
it("stores workspace cache entries in session storage scoped to the active token", async () => {
localStorage.setItem("token", "operator-a-token");
authenticatedRequestMock.mockResolvedValueOnce({
data: {
data: [
{
id: 101,
department_id: 55,
name: "Privileged gateway",
metadata: { agent_token: "secret-agent-token", health: "ok" },
bindings: [{ relay_id: 7, credential_secret: "binding-secret" }],
},
],
meta: { fleet_usage: { total: 1 }, api_key: "fleet-api-key" },
},
});
await listEdgeGateways({ departmentId: 55, view: "summary" });
expect(localStorage.getItem(EDGE_GATEWAY_WORKSPACE_CACHE_KEY)).toBeNull();
const persisted = JSON.parse(sessionStorage.getItem(EDGE_GATEWAY_WORKSPACE_CACHE_KEY));
expect(persisted.scope).toEqual(expect.any(String));
expect(persisted.scope).not.toContain("operator-a-token");
expect(persisted.lists).toBeTruthy();
expect(JSON.stringify(persisted)).not.toContain("secret-agent-token");
expect(JSON.stringify(persisted)).not.toContain("binding-secret");
expect(JSON.stringify(persisted)).not.toContain("fleet-api-key");
expect(peekCachedEdgeGateway(101)?.metadata?.health).toBe("ok");
localStorage.setItem("token", "operator-b-token");
expect(peekCachedEdgeGatewayList({ departmentId: 55, view: "summary" })).toBeNull();
expect(peekCachedEdgeGateway(101)).toBeNull();
});
it("expires stale workspace cache entries", async () => {
localStorage.setItem("token", "operator-a-token");
authenticatedRequestMock.mockResolvedValueOnce({
data: {
data: [{ id: 202, department_id: 55, name: "Stale gateway" }],
meta: {},
},
});
await listEdgeGateways({ departmentId: 55, view: "summary" });
const persisted = JSON.parse(sessionStorage.getItem(EDGE_GATEWAY_WORKSPACE_CACHE_KEY));
persisted.cachedAt = Date.now() - EDGE_GATEWAY_WORKSPACE_CACHE_TTL_MS - 1;
Object.values(persisted.lists).forEach((entry) => {
entry.cachedAt = persisted.cachedAt;
});
Object.values(persisted.details).forEach((entry) => {
entry.cachedAt = persisted.cachedAt;
});
sessionStorage.setItem(EDGE_GATEWAY_WORKSPACE_CACHE_KEY, JSON.stringify(persisted));
vi.resetModules();
const { peekCachedEdgeGateway: peekGateway, peekCachedEdgeGatewayList: peekList } = await import(
"@/services/edgeGateways.js"
);
expect(peekList({ departmentId: 55, view: "summary" })).toBeNull();
expect(peekGateway(202)).toBeNull();
expect(sessionStorage.getItem(EDGE_GATEWAY_WORKSPACE_CACHE_KEY)).toBeNull();
});
});
@@ -2,7 +2,13 @@ import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { inferEdgeGatewayErrorCode, normalizeEdgeGatewayError } from "@/features/edgeGateways/edgeGatewayErrors.js";
import {
inferEdgeGatewayErrorCode,
normalizeEdgeGatewayError,
normalizeGatewayWebSocketClose,
redactEdgeGatewayDiagnostic,
redactEdgeGatewayUrl,
} from "@/features/edgeGateways/edgeGatewayErrors.js";
const managerSource = readFileSync(join(process.cwd(), "src/features/edgeGateways/EdgeGatewayManager.vue"), "utf8");
const tasksSource = readFileSync(join(process.cwd(), "src/features/edgeGateways/EdgeGatewayTasksPage.vue"), "utf8");
@@ -49,4 +55,47 @@ describe("edge gateway workflow helpers", () => {
expect(normalized.title).toMatch(/legitimationsoplysninger/i);
expect(normalized.message).toContain("Invalid edge gateway token");
});
it("redacts broker diagnostic secrets before rendering technical details", () => {
expect(
redactEdgeGatewayUrl(
"wss://brokerUser:brokerPass@broker.internal/socket?access_token=ACCESSSECRET&signature=SIGSECRET&token=BROKER_TOKEN"
)
).toBe("wss://***:***@broker.internal/socket?access_token=***&signature=***&token=***");
expect(
redactEdgeGatewayDiagnostic(
"authorization=Bearer LASTERRSECRET url=wss://u:p@broker.internal/?jwt=ERRJWT signed_url=https://host/path?signature=DISCSIG&key=DISCKEY"
)
).not.toMatch(/LASTERRSECRET|ERRJWT|DISCSIG|DISCKEY|u:p/);
const normalized = normalizeEdgeGatewayError({
response: {
data: {
data: {
diagnostics: {
broker_url:
"wss://brokerUser:brokerPass@broker.internal/socket?access_token=ACCESSSECRET&signature=SIGSECRET&token=BROKER_TOKEN",
ws_url: "wss://agentUser:agentPass@gw.internal/terminal?token=WS_TOKEN&jwt=JWTSECRET",
broker_presence: {
last_error: "mqtt failed authorization=Bearer LASTERRSECRET url=wss://u:p@broker.internal/?jwt=ERRJWT",
disconnect_reason: "disconnect signed_url=https://host/path?signature=DISCSIG&key=DISCKEY",
},
},
},
},
},
});
const details = normalized.details.join("\n");
expect(details).toContain("Broker URL: wss://***:***@broker.internal/socket?access_token=***");
expect(details).toContain("WebSocket URL: wss://***:***@gw.internal/terminal?token=***&jwt=***");
expect(details).not.toMatch(
/brokerUser|brokerPass|ACCESSSECRET|SIGSECRET|BROKER_TOKEN|agentUser|agentPass|WS_TOKEN|JWTSECRET|LASTERRSECRET|ERRJWT|DISCSIG|DISCKEY|u:p/
);
const close = normalizeGatewayWebSocketClose({
reason: "disconnect signed_url=https://host/path?signature=DISCSIG&key=DISCKEY",
});
expect(close.details.join("\n")).not.toMatch(/DISCSIG|DISCKEY/);
});
});
@@ -131,6 +131,13 @@ describe("edge gateway workspace contract", () => {
it("keeps module configuration separate from the Edge Agents fleet workspace", () => {
expect(edgeGatewaysPageSource).toContain('data-testid="edge-gateway-module-config"');
expect(edgeGatewaysPageSource).toContain("gateway-module-disabled-state");
expect(edgeGatewaysPageSource).toContain("validatePublicBrokerUrl");
expect(edgeGatewaysPageSource).toContain("sanitizeBrokerAuthMode");
expect(edgeGatewaysPageSource).toContain("redactBrokerSharedSecret()");
expect(edgeGatewaysPageSource).toContain("stub (development only)");
expect(edgeGatewaysPageSource).toContain("Leave blank to keep the existing secret");
expect(edgeGatewaysPageSource).not.toContain("broker_shared_secret: response?.data?.config?.broker_shared_secret");
expect(edgeGatewaysPageSource).not.toContain("broker_shared_secret: moduleConfigMap.value.broker_shared_secret ||");
expect(edgeGatewaysPageSource).not.toContain("EdgeGatewayManager");
expect(edgeGatewaysPageSource).not.toContain("EdgeGatewayFleetLanding");
expect(edgeAgentsSource).toContain('data-testid="edge-agents-page"');
@@ -183,6 +183,7 @@ const selfWashCalls = () => requestMock.mock.calls.filter(([url]) => url === "/m
describe("Invoicing period queue-driven refresh", () => {
beforeEach(() => {
window.localStorage.setItem("token", "unit-test-period-cache-token");
requestMock.mockReset();
mockPeriodResponses(emptyPeriodResponse());
routeState.query.activeTab = "period";
@@ -206,6 +207,8 @@ describe("Invoicing period queue-driven refresh", () => {
afterEach(() => {
mountedWrappers.forEach((wrapper) => wrapper.unmount());
mountedWrappers = [];
window.sessionStorage.clear();
window.localStorage.clear();
});
it("refreshes the current paginated page when queue activity reaches a terminal state", async () => {
+110 -13
View File
@@ -419,6 +419,8 @@ describe("MyWashStart", () => {
});
it("wires child updates back into the self-serve runtime", async () => {
vi.useFakeTimers();
const wrapper = mountWithApp(MyWashStart, {
global: {
stubs: stubComponents,
@@ -430,6 +432,7 @@ describe("MyWashStart", () => {
await wrapper.get('[data-testid="emit-registration"]').trigger("click");
await wrapper.get('[data-testid="emit-vehicle-type"]').trigger("click");
await nextTick();
await vi.advanceTimersByTimeAsync(300);
await flushPromises();
expect(mocks.fetchSelfServeDataInternal).toHaveBeenCalledWith(6, 2, 7, "AB12345");
@@ -449,6 +452,34 @@ describe("MyWashStart", () => {
});
});
it("debounces registration, lane, and vehicle type updates into one self-serve fetch", async () => {
vi.useFakeTimers();
const wrapper = mountWithApp(MyWashStart, {
global: {
stubs: stubComponents,
},
});
await flushPromises();
mocks.fetchSelfServeDataInternal.mockClear();
await wrapper.get('[data-testid="emit-registration"]').trigger("click");
await wrapper.get('[data-testid="emit-lane"]').trigger("click");
await wrapper.get('[data-testid="emit-vehicle-type"]').trigger("click");
await nextTick();
await vi.advanceTimersByTimeAsync(299);
await flushPromises();
expect(mocks.fetchSelfServeDataInternal).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(1);
await flushPromises();
expect(mocks.fetchSelfServeDataInternal).toHaveBeenCalledTimes(1);
expect(mocks.fetchSelfServeDataInternal).toHaveBeenCalledWith(6, 2, 7, "AB12345");
});
it("hides machine tasks while manual wash is selected", async () => {
mocks.activeTasks.value = [
{ id: 31, task: "Machine checklist", services: ["MACHINE"] },
@@ -597,6 +628,8 @@ describe("MyWashStart", () => {
});
it("falls back to available lane when restored lane is stale for the selected department", async () => {
vi.useFakeTimers();
mocks.restoredProgressPayload = {
washInProgress: false,
washLaneId: null,
@@ -619,6 +652,8 @@ describe("MyWashStart", () => {
},
});
await flushPromises();
await vi.advanceTimersByTimeAsync(300);
await flushPromises();
expect(mocks.fetchSelfServeDataInternal).toHaveBeenCalledWith(6, 2, 7, "AB12345");
@@ -826,7 +861,7 @@ describe("MyWashStart", () => {
it("retries server active wash restore when the authenticated customer number arrives after mount", async () => {
mocks.sessionCustomerNumber.value = null;
mocks.sessionRequest.mockImplementation(async (path, method, payload) => {
if (path === "/modules/self-serve/lane/wash/in-progress" && method === "GET" && payload?.lane_id === 7) {
if (path === "/modules/self-serve/lane/wash/my-active-wash" && method === "GET") {
return {
data: {
data: {
@@ -882,9 +917,7 @@ describe("MyWashStart", () => {
await new Promise((resolve) => setTimeout(resolve, 0));
await flushPromises();
expect(mocks.sessionRequest).toHaveBeenCalledWith("/modules/self-serve/lane/wash/in-progress", "GET", {
lane_id: 7,
});
expect(mocks.sessionRequest).toHaveBeenCalledWith("/modules/self-serve/lane/wash/my-active-wash", "GET");
expect(mocks.fetchWashSummary).toHaveBeenCalledWith({ session_id: 805, vehicle_type: 2 }, false);
expect(mocks.startElapsedTimer).toHaveBeenCalled();
expect(
@@ -1155,7 +1188,10 @@ describe("MyWashStart", () => {
};
mocks.guestDepartments.value = [mocks.nearestDepartment.value, odense];
mocks.sessionRequest.mockImplementation(async (path, method, payload) => {
if (path === "/modules/self-serve/lane/wash/in-progress" && method === "GET" && payload?.lane_id === 9) {
if (
(path === "/modules/self-serve/lane/wash/my-active-wash" && method === "GET") ||
(path === "/modules/self-serve/lane/wash/in-progress" && method === "GET" && payload?.lane_id === 9)
) {
return {
data: {
data: {
@@ -1199,12 +1235,10 @@ describe("MyWashStart", () => {
await new Promise((resolve) => setTimeout(resolve, 0));
await flushPromises();
expect(mocks.sessionRequest).toHaveBeenCalledWith("/modules/self-serve/lane/wash/in-progress", "GET", {
expect(mocks.sessionRequest).toHaveBeenCalledWith("/modules/self-serve/lane/wash/my-active-wash", "GET");
expect(mocks.sessionRequest).not.toHaveBeenCalledWith("/modules/self-serve/lane/wash/in-progress", "GET", {
lane_id: 7,
});
expect(mocks.sessionRequest).toHaveBeenCalledWith("/modules/self-serve/lane/wash/in-progress", "GET", {
lane_id: 9,
});
expect(mocks.fetchWashSummary).toHaveBeenCalledWith({ session_id: 704, vehicle_type: 2 }, false);
expect(mocks.startElapsedTimer).toHaveBeenCalled();
expect(mocks.saveProgress).toHaveBeenCalledWith("serverActiveWash");
@@ -1226,7 +1260,7 @@ describe("MyWashStart", () => {
})
);
mocks.sessionRequest.mockImplementation(async (path, method, payload) => {
if (path === "/modules/self-serve/lane/wash/in-progress" && method === "GET" && payload?.lane_id === 7) {
if (path === "/modules/self-serve/lane/wash/my-active-wash" && method === "GET") {
return {
data: {
data: {
@@ -1270,9 +1304,7 @@ describe("MyWashStart", () => {
await new Promise((resolve) => setTimeout(resolve, 0));
await flushPromises();
expect(mocks.sessionRequest).toHaveBeenCalledWith("/modules/self-serve/lane/wash/in-progress", "GET", {
lane_id: 7,
});
expect(mocks.sessionRequest).toHaveBeenCalledWith("/modules/self-serve/lane/wash/my-active-wash", "GET");
expect(mocks.fetchWashSummary).not.toHaveBeenCalled();
expect(mocks.startElapsedTimer).not.toHaveBeenCalled();
expect(mocks.saveProgress).not.toHaveBeenCalledWith("serverActiveWash");
@@ -1281,6 +1313,71 @@ describe("MyWashStart", () => {
);
});
it("clears restored in-progress state when active wash refresh cannot verify authenticated ownership", async () => {
mocks.restoredProgressPayload = {
washInProgress: true,
washLaneId: 7,
washStartTime: Date.now() - 30_000,
licensePlateInput: "AB12345",
vehicleTypeSelect: 2,
radioWashType: "Manual",
radioLaneOption: 7,
customerNumberInput: 12345679,
isForcingNearestDepartment: false,
forceNearestDepartmentEvaluationId: 0,
answers: {},
completedTasks: {},
currentStep: 4,
};
mocks.sessionRequest.mockImplementation(async (path, method, payload) => {
if (path === "/modules/self-serve/lane/wash/in-progress" && method === "GET" && payload?.lane_id === 7) {
return {
data: {
data: {
lane_id: 7,
in_progress: true,
session: {
id: 909,
reg: "VICTIM42",
customer_number: null,
vehicle_type_id: 2,
machine_relay_enabled: true,
wash_started_at: "2026-04-28 10:15:00",
},
customer: {},
vehicle: { reg: "VICTIM42", type: 2 },
},
},
};
}
return undefined;
});
const wrapper = mountWithApp(MyWashStart, {
global: {
stubs: stubComponents,
},
});
await flushPromises();
await nextTick();
await flushPromises();
expect(mocks.sessionRequest).toHaveBeenCalledWith("/modules/self-serve/lane/wash/in-progress", "GET", {
lane_id: 7,
});
expect(mocks.fetchWashSummary).not.toHaveBeenCalledWith({ session_id: 909, vehicle_type: 2 }, false);
expect(mocks.saveProgress).not.toHaveBeenCalledWith("serverActiveWashRefresh");
expect(mocks.stopElapsedTimer).toHaveBeenCalled();
expect(mocks.clearProgress).toHaveBeenCalled();
expect(wrapper.get('[data-testid="self-serve-bottom-actions"]').attributes("style") || "").toContain(
"display: none"
);
wrapper.unmount();
});
it("clears restored in-progress state when active wash refresh says the lane is no longer in progress", async () => {
mocks.restoredProgressPayload = {
washInProgress: true,
@@ -0,0 +1,55 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("sweetalert2", () => ({
default: {
fire: vi.fn(),
},
}));
vi.mock("@/i18n", () => ({
default: {
global: {
t: (key) => key,
},
},
}));
vi.mock("@/components/session/authenticatedRequest.vue", () => ({
authenticatedRequest: vi.fn(),
unauthenticatedRequest: vi.fn(),
}));
vi.mock("@/components/session/token/SessionUser/Objects/systemUserIds.js", () => ({
getSystemUserIds: () => [],
}));
import { ObjectsGlobal } from "@/components/session/token/SessionUser/Objects/ObjectsGlobal.vue";
describe("ObjectsGlobal select editor escaping", () => {
it("escapes option ids and names before rendering SweetAlert HTML", async () => {
const object = {
columns: {
relay_in_id: {
label: "Relay",
type: "select",
options: vi.fn().mockResolvedValue([
{
id: 'relay-1" autofocus onfocus="alert(1)',
name: '</option></select><iframe src="javascript:parent.localStorage.token"></iframe>',
},
]),
},
},
};
const html = await ObjectsGlobal.generateEditObjectFieldForm(object, "relay_in_id", null);
expect(html).not.toContain("</option></select><iframe");
expect(html).not.toContain("<iframe");
expect(html).not.toContain('value="relay-1" autofocus');
expect(html).toContain("relay-1&quot; autofocus onfocus=&quot;alert(1)");
expect(html).toContain(
"&lt;/option&gt;&lt;/select&gt;&lt;iframe src=&quot;javascript:parent.localStorage.token&quot;&gt;&lt;/iframe&gt;"
);
});
});
@@ -0,0 +1,132 @@
// @vitest-environment jsdom
import { mount } from "@vue/test-utils";
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
downloadAttachment: vi.fn(),
fetchAttachments: vi.fn(),
uploadAttachment: vi.fn(),
showAttachWashCertificateForm: vi.fn(),
canAccessAdmin: vi.fn(() => true),
canAccessSuperUser: vi.fn(() => false),
hasPermission: vi.fn(() => false),
}));
vi.mock("vue-i18n", () => ({
useI18n: () => ({
t: (key) => key,
}),
}));
vi.mock("sweetalert2", () => ({
default: {
fire: vi.fn(),
},
}));
vi.mock("@/components/session/token/SessionUser.vue", () => ({
SessionUser: {
canAccessAdmin: mocks.canAccessAdmin,
canAccessSuperUser: mocks.canAccessSuperUser,
hasPermission: mocks.hasPermission,
objects: {
orders: {
functions: {
downloadAttachment: mocks.downloadAttachment,
fetchAttachments: mocks.fetchAttachments,
uploadAttachment: mocks.uploadAttachment,
showAttachWashCertificateForm: mocks.showAttachWashCertificateForm,
},
},
},
},
}));
import OrderAttachmentsActionButton from "@/components/displays/department/pos/orders/OrderAttachmentsActionButton.vue";
const flushPromises = async () => {
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
};
const ActionSettingsWheelItemStub = {
props: ["label", "clickAction", "disabled"],
template:
'<button type="button" class="action-settings-wheel-item-stub" :disabled="disabled" @click="clickAction">{{ label }}</button>',
};
const ActionSettingsWheelItemLabelStub = {
props: ["label"],
template: '<div class="action-settings-wheel-item-label-stub">{{ label }}</div>',
};
const mountButton = () =>
mount(OrderAttachmentsActionButton, {
props: {
order: {
id: 123,
attachments: [
{
id: 456,
content: {
document: "invoice.pdf",
},
},
],
},
refreshFunction: vi.fn(),
},
global: {
stubs: {
ActionSettingsWheelItem: ActionSettingsWheelItemStub,
ActionSettingsWheelItemLabel: ActionSettingsWheelItemLabelStub,
},
},
});
describe("OrderAttachmentsActionButton", () => {
beforeEach(() => {
vi.restoreAllMocks();
mocks.downloadAttachment.mockResolvedValue("https://attachments.example/download/456");
mocks.fetchAttachments.mockResolvedValue([]);
mocks.canAccessAdmin.mockReturnValue(true);
mocks.canAccessSuperUser.mockReturnValue(false);
mocks.hasPermission.mockReturnValue(false);
});
it("sandboxes document previews and coerces downloaded content to a safe PDF blob type", async () => {
const createObjectUrl = vi.fn(() => "blob:safe-preview");
Object.defineProperty(URL, "createObjectURL", {
configurable: true,
writable: true,
value: createObjectUrl,
});
vi.stubGlobal(
"fetch",
vi.fn(async () => ({
ok: true,
blob: async () => new Blob(["<script>localStorage.token</script>"], { type: "text/html" }),
}))
);
const wrapper = mountButton();
await wrapper.find(".dropdown-trigger button").trigger("click");
await wrapper.find(".order-attachments-previewable-item").trigger("mouseenter");
await flushPromises();
expect(mocks.downloadAttachment).toHaveBeenCalledWith(123, 456, false);
expect(fetch).toHaveBeenCalledWith("https://attachments.example/download/456", { method: "GET" });
expect(createObjectUrl).toHaveBeenCalledTimes(1);
expect(createObjectUrl.mock.calls[0][0]).toBeInstanceOf(Blob);
expect(createObjectUrl.mock.calls[0][0].type).toBe("application/pdf");
const iframe = wrapper.find("iframe.order-attachments-preview-panel__document");
expect(iframe.exists()).toBe(true);
expect(iframe.attributes("src")).toBe("blob:safe-preview");
expect(iframe.attributes()).toHaveProperty("sandbox");
});
});
+153 -10
View File
@@ -86,13 +86,31 @@ vi.mock("@/components/session/token/SessionUser.vue", () => ({
single: vi.fn(),
},
set: {
booking_id: vi.fn(),
department_id: vi.fn(),
notes: vi.fn(),
po: vi.fn(),
reference: vi.fn(),
reg_1: vi.fn(),
reg_2: vi.fn(),
},
},
order_bookings: {
meta: {
endpoint: "/order_bookings",
},
get: {
single: vi.fn(),
},
set: {
order_id: vi.fn(),
},
},
products: {
get: {
all: vi.fn(),
single: vi.fn(),
},
},
vehicles: {
meta: {
@@ -130,10 +148,13 @@ import {
customer_id,
customer_name,
department_id,
ensureVehiclePlateBookingsLoaded,
getVehiclePlateBookings,
invoiceCollectionId,
loadOrderItems,
order_id,
order_items,
hydrateSelectedOrderBookingForDesktop,
order_notes,
order_po,
order_safety_seal,
@@ -141,13 +162,68 @@ import {
reg_1,
reg_2,
reg_3,
pendingBookings,
restoreStoredPosOrderId,
searchAndSelectCustomer,
scan_data,
scans,
selectedOrderBookingId,
step,
} from "@/components/shop/POSDepartmentProcess.vue";
describe("POSDepartmentProcess.ensureVehiclePlateBookingsLoaded", () => {
beforeEach(() => {
department_id.value = 531;
SessionUser.request.mockReset();
});
it("uses exact-plate cache even when forced again", async () => {
SessionUser.request.mockImplementation(async (_endpoint, _method, params = {}) => {
const filters = String(params.filters || "");
if (filters.includes("reg_1:EC21234")) {
return {
data: {
data: [
{
id: 9910,
department: 531,
reg_1: "EC21234",
reg_2: "",
order_id: null,
datetime: "2026-03-20T08:00:00.000Z",
},
],
},
};
}
return { data: { data: [] } };
});
await expect(ensureVehiclePlateBookingsLoaded("EC21234", { force: true })).resolves.toEqual([
expect.objectContaining({ id: 9910 }),
]);
await expect(ensureVehiclePlateBookingsLoaded("EC21234", { force: true })).resolves.toEqual([
expect.objectContaining({ id: 9910 }),
]);
expect(getVehiclePlateBookings("EC21234")).toEqual([expect.objectContaining({ id: 9910 })]);
expect(SessionUser.request).toHaveBeenCalledTimes(2);
expect(SessionUser.request).toHaveBeenNthCalledWith(
1,
"/order_bookings",
"GET",
expect.objectContaining({ filters: "department:531,reg_1:EC21234,order_id:is null", limit: 250 })
);
expect(SessionUser.request).toHaveBeenNthCalledWith(
2,
"/order_bookings",
"GET",
expect.objectContaining({ filters: "department:531,reg_2:EC21234,order_id:is null", limit: 250 })
);
});
});
describe("POSDepartmentProcess.loadOrderItems", () => {
beforeEach(() => {
order_id.value = null;
@@ -269,6 +345,65 @@ describe("POSDepartmentProcess.searchAndSelectCustomer", () => {
});
});
describe("POSDepartmentProcess.hydrateSelectedOrderBookingForDesktop", () => {
beforeEach(() => {
order_id.value = 51207;
customer_id.value = 12345679;
customer_name.value = "";
department_id.value = 2;
reference.value = "";
order_notes.value = "";
order_po.value = "";
reg_1.value = "";
reg_2.value = "";
order_items.value = [];
selectedOrderBookingId.value = 7001;
pendingBookings.value = [];
mocks.getOrderItems.mockReset();
mocks.createOrderItem.mockReset();
SessionUser.objects.products.get.single.mockReset();
SessionUser.objects.products.get.all.mockReset();
SessionUser.objects.orders.set.reference.mockReset();
SessionUser.objects.orders.set.reg_1.mockReset();
SessionUser.objects.orders.set.reg_2.mockReset();
SessionUser.objects.orders.set.notes.mockReset();
SessionUser.objects.orders.set.po.mockReset();
SessionUser.objects.order_bookings.get.single.mockReset();
});
it("hydrates booking items without forwarding booking-controlled prices", async () => {
pendingBookings.value = [
{
id: 7001,
customer_number: 12345679,
reference: "BOOK-7001",
reg_1: "AB12345",
reg_2: "CD12345",
notes: "Booking notes",
po: "PO-7001",
items: [
{ id: 10, price: 1, quantity: 1 },
{ id: 20, price: 2, quantity: 3, notes: "Addon note" },
],
},
];
mocks.getOrderItems.mockResolvedValueOnce({ data: { data: [] } }).mockResolvedValueOnce({ data: { data: [] } });
SessionUser.objects.products.get.single.mockResolvedValue({ id: 10, is_wash: true, price: 500 });
mocks.createOrderItem.mockResolvedValueOnce({ data: { data: { id: 9001 } } }).mockResolvedValueOnce({});
await expect(hydrateSelectedOrderBookingForDesktop()).resolves.toBe(true);
expect(mocks.createOrderItem).toHaveBeenNthCalledWith(1, 51207, 10, 1, null, null, 500);
expect(mocks.createOrderItem).toHaveBeenNthCalledWith(2, 51207, 20, 3, 9001, "Addon note", 500);
expect(SessionUser.objects.products.get.single).toHaveBeenCalledWith(10, {
department_id: 2,
customer_id: 12345679,
category_id: null,
final_price: true,
});
});
});
describe("POSDepartmentProcess.clearActivePosOrderContext", () => {
it("clears active order, customer, metadata and stored mobile order id", () => {
localStorage.setItem("pos_order_id", "51211");
@@ -389,7 +524,7 @@ describe("POSDepartmentProcess.restoreStoredPosOrderId", () => {
expect(localStorage.getItem("pos_order_id")).toBeNull();
});
it("moves a stored current order to the selected department when requested", async () => {
it("rejects a stored current order from a different department instead of moving it", async () => {
localStorage.setItem("pos_order_id", "51211");
department_id.value = 2;
SessionUser.objects.orders.get.single.mockResolvedValue({
@@ -399,7 +534,6 @@ describe("POSDepartmentProcess.restoreStoredPosOrderId", () => {
completed_at: null,
});
SessionUser.objects.orders.functions.get_department_id.mockResolvedValue(88);
SessionUser.objects.orders.set.department_id.mockResolvedValue({ data: { success: true } });
await expect(
restoreStoredPosOrderId({
@@ -408,12 +542,11 @@ describe("POSDepartmentProcess.restoreStoredPosOrderId", () => {
departmentId: 2,
syncDepartment: true,
})
).resolves.toBe(51211);
).resolves.toBeNull();
expect(SessionUser.objects.orders.set.department_id).toHaveBeenCalledWith(51211, 2);
expect(order_id.value).toBe(51211);
expect(department_id.value).toBe(2);
expect(localStorage.getItem("pos_order_id")).toBe("51211");
expect(SessionUser.objects.orders.set.department_id).not.toHaveBeenCalled();
expect(order_id.value).toBeNull();
expect(localStorage.getItem("pos_order_id")).toBeNull();
});
});
@@ -429,15 +562,25 @@ describe("POSDepartmentProcess.createOrder department sync", () => {
localStorage.setItem("token", "test-token");
});
it("updates an existing current order to the selected department instead of creating a new order", async () => {
it("rejects an existing current order from a different department instead of updating it", async () => {
order_id.value = 9201;
department_id.value = 7;
SessionUser.objects.orders.functions.get_department_id.mockResolvedValue(3);
SessionUser.objects.orders.set.department_id.mockResolvedValue({ data: { success: true } });
await expect(createOrder({ isMobile: true })).resolves.toBe(false);
expect(SessionUser.objects.orders.set.department_id).not.toHaveBeenCalled();
expect(axios.post).not.toHaveBeenCalled();
});
it("reuses an existing current order when it already belongs to the selected department", async () => {
order_id.value = 9201;
department_id.value = 7;
SessionUser.objects.orders.functions.get_department_id.mockResolvedValue(7);
await expect(createOrder({ isMobile: true })).resolves.toBe(true);
expect(SessionUser.objects.orders.set.department_id).toHaveBeenCalledWith(9201, 7);
expect(SessionUser.objects.orders.set.department_id).not.toHaveBeenCalled();
expect(axios.post).not.toHaveBeenCalled();
});
+31
View File
@@ -163,6 +163,18 @@ describe("release bootstrap", () => {
expect(shouldLoadRemoteRelease(null)).toBe(false);
});
it("does not load a remote release from an untrusted frontend origin", () => {
const runtime = {
channel: { slug: "canary", default_channel: false },
availability: { configured: true },
urls: {
frontend_base_url: "https://attacker.example/canary/frontend",
},
};
expect(shouldLoadRemoteRelease(runtime)).toBe(false);
});
it("loads a non-default release entry without changing the browser URL", async () => {
const runtime = {
channel: { slug: "canary", default_channel: false },
@@ -241,6 +253,25 @@ describe("release bootstrap", () => {
});
});
it("rejects release entry assets outside the frontend origin", async () => {
const fetchFn = vi.fn(async () => ({
ok: true,
json: async () => ({
entry: "https://attacker.example/assets/index-canary.js",
css: [],
}),
}));
const importModule = vi.fn(async () => ({}));
const runtime = {
frontend_base_url: "https://api-v2.truckwash.io/canary/frontend",
};
await expect(loadRemoteReleaseEntry({ runtime, fetchFn, importModule, documentRef: document })).rejects.toThrow(
"Release entry asset URL is not on the trusted release frontend origin."
);
expect(importModule).not.toHaveBeenCalled();
});
it("injects release entry CSS only once", async () => {
const fetchFn = vi.fn(async () => ({
ok: true,
+3 -1
View File
@@ -105,6 +105,8 @@ const flattenStrings = (value, prefix = "") => {
);
};
const optionalCatalog = (name, catalog) => (catalog ? [[name, catalog]] : []);
const loadReleaseManagerCatalogs = () =>
activeLocales.flatMap((locale) => {
const runtimeV1 = readJsonFile(join(root, `src/i18n/locales/${locale}.json`)).configuration.release_manager;
@@ -118,7 +120,7 @@ const loadReleaseManagerCatalogs = () =>
return [
[`${locale} v1`, runtimeV1],
[`${locale} source`, source],
...optionalCatalog(`${locale} source`, source),
[`${locale} generated v2`, generatedV2],
[`${locale} runtime v2`, runtimeV2],
];
+97 -5
View File
@@ -122,7 +122,7 @@ describe("release timeline runtime", () => {
availability: { configured: true, missing: [], status: "ready" },
});
const summary = buildReleaseSessionSummary();
const summary = buildReleaseSessionSummary(undefined, { includeInfrastructureDetails: true });
expect(releaseRuntimeState.versions.service_set.name).toBe("Canary isolated stack");
expect(summary.channelLabel).toBe("Canary");
@@ -143,6 +143,53 @@ describe("release timeline runtime", () => {
expect(summary.serviceRows.find((row) => row.key === "minio")?.status).toBe("healthy");
});
it("redacts service set infrastructure from release summaries by default", () => {
const summary = buildReleaseSessionSummary({
trace_id: "trace-redacted",
channel: { slug: "canary", name: "Canary", default_channel: false },
versions: {
frontend: {
version_label: "frontend-canary",
commit_sha: "c0ffee0000001111222233334444555566667777",
repository: "truckwash/front-end-vue",
branch: "release/canary",
status: "deployed",
},
api: { version_label: "api-canary", status: "deployed" },
service_set: {
id: 53,
name: "Canary isolated stack",
stack: {
database: {
id: 54,
resource_name: "canary-db",
resource_uuid: "database-resource-uuid",
coolify_service_uuid: "database-service-uuid",
health_url: "https://coolify.internal/health/db",
},
},
},
bundle_id: 31,
},
urls: {
frontend_base_url: "https://api-v2.truckwash.io/canary/frontend",
api_base_url: "https://api-v2.truckwash.io/canary/api",
},
availability: { configured: true, missing: [], status: "ready" },
});
expect(summary.serviceSetLabel).toBe("Restricted to release operators");
expect(summary.serviceRows).toEqual([]);
expect(summary.appRows.find((row) => row.key === "frontend")).toMatchObject({
secondaryText: "",
url: "",
});
expect(JSON.stringify(summary)).not.toContain("canary-db");
expect(JSON.stringify(summary)).not.toContain("database-resource-uuid");
expect(JSON.stringify(summary)).not.toContain("coolify.internal");
expect(JSON.stringify(summary)).not.toContain("truckwash/front-end-vue#release/canary");
});
it("marks stable sessions without release bundle data as shared runtime", () => {
const summary = buildReleaseSessionSummary({
trace_id: "trace-stable",
@@ -212,7 +259,27 @@ describe("release timeline runtime", () => {
expect(resolveReleaseApiUrl("/orders")).toBe("https://api-v2.truckwash.io/canary/api/orders");
});
it("keeps same-origin release API URLs visible in the session summary", () => {
it("ignores untrusted runtime frontend and API URLs", () => {
configureReleaseRuntime({
trace_id: "trace-untrusted",
channel: { slug: "canary", name: "Canary" },
versions: {
frontend: { version_label: "frontend-canary" },
api: { version_label: "api-canary" },
bundle_id: 31,
},
urls: {
frontend_base_url: "https://attacker.example/frontend",
api_base_url: "https://attacker.example/api",
},
});
expect(releaseRuntimeState.frontendBaseUrl).toBeNull();
expect(releaseRuntimeState.apiBaseUrl).toBeNull();
expect(resolveReleaseApiUrl("/orders")).not.toBe("https://attacker.example/api/orders");
});
it("keeps same-origin release API URLs visible in privileged session summaries", () => {
configureReleaseRuntime({
trace_id: "trace-local-api",
channel: { slug: "canary", name: "Canary" },
@@ -226,7 +293,7 @@ describe("release timeline runtime", () => {
},
});
const summary = buildReleaseSessionSummary();
const summary = buildReleaseSessionSummary(undefined, { includeInfrastructureDetails: true });
expect(releaseRuntimeState.apiBaseUrl).toBe("/api");
expect(getReleaseRuntimeApiBaseUrl()).toBe("/api");
@@ -270,6 +337,26 @@ describe("release timeline runtime", () => {
});
});
it("redacts sensitive fields inside serialized request bodies", () => {
expect(
redactReleasePayload({
data: JSON.stringify({
customer_number: "cust-123",
password: "PlaintextP@ssw0rd!",
g_recaptcha_response: "recaptcha-response-secret",
}),
formData: "username=demo&password=PlaintextP%40ssw0rd%21&api_key=secret-key",
})
).toEqual({
data: JSON.stringify({
customer_number: "cust-123",
password: "[redacted]",
g_recaptcha_response: "[redacted]",
}),
formData: "username=demo&password=%5Bredacted%5D&api_key=%5Bredacted%5D",
});
});
it("always sends failure metadata and keeps non-failure events gated by capture policy", async () => {
const sentBodies = [];
__setReleaseTimelineTransportForTests(async (body) => {
@@ -282,8 +369,11 @@ describe("release timeline runtime", () => {
recordReleaseTimelineEvent(
"request_failed",
{
request: { headers: { Authorization: "Bearer token" } },
response: { status: 500 },
request: {
headers: { Authorization: "Bearer token" },
data: JSON.stringify({ password: "PlaintextP@ssw0rd!" }),
},
response: { status: 500, data: { token: "response-token" } },
},
{ severity: "error", moduleKey: "requestqueue" }
)
@@ -298,6 +388,8 @@ describe("release timeline runtime", () => {
module_key: "requestqueue",
});
expect(sentBodies[0].events[0].payload.request.headers.Authorization).toBe("[redacted]");
expect(sentBodies[0].events[0].payload.request.data).toBe("[capture-disabled]");
expect(sentBodies[0].events[0].payload.response.data).toBe("[capture-disabled]");
});
it("keeps a local redacted frontend failure buffer for error reports", () => {
+56 -1
View File
@@ -256,7 +256,8 @@ describe("RequestQueueProgress", () => {
expect(runtimeBox.text()).toContain("Ingoing bandwidth");
});
it("shows the active session release bundle, app URLs, and connected services", async () => {
it("shows the active session release bundle, app URLs, and connected services to release operators", async () => {
localStorage.setItem("superuser_token", "su-token");
configureReleaseRuntime({
generated_at: "2026-05-19T09:30:00.000Z",
trace_id: "trace-request-panel",
@@ -317,6 +318,60 @@ describe("RequestQueueProgress", () => {
expect(wrapper.get("[data-testid='request-queue-release-service-minio']").text()).toContain("canary-minio #56");
});
it("hides release infrastructure metadata from low-privileged subusers", async () => {
SessionUser.isSubuser.value = true;
SessionUser.subuser.grants.value = [{ billing_customer_number: 12345, permissions: ["order_read"] }];
SessionUser.subuser.selectedGrantCustomerNumber.value = 12345;
configureReleaseRuntime({
generated_at: "2026-05-19T09:30:00.000Z",
trace_id: "trace-low-privilege",
channel: { slug: "canary", name: "Canary", default_channel: false },
versions: {
frontend: {
version_label: "frontend-canary",
repository: "truckwash/front-end-vue",
branch: "release/canary",
status: "deployed",
},
api: { version_label: "api-canary", status: "deployed" },
service_set: {
id: 53,
name: "Canary isolated stack",
stack: {
database: {
id: 54,
resource_name: "canary-db",
resource_uuid: "database-resource-uuid",
coolify_service_uuid: "database-service-uuid",
health_url: "https://coolify.internal/health/db",
},
},
},
bundle_id: 31,
},
urls: {
frontend_base_url: "https://api-v2.truckwash.io/canary/frontend",
api_base_url: "https://api-v2.truckwash.io/canary/api",
},
});
const wrapper = mount(RequestQueueProgress);
await triggerShiftTriplePress();
await flushManyMicrotasks();
const runtimeBox = wrapper.get("[data-testid='request-queue-runtime-box']");
expect(runtimeBox.text()).toContain("Runtime details");
expect(runtimeBox.text()).not.toContain("Session release");
expect(runtimeBox.text()).not.toContain("trace-low-privilege");
expect(runtimeBox.text()).not.toContain("Canary isolated stack");
expect(runtimeBox.text()).not.toContain("canary-db");
expect(runtimeBox.text()).not.toContain("database-resource-uuid");
expect(runtimeBox.text()).not.toContain("coolify.internal");
expect(runtimeBox.text()).not.toContain("api-v2.truckwash.io");
expect(runtimeBox.text()).toContain("Restricted to release operators");
expect(wrapper.find("[data-testid='request-queue-release-service-database']").exists()).toBe(false);
});
it("shows subuser details in the user box", async () => {
SessionUser.isSubuser.value = true;
SessionUser.subuser.name.value = "Sub User";
@@ -0,0 +1,47 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
const readSource = (relativePath) => readFileSync(join(process.cwd(), relativePath), "utf8");
describe("self-serve studio gateway actions", () => {
const studioSource = () =>
readSource("src/views/dashboards/departmentDashboard/modules/self-serve/DepartmentSelfServeStudio.vue");
it("gates gateway actions on management and destructive-action permissions", () => {
const source = studioSource();
expect(source).toContain('const destructiveGatewayActions = new Set(["rotate_credentials", "uninstall"]);');
expect(source).toContain(
"const canManageGateways = computed(() => Boolean(permissions.value.can_manage_gateways));"
);
expect(source).toContain("Boolean(permissions.value.can_run_gateway_destructive_actions)");
expect(source).toContain("const canRunGatewayAction = (action) =>");
expect(source).toContain(":disabled=\"!canRunGatewayAction('discovery')\"");
expect(source).toContain(":disabled=\"!canRunGatewayAction('update')\"");
expect(source).toContain(":disabled=\"!canRunGatewayAction('rotate_credentials')\"");
expect(source).toContain(":disabled=\"!canRunGatewayAction('uninstall')\"");
});
it("requires an explicit confirmation prompt before destructive gateway actions", () => {
const source = studioSource();
expect(source).toContain("const confirmGatewayAction = (action) => {");
expect(source).toContain("return window.confirm(");
expect(source).toContain("const confirm = confirmGatewayAction(action);");
expect(source).toContain("if (gatewayActionRequiresConfirmation(action) && !confirm) {");
expect(source).toContain("@click=\"runGatewayAction(gateway.id, 'rotate_credentials')\"");
expect(source).toContain("@click=\"runGatewayAction(gateway.id, 'uninstall')\"");
expect(source).not.toContain("'rotate_credentials', true");
expect(source).not.toContain("'uninstall', true");
});
it("documents confirm as a required gateway action field in OpenAPI", () => {
const openApi = readSource("openapi.yaml");
expect(openApi).toContain("required: [department, gateway_id, action, confirm]");
expect(openApi).toContain(
"description: Required for dangerous gateway actions such as uninstall and credential rotation."
);
});
});
@@ -35,14 +35,27 @@ describe("self-serve studio managed inspector", () => {
expect(source).toContain("const normalizeVehicleTypeForm = (raw = {}) => {");
expect(source).toContain("const saveVehicleTypeProduct = async () => {");
expect(source).toContain("if (!canEditVehicleTypeProduct.value) {");
expect(source).toContain('await requestPut("/products", {');
expect(source).toContain('data-testid="studio-vehicle-type-form"');
expect(source).toContain('data-testid="studio-vehicle-type-name"');
expect(source).toContain(':disabled="!canEditVehicleTypeProduct"');
expect(source).toContain("const normalizeMachineTypeForm = (raw = {}) => {");
expect(source).toContain("const saveMachineType = async () => {");
expect(source).toContain('await requestPut("/department/selfserve/machine-types", {');
});
it("requires superuser access before saving vehicle type product catalog fields", () => {
const source = studioSource();
expect(source).toContain("const canEditVehicleTypeProduct = computed(() =>");
expect(source).toContain("Boolean(permissions.value.can_edit) && SessionUser.canAccessSuperUser()");
expect(source.indexOf("if (!canEditVehicleTypeProduct.value) {")).toBeLessThan(
source.indexOf('await requestPut("/products", {')
);
expect(source).toContain("Only superusers can update vehicle type product catalog fields.");
});
it("manages virtual hardware from selected gateway and relay binding nodes", () => {
const source = studioSource();
@@ -99,7 +99,8 @@ describe("self-serve studio task editing", () => {
expect(source).toContain('data-testid="studio-task-attachment-manager"');
expect(source).toContain('data-testid="studio-task-attachment-upload"');
expect(source).toContain('class="studio-debug-attachment-list"');
expect(source).toContain('@click.stop="openTaskAttachment(attachment)"');
expect(source).toContain('@click.stop="openTaskAttachment(attachment, data.object_id)"');
expect(source).toContain('@click.stop="openTaskAttachment(attachment, task.id)"');
expect(source).toContain("delete data.attachments");
});
@@ -0,0 +1,50 @@
import { describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
addObject: vi.fn(),
}));
vi.mock("@/components/session/token/SessionUser/Objects/ObjectsGlobal.vue", () => ({
ObjectsGlobal: {
add: {
object: mocks.addObject,
},
},
}));
vi.mock("@/components/session/token/SessionUser.vue", () => ({
SessionUser: {
objects: {},
},
}));
vi.mock("@/components/session/authenticatedRequest.vue", () => ({
authenticatedRequest: vi.fn(),
}));
import { SelfServeVehicleConditions } from "@/components/session/token/SessionUser/Objects/SelfServeVehicleConditions.vue";
describe("SelfServeVehicleConditions", () => {
it("does not allow caller options to enable live relay synchronization", async () => {
mocks.addObject.mockResolvedValue({ data: { data: {} } });
await SelfServeVehicleConditions.add(3, 7, 12345, "AB12345", 11, true, {
vehicle_type_id: "2",
activate_machine: true,
sync_relay_state: true,
unexpected: "ignored",
});
expect(mocks.addObject).toHaveBeenCalledWith("/department/selfserve/vehicle/conditions", {
department: 3,
lane: 7,
customer_id: 12345,
reg: "AB12345",
question: 11,
value: true,
vehicle_type: 2,
activate_machine: false,
sync_relay_state: false,
});
});
});
+51
View File
@@ -29,6 +29,16 @@ vi.mock("sweetalert2", () => ({
}));
import { getSessionData, SessionUser } from "@/components/session/token/SessionUser.vue";
import { EDGE_GATEWAY_WORKSPACE_CACHE_KEY } from "@/services/edgeGateways.js";
import {
buildPeriodCacheKey,
getCachedPeriodPage,
setCachedPeriodPage,
} from "@/views/dashboards/superUserDashboard/InvoicingBillingPeriod/imports/InvoicingBillingPeriodImportPaging.js";
import {
getCachedXlvaskUsageAmount,
setCachedXlvaskUsageAmount,
} from "@/components/displays/department/pos/sync/xlvaskUsageAmountCache.js";
const seedStoredSession = (token = "stored-token") => {
localStorage.setItem("token", token);
@@ -59,6 +69,47 @@ describe("session bootstrap failure handling", () => {
window.history.replaceState({}, "", "/user");
});
it("clears privileged session caches when forcing the session closed", () => {
seedStoredSession("cached-session-token");
const periodCacheKey = buildPeriodCacheKey({
dateFrom: "2026-05-01",
dateTo: "2026-05-31",
periodView: "all",
page: 1,
limit: 100,
search: "",
flagTab: "all",
includeRequiresAction: true,
includeBooked: true,
});
setCachedPeriodPage(
periodCacheKey,
{ types: { all: [{ customer_name: "Sensitive Customer" }] } },
{ page: 1, per_page: 100, total: 1 }
);
setCachedXlvaskUsageAmount(8101, {
order_items: [{ license_plate: "PII-123" }],
potential_duplicates: [],
});
window.sessionStorage.setItem(EDGE_GATEWAY_WORKSPACE_CACHE_KEY, JSON.stringify({ details: { 1: "gateway" } }));
window.localStorage.setItem(EDGE_GATEWAY_WORKSPACE_CACHE_KEY, JSON.stringify({ details: { 1: "legacy" } }));
expect(getCachedPeriodPage(periodCacheKey)?.data?.types?.all?.[0]?.customer_name).toBe("Sensitive Customer");
expect(getCachedXlvaskUsageAmount(8101)?.order_items?.[0]?.license_plate).toBe("PII-123");
expect(window.sessionStorage.getItem(EDGE_GATEWAY_WORKSPACE_CACHE_KEY)).toContain("gateway");
expect(window.localStorage.getItem(EDGE_GATEWAY_WORKSPACE_CACHE_KEY)).toContain("legacy");
SessionUser.auth.forceClearSession();
expect(getCachedPeriodPage(periodCacheKey)).toBeNull();
expect(getCachedXlvaskUsageAmount(8101)).toBeNull();
expect(Object.keys(window.sessionStorage).filter((key) => key.startsWith("invoicing-period-page:")).length).toBe(0);
expect(Object.keys(window.sessionStorage).filter((key) => key.startsWith("xlvask-usage-amount:")).length).toBe(0);
expect(window.sessionStorage.getItem(EDGE_GATEWAY_WORKSPACE_CACHE_KEY)).toBeNull();
expect(window.localStorage.getItem(EDGE_GATEWAY_WORKSPACE_CACHE_KEY)).toBeNull();
});
it("clears an invalid stored session after ping confirms the API is reachable", async () => {
seedStoredSession("invalid-token");
mocks.authenticatedRequest.mockRejectedValueOnce(invalidSessionError());
+62
View File
@@ -0,0 +1,62 @@
import { describe, expect, it } from "vitest";
import {
normalizeStripeInvoice,
parseStripeInvoicePaidFlag,
} from "@/components/displays/department/pos/displays/stripeEmailInvoice.js";
describe("stripeEmailInvoice", () => {
it("parses invoice paid flags without treating non-empty unpaid strings as paid", () => {
expect(parseStripeInvoicePaidFlag(true)).toBe(true);
expect(parseStripeInvoicePaidFlag(1)).toBe(true);
expect(parseStripeInvoicePaidFlag("true")).toBe(true);
expect(parseStripeInvoicePaidFlag("1")).toBe(true);
expect(parseStripeInvoicePaidFlag(false)).toBe(false);
expect(parseStripeInvoicePaidFlag(0)).toBe(false);
expect(parseStripeInvoicePaidFlag("false")).toBe(false);
expect(parseStripeInvoicePaidFlag("0")).toBe(false);
expect(parseStripeInvoicePaidFlag("open")).toBe(false);
});
it("requires paid status, a paid flag, and covered amount before unlocking invoice completion", () => {
expect(
normalizeStripeInvoice({
id: "in_unpaid_string",
paid: "false",
status: "open",
amount_due: "1000",
amount_paid: "0",
})?.paid
).toBe(false);
expect(
normalizeStripeInvoice({
id: "in_open_true_flag",
paid: true,
status: "open",
amount_due: "1000",
amount_paid: "1000",
})?.paid
).toBe(false);
expect(
normalizeStripeInvoice({
id: "in_underpaid",
paid: true,
status: "paid",
amount_due: "1000",
amount_paid: "999",
})?.paid
).toBe(false);
expect(
normalizeStripeInvoice({
id: "in_paid",
paid: "true",
status: "paid",
amount_due: "1000",
amount_paid: "1000",
})?.paid
).toBe(true);
});
});
+17
View File
@@ -85,6 +85,23 @@ describe("superuser release manager service", () => {
);
});
it("rejects arbitrary control API overrides before storing or requesting them", async () => {
const attackerUrl = "https://attacker.example.test";
expect(setReleaseManagerControlApiUrl(attackerUrl)).toBe("");
expect(localStorage.getItem(RELEASE_MANAGER_CONTROL_API_STORAGE_KEY)).toBeNull();
localStorage.setItem(RELEASE_MANAGER_CONTROL_API_STORAGE_KEY, attackerUrl);
sessionStorage.setItem("release_manager_last_working_control_api_url", attackerUrl);
axiosMock.mockResolvedValueOnce({ status: 200, data: { data: { channels: [] } } });
await getReleaseSummary();
expect(releaseManagerControlApiCandidates()).not.toContain(attackerUrl);
expect(axiosMock).toHaveBeenCalledTimes(1);
expect(axiosMock.mock.calls[0][0].url).not.toContain(attackerUrl);
});
it("can clear the explicit control API override", () => {
setReleaseManagerControlApiUrl("https://control.example.test");
clearReleaseManagerControlApiUrl();
@@ -0,0 +1,88 @@
// @vitest-environment jsdom
import { defineComponent, ref } from "vue";
import { mount } from "@vue/test-utils";
import { afterEach, describe, expect, it, vi } from "vitest";
import { useOrderMetadataAutosave } from "@/composables/useOrderMetadataAutosave.js";
const mountAutosave = (options = {}) => {
let autosave;
const source = ref(options.source ?? "saved");
const saveValue = options.saveValue ?? vi.fn(async (value) => value);
const onError = options.onError ?? vi.fn();
const wrapper = mount(
defineComponent({
setup() {
autosave = useOrderMetadataAutosave({
source,
saveValue,
delay: 1,
onError,
});
return {};
},
template: "<div />",
})
);
return { autosave, onError, saveValue, source, wrapper };
};
describe("useOrderMetadataAutosave", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("does not immediately retry a failed save while the draft remains dirty", async () => {
const saveValue = vi.fn(async () => {
throw new Error("server rejected metadata");
});
const { autosave, onError, wrapper } = mountAutosave({ saveValue });
autosave.draft.value = "rejected";
await expect(autosave.flush()).resolves.toBe(false);
await Promise.resolve();
expect(saveValue).toHaveBeenCalledTimes(1);
expect(saveValue).toHaveBeenCalledWith("rejected");
expect(onError).toHaveBeenCalledTimes(1);
expect(autosave.isDirty.value).toBe(true);
autosave.syncFromSource("rejected");
wrapper.unmount();
});
it("continues saving after a successful request when a newer dirty draft was queued", async () => {
let resolveFirstSave;
const saveValue = vi.fn((value) => {
if (value === "first") {
return new Promise((resolve) => {
resolveFirstSave = () => resolve(value);
});
}
return Promise.resolve(value);
});
const { autosave, wrapper } = mountAutosave({ saveValue });
autosave.draft.value = "first";
const firstFlush = autosave.flush();
autosave.draft.value = "second";
await expect(autosave.flush()).resolves.toBe(false);
resolveFirstSave();
await expect(firstFlush).resolves.toBe(true);
await Promise.resolve();
expect(saveValue).toHaveBeenCalledTimes(2);
expect(saveValue).toHaveBeenNthCalledWith(1, "first");
expect(saveValue).toHaveBeenNthCalledWith(2, "second");
await Promise.resolve();
wrapper.unmount();
});
});

Some files were not shown because too many files have changed in this diff Show More