Compare commits

...
Author SHA1 Message Date
Jeppe Bundgaard 2f671024ab Add mocks for department daily report components and update Vitest configuration for SCSS 2026-06-02 17:41:59 +02:00
Jeppe Bundgaard 605a7ca433 Refactor department weather component to support multi-day hourly mode and update related tests 2026-06-02 17:26:17 +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
Jeppe B e2b2d50e5e Fix self-serve path editor answer switch layout 2026-06-01 20:19:35 +02:00
Jeppe B 55d61e37f3 Merge pull request #59 from copenhagentruckwash/fix-e2e-tests-in-i18n-v2-catalog
Fix i18n v2 catalog integrity and split Release Manager source fragments
2026-06-01 19:45:57 +02:00
Jeppe Bundgaard b995031255 Enable "Selv vask" button in MobileFooter.vue 2026-06-01 19:43:57 +02:00
Jeppe Bundgaard d096444ca0 Remove unused lane unavailable warning message from SelfServeLaneStep.vue 2026-06-01 19:41:16 +02:00
136 changed files with 4601 additions and 849 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:
+29 -11
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",
@@ -42,6 +41,7 @@
"dayspan-vuetify": "^0.4.0",
"flow": "^0.2.3",
"html2canvas": "^1.4.1",
"pinia": "^3.0.4",
"qrcode": "^1.5.4",
"sass": "^1.81.1",
"sweetalert2": "11.22.4",
@@ -5070,7 +5070,6 @@
"version": "7.7.9",
"resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-7.7.9.tgz",
"integrity": "sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vue/devtools-shared": "^7.7.9",
@@ -5086,7 +5085,6 @@
"version": "7.7.9",
"resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-7.7.9.tgz",
"integrity": "sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA==",
"dev": true,
"license": "MIT",
"dependencies": {
"rfdc": "^1.4.1"
@@ -5738,7 +5736,6 @@
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz",
"integrity": "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/antfu"
@@ -6426,7 +6423,6 @@
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-4.0.5.tgz",
"integrity": "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-what": "^5.2.0"
@@ -8989,7 +8985,6 @@
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/is-what/-/is-what-5.5.0.tgz",
"integrity": "sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
@@ -9572,7 +9567,6 @@
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz",
"integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==",
"dev": true,
"license": "MIT"
},
"node_modules/mkdirp": {
@@ -10110,7 +10104,6 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz",
"integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==",
"dev": true,
"license": "MIT"
},
"node_modules/performance-now": {
@@ -10172,6 +10165,34 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/pinia": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.4.tgz",
"integrity": "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==",
"dependencies": {
"@vue/devtools-api": "^7.7.7"
},
"funding": {
"url": "https://github.com/sponsors/posva"
},
"peerDependencies": {
"typescript": ">=4.5.0",
"vue": "^3.5.11"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/pinia/node_modules/@vue/devtools-api": {
"version": "7.7.9",
"resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-7.7.9.tgz",
"integrity": "sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g==",
"dependencies": {
"@vue/devtools-kit": "^7.7.9"
}
},
"node_modules/pixelmatch": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/pixelmatch/-/pixelmatch-4.0.2.tgz",
@@ -10729,7 +10750,6 @@
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz",
"integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==",
"dev": true,
"license": "MIT"
},
"node_modules/rgbcolor": {
@@ -11647,7 +11667,6 @@
"version": "14.0.1",
"resolved": "https://registry.npmjs.org/speakingurl/-/speakingurl-14.0.1.tgz",
"integrity": "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
@@ -11919,7 +11938,6 @@
"version": "2.2.6",
"resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.6.tgz",
"integrity": "sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==",
"dev": true,
"license": "MIT",
"dependencies": {
"copy-anything": "^4"
+1 -1
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",
@@ -87,6 +86,7 @@
"dayspan-vuetify": "^0.4.0",
"flow": "^0.2.3",
"html2canvas": "^1.4.1",
"pinia": "^3.0.4",
"qrcode": "^1.5.4",
"sass": "^1.81.1",
"sweetalert2": "11.22.4",
@@ -1,22 +0,0 @@
import fs from "node:fs";
import path from "node:path";
import { spawnSync } from "node:child_process";
const helperScriptPath = path.resolve(process.cwd(), "..", "scripts", "sync-playwright-root-links.mjs");
if (!fs.existsSync(helperScriptPath)) {
console.log(
`Skipping root Playwright link sync: helper script not found at ${helperScriptPath}.`
);
process.exit(0);
}
const result = spawnSync(process.execPath, [helperScriptPath], {
stdio: "inherit",
});
if (typeof result.status === "number") {
process.exit(result.status);
}
process.exit(1);
+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.
}
@@ -0,0 +1,100 @@
<script setup lang="ts">
import { ref, watch } from "vue";
import { BSkeleton } from "buefy";
const props = defineProps<{
src: string | null;
alt?: string;
}>();
const emit = defineEmits<{
(e: "error"): void;
(e: "load"): void;
}>();
const isLoading = ref(false);
watch(
() => props.src,
(src) => {
isLoading.value = !!src;
},
{ immediate: true }
);
const onLoad = () => {
isLoading.value = false;
emit("load");
};
const onError = () => {
isLoading.value = false;
emit("error");
};
</script>
<template>
<div
v-if="src"
class="self-serve-dynamic-image-frame mb-4"
:class="{ 'is-loading': isLoading }"
data-testid="self-serve-dynamic-image-frame"
>
<b-skeleton
v-if="isLoading"
class="self-serve-dynamic-image-skeleton"
width="100%"
height="100%"
data-testid="self-serve-dynamic-image-skeleton"
/>
<img
:key="src"
:src="src"
:alt="alt || 'Machine status'"
data-testid="self-serve-dynamic-image"
class="self-serve-dynamic-image"
:class="{ 'is-loading': isLoading }"
@load="onLoad"
@error="onError"
/>
</div>
</template>
<style scoped>
.self-serve-dynamic-image-frame {
position: relative;
width: 100%;
max-width: 100%;
aspect-ratio: 16 / 9;
margin-left: auto;
margin-right: auto;
overflow: hidden;
}
@media screen and (min-width: 769px) {
.self-serve-dynamic-image-frame {
max-width: 640px;
}
}
.self-serve-dynamic-image-skeleton {
position: absolute;
inset: 0;
overflow: hidden;
border-radius: 4px;
}
.self-serve-dynamic-image {
width: 100%;
height: 100%;
border-radius: 4px;
display: block;
object-fit: contain;
transition: opacity 120ms ease;
}
.self-serve-dynamic-image.is-loading {
opacity: 0;
}
</style>
@@ -1,7 +1,8 @@
<script setup lang="ts">
import { computed } from "vue";
import { BIcon, BMessage, BRadioButton } from "buefy";
defineProps<{
const props = defineProps<{
lanes: Array<any>;
selectedLaneId: number | string | null;
washType: string;
@@ -14,6 +15,8 @@ const emit = defineEmits<{
(e: "update:selectedLaneId", value: number): void;
(e: "update:washType", value: string): void;
}>();
const isSelectedMachineAvailable = computed(() => props.isMachineAvailable(props.selectedLaneId));
</script>
<template>
@@ -92,16 +95,16 @@ const emit = defineEmits<{
:model-value="washType"
native-value="Machine"
type="is-link"
:disabled="!isMachineAvailable(selectedLaneId)"
:title="!isMachineAvailable(selectedLaneId) ? $t('self_wash.machine_unavailable_for_lane') : null"
:aria-label="!isMachineAvailable(selectedLaneId) ? $t('self_wash.machine_unavailable_for_lane') : null"
:disabled="!isSelectedMachineAvailable"
:title="!isSelectedMachineAvailable ? $t('self_wash.unavailable') : null"
:aria-label="!isSelectedMachineAvailable ? $t('self_wash.unavailable') : null"
data-testid="self-serve-wash-type-machine"
@update:model-value="emit('update:washType', 'Machine')"
@input="emit('update:washType', 'Machine')"
>
<span>
<span>{{ $t("self_wash.machine") }}<br /></span>
<span v-if="!isMachineAvailable(selectedLaneId)">
<span v-if="!isSelectedMachineAvailable">
<small>
<b-icon icon="times-circle" type="is-danger" pack="fas" class="mr-1" />
{{ $t("self_wash.unavailable") }}
@@ -117,15 +120,6 @@ const emit = defineEmits<{
</b-radio-button>
</div>
</div>
<b-message
v-if="selectedLaneId && !isMachineAvailable(selectedLaneId)"
type="is-warning"
has-icon
:closable="false"
data-testid="self-serve-machine-unavailable-guidance"
>
{{ $t("self_wash.machine_unavailable_for_lane") }}
</b-message>
</section>
</div>
</template>
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { ref, watch } from "vue";
import { BIcon, BSkeleton } from "buefy";
import { BIcon } from "buefy";
import SelfServeDynamicImageFrame from "@/components/displays/selfServe/SelfServeDynamicImageFrame.vue";
import SelfServeTaskList from "@/components/displays/selfServe/SelfServeTaskList.vue";
const props = defineProps<{
@@ -18,12 +18,6 @@ const emit = defineEmits<{
(e: "clear-dynamic-image"): void;
}>();
const isDynamicImageLoading = ref(false);
watch(() => props.dynamicImageUrl, (dynamicImageUrl) => {
isDynamicImageLoading.value = !!dynamicImageUrl;
}, { immediate: true });
const emitToggleTask = (taskId: number, value: boolean) => {
emit("toggle-task", taskId, value);
};
@@ -32,12 +26,7 @@ const emitDownloadAttachment = (taskId: number, attachmentId: number | undefined
emit("download-attachment", taskId, attachmentId, attachment);
};
const onDynamicImageLoad = () => {
isDynamicImageLoading.value = false;
};
const onDynamicImageError = () => {
isDynamicImageLoading.value = false;
emit("clear-dynamic-image");
};
</script>
@@ -58,30 +47,11 @@ const onDynamicImageError = () => {
<span class="ml-2">{{ $t("self_wash.loading_data") }}</span>
</div>
<div
v-if="dynamicImageUrl"
class="self-serve-dynamic-image-frame mb-4"
:class="{ 'is-loading': isDynamicImageLoading }"
data-testid="self-serve-dynamic-image-frame"
>
<b-skeleton
v-if="isDynamicImageLoading"
class="self-serve-dynamic-image-skeleton"
width="100%"
height="100%"
data-testid="self-serve-dynamic-image-skeleton"
/>
<img
:key="dynamicImageUrl"
:src="dynamicImageUrl"
alt="Machine status"
data-testid="self-serve-dynamic-image"
class="self-serve-dynamic-image"
:class="{ 'is-loading': isDynamicImageLoading }"
@load="onDynamicImageLoad"
@error="onDynamicImageError"
/>
</div>
<SelfServeDynamicImageFrame
:src="dynamicImageUrl"
alt="Machine status"
@error="onDynamicImageError"
/>
<div v-show="allVisibleQuestionsAnswered && !editAnswers" class="notification is-info is-light mb-4">
<h1 class="title has-text-centered mb-2" v-if="activeTasks.length > 0">{{ $t("self_wash.start_machine") }}</h1>
<h1 class="title has-text-centered mb-2" v-else>{{ $t("self_wash.questions_answered") }}</h1>
@@ -95,41 +65,3 @@ const onDynamicImageError = () => {
</div>
</div>
</template>
<style scoped>
.self-serve-dynamic-image-frame {
position: relative;
width: 100%;
max-width: 100%;
aspect-ratio: 16 / 9;
margin-left: auto;
margin-right: auto;
overflow: hidden;
}
@media screen and (min-width: 769px) {
.self-serve-dynamic-image-frame {
max-width: 640px;
}
}
.self-serve-dynamic-image-skeleton {
position: absolute;
inset: 0;
overflow: hidden;
border-radius: 4px;
}
.self-serve-dynamic-image {
width: 100%;
height: 100%;
border-radius: 4px;
display: block;
object-fit: contain;
transition: opacity 120ms ease;
}
.self-serve-dynamic-image.is-loading {
opacity: 0;
}
</style>
@@ -2,6 +2,8 @@
import Swal from "sweetalert2";
import { usePaginatedListInstance } from "@/components/pagination/paginatedList.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import ActionSettingsWheelButton from "@/components/displays/buttons/ActionSettingsWheelButton.vue";
import ActionSettingsWheelItem from "@/components/displays/buttons/ActionSettingsWheelItem.vue";
const props = defineProps({
objects: {
@@ -23,6 +25,8 @@ const canDisableAccess = () =>
const canResendInvite = (subuser) =>
(props.showCustomer ? SessionUser.canAccessSuperUser() : SessionUser.canAccessUser() || SessionUser.hasPermission("SUBUSERS_EDIT"))
&& Boolean(subuser?.can_resend_invite ?? subuser?.setup_required);
const hasRowActions = (subuser) =>
(canEditPermissions() && subuser?.grant_id) || canResendInvite(subuser) || (canDisableAccess() && subuser?.grant_id);
const formatDateTime = (dateString) => {
if (!dateString) {
@@ -241,14 +245,6 @@ const onResendInvite = async (subuser) => {
<td>
<div>{{ subuser.grant_note || "-" }}</div>
<button
v-if="canEditPermissions() && subuser.grant_id"
class="button is-text is-small px-0 mt-1"
type="button"
@click="onEditNote(subuser)"
>
Redigér note
</button>
</td>
<td>{{ formatDateTime(subuser.created_at) }}</td>
@@ -256,36 +252,42 @@ const onResendInvite = async (subuser) => {
<td>
<div class="buttons is-justify-content-flex-end action-buttons">
<button
v-if="canEditPermissions() && subuser.grant_id"
class="button is-small"
type="button"
:data-testid="`subuser-permissions-${subuser.id}`"
@click="onEditPermissions(subuser)"
>
Tilladelser
</button>
<ActionSettingsWheelButton v-if="hasRowActions(subuser)" display-actions-directly>
<template #actions>
<ActionSettingsWheelItem
v-if="canEditPermissions() && subuser.grant_id"
icon="fas fa-pen"
label="Redigér note"
:click-action="() => onEditNote(subuser)"
:test-id="`subuser-note-${subuser.id}`"
/>
<button
v-if="canResendInvite(subuser)"
class="button is-small"
type="button"
:data-testid="`subuser-resend-${subuser.id}`"
@click="onResendInvite(subuser)"
>
Gensend
</button>
<ActionSettingsWheelItem
v-if="canEditPermissions() && subuser.grant_id"
icon="fas fa-user-shield"
label="Tilladelser"
:click-action="() => onEditPermissions(subuser)"
:test-id="`subuser-permissions-${subuser.id}`"
/>
<button
v-if="canDisableAccess() && subuser.grant_id"
class="button is-small"
:class="subuser.grant_enabled ? 'is-danger is-light' : 'is-success is-light'"
type="button"
:data-testid="`subuser-toggle-${subuser.id}`"
@click="onToggleEnabled(subuser, !subuser.grant_enabled)"
>
{{ subuser.grant_enabled ? "Deaktivér" : "Aktivér" }}
</button>
<ActionSettingsWheelItem
v-if="canResendInvite(subuser)"
icon="fas fa-paper-plane"
label="Gensend"
:click-action="() => onResendInvite(subuser)"
:test-id="`subuser-resend-${subuser.id}`"
/>
<ActionSettingsWheelItem
v-if="canDisableAccess() && subuser.grant_id"
:icon="subuser.grant_enabled ? 'fas fa-ban' : 'fas fa-check'"
:label="subuser.grant_enabled ? 'Deaktivér' : 'Aktivér'"
:template="subuser.grant_enabled ? 'danger' : 'success'"
:click-action="() => onToggleEnabled(subuser, !subuser.grant_enabled)"
:test-id="`subuser-toggle-${subuser.id}`"
/>
</template>
</ActionSettingsWheelButton>
</div>
</td>
</tr>
@@ -10,6 +10,7 @@ import LoadButtonWhileAwait from "@/components/request/LoadButtonWhileAwait.vue"
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import TwoFactorVerify from "@/components/forms/auth/TwoFactorVerify.vue";
import { isPasskeySupported, authenticateWithPasskey } from "@/services/PasskeyAuthService.js";
import { clearEdgeGatewayWorkspaceCache } from "@/services/edgeGateways.js";
const employees = ref([]);
const { t } = useI18n();
@@ -52,6 +53,7 @@ const login = async () => {
}
// Save the token in the local storage
clearEdgeGatewayWorkspaceCache();
localStorage.setItem('token', data.token);
// Redirect to the dashboard
window.location.href = '/admin';
@@ -72,6 +74,7 @@ const loginWithPasskey = async () => {
const result = await authenticateWithPasskey('employee', null, recaptchaToken);
if (result.token) {
clearEdgeGatewayWorkspaceCache();
localStorage.setItem('token', result.token);
window.location.href = '/admin';
return;
+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
);
}
@@ -26,7 +26,7 @@ const pages = {
to: "/user/wash/start",
label: "Selv vask",
icon: "tint",
disabled: !IS_DEV,
disabled: false,
},
bookings: {
to: "/user/bookings",
+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();
}
}
};
+72 -6
View File
@@ -1,5 +1,7 @@
import { computed, ref } from "vue";
import { createPinia, defineStore, getActivePinia, setActivePinia, storeToRefs } from "pinia";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { safeAttachmentDownloadLink } from "@/services/attachmentDownloadLinks.js";
const normalizeBooleanAnswer = (value) => {
if (value === true || value === false) {
@@ -270,7 +272,26 @@ const mergeByNumericId = (existingItems, incomingItems) => {
return merged;
};
export function useSelfServeLogic() {
let selfServeLogicStoreInstance = 0;
let fallbackSelfServePinia = null;
const ensureSelfServePinia = () => {
if (typeof document === "undefined") {
fallbackSelfServePinia ||= createPinia();
setActivePinia(fallbackSelfServePinia);
return fallbackSelfServePinia;
}
const activePinia = getActivePinia();
if (activePinia) {
return activePinia;
}
fallbackSelfServePinia ||= createPinia();
setActivePinia(fallbackSelfServePinia);
return fallbackSelfServePinia;
};
const createSelfServeLogicStore = (storeId) => defineStore(storeId, () => {
const loading = ref(false);
const requestError = ref(null);
const preview = ref(null);
@@ -526,7 +547,7 @@ export function useSelfServeLogic() {
};
const downloadAttachment = async (taskId, attachmentId, attachment = null) => {
const existingDownloadLink = attachment?.download_link || null;
const existingDownloadLink = safeAttachmentDownloadLink(attachment?.download_link);
if (existingDownloadLink) {
window.open(existingDownloadLink, "_blank", "noopener");
return;
@@ -534,10 +555,9 @@ export function useSelfServeLogic() {
try {
const response = await SessionUser.objects.self_serve_tasks.attachments.download(taskId, attachmentId);
if (response?.data?.download_link) {
window.open(response.data.download_link, "_blank", "noopener");
} else if (response?.download_link) {
window.open(response.download_link, "_blank", "noopener");
const downloadLink = safeAttachmentDownloadLink(response?.data?.download_link || response?.download_link);
if (downloadLink) {
window.open(downloadLink, "_blank", "noopener");
}
} catch (error) {
console.error("Error downloading attachment:", error);
@@ -1075,4 +1095,50 @@ export function useSelfServeLogic() {
answerQuestion,
removeAnswer,
};
});
export function useSelfServeLogic(options = {}) {
const pinia = ensureSelfServePinia();
const explicitStoreId = typeof options === "string" ? options : options?.storeId;
const storeId = explicitStoreId || `selfServeLogic:${++selfServeLogicStoreInstance}`;
const store = createSelfServeLogicStore(storeId)(pinia);
const refs = storeToRefs(store);
const {
fetchSelfServeData,
fetchWashSummary,
syncVehicleAnswer,
clearVehicleAnswers,
evaluateRule,
evaluateCondition,
isQuestionVisible,
isTaskActive,
isServiceAllowed,
updateLaneAllowedServices,
enableMachineRelay,
isImage,
downloadAttachment,
reset,
answerQuestion,
removeAnswer,
} = store;
return {
...refs,
fetchSelfServeData,
fetchWashSummary,
syncVehicleAnswer,
clearVehicleAnswers,
evaluateRule,
evaluateCondition,
isQuestionVisible,
isTaskActive,
isServiceAllowed,
updateLaneAllowedServices,
enableMachineRelay,
isImage,
downloadAttachment,
reset,
answerQuestion,
removeAnswer,
};
}
+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,
+47 -12
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;
@@ -227,12 +250,31 @@ export function useWashSessionActions(options) {
const startResponse = await executeSelfServeCommand(laneId, "START", {
customer_number: parseInt(customerNumber),
license_plate: licensePlate.trim().toUpperCase(),
defer_relay_side_effects: true,
defer_relay_side_effects: false,
});
if (!startResponse) {
return false;
}
if (radioWashType.value === "Machine" && isServiceAllowed("MACHINE")) {
let machineRelayResponse = null;
try {
machineRelayResponse = await enableMachineRelay(laneId);
} catch (error) {
console.error("Error enabling machine relay after wash start:", error);
await rollbackStartedWash(laneId);
alertFn(extractCommandErrorMessage(error, "Kunne ikke starte maskinen. Prøv igen."));
return false;
}
const machineRelaySuccessValue = machineRelayResponse?.data?.success ?? machineRelayResponse?.success;
if (machineRelaySuccessValue === false) {
await rollbackStartedWash(laneId);
alertFn(extractCommandErrorMessage(machineRelayResponse, "Kunne ikke starte maskinen. Prøv igen."));
return false;
}
}
washLaneId.value = laneId;
washStartTime.value = Date.now();
completedDurationMs.value = null;
@@ -246,13 +288,6 @@ export function useWashSessionActions(options) {
reg: licensePlate.trim().toUpperCase(),
});
if (radioWashType.value === "Machine" && isServiceAllowed("MACHINE")) {
try {
await enableMachineRelay(laneId);
} catch (error) {
console.error("Error enabling machine relay after wash start:", error);
}
}
return true;
} catch (error) {
console.error("Error starting self-serve wash:", error);
+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
@@ -4595,7 +4595,6 @@
"loading_vehicle_types": "@.capitalize:{'words.generated.indlæser'} @:{'words.generated.tilgængelige'} @:{'words.generated.køretøjstyper'}...",
"lane_unavailable": "Vaskebanen er ikke tilgængelig",
"machine": "@.capitalize:{'words.generated.maskine'}",
"machine_unavailable_for_lane": "Maskinvask er ikke tilgængelig for den valgte bane. Vælg manuel vask eller en anden ledig bane.",
"no_departments_found": "@:{'templates.generated.compat.departments.no_departments'}",
"occupied": "Optaget",
"open_property_access_gate": "@:{'words.generated.abn'} adgangsport",
-1
View File
@@ -4705,7 +4705,6 @@
"loading_vehicle_types": "@.capitalize:{'words.generated.verf'}?@:{'words.generated.gbare'} @:{'words.generated.fahrzeugtypen'} @:{'words.generated.werden'} @:{'words.generated.geladen'}...",
"lane_unavailable": "Waschspur nicht verfugbar",
"machine": "@:{'words.generated.maschine'}",
"machine_unavailable_for_lane": "Maschinenwasche ist fur die ausgewahlte Spur nicht verfugbar. Wahlen Sie manuelle Wasche oder eine andere freie Spur.",
"no_departments_found": "@:{'templates.generated.compat.departments.no_departments'}",
"occupied": "Belegt",
"open_property_access_gate": "Grundstueckszugangstor @:{'words.generated.oeffnen'}",
-1
View File
@@ -4429,7 +4429,6 @@
"loading_vehicle_types": "@.capitalize:{'words.generated.loading'} @:{'words.generated.available'} @:{'words.generated.vehicle'} @:{'words.generated.types'}...",
"lane_unavailable": "Lane unavailable",
"machine": "@.capitalize:{'words.generated.machine'}",
"machine_unavailable_for_lane": "Machine wash is unavailable for the selected lane. Choose manual wash or another available lane.",
"no_departments_found": "@:{'templates.generated.compat.departments.no_departments'}",
"occupied": "Occupied",
"open_property_access_gate": "@.capitalize:{'words.generated.open'} @:{'words.generated.property'} @:{'words.generated.access'} @:{'words.generated.gate'}",
-1
View File
@@ -4224,7 +4224,6 @@
"loading_vehicle_types": "@:{'templates.generated.compat.self_wash.loading_vehicle_types'}",
"lane_unavailable": "@:{'templates.generated.compat.self_wash.lane_unavailable'}",
"machine": "@:{'templates.generated.compat.self_wash.machine'}",
"machine_unavailable_for_lane": "@:{'templates.generated.compat.self_wash.machine_unavailable_for_lane'}",
"no_departments_found": "@:{'templates.generated.compat.self_wash.no_departments_found'}",
"no_vehicles_found": "@:vehicles.no_vehicles",
"occupied": "@:{'templates.generated.compat.self_wash.occupied'}",
-1
View File
@@ -4706,7 +4706,6 @@
"loading_vehicle_types": "@.capitalize:{'words.generated.laster'} @:{'words.generated.inn'} @:{'words.generated.tilgjengelige'} @:{'words.generated.kjøretøytyper'}...",
"lane_unavailable": "Vaskebanen er ikke tilgjengelig",
"machine": "@.capitalize:{'words.generated.maskin'}",
"machine_unavailable_for_lane": "Maskinvask er ikke tilgjengelig for valgt bane. Velg manuell vask eller en annen ledig bane.",
"no_departments_found": "@:{'templates.generated.compat.departments.no_departments'}",
"occupied": "Okkupert",
"open_property_access_gate": "@:{'words.generated.aapne'} @:{'words.generated.eiendommens'} inngangsport",
-1
View File
@@ -4756,7 +4756,6 @@
"loading_vehicle_types": "@.capitalize:{'words.generated.laser'} @:{'words.generated.in'} @:{'words.generated.tillgangliga'} @:{'words.generated.fordonstyper'}...",
"lane_unavailable": "Tvattbanan ar inte tillganglig",
"machine": "@.capitalize:{'words.generated.machine'}",
"machine_unavailable_for_lane": "Maskintvatt ar inte tillganglig for vald bana. Valj manuell tvatt eller en annan ledig bana.",
"no_departments_found": "@.capitalize:{'words.generated.inga'} @:{'words.generated.avdelningar'} @:{'words.generated.hittades'}",
"occupied": "Upptagen",
"open_property_access_gate": "@:{'words.generated.oeppna'} @:{'words.generated.fastighetens'} ingangsgrind",
-1
View File
@@ -3663,7 +3663,6 @@
"loading_vehicle_types": "Indlæser tilgængelige køretøjstyper...",
"lane_unavailable": "Vaskebanen er ikke tilgængelig",
"machine": "Maskine",
"machine_unavailable_for_lane": "Maskinvask er ikke tilgængelig for den valgte bane. Vælg manuel vask eller en anden ledig bane.",
"vehicle_step_missing_customer_number": "Indtast kundenummer for at fortsætte.",
"vehicle_step_missing_department": "Vent på, at nærmeste afdeling er indlæst, før du fortsætter.",
"vehicle_step_missing_registration": "Indtast registreringsnummer for at fortsætte.",
-1
View File
@@ -3663,7 +3663,6 @@
"loading_vehicle_types": "Verf?gbare Fahrzeugtypen werden geladen...",
"lane_unavailable": "Waschspur nicht verfugbar",
"machine": "Maschine",
"machine_unavailable_for_lane": "Maschinenwasche ist fur die ausgewahlte Spur nicht verfugbar. Wahlen Sie manuelle Wasche oder eine andere freie Spur.",
"vehicle_step_missing_customer_number": "Geben Sie Ihre Kundennummer ein, um fortzufahren.",
"vehicle_step_missing_department": "Warten Sie, bis die nachste Abteilung geladen ist, bevor Sie fortfahren.",
"vehicle_step_missing_registration": "Geben Sie das Kennzeichen ein, um fortzufahren.",
-1
View File
@@ -3662,7 +3662,6 @@
"loading_vehicle_types": "Loading available vehicle types...",
"lane_unavailable": "Lane unavailable",
"machine": "Machine",
"machine_unavailable_for_lane": "Machine wash is unavailable for the selected lane. Choose manual wash or another available lane.",
"vehicle_step_missing_customer_number": "Enter your customer number to continue.",
"vehicle_step_missing_department": "Wait for the nearest department to load before continuing.",
"vehicle_step_missing_registration": "Enter your registration number to continue.",
-1
View File
@@ -3663,7 +3663,6 @@
"loading_vehicle_types": "Laster inn tilgjengelige kjøretøytyper...",
"lane_unavailable": "Vaskebanen er ikke tilgjengelig",
"machine": "Maskin",
"machine_unavailable_for_lane": "Maskinvask er ikke tilgjengelig for valgt bane. Velg manuell vask eller en annen ledig bane.",
"vehicle_step_missing_customer_number": "Skriv inn kundenummer for a fortsette.",
"vehicle_step_missing_department": "Vent til naermeste avdeling er lastet inn for du fortsetter.",
"vehicle_step_missing_registration": "Skriv inn registreringsnummer for a fortsette.",
-1
View File
@@ -3663,7 +3663,6 @@
"loading_vehicle_types": "Läser in tillgängliga fordonstyper...",
"lane_unavailable": "Tvattbanan ar inte tillganglig",
"machine": "Machine",
"machine_unavailable_for_lane": "Maskintvatt ar inte tillganglig for vald bana. Valj manuell tvatt eller en annan ledig bana.",
"vehicle_step_missing_customer_number": "Ange kundnummer for att fortsatta.",
"vehicle_step_missing_department": "Vanta tills narmaste avdelning har laddats innan du fortsatter.",
"vehicle_step_missing_registration": "Ange registreringsnummer for att fortsatta.",
@@ -18,7 +18,6 @@
"loading_vehicle_types": "@.capitalize:{'terms.glossary.indlæser'} @:{'terms.glossary.tilgængelige'} @:{'terms.glossary.køretøjstyper'}...",
"lane_unavailable": "Vaskebanen er ikke tilgængelig",
"machine": "@.capitalize:{'terms.glossary.maskine'}",
"machine_unavailable_for_lane": "Maskinvask er ikke tilgængelig for den valgte bane. Vælg manuel vask eller en anden ledig bane.",
"no_departments_found": "@:{'phrases.compat.departments.no_departments'}",
"occupied": "Optaget",
"open_property_access_gate": "@:{'terms.glossary.abn'} adgangsport",
@@ -18,7 +18,6 @@
"loading_vehicle_types": "@.capitalize:{'terms.glossary.verf'}?@:{'terms.glossary.gbare'} @:{'terms.glossary.fahrzeugtypen'} @:{'terms.glossary.werden'} @:{'terms.glossary.geladen'}...",
"lane_unavailable": "Waschspur nicht verfugbar",
"machine": "@:{'terms.glossary.maschine'}",
"machine_unavailable_for_lane": "Maschinenwasche ist fur die ausgewahlte Spur nicht verfugbar. Wahlen Sie manuelle Wasche oder eine andere freie Spur.",
"no_departments_found": "@:{'phrases.compat.departments.no_departments'}",
"occupied": "Belegt",
"open_property_access_gate": "Grundstueckszugangstor @:{'terms.glossary.oeffnen'}",
@@ -18,7 +18,6 @@
"loading_vehicle_types": "@.capitalize:{'terms.glossary.loading'} @:{'terms.glossary.available'} @:{'terms.glossary.vehicle'} @:{'terms.glossary.types'}...",
"lane_unavailable": "Lane unavailable",
"machine": "@.capitalize:{'terms.glossary.machine'}",
"machine_unavailable_for_lane": "Machine wash is unavailable for the selected lane. Choose manual wash or another available lane.",
"no_departments_found": "@:{'phrases.compat.departments.no_departments'}",
"occupied": "Occupied",
"open_property_access_gate": "@.capitalize:{'terms.glossary.open'} @:{'terms.glossary.property'} @:{'terms.glossary.access'} @:{'terms.glossary.gate'}",
@@ -21,7 +21,6 @@
"loading_vehicle_types": "@:{'phrases.compat.self_wash.loading_vehicle_types'}",
"lane_unavailable": "@:{'phrases.compat.self_wash.lane_unavailable'}",
"machine": "@:{'phrases.compat.self_wash.machine'}",
"machine_unavailable_for_lane": "@:{'phrases.compat.self_wash.machine_unavailable_for_lane'}",
"no_departments_found": "@:{'phrases.compat.self_wash.no_departments_found'}",
"no_vehicles_found": "@:vehicles.no_vehicles",
"occupied": "@:{'phrases.compat.self_wash.occupied'}",
@@ -18,7 +18,6 @@
"loading_vehicle_types": "@.capitalize:{'terms.glossary.laster'} @:{'terms.glossary.inn'} @:{'terms.glossary.tilgjengelige'} @:{'terms.glossary.kjøretøytyper'}...",
"lane_unavailable": "Vaskebanen er ikke tilgjengelig",
"machine": "@.capitalize:{'terms.glossary.maskin'}",
"machine_unavailable_for_lane": "Maskinvask er ikke tilgjengelig for valgt bane. Velg manuell vask eller en annen ledig bane.",
"no_departments_found": "@:{'phrases.compat.departments.no_departments'}",
"occupied": "Okkupert",
"open_property_access_gate": "@:{'terms.glossary.aapne'} @:{'terms.glossary.eiendommens'} inngangsport",
@@ -18,7 +18,6 @@
"loading_vehicle_types": "@.capitalize:{'terms.glossary.laser'} @:{'terms.glossary.in'} @:{'terms.glossary.tillgangliga'} @:{'terms.glossary.fordonstyper'}...",
"lane_unavailable": "Tvattbanan ar inte tillganglig",
"machine": "@.capitalize:{'terms.glossary.machine'}",
"machine_unavailable_for_lane": "Maskintvatt ar inte tillganglig for vald bana. Valj manuell tvatt eller en annan ledig bana.",
"no_departments_found": "@.capitalize:{'terms.glossary.inga'} @:{'terms.glossary.avdelningar'} @:{'terms.glossary.hittades'}",
"occupied": "Upptagen",
"open_property_access_gate": "@:{'terms.glossary.oeppna'} @:{'terms.glossary.fastighetens'} ingangsgrind",
+2 -1
View File
@@ -10,6 +10,7 @@ import applyMiddleware from '@/middleware/index';
import store from './store/user.vue'
import { createHead } from "@vueuse/head";
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import Router from "@/router";
import App from "@/App.vue";
import Buefy from 'buefy'
@@ -71,7 +72,6 @@ const logBuildBanner = () => {
'',
`Build: ${formatCommit(VITE_COMMIT_HASH)} @ ${formatDateTime(VITE_BUILD_DATE)} (${IS_DEV ? 'development' : 'production'})`,
`Remote API: ${getReleaseRuntimeApiBaseUrl()}`,
`Debug: Is running beta FE.`,
].join('\n'));
};
@@ -109,6 +109,7 @@ applyMiddleware(Router)
const app = createApp(App)
.use(Router)
.use(createHead())
.use(createPinia())
.use(store)
.use(ToastPlugin)
.use(Buefy)
+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);
}
};
+152
View File
@@ -0,0 +1,152 @@
import { defineStore } from "pinia";
const createValidationState = () => ({ valid: true, items: [], errors: [], warnings: [] });
const createGatewayWorkspaceState = () => ({ gateways: [], relays: [], lanes: [], issues: [], actions: [] });
const createSimulatorForm = () => ({
lane_id: null,
reg: "TEST123",
customer_number: null,
config_source: "draft",
include_hardware: true,
hardware_mode: "studio",
});
const createPathEditorQuestionDraft = () => ({
question: "New question",
description: "",
scope: "department_lane",
vehicle_mode: "current",
vehicle_type_values: [],
});
const createPathEditorForm = () => ({
path_key: "",
previous_path_key: "",
answers: {},
machine_allowed: true,
task: "Start machine",
description: "",
services: ["MACHINE"],
program_button: "",
press_reset: false,
press_start: true,
extra_buttons: [],
dynamic_images_vehicle_type: "0",
});
const createVirtualHardwareForm = () => ({
gateway_key: "virtual-main",
gateway_label: "Virtual Studio Gateway",
relay_id: "",
role: "MACHINE",
label: "",
});
const createScopeFilters = () => ({
lane_id: "",
vehicle_type_id: "",
});
const createCanvasFilters = () => ({
...createScopeFilters(),
machine_type_id: "",
dynamic_image_id: "",
});
export const useSelfServeStudioStore = defineStore("selfServeStudio", {
state: () => ({
flowNodes: [],
flowEdges: [],
graphPayload: null,
lookups: {},
validation: createValidationState(),
gatewayWorkspace: createGatewayWorkspaceState(),
versions: [],
activePanel: "flow",
selectedNodeId: null,
inspectorForm: {},
simulatorForm: createSimulatorForm(),
simulatorResult: null,
simulatorAnswerOverrides: {},
simulatorShowJson: false,
simulatorDynamicImageHidden: false,
simulatorDynamicImageError: "",
simulatorCompletedTasks: {},
isSimulatorRunning: false,
simulatorRunReason: "",
simulatorRequestSequence: 0,
simulatorLastRequestKey: "",
simulatorLastError: "",
isRestoringSimulatorStorage: false,
pathOutcomes: null,
isLoadingPathOutcomes: false,
pathOutcomesError: "",
pathOutcomesRequestKey: "",
selectedPathOutcomeId: null,
selectedPathCaseSignature: "",
selectedPathCaseIndex: 0,
pathCaseRunSignatures: {},
pathCaseConfirmingSignature: "",
pathEditorDirty: false,
isHydratingPathEditor: false,
pathEditorTaskTitleOverrides: {},
editingPathEditorTaskTitleKey: "",
editingPathEditorTaskTitle: "",
pathVerificationDynamicImageHidden: false,
pathVerificationDynamicImageError: "",
isPathVerificationDynamicImageLoading: false,
pathEditorQuestionDraftVisible: false,
pathEditorQuestionDraft: createPathEditorQuestionDraft(),
pathEditorForm: createPathEditorForm(),
simulatorCustomerSearchQuery: "",
selectedSimulatorCustomer: null,
showSimulatorCustomerResults: false,
scopeGuideDraft: createScopeFilters(),
isScopeGuideManuallyOpen: false,
isSyncingScopeFromRoute: false,
isWritingScopeToRoute: false,
virtualHardwareForm: createVirtualHardwareForm(),
laneEditorMode: "edit",
selectedLaneManagementId: null,
laneForm: {},
lanePreviewHidden: false,
inspectorLanePreviewHidden: false,
inspectorTaskDynamicImageHidden: false,
inspectorTaskDynamicImageError: "",
searchTerm: "",
filters: createCanvasFilters(),
isLoading: false,
isSaving: false,
isUploadingTaskAttachment: false,
deletingTaskAttachmentId: null,
isStudioFullscreen: false,
hasNativeStudioFullscreen: false,
suppressLayoutSave: false,
exportText: "",
importText: "",
draggedTemplateKind: null,
unfilteredNodePositions: null,
}),
actions: {
resetPathEditorQuestionDraft() {
this.pathEditorQuestionDraft = createPathEditorQuestionDraft();
},
resetPathEditorForm() {
this.pathEditorForm = createPathEditorForm();
},
applyRouteScope(scopeFilters = {}) {
const nextScope = {
...createScopeFilters(),
...scopeFilters,
};
this.scopeGuideDraft = nextScope;
this.filters = {
...this.filters,
...nextScope,
};
},
},
});
+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 = "/";
@@ -95,6 +95,25 @@ const getDominantWeather = (entries) => {
return dominant;
};
const getProductivityStatus = (washes, hours) => {
if (hours <= 0) {
return "unknown";
}
const productivity = washes / hours;
return productivity >= 1.3 ? "healthy" : productivity >= 1.0 ? "degraded" : "unhealthy";
};
const getNormalizedTime = (entry) => {
const timeValue = typeof entry?.time === "string" ? entry.time : "";
const normalizedTime = timeValue.slice(0, 5);
return /^\d{2}:\d{2}$/.test(normalizedTime) ? normalizedTime : null;
};
const formatMergedNumber = (value) => {
return Number.isInteger(value) ? value : Number(value.toFixed(2));
};
const summarizeEntriesByDay = (entries) => {
const groupedByDate = new Map();
@@ -120,14 +139,48 @@ const summarizeEntriesByDay = (entries) => {
time: "00:00",
current: dayEntries.some((entry) => entry?.current === true),
weather: getDominantWeather(dayEntries),
washes: Number.isInteger(washes) ? washes : Number(washes.toFixed(2)),
washes: formatMergedNumber(washes),
hours: Number(hours.toFixed(2)),
/**
* Below 1.0 cars/workhour = unhealthy (red)
* 1.0-1.3 cars/workhour = degraded (yellow)
* Above 1.3 cars/workhour = healthy (green)
*/
status: hours > 0 ? (washes / hours >= 1.3 ? "healthy" : washes / hours >= 1.0 ? "degraded" : "unhealthy") : "unknown",
status: getProductivityStatus(washes, hours),
};
});
};
const summarizeEntriesByHourOfDay = (entries) => {
const groupedByTime = new Map();
entries.forEach((entry) => {
const time = getNormalizedTime(entry);
if (!time) {
return;
}
if (!groupedByTime.has(time)) {
groupedByTime.set(time, []);
}
groupedByTime.get(time).push(entry);
});
return Array.from(groupedByTime.entries())
.sort(([timeA], [timeB]) => timeA.localeCompare(timeB))
.map(([time, hourEntries]) => {
const washes = hourEntries.reduce((sum, entry) => sum + toNumberOrZero(entry?.washes), 0);
const hours = hourEntries.reduce((sum, entry) => sum + toNumberOrZero(entry?.hours), 0);
return {
date: null,
test_id_date: "multi-day",
time,
current: hourEntries.some((entry) => entry?.current === true),
weather: getDominantWeather(hourEntries),
washes: formatMergedNumber(washes),
hours: Number(hours.toFixed(2)),
status: getProductivityStatus(washes, hours),
};
});
};
@@ -136,6 +189,10 @@ const hourlyDisplayedState = computed(() => {
return state.value.filter((entry) => hasOperationalData(entry));
});
const hourlyMergedDisplayedState = computed(() => {
return summarizeEntriesByHourOfDay(hourlyDisplayedState.value).filter((entry) => hasOperationalData(entry));
});
const dailyDisplayedState = computed(() => {
return summarizeEntriesByDay(state.value).filter((entry) => hasOperationalData(entry));
});
@@ -144,6 +201,9 @@ const displayedState = computed(() => {
if (summarizeDaily.value && isMultiDaySelection.value) {
return dailyDisplayedState.value;
}
if (isMultiDaySelection.value) {
return hourlyMergedDisplayedState.value;
}
return hourlyDisplayedState.value;
});
@@ -167,7 +227,9 @@ const getEntryKey = (entry, index) => {
};
const getEntryTestId = (prefix, entry) => {
const date = isDateString(entry?.date) ? entry.date : "no-date";
const date = typeof entry?.test_id_date === "string"
? entry.test_id_date
: isDateString(entry?.date) ? entry.date : "no-date";
const time = typeof entry?.time === "string" && entry.time !== "" ? entry.time : "no-time";
return `department-weather-${prefix}-${date}-${time}`;
};
@@ -177,9 +239,8 @@ const getHourDetailsSlotKey = (entry) => {
return null;
}
const timeValue = typeof entry?.time === "string" ? entry.time : "";
const normalizedTime = timeValue.slice(0, 5);
if (!/^\d{2}:\d{2}$/.test(normalizedTime)) {
const normalizedTime = getNormalizedTime(entry);
if (!normalizedTime) {
return null;
}
@@ -1,5 +1,6 @@
<script setup>
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { storeToRefs } from "pinia";
import { useRoute, useRouter } from "vue-router";
import { Handle, Position, VueFlow, useVueFlow } from "@vue-flow/core";
import "@vue-flow/core/dist/style.css";
@@ -18,6 +19,7 @@ import {
isSearching as isSearchingCustomers,
} from "@/components/search/economic/customerSearch.vue";
import { resolveReleaseApiUrl } from "@/services/releaseTimeline.js";
import { safeAttachmentDownloadLink } from "@/services/attachmentDownloadLinks.js";
import {
buildSelfServeDynamicImageUrl,
getSelfServeCompletedDynamicImageStep,
@@ -33,6 +35,7 @@ import {
formatSelfServeTaskButtons,
normalizeSelfServeTaskButtons,
} from "@/components/session/token/SessionUser/Objects/selfServeTaskButtons.js";
import { useSelfServeStudioStore } from "@/stores/selfServeStudioStore.js";
import { resolveAuditIssueNodeIds } from "./auditIssueNavigation.js";
const currentRoute = useRoute();
@@ -366,111 +369,88 @@ const AUTO_LAYOUT_RUNTIME_STAGE_ORDER = {
finish: 2,
};
const flowNodes = ref([]);
const flowEdges = ref([]);
const graphPayload = ref(null);
const lookups = ref({});
const validation = ref({ valid: true, items: [], errors: [], warnings: [] });
const gatewayWorkspace = ref({ gateways: [], relays: [], lanes: [], issues: [], actions: [] });
const versions = ref([]);
const activePanel = ref(panelFromRoute() || "flow");
const selectedNodeId = ref(null);
const inspectorForm = ref({});
const simulatorForm = ref({
lane_id: null,
reg: "TEST123",
customer_number: null,
config_source: "draft",
include_hardware: true,
hardware_mode: "studio",
});
const simulatorResult = ref(null);
const simulatorAnswerOverrides = ref({});
const simulatorShowJson = ref(false);
const simulatorDynamicImageHidden = ref(false);
const simulatorDynamicImageError = ref("");
const simulatorCompletedTasks = ref({});
const isSimulatorRunning = ref(false);
const simulatorRunReason = ref("");
const studioStore = useSelfServeStudioStore();
const {
flowNodes,
flowEdges,
graphPayload,
lookups,
validation,
gatewayWorkspace,
versions,
activePanel,
selectedNodeId,
inspectorForm,
simulatorForm,
simulatorResult,
simulatorAnswerOverrides,
simulatorShowJson,
simulatorDynamicImageHidden,
simulatorDynamicImageError,
simulatorCompletedTasks,
isSimulatorRunning,
simulatorRunReason,
simulatorRequestSequence,
simulatorLastRequestKey,
simulatorLastError,
isRestoringSimulatorStorage,
pathOutcomes,
isLoadingPathOutcomes,
pathOutcomesError,
pathOutcomesRequestKey,
selectedPathOutcomeId,
selectedPathCaseSignature,
selectedPathCaseIndex,
pathCaseRunSignatures,
pathCaseConfirmingSignature,
pathEditorDirty,
isHydratingPathEditor,
pathEditorTaskTitleOverrides,
editingPathEditorTaskTitleKey,
editingPathEditorTaskTitle,
pathVerificationDynamicImageHidden,
pathVerificationDynamicImageError,
isPathVerificationDynamicImageLoading,
pathEditorQuestionDraftVisible,
pathEditorQuestionDraft,
pathEditorForm,
simulatorCustomerSearchQuery,
selectedSimulatorCustomer,
showSimulatorCustomerResults,
scopeGuideDraft,
isScopeGuideManuallyOpen,
isSyncingScopeFromRoute,
isWritingScopeToRoute,
virtualHardwareForm,
laneEditorMode,
selectedLaneManagementId,
laneForm,
lanePreviewHidden,
inspectorLanePreviewHidden,
inspectorTaskDynamicImageHidden,
inspectorTaskDynamicImageError,
searchTerm,
filters,
isLoading,
isSaving,
isUploadingTaskAttachment,
deletingTaskAttachmentId,
isStudioFullscreen,
hasNativeStudioFullscreen,
suppressLayoutSave,
exportText,
importText,
draggedTemplateKind,
unfilteredNodePositions,
} = storeToRefs(studioStore);
activePanel.value = panelFromRoute() || "flow";
studioStore.applyRouteScope(scopeFiltersFromRoute());
const simulatorRunTimer = ref(null);
const simulatorRequestSequence = ref(0);
const simulatorLastRequestKey = ref("");
const simulatorLastError = ref("");
const isRestoringSimulatorStorage = ref(false);
const pathOutcomes = ref(null);
const isLoadingPathOutcomes = ref(false);
const pathOutcomesError = ref("");
const pathOutcomesRequestKey = ref("");
const pathOutcomesAbortController = ref(null);
const selectedPathOutcomeId = ref(null);
const selectedPathCaseSignature = ref("");
const selectedPathCaseIndex = ref(0);
const pathCaseRunSignatures = ref({});
const pathCaseConfirmingSignature = ref("");
const pathEditorDirty = ref(false);
const isHydratingPathEditor = ref(false);
const pathEditorTaskTitleOverrides = ref({});
const editingPathEditorTaskTitleKey = ref("");
const editingPathEditorTaskTitle = ref("");
const pathVerificationDynamicImageHidden = ref(false);
const pathVerificationDynamicImageError = ref("");
const isPathVerificationDynamicImageLoading = ref(false);
const pathEditorQuestionDraftVisible = ref(false);
const pathEditorQuestionDraft = ref(emptyPathEditorQuestionDraft());
const pathEditorForm = ref({
path_key: "",
previous_path_key: "",
answers: {},
machine_allowed: true,
task: "Start machine",
description: "",
services: ["MACHINE"],
program_button: "",
press_reset: false,
press_start: true,
extra_buttons: [],
dynamic_images_vehicle_type: "0",
});
const simulatorCustomerSearchQuery = ref("");
const selectedSimulatorCustomer = ref(null);
const showSimulatorCustomerResults = ref(false);
const scopeGuideDraft = ref(scopeFiltersFromRoute());
const isScopeGuideManuallyOpen = ref(false);
const isSyncingScopeFromRoute = ref(false);
const isWritingScopeToRoute = ref(false);
const virtualHardwareForm = ref({
gateway_key: "virtual-main",
gateway_label: "Virtual Studio Gateway",
relay_id: "",
role: "MACHINE",
label: "",
});
const laneEditorMode = ref("edit");
const selectedLaneManagementId = ref(null);
const laneForm = ref({});
const lanePreviewHidden = ref(false);
const inspectorLanePreviewHidden = ref(false);
const inspectorTaskDynamicImageHidden = ref(false);
const inspectorTaskDynamicImageError = ref("");
const searchTerm = ref("");
const filters = ref({
...scopeFiltersFromRoute(),
machine_type_id: "",
dynamic_image_id: "",
});
const isLoading = ref(false);
const isSaving = ref(false);
const isUploadingTaskAttachment = ref(false);
const deletingTaskAttachmentId = ref(null);
const isStudioFullscreen = ref(false);
const hasNativeStudioFullscreen = ref(false);
const layoutSaveTimer = ref(null);
const suppressLayoutSave = ref(false);
const exportText = ref("");
const importText = ref("");
const draggedTemplateKind = ref(null);
const studioRoot = ref(null);
const unfilteredNodePositions = ref(null);
const graphStats = computed(() => {
const counts = {};
@@ -482,6 +462,18 @@ const graphStats = computed(() => {
});
const permissions = computed(() => graphPayload.value?.permissions || {});
const canEditVehicleTypeProduct = computed(() =>
Boolean(permissions.value.can_edit) && SessionUser.canAccessSuperUser()
);
const destructiveGatewayActions = new Set(["rotate_credentials", "uninstall"]);
const canManageGateways = computed(() => Boolean(permissions.value.can_manage_gateways));
const canRunGatewayDestructiveActions = computed(() =>
Boolean(permissions.value.can_run_gateway_destructive_actions)
);
const gatewayActionRequiresConfirmation = (action) => destructiveGatewayActions.has(action);
const canRunGatewayAction = (action) =>
canManageGateways.value &&
(!gatewayActionRequiresConfirmation(action) || canRunGatewayDestructiveActions.value);
const selectedNode = computed(() => flowNodes.value.find((node) => node.id === selectedNodeId.value) || null);
const selectedNodeKind = computed(() => selectedNode.value?.data?.kind || "");
const editableNodeKinds = ["question", "condition", "task", "action", "lane"];
@@ -3149,14 +3141,14 @@ const inspectorTaskDynamicImageUnavailableReason = computed(() => {
});
const openTaskAttachment = async (attachment, taskId = null) => {
let downloadLink = String(attachment?.download_link || "").trim();
let downloadLink = safeAttachmentDownloadLink(attachment?.download_link);
if (!downloadLink && taskId && attachment?.id) {
try {
const payload = await requestGet("/department/selfserve/tasks/attachments/download", {
task_id: taskId,
attachment_id: attachment.id,
});
downloadLink = String(payload?.download_link || "").trim();
downloadLink = safeAttachmentDownloadLink(payload?.download_link);
} catch (error) {
withErrorToast(error, "Attachment download could not be prepared.");
return;
@@ -4677,6 +4669,11 @@ const deleteSelected = async () => {
};
const saveVehicleTypeProduct = async () => {
if (!canEditVehicleTypeProduct.value) {
toast.error("Only superusers can update vehicle type product catalog fields.");
return;
}
const id = parseNullableInt(
inspectorForm.value.id || selectedRaw.value.product || selectedRaw.value.product_id || selectedRaw.value.id
);
@@ -4762,7 +4759,26 @@ const rollbackVersion = async (versionId) => {
}
};
const runGatewayAction = async (gatewayId, action, confirm = false) => {
const confirmGatewayAction = (action) => {
if (!gatewayActionRequiresConfirmation(action)) {
return false;
}
const actionLabel = action === "rotate_credentials" ? "rotate gateway credentials" : "uninstall this gateway";
return window.confirm(`Are you sure you want to ${actionLabel}? This operation can disrupt live edge hardware.`);
};
const runGatewayAction = async (gatewayId, action) => {
if (!canRunGatewayAction(action)) {
toast.error("You do not have permission to run this gateway action.");
return;
}
const confirm = confirmGatewayAction(action);
if (gatewayActionRequiresConfirmation(action) && !confirm) {
return;
}
try {
await requestPost("/department/selfserve/studio/gateway-action", {
department: departmentId.value,
@@ -7026,7 +7042,7 @@ onBeforeUnmount(() => {
class="studio-debug-attachment"
:disabled="!attachment.download_link"
:title="taskAttachmentLabel(attachment)"
@click.stop="openTaskAttachment(attachment)"
@click.stop="openTaskAttachment(attachment, task.id)"
>
<span class="icon is-small"><i :class="taskAttachmentIcon(attachment)"></i></span>
<span>{{ taskAttachmentLabel(attachment) }}</span>
@@ -7484,7 +7500,7 @@ onBeforeUnmount(() => {
class="studio-node-attachment"
:disabled="!attachment.download_link"
:title="taskAttachmentLabel(attachment)"
@click.stop="openTaskAttachment(attachment)"
@click.stop="openTaskAttachment(attachment, data.object_id)"
@mousedown.stop
>
<span class="icon is-small"><i :class="taskAttachmentIcon(attachment)"></i></span>
@@ -8025,7 +8041,7 @@ onBeforeUnmount(() => {
/>
<span>Wash product</span>
</label>
<button class="button is-primary" type="submit" :disabled="!permissions.can_edit">
<button class="button is-primary" type="submit" :disabled="!canEditVehicleTypeProduct">
<span class="icon"><i class="fas fa-floppy-disk"></i></span>
<span>Save vehicle type</span>
</button>
@@ -8102,6 +8118,7 @@ onBeforeUnmount(() => {
type="button"
title="Discover gateway"
@click="runGatewayAction(inspectorForm.key || selectedGatewayId, 'discovery')"
:disabled="!canRunGatewayAction('discovery')"
>
<span class="icon"><i class="fas fa-radar"></i></span>
<span>Discover</span>
@@ -8111,6 +8128,7 @@ onBeforeUnmount(() => {
type="button"
title="Update gateway"
@click="runGatewayAction(inspectorForm.key || selectedGatewayId, 'update')"
:disabled="!canRunGatewayAction('update')"
>
<span class="icon"><i class="fas fa-upload"></i></span>
<span>Update</span>
@@ -8119,7 +8137,8 @@ onBeforeUnmount(() => {
class="button is-small is-warning is-light"
type="button"
title="Rotate gateway credentials"
@click="runGatewayAction(inspectorForm.key || selectedGatewayId, 'rotate_credentials', true)"
@click="runGatewayAction(inspectorForm.key || selectedGatewayId, 'rotate_credentials')"
:disabled="!canRunGatewayAction('rotate_credentials')"
>
<span class="icon"><i class="fas fa-key"></i></span>
<span>Rotate</span>
@@ -8128,7 +8147,8 @@ onBeforeUnmount(() => {
class="button is-small is-danger is-light"
type="button"
title="Uninstall gateway"
@click="runGatewayAction(inspectorForm.key || selectedGatewayId, 'uninstall', true)"
@click="runGatewayAction(inspectorForm.key || selectedGatewayId, 'uninstall')"
:disabled="!canRunGatewayAction('uninstall')"
>
<span class="icon"><i class="fas fa-trash"></i></span>
<span>Uninstall</span>
@@ -9316,6 +9336,7 @@ onBeforeUnmount(() => {
class="button is-small is-link is-light"
title="Discover gateway"
@click="runGatewayAction(gateway.id, 'discovery')"
:disabled="!canRunGatewayAction('discovery')"
>
<span class="icon"><i class="fas fa-radar"></i></span>
</button>
@@ -9323,20 +9344,23 @@ onBeforeUnmount(() => {
class="button is-small is-light"
title="Update gateway"
@click="runGatewayAction(gateway.id, 'update')"
:disabled="!canRunGatewayAction('update')"
>
<span class="icon"><i class="fas fa-upload"></i></span>
</button>
<button
class="button is-small is-warning is-light"
title="Rotate gateway credentials"
@click="runGatewayAction(gateway.id, 'rotate_credentials', true)"
@click="runGatewayAction(gateway.id, 'rotate_credentials')"
:disabled="!canRunGatewayAction('rotate_credentials')"
>
<span class="icon"><i class="fas fa-key"></i></span>
</button>
<button
class="button is-small is-danger is-light"
title="Uninstall gateway"
@click="runGatewayAction(gateway.id, 'uninstall', true)"
@click="runGatewayAction(gateway.id, 'uninstall')"
:disabled="!canRunGatewayAction('uninstall')"
>
<span class="icon"><i class="fas fa-trash"></i></span>
</button>
@@ -9802,7 +9826,7 @@ onBeforeUnmount(() => {
class="studio-debug-attachment"
:disabled="!attachment.download_link"
:title="taskAttachmentLabel(attachment)"
@click.stop="openTaskAttachment(attachment)"
@click.stop="openTaskAttachment(attachment, task.id)"
>
<span class="icon is-small"><i :class="taskAttachmentIcon(attachment)"></i></span>
<span>{{ taskAttachmentLabel(attachment) }}</span>
@@ -10685,10 +10709,12 @@ onBeforeUnmount(() => {
background: #ffffff;
border: 1px solid #e2e8f0;
border-radius: 7px;
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: 8px;
min-height: 46px;
min-width: 0;
padding: 8px 10px;
}
@@ -10711,10 +10737,13 @@ onBeforeUnmount(() => {
.studio-verification-answer-controls {
align-items: center;
align-self: stretch;
display: flex;
flex-wrap: wrap;
gap: 6px;
justify-content: flex-end;
justify-content: flex-start;
max-width: 100%;
min-width: 0;
}
.studio-answer-switch,
@@ -10732,12 +10761,19 @@ onBeforeUnmount(() => {
border-radius: 999px;
color: #334155;
display: inline-flex;
flex: 1 1 132px;
gap: 7px;
justify-content: space-between;
max-width: 100%;
min-height: 32px;
min-width: 0;
padding: 4px 8px 4px 5px;
}
.studio-answer-switch strong {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@@ -10758,6 +10794,7 @@ onBeforeUnmount(() => {
background: #0f172a;
border-radius: 999px;
display: inline-flex;
flex: 0 0 38px;
height: 20px;
padding: 2px;
width: 38px;
@@ -10788,6 +10825,7 @@ onBeforeUnmount(() => {
border-radius: 999px;
color: #475569;
display: inline-flex;
flex: 0 0 30px;
height: 30px;
justify-content: center;
width: 30px;
@@ -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"
@@ -55,6 +55,7 @@ const hasStartFormUserInput = ref(false);
const shouldRestoreServerActiveWash = ref(false);
const isRestoringServerActiveWash = ref(false);
const isSyncingActiveWash = ref(false);
const hasConfirmedActiveWashOnServer = ref(false);
const MIN_FINISHING_SCREEN_MS = 250;
const RECENT_COMPLETED_WASH_KEY = "mywash_recent_completed_v1";
const RECENT_COMPLETED_WASH_SUPPRESSION_MS = 10 * 60 * 1000;
@@ -83,6 +84,7 @@ const {
isForcingNearestDepartment,
forceNearestDepartmentEvaluationId,
isSearchingDepartments,
departmentFetchError,
availableProductIds,
doesCurrentDepartmentSelectionHaveSelfServeEnabled,
fetchDepartments,
@@ -436,6 +438,8 @@ const extractErrorMessage = (error: any, fallback: string) => {
return fallback;
};
const getRequestStatus = (error: any) => Number(error?.response?.status ?? error?.status ?? 0);
watch(dynamicImageUrl, () => {
hideDynamicImage.value = false;
});
@@ -654,18 +658,28 @@ const fetchSelfServeData = async () => {
await fetchSelfServeDataInternal(departmentId, vehicleTypeSelect.value || null, laneId, normalizedReg);
};
const getServerActiveWashCandidates = () => {
const SERVER_ACTIVE_WASH_ENDPOINT = "/modules/self-serve/lane/wash/my-active-wash";
const findServerActiveWashCandidate = (laneId: number | string | null) => {
const normalizedLaneId = normalizeLaneId(laneId);
if (!normalizedLaneId) {
return null;
}
const departments = Array.isArray(guestDepartments.value) ? guestDepartments.value : [];
return departments.flatMap((department: any) => {
for (const department of departments) {
const lanes = Array.isArray(department?.lanes) ? department.lanes : [];
return lanes
.map((lane: any) => ({
const lane = lanes.find((candidateLane: any) => normalizeLaneId(candidateLane?.id) === normalizedLaneId);
if (lane) {
return {
department,
lane,
laneId: normalizeLaneId(lane?.id),
}))
.filter((candidate: any) => candidate.laneId);
});
laneId: normalizedLaneId,
};
}
}
return { laneId: normalizedLaneId };
};
const isAuthenticatedCustomerActiveWash = (details: any, customerNumber: number) => {
@@ -769,31 +783,34 @@ const fetchServerActiveWash = async () => {
return null;
}
const candidates = getServerActiveWashCandidates();
if (candidates.length === 0) {
let response = null;
try {
response = await SessionUser.request(SERVER_ACTIVE_WASH_ENDPOINT, "GET");
} catch (error) {
if (getRequestStatus(error) === 404) {
return null;
}
throw error;
}
const details = unwrapApiData(response);
if (!isAuthenticatedCustomerActiveWash(details, customerNumber)) {
return null;
}
const responses = await Promise.allSettled(
candidates.map(async (candidate: any) => {
const response = await SessionUser.request("/modules/self-serve/lane/wash/in-progress", "GET", {
lane_id: candidate.laneId,
});
return {
...candidate,
details: unwrapApiData(response),
};
})
);
const laneId = normalizeLaneId(details?.lane_id ?? details?.session?.lane_id);
const activeWashCandidate = findServerActiveWashCandidate(laneId);
const activeWash = {
...(activeWashCandidate || {}),
details,
};
const activeMatches = responses
.filter((entry): entry is PromiseFulfilledResult<any> => entry.status === "fulfilled")
.map((entry) => entry.value)
.filter((entry) => isAuthenticatedCustomerActiveWash(entry.details, customerNumber))
.filter((entry) => !isRecentlyCompletedActiveWash(entry))
.sort((left, right) => activeWashTimestamp(right.details) - activeWashTimestamp(left.details));
if (isRecentlyCompletedActiveWash(activeWash)) {
return null;
}
return activeMatches[0] || null;
return activeWash;
};
const applyServerActiveWash = async (activeWash: any) => {
@@ -817,6 +834,7 @@ const applyServerActiveWash = async (activeWash: any) => {
}
washInProgress.value = true;
hasConfirmedActiveWashOnServer.value = true;
washLaneId.value = laneId;
radioLaneOption.value = laneId;
licensePlateInput.value = reg;
@@ -845,7 +863,10 @@ const applyServerActiveWash = async (activeWash: any) => {
const summary = await fetchWashSummary(summaryParams, false);
if (!summary) {
await fetchSelfServeDataInternal(departmentId || nearestDepartment.value?.id, selectedVehicleTypeId, laneId, reg);
const fallbackDepartmentId = departmentId || nearestDepartment.value?.id;
if (fallbackDepartmentId) {
await fetchSelfServeDataInternal(fallbackDepartmentId, selectedVehicleTypeId, laneId, reg);
}
}
saveProgress("serverActiveWash");
@@ -898,6 +919,7 @@ const clearLocalActiveWashFromServer = () => {
completedDurationMs.value = washStartTime.value ? now.value - washStartTime.value : 0;
currentStep.value = steps.COMPLETED;
washInProgress.value = false;
hasConfirmedActiveWashOnServer.value = false;
washLaneId.value = null;
washStartTime.value = null;
shouldRestoreServerActiveWash.value = false;
@@ -932,9 +954,15 @@ const syncActiveWashWithServer = async () => {
details?.session?.customer_number ?? details?.customer?.customer_number
);
const serverStillMatchesCurrentWash =
!!details?.in_progress && (!customerNumber || !serverCustomerNumber || serverCustomerNumber === customerNumber);
!!details?.in_progress && (!customerNumber || serverCustomerNumber === customerNumber);
const serverReportedNoActiveWash = details?.in_progress === false;
const shouldPreserveUnconfirmedLocalWash = serverReportedNoActiveWash && !hasConfirmedActiveWashOnServer.value;
if (!serverStillMatchesCurrentWash || isRecentlyCompletedActiveWash({ details, laneId })) {
if (shouldPreserveUnconfirmedLocalWash) {
return;
}
const isWithinStartGracePeriod =
washStartTime.value && Date.now() - washStartTime.value < WASH_START_SERVER_SYNC_GRACE_MS;
if (isWithinStartGracePeriod && !isRecentlyCompletedActiveWash({ details, laneId })) {
@@ -945,6 +973,7 @@ const syncActiveWashWithServer = async () => {
return;
}
hasConfirmedActiveWashOnServer.value = true;
const session = details?.session || {};
const vehicle = details?.vehicle || {};
const serverReg = normalizeLicensePlate(session?.reg ?? vehicle?.reg);
@@ -1158,6 +1187,7 @@ const onConfirmNext = async () => {
const onCloseCompleted = () => {
stopElapsedTimer();
washInProgress.value = false;
hasConfirmedActiveWashOnServer.value = false;
washLaneId.value = null;
washStartTime.value = null;
completedDurationMs.value = null;
@@ -1328,10 +1358,15 @@ watch(
watch([() => washInProgress.value, () => washLaneId.value], ([inProgress]) => {
if (inProgress) {
if (isRestoring.value) {
return;
}
hasConfirmedActiveWashOnServer.value = true;
startActiveWashRefresh();
return;
}
hasConfirmedActiveWashOnServer.value = false;
stopActiveWashRefresh();
});
@@ -1380,6 +1415,9 @@ watch(
if (!restoring) {
ensureEffectiveLaneSelection();
fetchSelfServeData();
if (washInProgress.value) {
startActiveWashRefresh();
}
}
}
);
@@ -1436,10 +1474,31 @@ watch(
</div>
</section>
<b-message
v-if="departmentFetchError"
type="is-warning"
has-icon
:closable="false"
data-testid="self-serve-departments-error"
>
<div class="is-flex is-align-items-center is-justify-content-space-between is-flex-wrap-wrap">
<span class="mr-3">{{ departmentFetchError }}</span>
<b-button
size="is-small"
type="is-warning is-light"
icon-pack="fas"
icon-left="sync-alt"
data-testid="self-serve-departments-retry"
@click="fetchDepartments"
>
{{ $t("common.try_again") }}
</b-button>
</div>
</b-message>
<b-message
v-if="selfServeDataError"
type="is-danger"
has-icon
:closable="false"
data-testid="self-serve-runtime-error"
>
+8 -8
View File
@@ -265,11 +265,11 @@ test.describe("Admin daily report", () => {
await page.goto("/admin/1/modules/daily-report?departmentIds=1&dateFrom=2026-03-23&dateTo=2026-03-24");
await expect(page.getByTestId("daily-report-page")).toBeVisible();
await expect(page.getByTestId("department-weather-hours-2026-03-23-06:00")).toHaveText("1");
await expect(page.getByTestId("department-weather-hours-2026-03-23-19:00")).toHaveText("19");
await expect(page.getByTestId("department-weather-hours-2026-03-23-20:00")).toHaveText("21");
await expect(page.getByTestId("department-weather-productivity-2026-03-23-06:00")).toHaveClass(/red/);
await expect(page.getByTestId("department-weather-productivity-2026-03-24-08:00")).toHaveClass(/gray/);
await expect(page.getByTestId("department-weather-hours-multi-day-06:00")).toHaveText("1");
await expect(page.getByTestId("department-weather-hours-multi-day-19:00")).toHaveText("19");
await expect(page.getByTestId("department-weather-hours-multi-day-20:00")).toHaveText("21");
await expect(page.getByTestId("department-weather-productivity-multi-day-06:00")).toHaveClass(/red/);
await expect(page.getByTestId("department-weather-productivity-multi-day-08:00")).toHaveClass(/gray/);
await page.getByTestId("department-weather-summary-toggle").click();
@@ -319,9 +319,9 @@ test.describe("Admin daily report", () => {
await page.goto("/admin/1/modules/daily-report?departmentIds=1&dateFrom=2026-03-23&dateTo=2026-03-24");
await expect(page.getByTestId("daily-report-page")).toBeVisible();
await expect(page.getByTestId("department-weather-hours-2026-03-23-12:00")).toHaveText("1");
await expect(page.getByTestId("department-weather-hours-2026-03-24-13:00")).toHaveText("1");
await expect(page.getByTestId("department-weather-hours-2026-03-24-14:00")).toHaveCount(0);
await expect(page.getByTestId("department-weather-hours-multi-day-12:00")).toHaveText("1");
await expect(page.getByTestId("department-weather-hours-multi-day-13:00")).toHaveText("1");
await expect(page.getByTestId("department-weather-hours-multi-day-14:00")).toHaveCount(0);
await page.getByTestId("department-weather-summary-toggle").click();
@@ -466,10 +466,10 @@ test.describe("Admin overview night washes", () => {
await expect(machineStatusTooltip).toContainText("2026");
}
await expect(page.locator("#dognvask-1-101")).toBeChecked();
await expect(page.locator("#dognvask-2-202")).not.toBeChecked();
await expect(page.locator("#dognvask-2-202")).toBeChecked();
await expect(page.getByTestId("overview-department-2-lane-202-dognvask").locator(".switch")).toHaveCSS(
"background-color",
"rgb(238, 75, 43)"
"rgb(146, 208, 80)"
);
await expect(page.getByTestId("overview-department-3-night-washes")).not.toContainText(
/mangler|Opening hours missing|Missing Hours/
+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 }) => {
-2
View File
@@ -31,8 +31,6 @@ const expectedFuldfValues = {
const expectedSelfWashValues = {
"self_wash.lane_unavailable": "Vaskebanen er ikke tilgængelig",
"self_wash.machine_unavailable_for_lane":
"Maskinvask er ikke tilgængelig for den valgte bane. Vælg manuel vask eller en anden ledig bane.",
"self_wash.vehicle_step_missing_customer_number": "Indtast kundenummer for at fortsætte.",
"self_wash.vehicle_step_missing_department": "Vent på, at nærmeste afdeling er indlæst, før du fortsætter.",
"self_wash.vehicle_step_missing_registration": "Indtast registreringsnummer for at fortsætte.",
+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({
+30 -9
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,
@@ -705,7 +726,7 @@ test.describe("Self-serve wash", () => {
expect(startCommandRequest.postDataJSON?.()).toMatchObject({
command: "START",
customer_number: 12345679,
defer_relay_side_effects: true,
defer_relay_side_effects: false,
});
await page.waitForTimeout(250);
expect(orderFilters.some((filters) => filters.includes("customer_id:777"))).toBe(false);
+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");
});
});

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