Compare commits

..
Author SHA1 Message Date
copilot-swe-agent[bot] 51b996d8cb Initial plan 2026-06-07 20:16:58 +00:00
Jeppe Bundgaard 880680d7f8 Add reusable ErrorBanner component and integrate into MyWashStart 2026-06-07 22:07:56 +02:00
Jeppe Bundgaard 7d5ed772c2 Avoid empty MyWash lane service sync 2026-06-04 07:40:33 +02:00
Jeppe Bundgaard 83fbcdeaa8 Refresh MyWash eligibility before service sync 2026-06-04 07:31:41 +02:00
Jeppe Bundgaard 55a761d3a5 Publish MyWash tasks before hydration 2026-06-04 07:27:12 +02:00
Jeppe Bundgaard abe8e7750f Preserve MyWash machine services during sync 2026-06-04 07:21:07 +02:00
Jeppe Bundgaard aeef75b831 Clear stale MyWash summary after preview 2026-06-04 07:13:00 +02:00
Jeppe Bundgaard d6356d0244 Stop MyWash eligibility refresh after completion 2026-06-04 06:35:26 +02:00
Jeppe Bundgaard bbf2aeb95f Keep MyWash completion visible after stop 2026-06-04 06:05:41 +02:00
Jeppe Bundgaard 3a7d78ae05 Serve release-prefixed index shell 2026-06-04 04:08:33 +02:00
Jeppe Bundgaard 2a2f752692 Refresh recent completion marker during startup 2026-06-04 03:51:43 +02:00
Jeppe Bundgaard 8883bf8a07 Seed recently completed wash state before startup awaits 2026-06-04 03:44:29 +02:00
Jeppe Bundgaard 6ec80e2796 Recover recently completed self-serve screen 2026-06-04 03:29:55 +02:00
Jeppe Bundgaard 849a7b77cf Adopt resolved self-serve vehicle type 2026-06-04 03:16:59 +02:00
Jeppe Bundgaard 36ccd0c381 Keep completed wash fallback visible 2026-06-04 03:01:09 +02:00
Jeppe Bundgaard 58e7c8d6c9 Keep completed wash after lane refresh 2026-06-04 02:44:48 +02:00
Jeppe Bundgaard d04fc8cc0f Keep completed self-serve wash visible 2026-06-04 02:29:16 +02:00
Jeppe Bundgaard 339a1b1452 Recover completed wash after stop timeout 2026-06-04 02:15:15 +02:00
Jeppe Bundgaard 2d2d63d09e Merge remote-tracking branch 'origin/master' 2026-06-04 02:02:26 +02:00
Jeppe B 8241f7fd2f Treat unoccupied stop invoice errors as completed washes 2026-06-04 02:01:35 +02:00
Jeppe Bundgaard 985571af87 Treat unoccupied stop invoice errors as completed washes 2026-06-04 01:55:35 +02:00
Jeppe Bundgaard 82e1e4a466 Ignore preview-only active wash reservations 2026-06-04 01:21:25 +02:00
Jeppe Bundgaard 9a7f7bbb78 Prevent preview-only self-serve sessions from auto-restoring 2026-06-04 01:08:04 +02:00
Jeppe Bundgaard 359f29b75e Keep self-serve preview questions visible during summary load 2026-06-04 00:45:20 +02:00
Jeppe B ceced7cbaa Show MyWash completion when Buefy hides step panel 2026-06-04 00:21:33 +02:00
Jeppe Bundgaard 6b5cd00b10 Show completed wash outside step panels 2026-06-04 00:10:36 +02:00
Jeppe Bundgaard 02eae47d34 Refactor toggle-task event emission in SelfServeTaskList component 2026-06-04 00:00:28 +02:00
Jeppe Bundgaard 3f01d38676 Enhance self-serve image loading with preload and error handling 2026-06-03 23:53:54 +02:00
Jeppe Bundgaard 390a29ef55 Merge remote-tracking branch 'origin/master' 2026-06-03 23:34:05 +02:00
Jeppe Bundgaard b7f5786747 Add vehicle type selection logic for departments without product allow-list 2026-06-03 23:33:54 +02:00
Jeppe B 814c535757 Use relative release manifest asset paths 2026-06-03 23:13:26 +02:00
Jeppe Bundgaard ed0e27e4fd Use relative release manifest asset paths 2026-06-03 23:07:17 +02:00
Jeppe Bundgaard 659df480d7 Merge remote-tracking branch 'origin/master' 2026-06-03 23:04:43 +02:00
Jeppe B d0c45f6180 Fix release frontend origin routing 2026-06-03 22:45:32 +02:00
Jeppe Bundgaard 5a20461bd4 Fix release frontend origin routing 2026-06-03 22:40:49 +02:00
Jeppe Bundgaard 849f52fb8e Refactor error message display and update test descriptions for clarity 2026-06-03 22:16:27 +02:00
Jeppe Bundgaard da0c699b88 Add self-serve session handling and lane selection normalization 2026-06-03 22:07:47 +02:00
Jeppe Bundgaard af5514c982 Implement dynamic base path handling for frontend assets and update routing logic 2026-06-03 22:00:18 +02:00
Jeppe Bundgaard cc689e80a9 Use release base for frontend HTML assets 2026-06-03 21:45:25 +02:00
Jeppe Bundgaard 3b51374678 Add dynamic release frontend base path handling and update static asset references 2026-06-03 21:44:27 +02:00
Jeppe Bundgaard f07569db98 Allow active self-serve lane selection 2026-06-03 21:32:58 +02:00
Jeppe Bundgaard c5fcb90683 Include emitted chunks in release manifest 2026-06-03 21:12:08 +02:00
Jeppe Bundgaard 270f87dee1 Split release smoke static and app targets 2026-06-03 21:03:03 +02:00
Jeppe Bundgaard 765b8f3b35 Fix self-serve entry regressions 2026-06-03 20:51:06 +02:00
Jeppe Bundgaard 3ff695899c Restore self-serve task phase from active sessions 2026-06-03 20:32:40 +02:00
Jeppe Bundgaard be35baa838 Refactor self-serve registration field for improved layout and accessibility 2026-06-03 20:06:31 +02:00
Jeppe B 470d4d7314 Normalize release bootstrap cleanup 2026-06-03 19:38:54 +02:00
Jeppe B 0976608a25 Refresh release bootstrap asset hash 2026-06-03 19:34:48 +02:00
Jeppe B e7f4dfa07e Trigger frontend release after asset mismatch 2026-06-03 19:23:01 +02:00
Jeppe B 2c68f335c6 Trigger frontend release for self-serve vehicle fix 2026-06-03 18:08:25 +02:00
Jeppe Bundgaard cf29589e29 Fix self-serve vehicle step progression 2026-06-03 17:51:46 +02:00
Jeppe Bundgaard 05cabd8edc Add margin and padding adjustments to self-serve task checkbox 2026-06-02 20:09:18 +02:00
Jeppe Bundgaard 2f4ecba51a Merge remote-tracking branch 'origin/master' 2026-06-02 20:04:11 +02:00
Jeppe Bundgaard 74a7c9f23f Refactor self-serve task list to improve task title handling and remove unnecessary description checks 2026-06-02 20:03:55 +02:00
Jeppe B b075a717f7 Cover hidden customer number self-serve flow 2026-06-02 19:59:30 +02:00
Jeppe B a3dcb888d3 Cover self-serve task title presentation 2026-06-02 19:59:12 +02:00
Jeppe B 02706af508 Allow QR self-serve vehicle step without customer field 2026-06-02 19:58:50 +02:00
Jeppe B 1f2c007776 Tighten self-serve task presentation 2026-06-02 19:58:28 +02:00
Jeppe Bundgaard 42dfdfef13 Add mocks for department daily report components and update Vitest configuration for SCSS 2026-06-02 17:54:37 +02:00
Jeppe B b40bf0b9e1 Merge pull request #110 from copenhagentruckwash/create-playwright-test-suite-for-mywashstart
Add mocked Playwright tests for MyWashStart flow and improve answer-sync & department fallback
2026-06-02 13:52:00 +02:00
Jeppe B cc15a9c468 Merge branch 'master' into create-playwright-test-suite-for-mywashstart 2026-06-02 13:51:52 +02:00
Jeppe B 5d8b1eecb4 Merge pull request #111 from copenhagentruckwash/create-playwright-test-suite-for-mywashstart-6chtnh
Add mocked Playwright tests for MyWashStart flow and improve answer-sync & department fallback
2026-06-02 13:50:32 +02:00
Jeppe B 093d82d762 Merge branch 'master' into create-playwright-test-suite-for-mywashstart-6chtnh 2026-06-02 13:50:24 +02:00
Jeppe B 686577c209 Merge pull request #114 from copenhagentruckwash/update-mywashstart.vue-with-timeout-handling
Fix delayed active wash restore cleanup
2026-06-02 13:48:47 +02:00
Jeppe B c2b333a78b Merge branch 'master' into update-mywashstart.vue-with-timeout-handling 2026-06-02 13:48:33 +02:00
Jeppe B cef8b8bf0f Merge pull request #115 from copenhagentruckwash/update-userdashboard-to-inspect-lane-readiness
Guard wash entry self-serve CTA
2026-06-02 13:47:39 +02:00
Jeppe B 62a46710ba Merge branch 'master' into update-userdashboard-to-inspect-lane-readiness 2026-06-02 13:47:31 +02:00
Jeppe B a3e6feb07e Merge pull request #113 from copenhagentruckwash/refactor-self-serve-data-loading-with-fetch-scheduler
Debounce self-serve wash data fetches
2026-06-02 13:43:40 +02:00
Jeppe B 2ad093d4f1 Merge pull request #112 from copenhagentruckwash/update-mywashstart.vue-for-answer-sync-state
Block wash question flow on failed answer sync
2026-06-02 13:43:22 +02:00
Jeppe B 6017d576fc Merge pull request #116 from copenhagentruckwash/update-wash-department-handling-and-ui
Handle self-serve wash when geolocation is unavailable
2026-06-02 13:42:51 +02:00
Jeppe B 08ac38dc87 Merge pull request #117 from copenhagentruckwash/add-and-extend-unit-and-e2e-tests
Add production-ready tests for user self-serve wash start (unit + mocked e2e)
2026-06-02 13:42:36 +02:00
Jeppe B 3960daa556 Add production wash start test matrix 2026-06-02 13:42:22 +02:00
Jeppe B 395cbd87dd Handle self-serve wash without geolocation 2026-06-02 13:42:16 +02:00
Jeppe B 23429c047f Guard wash entry self-serve CTA 2026-06-02 13:42:09 +02:00
Jeppe B af68748c16 Fix active wash restore timeout cleanup 2026-06-02 13:42:02 +02:00
Jeppe B 18ab007f87 Debounce self-serve wash data fetches 2026-06-02 13:41:55 +02:00
Jeppe B 63942045be Block wash question flow on failed answer sync 2026-06-02 13:41:51 +02:00
Jeppe B a4cdc6e4f3 Add mocked MyWashStart flow coverage 2026-06-02 13:41:44 +02:00
Jeppe B 07c611bdbe Add mocked MyWashStart flow coverage 2026-06-02 13:41:32 +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
142 changed files with 8641 additions and 1041 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
+5 -3
View File
@@ -19,10 +19,12 @@ jobs:
runs-on: [self-hosted, Linux, X64, default]
env:
RELEASE_BASE_URL: https://api-v2.truckwash.io/master/frontend
PLAYWRIGHT_BASE_URL: https://api-v2.truckwash.io/master/frontend
PLAYWRIGHT_BASE_URL: https://dev.truckwash.io
PLAYWRIGHT_RELEASE_STATIC_BASE_URL: https://api-v2.truckwash.io/master/frontend
PLAYWRIGHT_RELEASE_API_BASE_URL: https://api-v2.truckwash.io
PLAYWRIGHT_RELEASE_API_PING_PATHS: /ping,/master/api/ping,/canary/api/ping,/stable/api/ping
PLAYWRIGHT_RELEASE_API_PING_PATHS: /master/api/ping
RELEASE_BUILD_ID: ${{ github.run_id }}-${{ github.run_attempt }}
RELEASE_EXPECTED_BUILD_ID: ${{ github.run_id }}-${{ github.run_attempt }}
RELEASE_EXPECTED_COMMIT: ${{ github.sha }}
RELEASE_WAIT_INITIAL_SECONDS: 45
RELEASE_WAIT_TIMEOUT_SECONDS: 600
@@ -98,7 +100,7 @@ jobs:
-X POST "$RELEASE_MANAGER_GATE_URL" \
-H "Authorization: Bearer $RELEASE_MANAGER_GATE_TOKEN" \
-H "Content-Type: application/json" \
--data "{\"environment_url\":\"$RELEASE_BASE_URL\",\"channel_slug\":\"stable\",\"app\":\"frontend\",\"repository\":\"$RELEASE_REPOSITORY\",\"branch\":\"$RELEASE_BRANCH\",\"expected_commit\":\"$RELEASE_EXPECTED_COMMIT\",\"workflow_url\":\"$RELEASE_WORKFLOW_URL\",\"auto_sync\":true,\"wait_timeout_seconds\":300,\"poll_interval_seconds\":10,\"required_checks\":[\"static_artifact\",\"api_gateway\"]}"
--data "{\"environment_url\":\"$RELEASE_BASE_URL\",\"channel_slug\":\"stable\",\"app\":\"frontend\",\"repository\":\"$RELEASE_REPOSITORY\",\"branch\":\"$RELEASE_BRANCH\",\"expected_commit\":\"$RELEASE_EXPECTED_COMMIT\",\"build_id\":\"$RELEASE_EXPECTED_BUILD_ID\",\"workflow_url\":\"$RELEASE_WORKFLOW_URL\",\"auto_sync\":true,\"wait_timeout_seconds\":300,\"poll_interval_seconds\":10,\"required_checks\":[\"static_artifact\",\"api_gateway\"]}"
env:
RELEASE_MANAGER_GATE_URL: ${{ secrets.RELEASE_MANAGER_GATE_URL || 'https://api.truckwash.io/release/gate/test-runs' }}
RELEASE_MANAGER_GATE_TOKEN: ${{ secrets.RELEASE_MANAGER_GATE_TOKEN }}
+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
+4
View File
@@ -0,0 +1,4 @@
{
"version": 1,
"setupCompletedAt": "2026-06-07T11:37:04.444Z"
}
+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
@@ -0,0 +1,33 @@
# User wash start production-readiness QA matrix
This note documents deterministic coverage for the self-serve user wash start flow. The `@dynamic-image` Playwright case remains separated because it validates rendered image behavior in addition to deterministic state and API transitions.
## Unit matrix
| Area | Required scenario | Coverage |
| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `useWashFlowState` | Step transitions for vehicle, questions, lane, tasks, in-progress, completed | `tests/unit/use-wash-flow-state-production.spec.js` validates step clickability, next-button gating, questions-to-lane updates, lane-to-start target selection, task completion transition, in-progress navigation, and completed-step non-clickability contract. |
| `useWashSessionActions` | START success and failure | `tests/unit/use-wash-session-actions-production.spec.js` covers successful manual START state mutation and failed START retryability without active-wash mutation. |
| `useWashSessionActions` | Relay enable success and relay enable failure with STOP rollback | `tests/unit/use-wash-session-actions-production.spec.js` covers machine relay success and relay failure rollback via STOP. |
| `useWashSessionActions` | STOP failure and already-stopped STOP recovery | `tests/unit/use-wash-session-actions-production.spec.js` covers failed STOP preserving active state and already-not-occupied STOP clearing local state. |
| `useSelfServeLogic` | Preview/summary merge | `tests/unit/use-self-serve-logic-production.spec.js` covers preview questions merging with summary questions, answer maps, visible question order, tasks, and allowed services. |
| `useSelfServeLogic` | Request race handling | `tests/unit/use-self-serve-logic-production.spec.js` covers stale preview/summary responses being ignored when a newer request wins. |
| `useSelfServeLogic` | Allowed services updates | `tests/unit/use-self-serve-logic-production.spec.js` covers lane allowed-service endpoint updates and first-failure fallback behavior. |
| `useSelfServeLogic` | Answer sync failures | `tests/unit/use-self-serve-logic-production.spec.js` covers error reporting while preserving the caller's optimistic local answer. |
| `MyWashStart.vue` | Local progress restore, server active-wash restore, recent-completion suppression, unmount cleanup, duplicate-fetch prevention | `tests/unit/my-wash-start-production.spec.js` locks the component contracts for restore ordering, authenticated active-wash application, recent-completion suppression across restore/polling, unmount cleanup, and duplicate-fetch/sync guards. |
## E2E mocked matrix
| Required scenario | Coverage |
| -------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Full manual flow | `tests/e2e/self-serve-wash.spec.js` covers direct manual wash start, guided completion, STOP, exit-gate open, completed state, and close/reset. |
| Full machine flow | `tests/e2e/self-serve-wash.spec.js` covers machine task rendering, dynamic image progress, required task completion, machine start path, and reload persistence. |
| Reload/resume active wash | `tests/e2e/self-serve-wash.spec.js` covers local progress reload and authenticated server active-wash resume from another device. |
| Backend says wash completed during polling | `tests/e2e/self-serve-wash.spec.js` covers server polling of active wash and local transition to completed when the backend no longer reports the matching in-progress wash. |
| Network failures for preview, answer sync, START, relay enable, STOP | `tests/e2e/self-serve-wash.spec.js` covers retryable START failure, STOP failure preservation, allowed-services gateway timeout/retry, answer sync background resilience, and unit-level relay rollback. Add mocked network route overrides when expanding browser-level failure assertions. |
## Optional live smoke
| Optional scenario | Coverage |
| ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Dynamic image smoke | The `@dynamic-image` Playwright test in `tests/e2e/self-serve-wash.spec.js` is tagged separately from deterministic CI coverage so it can be included or excluded explicitly with Playwright grep controls. |
+13 -4
View File
@@ -2,10 +2,19 @@
<html lang="" class="theme-light" data-theme="light">
<head>
<meta charset="UTF-8">
<link rel="icon" type="image/png" href="/assets/favicons/favicon-96x96.png" sizes="96x96" />
<link rel="icon" type="image/svg+xml" href="/assets/favicons/favicon.svg" />
<link rel="shortcut icon" href="/assets/favicons/favicon.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="/assets/favicons/apple-touch-icon.png" />
<script>
(function () {
var match = window.location.pathname.match(/^\/[^/]+\/frontend(?:\/|$)/);
var href = match ? match[0].replace(/\/+$/, '') + '/' : '/';
var base = document.createElement('base');
base.href = href;
document.currentScript.after(base);
})();
</script>
<link rel="icon" type="image/png" href="%BASE_URL%assets/favicons/favicon-96x96.png" sizes="96x96" />
<link rel="icon" type="image/svg+xml" href="%BASE_URL%assets/favicons/favicon.svg" />
<link rel="shortcut icon" href="%BASE_URL%assets/favicons/favicon.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="%BASE_URL%assets/favicons/apple-touch-icon.png" />
<meta name="apple-mobile-web-app-title" content="Truck Wash Kundeportal" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta name="apple-mobile-web-app-capable" content="yes">
+14 -1
View File
@@ -5,22 +5,31 @@ server {
root /usr/share/nginx/html;
index index.html;
add_header Content-Security-Policy "frame-ancestors 'self'" always;
add_header X-Frame-Options "SAMEORIGIN" always;
location ~ ^/(release-entry|release-manifest)\.json$ {
add_header Cache-Control "no-store";
add_header Content-Security-Policy "frame-ancestors 'self'" always;
add_header X-Frame-Options "SAMEORIGIN" always;
try_files $uri =404;
}
location ~ ^/(master|beta|canary|internal)/frontend/(release-entry|release-manifest)\.json$ {
add_header Cache-Control "no-store";
add_header Content-Security-Policy "frame-ancestors 'self'" always;
add_header X-Frame-Options "SAMEORIGIN" always;
try_files /$2.json =404;
}
location ~ ^/(?:.+/)?(?<static_asset_path>(?:assets|resources|favicons|icons|img|sounds|\.well-known)/.+)$ {
add_header Cache-Control "public, max-age=31536000, immutable";
add_header Content-Security-Policy "frame-ancestors 'self'" always;
add_header X-Frame-Options "SAMEORIGIN" always;
try_files /$static_asset_path =404;
}
location ~ ^/(?:.+/)?(?<static_file_path>manifest\.json|manifest\.webmanifest|favicon\.ico|favicon_default\.ico|pleno-favicon\.ico|release-entry\.json|release-manifest\.json|registerSW\.js|sw\.js|workbox-[^/]+\.js)$ {
location ~ ^/(?:.+/)?(?<static_file_path>index\.html|manifest\.json|manifest\.webmanifest|favicon\.ico|favicon_default\.ico|pleno-favicon\.ico|release-entry\.json|release-manifest\.json|registerSW\.js|sw\.js|workbox-[^/]+\.js)$ {
try_files /$static_file_path =404;
}
@@ -30,11 +39,15 @@ server {
location /assets/ {
add_header Cache-Control "public, max-age=31536000, immutable";
add_header Content-Security-Policy "frame-ancestors 'self'" always;
add_header X-Frame-Options "SAMEORIGIN" always;
try_files $uri =404;
}
location ~ ^/(master|beta|canary|internal)/frontend/assets/ {
add_header Cache-Control "public, max-age=31536000, immutable";
add_header Content-Security-Policy "frame-ancestors 'self'" always;
add_header X-Frame-Options "SAMEORIGIN" always;
rewrite ^/(master|beta|canary|internal)/frontend/(.*)$ /$2 break;
try_files $uri =404;
}
+113 -19
View File
@@ -4624,7 +4624,7 @@ paths:
tags:
- Self-Serve
summary: Add vehicle condition
description: Add a new vehicle condition (answer to a question). Customers can only add conditions for their own vehicles.
description: Add a new vehicle condition (answer to a question). Customers can only add conditions for their own vehicles. This answer mutation does not activate machines or synchronize live relay state; hardware changes are handled only by the explicit wash start flow.
operationId: addSelfserveVehicleCondition
requestBody:
required: true
@@ -4659,14 +4659,6 @@ paths:
type: integer
nullable: true
description: Alias for vehicle_type.
activate_machine:
type: boolean
default: true
description: Whether the session synchronization may enable the machine relay. User wash-start saves answers with false.
sync_relay_state:
type: boolean
default: true
description: Whether the answer mutation should synchronize live relay state.
responses:
'200':
description: Successfully added vehicle condition
@@ -4683,7 +4675,7 @@ paths:
tags:
- Self-Serve
summary: Update vehicle condition
description: Update an existing vehicle condition. Customers can only update conditions for their own vehicles.
description: Update an existing vehicle condition. Customers can only update conditions for their own vehicles. This answer mutation does not activate machines or synchronize live relay state; hardware changes are handled only by the explicit wash start flow.
operationId: updateSelfserveVehicleCondition
parameters:
- name: id
@@ -4719,14 +4711,6 @@ paths:
type: integer
nullable: true
description: Alias for vehicle_type.
activate_machine:
type: boolean
default: true
description: Whether the session synchronization may enable the machine relay.
sync_relay_state:
type: boolean
default: true
description: Whether the mutation should synchronize live relay state.
responses:
'200':
description: Successfully updated vehicle condition
@@ -5402,7 +5386,7 @@ paths:
application/json:
schema:
type: object
required: [department, gateway_id, action]
required: [department, gateway_id, action, confirm]
properties:
department: { type: integer }
gateway_id: { type: integer }
@@ -8704,6 +8688,116 @@ paths:
schema:
$ref: '#/components/schemas/SelfServeLaneStatus'
/modules/self-serve/lane/wash/my-active-wash:
get:
tags:
- Modules
summary: Get the authenticated customer's active self-serve wash
description: |
Returns the current authenticated customer's open self-serve wash session,
if one exists. Regular customers must only receive their own active wash
details from this endpoint.
operationId: getMyActiveSelfServeWash
responses:
'200':
description: Authenticated customer's active wash details resolved
content:
application/json:
schema:
type: object
properties:
lane_id:
type: integer
nullable: true
in_progress:
type: boolean
session:
type: object
nullable: true
properties:
id:
type: integer
lane_id:
type: integer
nullable: true
department_id:
type: integer
nullable: true
status:
type: string
reg:
type: string
customer_number:
type: integer
nullable: true
vehicle_id:
type: integer
nullable: true
vehicle_type_id:
type: integer
nullable: true
included_minutes:
type: integer
nullable: true
machine_type_id:
type: integer
nullable: true
machine_relay_enabled:
type: boolean
machine_relay_enabled_at:
type: string
nullable: true
machine_start_triggered:
type: boolean
machine_start_triggered_at:
type: string
nullable: true
wash_started_at:
type: string
nullable: true
created_at:
type: string
updated_at:
type: string
nullable: true
customer:
type: object
nullable: true
properties:
id:
type: integer
nullable: true
customer_number:
type: integer
nullable: true
display_name:
type: string
nullable: true
email:
type: string
nullable: true
phone_country_code:
type: integer
nullable: true
phone:
type: string
nullable: true
vehicle:
type: object
nullable: true
properties:
id:
type: integer
customer_id:
type: integer
type:
type: integer
reg:
type: string
reference:
type: string
nullable: true
/modules/self-serve/lane/wash/in-progress:
get:
tags:
-1
View File
@@ -7,7 +7,6 @@
"": {
"name": "truckwashdashboardsfrontend",
"version": "0.0.0",
"hasInstallScript": true,
"dependencies": {
"@azure/msal-browser": "^4.12.0",
"@bubblewrap/cli": "^1.23.0",
-1
View File
@@ -11,7 +11,6 @@
"format:tests:commit": "node scripts/pre-commit-format-tests.mjs",
"format:tests:check": "prettier --check \"tests/**/*.{js,ts}\"",
"prepare": "node scripts/prepare-husky.mjs",
"postinstall": "node scripts/postinstall-sync-playwright-root-links.mjs",
"preview": "vite preview",
"preview:prod": "npm run build && npm run preview -- --host 127.0.0.1 --port 4173",
"text:fix-encoding": "node scripts/text-encoding.mjs fix",
+4 -4
View File
@@ -23,17 +23,17 @@
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^.+/((?:manifest\.json|manifest\.webmanifest|favicon\.ico|favicon_default\.ico|pleno-favicon\.ico|release-entry\.json|release-manifest\.json|registerSW\.js|sw\.js|workbox-[^/]+\.js))$ $1 [L]
RewriteRule ^.+/((?:index\.html|manifest\.json|manifest\.webmanifest|favicon\.ico|favicon_default\.ico|pleno-favicon\.ico|release-entry\.json|release-manifest\.json|registerSW\.js|sw\.js|workbox-[^/]+\.js))$ $1 [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{DOCUMENT_ROOT}/public/$1 -f
RewriteRule ^(?:.*?/)?((?:manifest\.json|manifest\.webmanifest|favicon\.ico|favicon_default\.ico|pleno-favicon\.ico|release-entry\.json|release-manifest\.json|registerSW\.js|sw\.js|workbox-[^/]+\.js))$ public/$1 [L]
RewriteRule ^(?:.*?/)?((?:index\.html|manifest\.json|manifest\.webmanifest|favicon\.ico|favicon_default\.ico|pleno-favicon\.ico|release-entry\.json|release-manifest\.json|registerSW\.js|sw\.js|workbox-[^/]+\.js))$ public/$1 [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{DOCUMENT_ROOT}/dist/$1 -f
RewriteRule ^(?:.*?/)?((?:manifest\.json|manifest\.webmanifest|favicon\.ico|favicon_default\.ico|pleno-favicon\.ico|release-entry\.json|release-manifest\.json|registerSW\.js|sw\.js|workbox-[^/]+\.js))$ dist/$1 [L]
RewriteRule ^(?:.*?/)?((?:index\.html|manifest\.json|manifest\.webmanifest|favicon\.ico|favicon_default\.ico|pleno-favicon\.ico|release-entry\.json|release-manifest\.json|registerSW\.js|sw\.js|workbox-[^/]+\.js))$ dist/$1 [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
@@ -41,7 +41,7 @@
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^(?:.*?/)?(?:manifest\.json|manifest\.webmanifest|favicon\.ico|favicon_default\.ico|pleno-favicon\.ico|release-entry\.json|release-manifest\.json|registerSW\.js|sw\.js|workbox-[^/]+\.js)$ - [R=404,L]
RewriteRule ^(?:.*?/)?(?:index\.html|manifest\.json|manifest\.webmanifest|favicon\.ico|favicon_default\.ico|pleno-favicon\.ico|release-entry\.json|release-manifest\.json|registerSW\.js|sw\.js|workbox-[^/]+\.js)$ - [R=404,L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
@@ -1,22 +0,0 @@
import fs from "node:fs";
import path from "node:path";
import { spawnSync } from "node:child_process";
const helperScriptPath = path.resolve(process.cwd(), "..", "scripts", "sync-playwright-root-links.mjs");
if (!fs.existsSync(helperScriptPath)) {
console.log(
`Skipping root Playwright link sync: helper script not found at ${helperScriptPath}.`
);
process.exit(0);
}
const result = spawnSync(process.execPath, [helperScriptPath], {
stdio: "inherit",
});
if (typeof result.status === "number") {
process.exit(result.status);
}
process.exit(1);
+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");
+1
View File
@@ -35,6 +35,7 @@ export const ownedFilesByRole = {
"userHome.spec.ts",
"userInvoices.spec.ts",
"userMyWashStart.spec.ts",
"userMyWashStartFlow.spec.ts",
"userProfileInvoicing.spec.ts",
"userProfileNotifications.spec.ts",
"userProfileSecurity.spec.ts",
@@ -97,6 +97,10 @@ const props = defineProps({
type: Boolean,
default: false,
},
allowBookingDeletion: {
type: Boolean,
default: false,
},
department_lane_id: {
type: Number,
default: null,
@@ -1752,6 +1756,11 @@ const acceptSelfServeWashDraft = async () => {
}
};
const canDeleteOrderBooking = computed(() =>
!props.order_id &&
(props.allowBookingDeletion || SessionUser.canAccessAdmin() || SessionUser.canAccessSuperUser())
);
const flatBuiltInMenuSections = computed(() => {
const sections = [];
@@ -1806,7 +1815,7 @@ const flatBuiltInMenuSections = computed(() => {
),
})
: null,
!props.order_id
canDeleteOrderBooking.value
? buildMenuAction("booking-delete", {
icon: "fas fa-trash-alt",
label: t("admin.pos.settings_wheel.delete_booking"),
@@ -15,6 +15,7 @@ import {
selectPreferredStripeTerminalReaderId,
STRIPE_TERMINAL_STATUS,
} from "@/components/displays/department/pos/displays/stripeTerminalReaders.js";
import { normalizeStripeInvoice } from "@/components/displays/department/pos/displays/stripeEmailInvoice.js";
const POLLING_INTERVAL_MS = 5000;
const STRIPE_TERMINAL_SETUP_REQUIRED_CODE = 'stripe_terminal_setup_required';
@@ -87,29 +88,6 @@ const selectedTaxRate = ref(1);
const paymentIntent = computed(() => StripeModule.paymentIntents.paymentIntent.value);
const isTerminalPaymentCaptured = computed(() => StripeModule.paymentIntents.isPaymentIntentAmountReceived(paymentIntent.value));
const normalizeStripeInvoice = (value) => {
if (!value || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).length === 0) {
return null;
}
const invoiceId = value.invoice_id || value.id || null;
if (!invoiceId) {
return null;
}
return {
id: value.id ?? props.order_id,
invoice_id: invoiceId,
customer_id: value.customer_id ?? null,
url: value.url || value.hosted_invoice_url || null,
created_at: value.created_at || null,
paid: Boolean(value.paid),
status: value.status || 'unknown',
amount_due: Number(value.amount_due ?? 0),
amount_paid: Number(value.amount_paid ?? 0),
};
};
const hasStripeEmailInvoice = computed(() => stripeInvoice.value !== null);
const isStripeEmailInvoicePaid = computed(() => stripeInvoice.value?.paid === true);
const isStripeEmailInvoiceTerminalState = computed(() => {
@@ -202,7 +180,7 @@ const loadStripeInvoiceState = async () => {
try {
const response = await getOrder(props.order_id, true);
const nextInvoice = normalizeStripeInvoice(response?.data?.includes?.stripeModuleOrders);
const nextInvoice = normalizeStripeInvoice(response?.data?.includes?.stripeModuleOrders, props.order_id);
stripeInvoice.value = nextInvoice;
if (nextInvoice) {
emailPanelState.value = 'tracking';
@@ -0,0 +1,60 @@
const STRIPE_INVOICE_PAID_STATUS = 'paid';
export const parseStripeInvoicePaidFlag = (value) => {
if (typeof value === 'boolean') {
return value;
}
if (typeof value === 'number') {
return value === 1;
}
if (typeof value === 'string') {
const normalizedValue = value.trim().toLowerCase();
if (['true', '1'].includes(normalizedValue)) {
return true;
}
if (['false', '0', ''].includes(normalizedValue)) {
return false;
}
}
return false;
};
const toFiniteNumber = (value, fallback = 0) => {
const parsedValue = Number(value ?? fallback);
return Number.isFinite(parsedValue) ? parsedValue : fallback;
};
export const normalizeStripeInvoice = (value, fallbackOrderId = null) => {
if (!value || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).length === 0) {
return null;
}
const invoiceId = value.invoice_id || value.id || null;
if (!invoiceId) {
return null;
}
const status = String(value.status || 'unknown').toLowerCase();
const amountDue = toFiniteNumber(value.amount_due);
const amountPaid = toFiniteNumber(value.amount_paid);
const hasCoveredAmountDue = amountDue <= 0 || amountPaid >= amountDue;
const isPaid = status === STRIPE_INVOICE_PAID_STATUS
&& parseStripeInvoicePaidFlag(value.paid)
&& hasCoveredAmountDue;
return {
id: value.id ?? fallbackOrderId,
invoice_id: invoiceId,
customer_id: value.customer_id ?? null,
url: value.url || value.hosted_invoice_url || null,
created_at: value.created_at || null,
paid: isPaid,
status,
amount_due: amountDue,
amount_paid: amountPaid,
};
};
@@ -10,6 +10,16 @@ import { SessionUser } from "@/components/session/token/SessionUser.vue";
const acceptedOrderAttachmentFileTypes = "image/*,application/pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx";
const imageExtensions = [".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".svg"];
const officeExtensions = [".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx"];
const safePreviewBlobTypesByKind = {
image: {
fallback: "image/png",
allowed: new Set(["image/png", "image/jpeg", "image/gif", "image/webp", "image/bmp", "image/svg+xml"]),
},
document: {
fallback: "application/pdf",
allowed: new Set(["application/pdf"]),
},
};
const props = defineProps({
order: {
@@ -324,7 +334,21 @@ const hasCachedPreviewSource = (attachmentId) => {
return Object.prototype.hasOwnProperty.call(previewSourcesById.value, attachmentId);
};
const createEmbeddablePreviewUrl = async (downloadLink) => {
const createSafePreviewBlob = (fileBlob, previewKind) => {
const previewBlobTypes = safePreviewBlobTypesByKind[previewKind];
if (!previewBlobTypes) {
return null;
}
const normalizedBlobType = String(fileBlob.type || "").toLowerCase();
const safeBlobType = previewBlobTypes.allowed.has(normalizedBlobType)
? normalizedBlobType
: previewBlobTypes.fallback;
return new Blob([fileBlob], { type: safeBlobType });
};
const createEmbeddablePreviewUrl = async (downloadLink, previewKind) => {
if (!downloadLink) {
return null;
}
@@ -340,7 +364,12 @@ const createEmbeddablePreviewUrl = async (downloadLink) => {
return null;
}
const objectUrl = URL.createObjectURL(fileBlob);
const safePreviewBlob = createSafePreviewBlob(fileBlob, previewKind);
if (!safePreviewBlob) {
return null;
}
const objectUrl = URL.createObjectURL(safePreviewBlob);
generatedObjectUrls.add(objectUrl);
return objectUrl;
} catch (error) {
@@ -372,7 +401,7 @@ const ensurePreviewSource = async (attachment) => {
attachment.id,
false
);
const previewSource = await createEmbeddablePreviewUrl(downloadLink);
const previewSource = await createEmbeddablePreviewUrl(downloadLink, previewKind);
previewSourcesById.value = {
...previewSourcesById.value,
[attachment.id]: previewSource,
@@ -517,6 +546,7 @@ const toggleDropdown = async () => {
:src="activePreviewSource"
class="order-attachments-preview-panel__document"
title="Attachment preview"
sandbox
></iframe>
<a
v-else-if="activePreviewKind === 'link'"
@@ -92,6 +92,7 @@ const duplicateDetailsExpanded = ref(false);
const pendingNextResolution = ref(false);
const isDesktopLastWashCopying = ref(false);
let desktopStep1CoordinationPromise = Promise.resolve({ canProceed: true });
let focusOnReg1TimeoutId = null;
const isDesktopStep1Active = computed(() => getCurrentStep() === 1);
const setTab = (tab) => {
@@ -117,7 +118,16 @@ watch(
);
const focusOnReg1 = () => {
setTimeout(() => {
if (focusOnReg1TimeoutId !== null) {
clearTimeout(focusOnReg1TimeoutId);
}
focusOnReg1TimeoutId = setTimeout(() => {
focusOnReg1TimeoutId = null;
if (typeof document === "undefined") {
return;
}
const reg1Input = document.getElementById("reg_1");
if (reg1Input) {
reg1Input.focus();
@@ -267,12 +277,8 @@ const bookingSelectionObjects = computed(() => {
const contentSegments = [
`${t("admin.pos.order_booking_selector.customer_label")}: ${booking?.customer_name || t("admin.pos.not_found")}`,
`${t("admin.pos.order_booking_selector.plates_label")}: ${plateText || t("admin.pos.not_found")}`,
`${t("common.reference")}: ${
getOrderBookingReferenceValue(booking) || t("admin.pos.not_found")
}`,
`${t("common.services")}: ${
getOrderBookingServiceText(booking) || t("admin.pos.not_found")
}`,
`${t("common.reference")}: ${getOrderBookingReferenceValue(booking) || t("admin.pos.not_found")}`,
`${t("common.services")}: ${getOrderBookingServiceText(booking) || t("admin.pos.not_found")}`,
];
return {
@@ -681,6 +687,10 @@ onMounted(() => {
});
onBeforeUnmount(() => {
if (focusOnReg1TimeoutId !== null) {
clearTimeout(focusOnReg1TimeoutId);
focusOnReg1TimeoutId = null;
}
clearDesktopStep1PreflightHandler(handleDesktopStep1Preflight);
});
@@ -752,11 +762,7 @@ watch(
<ButtonsBox class="pos-actions pos-actions--stacked">
<Cancel tabindex="2" class="is-fullwidth" />
</ButtonsBox>
<div
v-if="shouldShowActionRailControls"
class="pos-shell-actions__rail"
data-testid="pos-step-1-action-rail"
>
<div v-if="shouldShowActionRailControls" class="pos-shell-actions__rail" data-testid="pos-step-1-action-rail">
<PosDesktopDuplicateWarning
v-if="shouldShowDuplicateWarningInActionRail"
:title="t('admin.pos.warning')"
@@ -299,14 +299,12 @@ const getSelectedBookingId = () => {
const completeStep2Order = async ({
bookingSafetySeal = null,
markOrderCompleted = false,
}: {
bookingSafetySeal?: string | null;
markOrderCompleted?: boolean;
} = {}) => {
await finalizeCurrentMobileOrder({
bookingSafetySeal,
markOrderCompleted,
markOrderCompleted: true,
});
popups.select("completed_transaction", {
message: `Order #${order_id.value} successfully created.`,
@@ -327,14 +325,12 @@ const step2 = async () => {
const resolvedSafetySeal = getResolvedMobileSafetySeal();
const hasResolvedSafetySeal = isNonEmptyString(resolvedSafetySeal);
const requiresBookingCompletionPopup = Boolean(selectedBookingId && hasWashCertificateInBasket);
const shouldMarkOrderAsCompleted = !selectedBookingId && hasWashCertificateInBasket;
try {
if (requiresBookingCompletionPopup && hasResolvedSafetySeal) {
syncMobileSafetySealState(resolvedSafetySeal);
await completeStep2Order({
bookingSafetySeal: resolvedSafetySeal,
markOrderCompleted: false,
});
return;
}
@@ -344,7 +340,6 @@ const step2 = async () => {
syncMobileSafetySealState(safetySeal);
await completeStep2Order({
bookingSafetySeal: String(safetySeal ?? ""),
markOrderCompleted: false,
});
};
@@ -358,9 +353,7 @@ const step2 = async () => {
return;
}
await completeStep2Order({
markOrderCompleted: shouldMarkOrderAsCompleted,
});
await completeStep2Order();
} catch (error: any) {
errors.value.push(error);
console.warn("An error occurred while completing the mobile order:", error);
@@ -3,6 +3,45 @@ export const XLVASK_USAGE_AMOUNT_CACHE_TTL_MS = 10 * 60 * 1000;
const CACHE_PREFIX = "xlvask-usage-amount:";
const memoryCache = new Map();
const getStorageValue = (key) => {
try {
if (typeof window === "undefined" || !window.localStorage) {
return "";
}
return window.localStorage.getItem(key) || "";
} catch {
return "";
}
};
const hashCacheScopePart = (value) => {
let hash = 5381;
for (let index = 0; index < value.length; index += 1) {
hash = ((hash << 5) + hash) ^ value.charCodeAt(index);
}
return (hash >>> 0).toString(36);
};
const getAuthenticatedCacheScope = () => {
const token = getStorageValue("token");
if (!token) {
return "";
}
return [
hashCacheScopePart(token),
getStorageValue("is_subuser") === "true" ? "subuser" : "user",
getStorageValue("selected_customer_number"),
]
.map((part) => encodeURIComponent(String(part ?? "")))
.join("|");
};
const getScopedCacheKey = (cacheKey) => {
const scope = getAuthenticatedCacheScope();
return scope ? `${scope}:${cacheKey}` : "";
};
const safeSessionStorage = () => {
try {
if (typeof window === "undefined" || !window.sessionStorage) {
@@ -15,9 +54,7 @@ const safeSessionStorage = () => {
};
const normalizeUsageLogId = (objectOrId) => {
const value = typeof objectOrId === "object"
? objectOrId?.usage_log_id ?? objectOrId?.id
: objectOrId;
const value = typeof objectOrId === "object" ? objectOrId?.usage_log_id ?? objectOrId?.id : objectOrId;
const parsed = Number.parseInt(String(value ?? ""), 10);
return Number.isInteger(parsed) && parsed > 0 ? parsed : 0;
};
@@ -34,17 +71,18 @@ export const buildXlvaskUsageAmountCacheKey = (objectOrId) => {
export const getCachedXlvaskUsageAmount = (objectOrId) => {
const cacheKey = buildXlvaskUsageAmountCacheKey(objectOrId);
if (!cacheKey) {
const scopedCacheKey = cacheKey ? getScopedCacheKey(cacheKey) : "";
if (!scopedCacheKey) {
return null;
}
const memoryEntry = memoryCache.get(cacheKey);
const memoryEntry = memoryCache.get(scopedCacheKey);
if (isFreshEntry(memoryEntry)) {
return memoryEntry.data;
}
if (memoryEntry) {
memoryCache.delete(cacheKey);
memoryCache.delete(scopedCacheKey);
}
const storage = safeSessionStorage();
@@ -52,14 +90,14 @@ export const getCachedXlvaskUsageAmount = (objectOrId) => {
return null;
}
const storageKey = `${CACHE_PREFIX}${cacheKey}`;
const storageKey = `${CACHE_PREFIX}${scopedCacheKey}`;
try {
const parsed = JSON.parse(storage.getItem(storageKey) || "null");
if (!isFreshEntry(parsed)) {
storage.removeItem(storageKey);
return null;
}
memoryCache.set(cacheKey, parsed);
memoryCache.set(scopedCacheKey, parsed);
return parsed.data;
} catch {
storage.removeItem(storageKey);
@@ -69,7 +107,8 @@ export const getCachedXlvaskUsageAmount = (objectOrId) => {
export const setCachedXlvaskUsageAmount = (objectOrId, data) => {
const cacheKey = buildXlvaskUsageAmountCacheKey(objectOrId);
if (!cacheKey) {
const scopedCacheKey = cacheKey ? getScopedCacheKey(cacheKey) : "";
if (!scopedCacheKey) {
return;
}
@@ -77,7 +116,7 @@ export const setCachedXlvaskUsageAmount = (objectOrId, data) => {
storedAt: Date.now(),
data,
};
memoryCache.set(cacheKey, entry);
memoryCache.set(scopedCacheKey, entry);
const storage = safeSessionStorage();
if (!storage) {
@@ -85,7 +124,7 @@ export const setCachedXlvaskUsageAmount = (objectOrId, data) => {
}
try {
storage.setItem(`${CACHE_PREFIX}${cacheKey}`, JSON.stringify(entry));
storage.setItem(`${CACHE_PREFIX}${scopedCacheKey}`, JSON.stringify(entry));
} catch {
// Best-effort cache. Quota errors should not block XL Vask usage rows.
}
@@ -94,11 +133,12 @@ export const setCachedXlvaskUsageAmount = (objectOrId, data) => {
export const clearCachedXlvaskUsageAmount = (objectOrId = null) => {
const storage = safeSessionStorage();
const cacheKey = objectOrId === null ? "" : buildXlvaskUsageAmountCacheKey(objectOrId);
const scopedCacheKey = cacheKey ? getScopedCacheKey(cacheKey) : "";
if (cacheKey) {
memoryCache.delete(cacheKey);
if (scopedCacheKey) {
memoryCache.delete(scopedCacheKey);
try {
storage?.removeItem(`${CACHE_PREFIX}${cacheKey}`);
storage?.removeItem(`${CACHE_PREFIX}${scopedCacheKey}`);
} catch {
// Best-effort cache cleanup.
}
@@ -0,0 +1,72 @@
<!--
ErrorBanner.vue reusable self-serve error notification bar.
Props:
message error text to display (blank hidden)
type Bulma tint: is-danger | is-warning (default is-danger)
iconLeft Font Awesome icon name
:loading whether retry button is in loading state
:showRetry whether to show the retry button
Emit:
retry fired when retry button is clicked
-->
<script setup lang="ts">
interface Props {
message: string | null;
type?: "is-danger" | "is-warning";
iconLeft?: string;
loading?: boolean;
showRetry?: boolean;
}
withDefaults(
defineProps<{
message: string | null;
type?: "is-danger" | "is-warning";
iconLeft?: string;
loading?: boolean;
showRetry?: boolean;
}>(),
{
type: "is-danger",
iconLeft: "sync-alt",
loading: false,
showRetry: true,
}
);
const emit = defineEmits<{
retry: [];
}>();
</script>
<template>
<b-message
v-if="message"
:type="type"
has-icon
:closable="false"
class="self-serve-error-banner"
>
<div class="is-flex is-align-items-center is-justify-content-space-between is-flex-wrap-wrap">
<span class="mr-3">{{ message }}</span>
<b-button
v-if="showRetry"
size="is-small"
:type="`${type} is-light`"
icon-pack="fas"
:icon-left="iconLeft"
:loading="loading"
@click="emit('retry')"
>
{{ $t("common.try_again") }}
</b-button>
</div>
</b-message>
</template>
<style scoped>
.self-serve-error-banner {
margin-bottom: 1rem;
}
</style>
@@ -0,0 +1,59 @@
<!--
LaneSelectionSection.vue bane-valg radio buttons and lane status display.
Props:
lanes array of lane objects from the department
selectedLaneId currently selected lane id
washType "Manual" | "Machine"
departmentName human-readable department name
isLaneAvailableFn function(lane) => boolean
isMachineAvailableFn function(laneId) => boolean
Emit:
update:selectedLaneId new lane id selected
update:washType "Manual" | "Machine"
-->
<script setup lang="ts">
import SelfServeLaneStep from "@/components/displays/selfServe/SelfServeLaneStep.vue";
interface Props {
lanes: any[];
selectedLaneId: number | string | null;
washType: string;
departmentName: string | null;
isLaneAvailableFn: (lane: any) => boolean;
isMachineAvailableFn: (laneId: number | string | null) => boolean;
}
defineProps<Props>();
const emit = defineEmits<{
"update:selectedLaneId": [id: number];
"update:washType": [type: string];
}>();
function handleLaneUpdate(id: number) {
emit("update:selectedLaneId", id);
}
function handleWashTypeUpdate(type: string) {
emit("update:washType", type);
}
</script>
<template>
<SelfServeLaneStep
:lanes="lanes"
:selected-lane-id="selectedLaneId"
:wash-type="washType"
:department-name="departmentName"
:is-lane-available="isLaneAvailableFn"
:is-machine-available="isMachineAvailableFn"
@update:selected-lane-id="handleLaneUpdate"
@update:wash-type="handleWashTypeUpdate"
/>
</template>
<style scoped>
/* Lane selection styling is inherited from SelfServeLaneStep */
</style>
@@ -117,6 +117,14 @@ const emit = defineEmits<{
</b-radio-button>
</div>
</div>
<b-message
v-if="!isMachineAvailable(selectedLaneId)"
type="is-warning"
aria-close-label="Luk besked"
data-testid="self-serve-machine-unavailable-guidance"
>
{{ $t("self_wash.machine_unavailable_for_lane") }}
</b-message>
</section>
</div>
</template>
@@ -21,16 +21,6 @@ const isImageAttachment = (attachment: any) => (
&& attachment.content.other.match(/\.(jpg|jpeg|png|gif|webp|svg)$/i)
);
const hasTaskDescription = (task: any) => {
const description = task?.description;
if (typeof description !== "string") {
return !!description;
}
const trimmedDescription = description.trim();
return trimmedDescription.length > 0 && trimmedDescription !== "-";
};
const getImageAttachments = (task: any) => {
if (!Array.isArray(task?.attachments)) {
return [];
@@ -83,7 +73,7 @@ const formatTaskTitle = (task: any) => {
const title = String(task?.task || "");
const programWheelSelection = getProgramWheelSelection(task);
if (programWheelSelection === null) {
if (programWheelSelection === null || /\bprogram\s*#\d+\b/i.test(title)) {
return title;
}
@@ -127,13 +117,11 @@ const formatTaskTitle = (task: any) => {
type="is-success"
:data-testid="`self-serve-task-${task.id}-toggle`"
@update:model-value="emit('toggle-task', task.id, $event)"
@input="emit('toggle-task', task.id, $event)"
/>
</b-field>
</div>
<div class="self-serve-task-image-overlay-text">
<p class="self-serve-task-title"><strong>{{ formatTaskTitle(task) }}</strong></p>
<p v-if="hasTaskDescription(task)" class="self-serve-task-description">{{ task.description }}</p>
</div>
</div>
</div>
@@ -156,22 +144,21 @@ const formatTaskTitle = (task: any) => {
</div>
</div>
</template>
<div v-else class="columns is-mobile is-vcentered">
<div v-if="props.showCheckboxes" class="column is-narrow">
<div v-else class="self-serve-task-row">
<div v-if="props.showCheckboxes" class="self-serve-task-checkbox">
<b-field>
<b-checkbox
class="mr-0 pr-0"
size="is-large"
:model-value="completedTasks[task.id] === true"
type="is-success"
:data-testid="`self-serve-task-${task.id}-toggle`"
@update:model-value="emit('toggle-task', task.id, $event)"
@input="emit('toggle-task', task.id, $event)"
/>
</b-field>
</div>
<div class="column">
<div class="self-serve-task-content">
<p><strong>{{ formatTaskTitle(task) }}</strong></p>
<p v-if="hasTaskDescription(task)" class="is-size-7">{{ task.description }}</p>
<div v-if="getVisibleServices(task).length > 0" class="tags mt-2">
<span v-for="service in getVisibleServices(task)" :key="service" class="tag is-success">{{ service }}</span>
</div>
@@ -239,7 +226,7 @@ const formatTaskTitle = (task: any) => {
padding: 0.65rem 0.75rem;
display: flex;
align-items: flex-start;
gap: 0.6rem;
gap: 0.35rem;
pointer-events: auto;
}
@@ -247,12 +234,30 @@ const formatTaskTitle = (task: any) => {
margin-bottom: 0;
}
.self-serve-task-row {
display: flex;
align-items: center;
gap: 0.35rem;
}
.self-serve-task-checkbox {
flex: 0 0 auto;
}
.self-serve-task-checkbox :deep(.field) {
margin-bottom: 0;
}
.self-serve-task-content {
flex: 1 1 auto;
min-width: 0;
}
.self-serve-task-content p {
margin-bottom: 0;
}
.self-serve-task-title {
margin: 0;
}
.self-serve-task-description {
margin: 0.25rem 0 0;
font-size: 0.85rem;
}
</style>
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { ref, watch } from "vue";
import { BIcon, BSkeleton } from "buefy";
import { computed, ref, watch } from "vue";
import { BIcon } from "buefy";
import SelfServeTaskList from "@/components/displays/selfServe/SelfServeTaskList.vue";
const props = defineProps<{
@@ -19,9 +19,16 @@ const emit = defineEmits<{
}>();
const isDynamicImageLoading = ref(false);
const failedDynamicImageUrl = ref<string | null>(null);
const showDynamicImageFrame = computed(
() => !!props.dynamicImageUrl
&& !isDynamicImageLoading.value
&& failedDynamicImageUrl.value !== props.dynamicImageUrl
);
watch(() => props.dynamicImageUrl, (dynamicImageUrl) => {
isDynamicImageLoading.value = !!dynamicImageUrl;
failedDynamicImageUrl.value = null;
}, { immediate: true });
const emitToggleTask = (taskId: number, value: boolean) => {
@@ -38,6 +45,7 @@ const onDynamicImageLoad = () => {
const onDynamicImageError = () => {
isDynamicImageLoading.value = false;
failedDynamicImageUrl.value = props.dynamicImageUrl;
emit("clear-dynamic-image");
};
</script>
@@ -58,26 +66,28 @@ const onDynamicImageError = () => {
<span class="ml-2">{{ $t("self_wash.loading_data") }}</span>
</div>
<img
v-if="dynamicImageUrl && isDynamicImageLoading"
:key="`${dynamicImageUrl}:preload`"
:src="dynamicImageUrl"
alt=""
data-testid="self-serve-dynamic-image-preload"
class="self-serve-dynamic-image-preload"
@load="onDynamicImageLoad"
@error="onDynamicImageError"
/>
<div
v-if="dynamicImageUrl"
v-if="showDynamicImageFrame"
class="self-serve-dynamic-image-frame mb-4"
:class="{ 'is-loading': isDynamicImageLoading }"
data-testid="self-serve-dynamic-image-frame"
>
<b-skeleton
v-if="isDynamicImageLoading"
class="self-serve-dynamic-image-skeleton"
width="100%"
height="100%"
data-testid="self-serve-dynamic-image-skeleton"
/>
<img
:key="dynamicImageUrl"
:src="dynamicImageUrl"
alt="Machine status"
data-testid="self-serve-dynamic-image"
class="self-serve-dynamic-image"
:class="{ 'is-loading': isDynamicImageLoading }"
@load="onDynamicImageLoad"
@error="onDynamicImageError"
/>
@@ -107,19 +117,20 @@ const onDynamicImageError = () => {
overflow: hidden;
}
.self-serve-dynamic-image-preload {
position: absolute;
width: 0;
height: 0;
opacity: 0;
pointer-events: none;
}
@media screen and (min-width: 769px) {
.self-serve-dynamic-image-frame {
max-width: 640px;
}
}
.self-serve-dynamic-image-skeleton {
position: absolute;
inset: 0;
overflow: hidden;
border-radius: 4px;
}
.self-serve-dynamic-image {
width: 100%;
height: 100%;
@@ -128,8 +139,4 @@ const onDynamicImageError = () => {
object-fit: contain;
transition: opacity 120ms ease;
}
.self-serve-dynamic-image.is-loading {
opacity: 0;
}
</style>
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, watch } from "vue";
import { computed, ref, watch } from "vue";
import { BAutocomplete, BField, BInput, BMessage } from "buefy";
import SelfServeVehicleTypeSelector from "@/components/displays/selfServe/SelfServeVehicleTypeSelector.vue";
@@ -21,8 +21,11 @@ const props = defineProps<{
const emit = defineEmits<{
(e: "update:customerNumber", value: string): void;
(e: "update:customer-number", value: string): void;
(e: "update:registrationNumber", value: string): void;
(e: "update:registration-number", value: string): void;
(e: "select-vehicle-type", selection: any): void;
(e: "selected", selection: any): void;
}>();
const extractRegistrationValue = (value: unknown): string => {
@@ -49,6 +52,19 @@ const normalizeRegistration = (value: unknown) => extractRegistrationValue(value
const typedRegistration = ref(normalizeRegistration(props.registrationNumber));
const filteredRegistrationOptions = computed(() => {
const query = normalizeRegistration(typedRegistration.value);
const options = props.registrationOptions || [];
if (!query) {
return options;
}
return options.filter((option) => {
const normalizedOption = normalizeRegistration(option);
return normalizedOption !== query && normalizedOption.includes(query);
});
});
watch(() => props.registrationNumber, (newValue) => {
typedRegistration.value = normalizeRegistration(newValue);
});
@@ -57,6 +73,18 @@ const emitRegistration = (value: unknown) => {
const normalized = normalizeRegistration(value);
typedRegistration.value = normalized;
emit("update:registrationNumber", normalized);
emit("update:registration-number", normalized);
};
const emitCustomerNumber = (value: unknown) => {
const normalized = String(value || "");
emit("update:customerNumber", normalized);
emit("update:customer-number", normalized);
};
const emitVehicleTypeSelection = (selection: any) => {
emit("select-vehicle-type", selection);
emit("selected", selection);
};
</script>
@@ -68,38 +96,41 @@ const emitRegistration = (value: unknown) => {
:model-value="customerNumber || ''"
data-testid="self-serve-customer-number"
:placeholder="$t('self_wash.enter_customer_number')"
@input="emit('update:customerNumber', String($event || ''))"
@input="emitCustomerNumber($event)"
@update:modelValue="emitCustomerNumber($event)"
/>
</b-field>
<b-field :label="$t('self_wash.registration_number')">
<b-autocomplete
:model-value="typedRegistration"
data-testid="self-serve-registration"
:data="registrationOptions"
:placeholder="$t('self_wash.enter_registration_number')"
:debounce="300"
:min-length="1"
:filter="(option, query) => option.toLowerCase().includes(query.toLowerCase())"
icon-pack="fas"
icon="car-side"
clearable
:loading="registrationOptions.length === 0 && isCustomerVehiclesLoading"
:selectable-header="true"
@select="emitRegistration($event)"
@typing="emitRegistration($event)"
@update:modelValue="emitRegistration($event)"
@select-header="emitRegistration(typedRegistration)"
>
<template #header>
<b-field :label="$t('self_wash.registration_number')" class="self-serve-registration-field">
<div class="self-serve-registration-control">
<b-autocomplete
:model-value="typedRegistration"
data-testid="self-serve-registration"
:data="filteredRegistrationOptions"
:placeholder="$t('self_wash.enter_registration_number')"
:debounce="300"
:min-length="1"
icon-pack="fas"
icon="car-side"
clearable
:loading="filteredRegistrationOptions.length === 0 && isCustomerVehiclesLoading"
@select="emitRegistration($event)"
@typing="emitRegistration($event)"
@update:modelValue="emitRegistration($event)"
>
</b-autocomplete>
<p
v-if="typedRegistration"
class="help self-serve-registration-guidance"
data-testid="self-serve-registration-guidance"
>
<template v-if="hasMatchingVehicle">
{{ $t("self_wash.select_from_vehicles", { plate: typedRegistration }) }}
</template>
<template v-else>
{{ $t("self_wash.add_as_new_vehicle", { plate: typedRegistration }) }}
</template>
</template>
<template #empty>{{ $t("self_wash.no_vehicles_found") }}</template>
</b-autocomplete>
</p>
</div>
</b-field>
<b-message
v-if="props.vehicleStepError"
@@ -124,7 +155,7 @@ const emitRegistration = (value: unknown) => {
<SelfServeVehicleTypeSelector
:selectedVehicleTypeId="selectedVehicleTypeId"
:restrictToProductIds="availableProductIds"
@selected="emit('select-vehicle-type', $event)"
@selected="emitVehicleTypeSelection"
/>
</template>
<template v-else-if="vehicleTypes.length === 0">
@@ -140,3 +171,34 @@ const emitRegistration = (value: unknown) => {
</b-field>
</div>
</template>
<style scoped>
.self-serve-registration-control {
display: block;
width: 100%;
min-width: 0;
}
.self-serve-registration-guidance {
display: block;
margin-top: 0.5rem;
line-height: 1.35;
white-space: normal;
}
.self-serve-registration-field :deep(.dropdown),
.self-serve-registration-field :deep(.autocomplete) {
width: 100%;
}
.self-serve-registration-field :deep(.dropdown-menu) {
left: 0;
right: 0;
width: 100%;
max-width: 100%;
}
.self-serve-registration-field :deep(.dropdown-item) {
white-space: normal;
}
</style>
@@ -0,0 +1,81 @@
<!--
VehicleInputSection.vue customer number, license plate, and vehicle type inputs.
Props:
showCustomerNumberInput whether to render the customer number field
customerNumber current customer number string
registrationNumber current license plate string
registrationOptions autocomplete options for existing vehicles
selectedVehicleTypeId currently selected vehicle type id
selectedVehicleName human-readable name of selected vehicle type
availableProductIds allowed product ids for the department
vehicleTypes list of vehicle type options
Emit:
update:customerNumber new customer number string
update:registrationNumber new license plate string
selectVehicleType selected VehicleTypeTemplate object
-->
<script setup lang="ts">
import { computed } from "vue";
import SelfServeVehicleStep from "@/components/displays/selfServe/SelfServeVehicleStep.vue";
interface Props {
showCustomerNumberInput: boolean;
customerNumber: string | null;
registrationNumber: string | null;
registrationOptions: string[];
selectedVehicleTypeId: number | null;
selectedVehicleName: string | null;
availableProductIds: (number | string)[];
vehicleTypes: any[];
}
defineProps<Props>();
const emit = defineEmits<{
"update:customerNumber": [value: string];
"update:registrationNumber": [value: string];
selectVehicleType: [type: { id: number }];
}>();
function handleCustomerNumberUpdate(value: string) {
emit("update:customerNumber", value);
}
function handleRegistrationNumberUpdate(value: string) {
emit("update:registrationNumber", value);
}
function handleSelectVehicleType(selection: any) {
emit("selectVehicleType", selection);
}
</script>
<template>
<SelfServeVehicleStep
:customer-number="customerNumber"
:show-customer-number-input="showCustomerNumberInput"
:registration-number="registrationNumber"
:registration-options="registrationOptions"
:is-customer-vehicles-loading="false"
:has-matching-vehicle="false"
:selected-vehicle-type-id="selectedVehicleTypeId"
:selected-vehicle-name="selectedVehicleName"
:selected-vehicle-description="null"
:available-product-ids="availableProductIds"
:vehicle-types="vehicleTypes"
:vehicle-step-error="null"
:vehicle-step-guidance="null"
@update:customer-number="handleCustomerNumberUpdate"
@update:customerNumber="handleCustomerNumberUpdate"
@update:registration-number="handleRegistrationNumberUpdate"
@update:registrationNumber="handleRegistrationNumberUpdate"
@select-vehicle-type="handleSelectVehicleType"
@selected="handleSelectVehicleType"
/>
</template>
<style scoped>
/* Vehicle input section styling is inherited from SelfServeVehicleStep */
</style>
@@ -0,0 +1,255 @@
<!--
WashProgressCard.vue timer, guided instructions, and bottom actions during active wash.
Renders when a wash session is in progress (WASH_IN_PROGRESS step).
Props:
currentGuidedWashStep index into guidedWashFlowSteps
guidedWashFlowSteps array of instruction step objects
formattedElapsed human-readable elapsed time string
isCompletingWash whether the wash is finishing right now
openingPropertyAccessGate loading state for access gate button
openingPropertyExitGate loading state for exit gate button
Emit:
update:currentGuidedWashStep new step index
goPreviousGuidedWashStep navigate to previous instruction
goNextGuidedWashStep navigate to next instruction
completeWash finish the wash session
openPropertyAccessGate open the property access gate (laneId)
openPropertyExitGate open the property exit gate (laneId)
requestAssistance call for assistance
-->
<script setup lang="ts">
import { computed } from "vue";
import { BButton } from "buefy";
interface Props {
currentGuidedWashStep: number;
guidedWashFlowSteps: any[];
formattedElapsed: string;
isCompletingWash: boolean;
openingPropertyAccessGate: boolean;
openingPropertyExitGate: boolean;
}
defineProps<Props>();
const emit = defineEmits<{
"update:currentGuidedWashStep": [step: number];
goPreviousGuidedWashStep: [];
goNextGuidedWashStep: [];
completeWash: [];
openPropertyAccessGate: [laneId: number | null];
openPropertyExitGate: [laneId: number | null];
requestAssistance: [];
}>();
const isLastGuidedWashStep = computed(
() =>
typeof guidedWashFlowSteps !== "undefined" &&
guidedWashFlowSteps.length > 0 &&
currentGuidedWashStep >= guidedWashFlowSteps.length - 1
);
function handleGoPrevious() {
emit("goPreviousGuidedWashStep");
}
function handleGoNext() {
emit("goNextGuidedWashStep");
}
function handleComplete() {
emit("completeWash");
}
function handleOpenAccessGate(laneId: number | null) {
emit("openPropertyAccessGate", laneId);
}
function handleOpenExitGate(laneId: number | null) {
emit("openPropertyExitGate", laneId);
}
function handleRequestAssistance() {
emit("requestAssistance");
}
</script>
<template>
<div data-testid="self-serve-wash-progress">
<!-- Finishing screen -->
<div
v-if="isCompletingWash"
class="notification is-info is-light self-serve-finishing-screen"
data-testid="self-serve-finishing-wash"
>
<p class="title is-5 mb-0">{{ $t("self_wash.finishing_wash_exit_opening") }}</p>
</div>
<!-- Guided wash instructions (stepped walkthrough) -->
<template v-if="!isCompletingWash">
<!--
Accept a callback slot for the guided-instructions component so the
parent can plug in SelfServeGuidedInstructions or its own implementation.
-->
<slot
name="guided-instructions"
:current-step="currentGuidedWashStep"
:steps="guidedWashFlowSteps"
:is-last-step="isLastGuidedWashStep"
@update:current-step="emit('update:currentGuidedWashStep', $event)"
>
<!-- Default: render nothing (parent provides via slot) -->
</slot>
<!-- Bottom action bar while wash is in progress -->
<div
class="self-serve-bottom-actions"
data-testid="self-serve-bottom-actions"
>
<!-- Help / assistance -->
<div class="self-serve-bottom-actions__row" data-testid="self-serve-session-bottom-actions">
<b-button
class="self-serve-bottom-actions__button"
type="is-link is-light"
icon-pack="fas"
icon-right="info-circle"
data-testid="self-serve-nav-help"
@click.prevent="handleRequestAssistance"
>
{{ $t("self_wash.assistance") }}
</b-button>
</div>
<!-- Property gate controls -->
<div class="self-serve-bottom-actions__row" data-testid="self-serve-property-gate-actions">
<b-button
class="self-serve-bottom-actions__button"
type="is-link is-light"
icon-pack="fas"
icon-left="sign-in-alt"
data-testid="self-serve-nav-open-property-access-gate"
:loading="openingPropertyAccessGate"
:disabled="openingPropertyAccessGate"
@click.prevent="$emit('openPropertyAccessGate', $props)"
>
{{ $t("self_wash.open_property_access_gate") }}
</b-button>
<b-button
class="self-serve-bottom-actions__button"
type="is-link is-light"
icon-pack="fas"
icon-left="sign-out-alt"
data-testid="self-serve-nav-open-property-exit-gate"
:loading="openingPropertyExitGate"
:disabled="openingPropertyExitGate"
@click.prevent="$emit('openPropertyExitGate', $props)"
>
{{ $t("self_wash.open_property_exit_gate") }}
</b-button>
</div>
<!-- Guided step navigation -->
<div class="self-serve-bottom-actions__row" data-testid="self-serve-guided-bottom-actions">
<b-button
class="self-serve-bottom-actions__button"
type="is-link is-light"
icon-pack="fas"
icon-left="arrow-left"
data-testid="self-serve-guided-prev"
:disabled="currentGuidedWashStep === 0"
@click.prevent="handleGoPrevious"
>
{{ $t("common.previous") }}
</b-button>
<b-button
v-if="!isLastGuidedWashStep"
class="self-serve-bottom-actions__button"
type="is-link"
icon-pack="fas"
icon-right="arrow-right"
data-testid="self-serve-guided-next"
@click.prevent="handleGoNext"
>
{{ $t("common.next") }}
</b-button>
<b-button
v-else
class="self-serve-bottom-actions__button"
type="is-link"
icon-pack="fas"
icon-right="check"
data-testid="self-serve-nav-complete"
:loading="isCompletingWash"
:disabled="isCompletingWash"
@click.prevent="handleComplete"
>
{{ $t("common.done") }}
</b-button>
</div>
</div>
</template>
</div>
</template>
<style scoped>
.self-serve-finishing-screen {
margin: 0 auto;
max-width: 32rem;
text-align: left;
}
.self-serve-bottom-actions {
display: flex;
flex-direction: column;
gap: 0.75rem;
justify-content: center;
margin: 0.75rem auto 0;
max-width: 30rem;
}
.self-serve-bottom-actions__row {
display: flex;
gap: 0.75rem;
justify-content: center;
width: 100%;
}
.self-serve-bottom-actions__button {
flex: 1 1 0;
font-weight: 700;
line-height: 1.15;
max-width: 14rem;
min-height: 2.75rem;
min-width: 0;
white-space: normal;
}
@media screen and (max-width: 768px) {
.self-serve-bottom-actions {
background: #ffffff;
border-top: 1px solid #dfe5f0;
bottom: calc(4.75rem + env(safe-area-inset-bottom, 0px));
box-shadow: 0 -0.25rem 0.75rem rgba(17, 47, 95, 0.08);
left: 0;
margin: 0;
max-width: none;
padding: 0.5rem 0.75rem;
position: fixed;
right: 0;
z-index: 41;
}
.self-serve-bottom-actions__button {
font-size: 0.88rem;
max-width: none;
min-height: 2.65rem;
padding-left: 0.55rem;
padding-right: 0.55rem;
}
}
</style>
@@ -0,0 +1,170 @@
<!--
WashTypeSelector.vue manual vs machine wash toggle.
Renders two radio-style cards: "Manual" (always available) and
"Machine" (available when the lane supports it).
Props:
modelValue current wash type ("Manual" | "Machine")
isMachineAvailable whether machine wash is available for this lane
Emit:
update:modelValue new wash type string
-->
<script setup lang="ts">
import { BRadioButton } from "buefy";
interface Props {
modelValue: string;
isMachineAvailable: boolean;
}
defineProps<Props>();
const emit = defineEmits<{
"update:modelValue": [type: string];
}>();
</script>
<template>
<section class="self-serve-choice-group" data-testid="self-serve-wash-type-group">
<h2 class="self-serve-choice-label">{{ $t("self_wash.wash_type") }}</h2>
<div
class="self-serve-choice-grid self-serve-choice-grid--centered"
data-testid="self-serve-wash-type-options"
>
<!-- Manual option (always available) -->
<div class="self-serve-choice-grid__item">
<b-radio-button
class="self-serve-choice-card"
:model-value="modelValue"
native-value="Manual"
type="is-link"
data-testid="self-serve-wash-type-manual"
@update:model-value="emit('update:modelValue', 'Manual')"
@input="emit('update:modelValue', 'Manual')"
>
<span>
<span>{{ $t("self_wash.manual") }}<br /></span>
<small>
<b-icon icon="check-circle" type="is-success" pack="fas" class="mr-1" />
{{ $t("self_wash.available") }}
</small>
</span>
</b-radio-button>
</div>
<!-- Machine option (conditioned) -->
<div class="self-serve-choice-grid__item">
<b-radio-button
class="self-serve-choice-card"
:model-value="modelValue"
native-value="Machine"
type="is-link"
:disabled="!isMachineAvailable"
:title="!isMachineAvailable ? $t('self_wash.machine_unavailable_for_lane') : null"
:aria-label="!isMachineAvailable ? $t('self_wash.machine_unavailable_for_lane') : null"
data-testid="self-serve-wash-type-machine"
@update:model-value="emit('update:modelValue', 'Machine')"
@input="emit('update:modelValue', 'Machine')"
>
<span>
<span>{{ $t("self_wash.machine") }}<br /></span>
<span v-if="!isMachineAvailable">
<small>
<b-icon icon="times-circle" type="is-danger" pack="fas" class="mr-1" />
{{ $t("self_wash.unavailable") }}
</small>
</span>
<span v-else>
<small>
<b-icon icon="check-circle" type="is-success" pack="fas" class="mr-1" />
{{ $t("self_wash.available") }}
</small>
</span>
</span>
</b-radio-button>
</div>
</div>
<b-message
v-if="!isMachineAvailable"
type="is-warning"
aria-close-label="Luk besked"
data-testid="self-serve-machine-unavailable-guidance"
>
{{ $t("self_wash.machine_unavailable_for_lane") }}
</b-message>
</section>
</template>
<style scoped>
.self-serve-choice-group {
margin-bottom: 1.25rem;
}
.self-serve-choice-label {
color: #303440;
font-size: 1.25rem;
font-weight: 700;
line-height: 1.2;
margin: 0 0 0.75rem;
}
.self-serve-choice-grid {
display: grid;
gap: 1rem;
grid-template-columns: repeat(2, minmax(0, 1fr));
margin-bottom: 0.75rem;
width: 100%;
}
.self-serve-choice-grid--centered {
justify-content: center;
}
.self-serve-choice-grid__item {
min-width: 0;
}
.self-serve-choice-card {
align-items: center;
display: flex;
height: 100%;
justify-content: center;
min-height: 5rem;
padding: 0.75rem 0.5rem;
white-space: normal;
width: 100%;
}
.self-serve-choice-card :deep(.button) {
align-items: center;
display: flex;
height: 100%;
justify-content: center;
min-height: 5rem;
padding: 0.75rem 0.5rem;
white-space: normal;
width: 100%;
}
.self-serve-choice-card :deep(.button),
.self-serve-choice-card span,
.self-serve-choice-card :deep(.button span) {
min-width: 0;
}
.self-serve-choice-card small,
.self-serve-choice-card :deep(.button small) {
display: inline-flex;
align-items: center;
line-height: 1.25;
}
@media screen and (min-width: 769px) {
.self-serve-choice-grid {
grid-template-columns: repeat(auto-fit, minmax(11rem, 15rem));
}
}
</style>
@@ -2,6 +2,8 @@
import Swal from "sweetalert2";
import { usePaginatedListInstance } from "@/components/pagination/paginatedList.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import ActionSettingsWheelButton from "@/components/displays/buttons/ActionSettingsWheelButton.vue";
import ActionSettingsWheelItem from "@/components/displays/buttons/ActionSettingsWheelItem.vue";
const props = defineProps({
objects: {
@@ -23,6 +25,8 @@ const canDisableAccess = () =>
const canResendInvite = (subuser) =>
(props.showCustomer ? SessionUser.canAccessSuperUser() : SessionUser.canAccessUser() || SessionUser.hasPermission("SUBUSERS_EDIT"))
&& Boolean(subuser?.can_resend_invite ?? subuser?.setup_required);
const hasRowActions = (subuser) =>
(canEditPermissions() && subuser?.grant_id) || canResendInvite(subuser) || (canDisableAccess() && subuser?.grant_id);
const formatDateTime = (dateString) => {
if (!dateString) {
@@ -241,14 +245,6 @@ const onResendInvite = async (subuser) => {
<td>
<div>{{ subuser.grant_note || "-" }}</div>
<button
v-if="canEditPermissions() && subuser.grant_id"
class="button is-text is-small px-0 mt-1"
type="button"
@click="onEditNote(subuser)"
>
Redigér note
</button>
</td>
<td>{{ formatDateTime(subuser.created_at) }}</td>
@@ -256,36 +252,42 @@ const onResendInvite = async (subuser) => {
<td>
<div class="buttons is-justify-content-flex-end action-buttons">
<button
v-if="canEditPermissions() && subuser.grant_id"
class="button is-small"
type="button"
:data-testid="`subuser-permissions-${subuser.id}`"
@click="onEditPermissions(subuser)"
>
Tilladelser
</button>
<ActionSettingsWheelButton v-if="hasRowActions(subuser)">
<template #actions>
<ActionSettingsWheelItem
v-if="canEditPermissions() && subuser.grant_id"
icon="fas fa-pen"
label="Redigér note"
:click-action="() => onEditNote(subuser)"
:test-id="`subuser-note-${subuser.id}`"
/>
<button
v-if="canResendInvite(subuser)"
class="button is-small"
type="button"
:data-testid="`subuser-resend-${subuser.id}`"
@click="onResendInvite(subuser)"
>
Gensend
</button>
<ActionSettingsWheelItem
v-if="canEditPermissions() && subuser.grant_id"
icon="fas fa-user-shield"
label="Tilladelser"
:click-action="() => onEditPermissions(subuser)"
:test-id="`subuser-permissions-${subuser.id}`"
/>
<button
v-if="canDisableAccess() && subuser.grant_id"
class="button is-small"
:class="subuser.grant_enabled ? 'is-danger is-light' : 'is-success is-light'"
type="button"
:data-testid="`subuser-toggle-${subuser.id}`"
@click="onToggleEnabled(subuser, !subuser.grant_enabled)"
>
{{ subuser.grant_enabled ? "Deaktivér" : "Aktivér" }}
</button>
<ActionSettingsWheelItem
v-if="canResendInvite(subuser)"
icon="fas fa-paper-plane"
label="Gensend"
:click-action="() => onResendInvite(subuser)"
:test-id="`subuser-resend-${subuser.id}`"
/>
<ActionSettingsWheelItem
v-if="canDisableAccess() && subuser.grant_id"
:icon="subuser.grant_enabled ? 'fas fa-ban' : 'fas fa-check'"
:label="subuser.grant_enabled ? 'Deaktivér' : 'Aktivér'"
:template="subuser.grant_enabled ? 'danger' : 'success'"
:click-action="() => onToggleEnabled(subuser, !subuser.grant_enabled)"
:test-id="`subuser-toggle-${subuser.id}`"
/>
</template>
</ActionSettingsWheelButton>
</div>
</td>
</tr>
@@ -10,6 +10,7 @@ import LoadButtonWhileAwait from "@/components/request/LoadButtonWhileAwait.vue"
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import TwoFactorVerify from "@/components/forms/auth/TwoFactorVerify.vue";
import { isPasskeySupported, authenticateWithPasskey } from "@/services/PasskeyAuthService.js";
import { clearEdgeGatewayWorkspaceCache } from "@/services/edgeGateways.js";
const employees = ref([]);
const { t } = useI18n();
@@ -52,6 +53,7 @@ const login = async () => {
}
// Save the token in the local storage
clearEdgeGatewayWorkspaceCache();
localStorage.setItem('token', data.token);
// Redirect to the dashboard
window.location.href = '/admin';
@@ -72,6 +74,7 @@ const loginWithPasskey = async () => {
const result = await authenticateWithPasskey('employee', null, recaptchaToken);
if (result.token) {
clearEdgeGatewayWorkspaceCache();
localStorage.setItem('token', result.token);
window.location.href = '/admin';
return;
+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)
@@ -12,28 +13,82 @@ const getSelectedCustomerNumber = () => {
return stored ? parseInt(stored) : null;
};
const MY_ACTIVE_WASH_ENDPOINT = '/modules/self-serve/lane/wash/my-active-wash';
const ACTIVE_WASH_STARTED_STATUSES = new Set(['MACHINE_RELAY_ENABLED', 'MACHINE_STARTED']);
const normalizeStatus = (status) => String(status || '').trim().toUpperCase();
const parseDateTimeMs = (value) => {
if (!value) {
return null;
}
const timestamp = Date.parse(String(value));
return Number.isFinite(timestamp) ? timestamp : null;
};
const hasActiveWashStartEvidence = (details) => {
const session = details?.session || {};
const status = normalizeStatus(session?.status ?? details?.status);
return (
ACTIVE_WASH_STARTED_STATUSES.has(status) ||
session?.machine_relay_enabled === true ||
session?.machine_start_triggered === true ||
details?.machine_relay_enabled === true ||
details?.machine_start_triggered === true ||
parseDateTimeMs(session?.wash_started_at) !== null ||
parseDateTimeMs(session?.machine_start_triggered_at) !== null ||
parseDateTimeMs(session?.machine_relay_enabled_at) !== null
);
};
const normalizeActiveWashResponse = (url, method, response) => {
if (String(method || '').toUpperCase() !== 'GET' || !String(url || '').includes(MY_ACTIVE_WASH_ENDPOINT)) {
return response;
}
const details = response?.data?.data;
if (!details?.in_progress || hasActiveWashStartEvidence(details)) {
return response;
}
return {
...response,
data: {
...response.data,
data: {
...details,
in_progress: false,
},
},
};
};
export const authenticatedRequest = (url, method, data, catchCallable = null, thenCallable = null) => {
const token = localStorage.getItem('token');
if (!token) {
//throw new Error('No token was found, unable to make authenticated request');
}
const requestUrl = resolveReleaseApiUrl(url);
const canSendCredentials = isTrustedReleaseUrl(requestUrl);
// Build headers
const headers = {
...buildCurrentReleaseHeaders(),
};
if (token && token.length > 0) {
if (canSendCredentials && token && token.length > 0) {
headers.Authorization = `Bearer ${token}`;
}
// Add X-Customer-Number header if subuser has selected a grant
const isSubuser = localStorage.getItem('is_subuser') === 'true';
const selectedCustomerNumber = getSelectedCustomerNumber();
if (isSubuser && selectedCustomerNumber) {
if (canSendCredentials && isSubuser && selectedCustomerNumber) {
headers['X-Customer-Number'] = selectedCustomerNumber;
}
const requestUrl = resolveReleaseApiUrl(url);
return enqueueRequest(
() => axios({
method,
@@ -60,6 +115,7 @@ export const authenticatedRequest = (url, method, data, catchCallable = null, th
throw error;
})
.then((response) => {
response = normalizeActiveWashResponse(url, method, response);
if (thenCallable) {
// Call the then callable
thenCallable(response);
+21 -1
View File
@@ -58,6 +58,12 @@ import {
releaseChannelRuntimeRequestParams,
setReleaseChannelSwitchNoticePrincipal,
} from "@/services/releaseChannelAvailability.js";
import { clearCachedXlvaskUsageAmount } from "@/components/displays/department/pos/sync/xlvaskUsageAmountCache.js";
import { clearEdgeGatewayWorkspaceCache } from "@/services/edgeGateways.js";
import {
clearPeriodCache,
clearSelfWashCountsCache,
} from "@/views/dashboards/superUserDashboard/InvoicingBillingPeriod/imports/InvoicingBillingPeriodImportPaging.js";
const normalizePositiveInteger = (value) => {
const parsedValue = Number.parseInt(String(value ?? ""), 10);
@@ -98,7 +104,16 @@ const hydrateSessionFromStorage = () => {
return true;
};
const clearPrivilegedSessionCaches = () => {
clearPeriodCache();
clearSelfWashCountsCache();
clearCachedXlvaskUsageAmount();
clearEdgeGatewayWorkspaceCache();
};
const clearStoredSession = () => {
clearPrivilegedSessionCaches();
if (typeof window === "undefined") {
return;
}
@@ -115,7 +130,8 @@ const applyReleaseRuntimeConfig = (runtime) => {
const normalizedRuntime = runtime || {};
configureReleaseRuntime(normalizedRuntime);
reconcileSelectedReleaseChannel(normalizedRuntime);
const runtimeUrls = normalizedRuntime.urls && typeof normalizedRuntime.urls === "object" ? normalizedRuntime.urls : {};
const runtimeUrls =
normalizedRuntime.urls && typeof normalizedRuntime.urls === "object" ? normalizedRuntime.urls : {};
SessionUser.runtimeConfig.release.traceId.value = normalizedRuntime.trace_id || null;
SessionUser.runtimeConfig.release.channel.value = normalizedRuntime.channel || null;
SessionUser.runtimeConfig.release.availableChannels.value =
@@ -377,6 +393,7 @@ export const authenticateUser = async (customer_number, password) => {
password,
})
.then((response) => {
clearPrivilegedSessionCaches();
SessionUser.token.value = response.data.token;
SessionUser.authenticated.value = true;
localStorage.setItem("token", response.data.token);
@@ -423,6 +440,7 @@ export const authenticateSubuser = async (credentials) => {
return await authenticatedRequest("/subusers/auth/password", "POST", requestBody)
.then((response) => {
clearPrivilegedSessionCaches();
SessionUser.token.value = response.data.data.session;
SessionUser.authenticated.value = true;
SessionUser.isSubuser.value = true;
@@ -669,6 +687,7 @@ export const SessionUser = {
* @param {number|null} customerNumber - The billing_customer_number to select, or null to clear
*/
selectGrant: (customerNumber) => {
clearEdgeGatewayWorkspaceCache();
if (customerNumber === null) {
localStorage.removeItem("selected_customer_number");
SessionUser.subuser.selectedGrantCustomerNumber.value = null;
@@ -886,6 +905,7 @@ export const SessionUser = {
* @param {string} token
*/
setToken: (token) => {
clearPrivilegedSessionCaches();
SessionUser.token.value = token;
localStorage.setItem("token", token);
// Reload the window to make sure the user's session is initiated
@@ -7,6 +7,13 @@ import { getSystemUserIds } from "@/components/session/token/SessionUser/Objects
// Helper function to get i18n translation
const t = (key) => i18n.global.t(key);
const escapeHtml = (value = "") => String(value)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll("\"", "&quot;")
.replaceAll("'", "&#39;");
const normalizeDateTimeLocalValue = (value) => {
if (value === null || value === undefined || value === '') {
return '';
@@ -765,9 +772,11 @@ export const ObjectsGlobal = {
console.log(response);
// How long are the list of options
for (let i = 0; i < response.length; i++) {
var tmp = response[i];
const tmp = response[i];
const optionValue = tmp?.id ?? "";
const optionLabel = tmp?.name ? tmp.name : optionValue;
console.log(tmp);
html += `<option value="${tmp.id}" ${(value == tmp.id || (value === null && tmp.id === 0)) ? 'selected' : ''}>${tmp.name ? tmp.name : tmp.id}</option>`;
html += `<option value="${escapeHtml(optionValue)}" ${(value == optionValue || (value === null && optionValue === 0)) ? 'selected' : ''}>${escapeHtml(optionLabel)}</option>`;
}
});
html += `</select>
@@ -19,6 +19,14 @@ const getProtectedDeletePayload = (error) => {
return payload;
};
const escapeHtml = (value) => String(value ?? "").replace(/[&<>"']/g, (character) => ({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
"\"": "&quot;",
"'": "&#39;",
})[character]);
const formatDeleteProtectionReason = (reason) => {
switch (reason) {
case "completed":
@@ -28,22 +36,23 @@ const formatDeleteProtectionReason = (reason) => {
case "attachments":
return "ordren har vedhæftninger";
default:
return String(reason || "").replace(/_/g, " ");
return escapeHtml(String(reason || "").replace(/_/g, " "));
}
};
const showProtectedOrderDeleteConfirmation = async (id, protectionPayload, onAfterSubmit = null) => {
const orderId = String(id);
const escapedOrderId = escapeHtml(orderId);
const reasons = Array.isArray(protectionPayload?.protected_reasons)
? protectionPayload.protected_reasons.map(formatDeleteProtectionReason).filter(Boolean)
: [];
const reasonText = reasons.length > 0 ? reasons.join(", ") : "ordren indeholder gemte data";
const result = await Swal.fire({
title: `Bekræft sletning af ordre #${orderId}`,
titleText: `Bekræft sletning af ordre #${orderId}`,
html: `
<p>Ordren kan stadig slettes, men kræver ekstra bekræftelse fordi ${reasonText}.</p>
<p>Skriv <strong>${orderId}</strong> for at slette ordren.</p>
<p>Skriv <strong>${escapedOrderId}</strong> for at slette ordren.</p>
`,
input: "text",
inputAttributes: {
@@ -127,6 +127,13 @@ export const SelfServeVehicleConditions = {
}
},
add: async (department, lane, customer_id, reg, question, value, options = {}) => {
const safeOptions = {};
const normalizedVehicleType = parseInt(options.vehicle_type ?? options.vehicle_type_id);
if (!Number.isNaN(normalizedVehicleType) && normalizedVehicleType > 0) {
safeOptions.vehicle_type = normalizedVehicleType;
}
return ObjectsGlobal.add.object(SelfServeVehicleConditions.meta.endpoint, {
department: parseInt(department),
lane: parseInt(lane),
@@ -134,7 +141,9 @@ export const SelfServeVehicleConditions = {
reg: reg,
question: parseInt(question),
value: value === "true" || value === true,
...options
...safeOptions,
activate_machine: false,
sync_relay_state: false
});
},
set: {
@@ -17,13 +17,15 @@ const normalizeEntries = (entries) => {
return [];
};
const SENSITIVE_WRITE_ONLY_KEYS = new Set(["broker_shared_secret"]);
const toConfigPayload = (entries) => {
const payload = {};
normalizeEntries(entries).forEach((entry) => {
if (!entry || !entry.variable) {
return;
}
payload[entry.variable] = entry.value;
payload[entry.variable] = SENSITIVE_WRITE_ONLY_KEYS.has(entry.variable) ? "" : entry.value;
});
return payload;
};
@@ -42,6 +44,11 @@ export const Config = {
test_broker: async (payload) => testEdgeGatewayBrokerConfig(payload),
set: async (variable, value) => {
const currentConfig = await Config.get_config();
SENSITIVE_WRITE_ONLY_KEYS.forEach((key) => {
if (key !== variable) {
delete currentConfig[key];
}
});
return Config.set_config({
...currentConfig,
[variable]: value,
@@ -73,7 +80,6 @@ export const Config = {
set: async (value) => Config.set("broker_auth_mode", value),
},
broker_shared_secret: {
get: async () => Config.get("broker_shared_secret"),
set: async (value) => Config.set("broker_shared_secret", value),
},
},
@@ -1,6 +1,7 @@
<script>
import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue";
import { parseError } from "@/components/request/HandleGlobalError.vue";
import { clearEdgeGatewayWorkspaceCache } from "@/services/edgeGateways.js";
import { DatabaseSystemObject } from "@/components/session/token/superUser/systemDatabase.vue";
import { Cron } from "@/components/session/token/superUser/cron.vue";
import { Economic } from "@/components/session/token/superUser/modules/Economic/Economic.vue";
@@ -106,6 +107,7 @@ export const SuperUserObject = {
const currentToken = localStorage.getItem("token");
localStorage.setItem("superuser_token", currentToken);
// Set the token in the local storage
clearEdgeGatewayWorkspaceCache();
localStorage.setItem("token", response.data.data.token);
// Redirect the user to the dashboard
window.location = "/";
@@ -143,6 +145,7 @@ export const SuperUserObject = {
// Get the superuser token
const superuserToken = localStorage.getItem("superuser_token");
// Set the token in the local storage
clearEdgeGatewayWorkspaceCache();
localStorage.setItem("token", superuserToken);
// Clear the superuser token
localStorage.removeItem("superuser_token");
+25 -22
View File
@@ -320,20 +320,26 @@ export const ensureCurrentOrderDepartment = async (targetDepartmentId = null) =>
}
try {
let currentDepartmentId = null;
try {
currentDepartmentId = await getCurrentOrderDepartmentId(normalizedOrderId);
} catch (error) {
currentDepartmentId = null;
}
const currentDepartmentId = await getCurrentOrderDepartmentId(normalizedOrderId);
if (currentDepartmentId === normalizedDepartmentId) {
department_id.value = normalizedDepartmentId;
return true;
}
await SessionUser.objects.orders.set.department_id(normalizedOrderId, normalizedDepartmentId);
department_id.value = normalizedDepartmentId;
return true;
parseError(
{
response: {
data: {
data: {
message: "Ordren tilhører ikke den valgte afdeling",
},
message: "Ordren tilhører ikke den valgte afdeling",
},
},
},
"stepError"
);
return false;
} catch (error) {
parseError(error, "stepError");
return false;
@@ -727,9 +733,9 @@ export const restoreStoredPosOrderId = async (
if (selectedDepartmentId && toPositiveInteger(storedOrder?.department_id) !== selectedDepartmentId) {
if (options.syncDepartment === true) {
order_id.value = storedOrderId;
const didSyncDepartment = await ensureCurrentOrderDepartment(selectedDepartmentId);
if (!didSyncDepartment) {
throw new Error("Stored order department could not be changed to current department");
const isCurrentDepartment = await ensureCurrentOrderDepartment(selectedDepartmentId);
if (!isCurrentDepartment) {
throw new Error("Stored order department does not match current department");
}
} else {
throw new Error("Stored order department does not match current department");
@@ -2012,18 +2018,14 @@ export const getVehiclePlateBookings = (vehiclePlate) => {
return sortPendingOrderBookings(matchingBookings);
};
export const ensureVehiclePlateBookingsLoaded = async (vehiclePlate, options = {}) => {
const normalizedOptions = {
force: false,
...options,
};
export const ensureVehiclePlateBookingsLoaded = async (vehiclePlate, _options = {}) => {
const normalizedVehiclePlate = normalizeVehiclePlateForBookingSelection(vehiclePlate);
const currentDepartmentKey = syncPendingBookingsDepartmentState();
if (!normalizedVehiclePlate || !department_id.value) {
return [];
}
if (!normalizedOptions.force && loadedPendingBookingPlates.has(normalizedVehiclePlate)) {
if (loadedPendingBookingPlates.has(normalizedVehiclePlate)) {
return getVehiclePlateBookings(normalizedVehiclePlate);
}
@@ -2167,7 +2169,7 @@ const getFirstAvailableWashProduct = async () => {
}
};
const hydrateSelectedOrderBookingForDesktop = async () => {
export const hydrateSelectedOrderBookingForDesktop = async () => {
const normalizedOrderId = toPositiveInteger(order_id.value);
const normalizedBookingId = toPositiveInteger(selectedOrderBookingId.value);
@@ -2250,8 +2252,7 @@ const hydrateSelectedOrderBookingForDesktop = async () => {
}
}
const forcedPrimaryPrice =
typeof primaryRawItem?.price === "number" ? Number(primaryRawItem.price) : primaryProduct.price ?? null;
const forcedPrimaryPrice = primaryProduct.price ?? null;
const primaryItemResponse = await createOrderItem(
normalizedOrderId,
primaryProduct.id,
@@ -2269,13 +2270,15 @@ const hydrateSelectedOrderBookingForDesktop = async () => {
continue;
}
const secondaryProduct = await fetchOrderBookingProductWithPricing(secondaryProductId);
await createOrderItem(
normalizedOrderId,
secondaryProductId,
Math.max(1, Number.parseInt(String(bookingItem?.quantity ?? 1), 10) || 1),
relatedPrimaryItemId,
String(bookingItem?.notes ?? "").trim() || null,
typeof bookingItem?.price === "number" ? Number(bookingItem.price) : null
secondaryProduct?.price ?? null
);
}
+7 -5
View File
@@ -64,6 +64,7 @@ export const useOrderMetadataAutosave = ({
const requestedValue = normalizeValue(draft.value);
const sequence = ++nextSequence;
let saveCompleted = false;
isSaving.value = true;
try {
@@ -83,6 +84,8 @@ export const useOrderMetadataAutosave = ({
await onSaved(savedValue, requestedValue);
}
saveCompleted = true;
return true;
} catch (error) {
if (typeof onError === "function") {
@@ -95,12 +98,11 @@ export const useOrderMetadataAutosave = ({
} finally {
isSaving.value = false;
if (saveAgainAfterCurrentRequest || isDirty.value) {
saveAgainAfterCurrentRequest = false;
const shouldSaveAgain = saveAgainAfterCurrentRequest;
saveAgainAfterCurrentRequest = false;
if (isDirty.value) {
void persist();
}
if (saveCompleted && (shouldSaveAgain || isDirty.value) && isDirty.value) {
void persist();
}
}
};
+123 -11
View File
@@ -1,5 +1,6 @@
import { computed, ref } from "vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { safeAttachmentDownloadLink } from "@/services/attachmentDownloadLinks.js";
const normalizeBooleanAnswer = (value) => {
if (value === true || value === false) {
@@ -270,6 +271,55 @@ const mergeByNumericId = (existingItems, incomingItems) => {
return merged;
};
const taskStableKey = (task) =>
String(task?.task ?? "")
.trim()
.toLowerCase();
const mergeTaskSets = (existingTasks, incomingTasks) => {
if (!Array.isArray(existingTasks) || existingTasks.length === 0) {
return Array.isArray(incomingTasks) ? [...incomingTasks] : [];
}
if (!Array.isArray(incomingTasks) || incomingTasks.length === 0) {
return [...existingTasks];
}
const usedExistingIndexes = new Set();
return incomingTasks.map((incomingTask) => {
const incomingId = extractTaskId(incomingTask);
let existingIndex = -1;
if (incomingId) {
existingIndex = existingTasks.findIndex((task) => extractTaskId(task) === incomingId);
} else {
const incomingKey = taskStableKey(incomingTask);
existingIndex = existingTasks.findIndex(
(task, index) => !usedExistingIndexes.has(index) && taskStableKey(task) === incomingKey
);
}
if (existingIndex === -1) {
return incomingTask;
}
usedExistingIndexes.add(existingIndex);
const existingTask = existingTasks[existingIndex];
return {
...existingTask,
...incomingTask,
id: incomingTask.id || existingTask.id,
task_id: incomingTask.task_id || existingTask.task_id,
attachments: Array.isArray(incomingTask.attachments) && incomingTask.attachments.length > 0
? incomingTask.attachments
: existingTask.attachments,
_attachmentsLoaded: incomingTask._attachmentsLoaded || existingTask._attachmentsLoaded,
};
});
};
export function useSelfServeLogic() {
const loading = ref(false);
const requestError = ref(null);
@@ -296,6 +346,30 @@ export function useSelfServeLogic() {
const summaryVisibleQuestionIds = ref([]);
const summaryQuestionOrder = ref({});
const latestFetchRequestId = ref(0);
const latestSuccessfulFetchKey = ref(null);
const inFlightFetchKey = ref(null);
const createFetchKey = (departmentId, vehicleTypeId, laneId, reg) => {
const normalizedDepartmentId = parseInt(departmentId);
const normalizedLaneId = parseInt(laneId);
const normalizedReg = String(reg || "").trim().toUpperCase();
const normalizedVehicleTypeId = parseInt(vehicleTypeId);
const vehicleTypeKey = !Number.isNaN(normalizedVehicleTypeId) && normalizedVehicleTypeId > 0
? normalizedVehicleTypeId
: "";
if (
Number.isNaN(normalizedDepartmentId)
|| normalizedDepartmentId <= 0
|| Number.isNaN(normalizedLaneId)
|| normalizedLaneId <= 0
|| normalizedReg.length < 2
) {
return null;
}
return [normalizedDepartmentId, normalizedLaneId, normalizedReg, vehicleTypeKey].join("|");
};
const beginFetchRequest = () => {
latestFetchRequestId.value += 1;
@@ -424,6 +498,10 @@ export function useSelfServeLogic() {
machineType.value = previewData?.machine_type || machineType.value;
vehicle.value = previewData?.vehicle || null;
session.value = previewData?.session || session.value;
if (!previewData?.session?.id) {
summary.value = null;
events.value = [];
}
if (Object.prototype.hasOwnProperty.call(previewData || {}, "config_version_id")) {
configVersionId.value = previewData?.config_version_id ?? null;
}
@@ -442,6 +520,7 @@ export function useSelfServeLogic() {
questions.value = nextQuestions;
answers.value = buildAnswersMap(nextQuestions);
setSummaryVisibleQuestions(nextQuestions);
const normalizedConditions = Array.isArray(previewData?.conditions)
? previewData.conditions.map(normalizeCondition)
@@ -461,13 +540,15 @@ export function useSelfServeLogic() {
? previewData.tasks.map(normalizeTask).sort((a, b) => a.order_priority - b.order_priority)
: [];
tasks.value = normalizedTasks;
applyAllowedServices(previewData);
const hydratedTasks = await hydrateTaskAttachments(normalizedTasks);
if (!isFetchRequestActive(requestId)) {
return false;
}
tasks.value = hydratedTasks;
applyAllowedServices(previewData);
return true;
};
@@ -512,8 +593,9 @@ export function useSelfServeLogic() {
const normalizedTasks = summaryData.tasks
.map(normalizeTask)
.sort((a, b) => a.order_priority - b.order_priority);
const mergedTasks = mergeTaskSets(tasks.value, normalizedTasks);
const hydratedTasks = await hydrateTaskAttachments(normalizedTasks);
const hydratedTasks = await hydrateTaskAttachments(mergedTasks);
if (!isFetchRequestActive(requestId)) {
return false;
}
@@ -526,7 +608,7 @@ export function useSelfServeLogic() {
};
const downloadAttachment = async (taskId, attachmentId, attachment = null) => {
const existingDownloadLink = attachment?.download_link || null;
const existingDownloadLink = safeAttachmentDownloadLink(attachment?.download_link);
if (existingDownloadLink) {
window.open(existingDownloadLink, "_blank", "noopener");
return;
@@ -534,10 +616,9 @@ export function useSelfServeLogic() {
try {
const response = await SessionUser.objects.self_serve_tasks.attachments.download(taskId, attachmentId);
if (response?.data?.download_link) {
window.open(response.data.download_link, "_blank", "noopener");
} else if (response?.download_link) {
window.open(response.download_link, "_blank", "noopener");
const downloadLink = safeAttachmentDownloadLink(response?.data?.download_link || response?.download_link);
if (downloadLink) {
window.open(downloadLink, "_blank", "noopener");
}
} catch (error) {
console.error("Error downloading attachment:", error);
@@ -575,10 +656,17 @@ export function useSelfServeLogic() {
}
};
const fetchSelfServeData = async (_departmentId, _vehicleTypeId, laneId = null, reg = null) => {
const fetchSelfServeData = async (_departmentId, _vehicleTypeId, laneId = null, reg = null, options = {}) => {
const fetchKey = createFetchKey(_departmentId, _vehicleTypeId, laneId, reg);
if (!options.force && fetchKey && (fetchKey === latestSuccessfulFetchKey.value || fetchKey === inFlightFetchKey.value)) {
return null;
}
const requestId = beginFetchRequest();
if (!laneId || !reg || reg.trim().length < 2) {
latestSuccessfulFetchKey.value = null;
inFlightFetchKey.value = null;
preview.value = null;
summary.value = null;
session.value = null;
@@ -606,6 +694,7 @@ export function useSelfServeLogic() {
loading.value = true;
requestError.value = null;
inFlightFetchKey.value = fetchKey;
try {
const normalizedReg = reg.trim().toUpperCase();
const normalizedVehicleTypeId = parseInt(_vehicleTypeId);
@@ -687,6 +776,10 @@ export function useSelfServeLogic() {
setSummaryVisibleQuestions(questions.value);
}
if (isFetchRequestActive(requestId)) {
latestSuccessfulFetchKey.value = fetchKey;
}
return previewData;
} catch (error) {
console.error("Error fetching self-serve preview:", error);
@@ -695,6 +788,9 @@ export function useSelfServeLogic() {
}
return null;
} finally {
if (inFlightFetchKey.value === fetchKey) {
inFlightFetchKey.value = null;
}
if (isFetchRequestActive(requestId)) {
loading.value = false;
}
@@ -749,7 +845,7 @@ export function useSelfServeLogic() {
updateResolvedVehicleTypeId(responseSummary, responseSummary?.session);
}
await fetchSelfServeData(departmentId, refreshVehicleTypeId, laneId, normalizedReg);
await fetchSelfServeData(departmentId, refreshVehicleTypeId, laneId, normalizedReg, { force: true });
answers.value = {
...answers.value,
[parseInt(questionId)]: value,
@@ -814,7 +910,7 @@ export function useSelfServeLogic() {
? normalizedRefreshVehicleTypeId
: null;
await fetchSelfServeData(departmentId, refreshVehicleTypeId, parseInt(laneId), normalizedReg);
await fetchSelfServeData(departmentId, refreshVehicleTypeId, parseInt(laneId), normalizedReg, { force: true });
return { deletedCount: conditionIdsToDelete.length };
} catch (error) {
@@ -986,7 +1082,22 @@ export function useSelfServeLogic() {
const updateLaneAllowedServices = async (laneId) => {
if (!laneId) return;
const taskIds = activeTasks.value.map((task) => extractTaskId(task)).filter(Boolean);
const activeTaskIds = activeTasks.value.map((task) => extractTaskId(task)).filter(Boolean);
const taskIds = activeTaskIds.length > 0
? activeTaskIds
: tasks.value.map((task) => extractTaskId(task)).filter(Boolean);
if (taskIds.length === 0) {
requestError.value = null;
return {
success: true,
data: {
lane_id: parseInt(laneId),
allowed_services: allowedServices.value,
skipped_empty_task_sync: true,
},
};
}
try {
const response = await SessionUser.request('/modules/self-serve/lane/services/allowed', 'post', {
@@ -1049,6 +1160,7 @@ export function useSelfServeLogic() {
answers,
completedTasks,
allowedServices,
resolvedVehicleTypeId: lastResolvedVehicleTypeId,
configVersionId,
evaluationTrace,
visibleQuestions,
+103 -39
View File
@@ -2,12 +2,20 @@ import { computed, nextTick, onUnmounted, ref, watch } from "vue";
import { getDepartmentsGuest } from "@/components/pagination/departmentTabs.vue";
import { locations } from "@/components/displays/department/pos/steps/mobile/objects/PosDepartmentStepMobileFlow.vue";
const isLaneSelfServeEnabled = (lane) => !(
lane?.selfserve_enabled === false
|| lane?.selfserve_enabled === 0
|| lane?.selfserve_enabled === "0"
|| ["false", "off", "no"].includes(String(lane?.selfserve_enabled ?? "").trim().toLowerCase())
);
const isLaneSelfServeEnabled = (lane) =>
!(
lane?.selfserve_enabled === false ||
lane?.selfserve_enabled === 0 ||
lane?.selfserve_enabled === "0" ||
["false", "off", "no"].includes(
String(lane?.selfserve_enabled ?? "")
.trim()
.toLowerCase()
)
);
const hasSelfServeEnabledLane = (department) =>
department?.self_serve_enabled === true && (department.lanes || []).some(isLaneSelfServeEnabled);
const toDepartmentViewModel = (department, distance = null) => ({
id: department.id,
@@ -18,12 +26,22 @@ const toDepartmentViewModel = (department, distance = null) => ({
self_serve_enabled: department.self_serve_enabled,
});
const extractDepartmentFetchErrorMessage = (error) => {
const candidates = [
error?.response?.data?.data?.message,
error?.response?.data?.message,
error?.response?.data?.error,
error?.message,
];
return (
candidates.find((candidate) => typeof candidate === "string" && candidate.trim() !== "") ||
"Kunne ikke hente vaskeafdelinger. Prøv igen."
);
};
export function useWashDepartments(options = {}) {
const {
includeLanes = false,
refreshIntervalMs = 0,
canAccessSuperUser = () => false,
} = options;
const { includeLanes = false, refreshIntervalMs = 0, canAccessSuperUser = () => false } = options;
const guestDepartments = ref([]);
const nearestDepartment = ref(null);
@@ -31,6 +49,8 @@ export function useWashDepartments(options = {}) {
const forceNearestDepartmentEvaluationId = ref(0);
const isSearchingDepartments = ref(false);
const lastDepartmentFetchTime = ref(null);
const departmentFetchError = ref(null);
const departmentSelectionStrategy = ref(null);
let refreshInterval = null;
@@ -57,13 +77,24 @@ export function useWashDepartments(options = {}) {
return nearestDepartment.value.self_serve_enabled === true;
});
const buildGuestDepartmentParams = () => (
includeLanes ? { include_lanes: true } : {}
);
const isDepartmentSelectionDistanceBased = computed(() => departmentSelectionStrategy.value === "distance");
const isDepartmentSelectionFallbackBased = computed(() => departmentSelectionStrategy.value === "fallback");
const hasLocationCoordinates = (locationValue = locations.location.value) => !!locationValue?.coords;
const buildGuestDepartmentParams = () => (includeLanes ? { include_lanes: true } : {});
const fetchDepartments = async () => {
guestDepartments.value = await getDepartmentsGuest(buildGuestDepartmentParams());
lastDepartmentFetchTime.value = Date.now();
try {
guestDepartments.value = await getDepartmentsGuest(buildGuestDepartmentParams());
lastDepartmentFetchTime.value = Date.now();
departmentFetchError.value = null;
} catch (error) {
departmentFetchError.value = extractDepartmentFetchErrorMessage(error);
console.warn("Failed to fetch wash departments:", error);
}
evaluateLocationDepartments(locations.location.value);
return guestDepartments.value;
};
@@ -74,39 +105,45 @@ export function useWashDepartments(options = {}) {
}
const currentTime = Date.now();
if (
!lastDepartmentFetchTime.value
|| (currentTime - lastDepartmentFetchTime.value) >= refreshIntervalMs
) {
if (!lastDepartmentFetchTime.value || currentTime - lastDepartmentFetchTime.value >= refreshIntervalMs) {
return fetchDepartments();
}
return guestDepartments.value;
};
const getForcedDepartment = () => guestDepartments.value.find(
(department) => department.id === forceNearestDepartmentEvaluationId.value
);
const getForcedDepartment = () =>
guestDepartments.value.find((department) => department.id === forceNearestDepartmentEvaluationId.value);
const evaluateLocationDepartments = (locationValue = locations.location.value) => {
if (isForcingNearestDepartment.value && forceNearestDepartmentEvaluationId.value) {
const forcedDepartment = getForcedDepartment();
if (forcedDepartment) {
nearestDepartment.value = toDepartmentViewModel(forcedDepartment);
departmentSelectionStrategy.value = "forced";
return nearestDepartment.value;
}
}
if (guestDepartments.value.length === 0) {
nearestDepartment.value = null;
departmentSelectionStrategy.value = null;
return nearestDepartment.value;
}
if (!hasLocationCoordinates(locationValue)) {
const fallbackDepartment = guestDepartments.value.find(hasSelfServeEnabledLane);
nearestDepartment.value = fallbackDepartment ? toDepartmentViewModel(fallbackDepartment) : null;
departmentSelectionStrategy.value = fallbackDepartment ? "fallback" : null;
return nearestDepartment.value;
}
let currentNearestDepartment = {
id: null,
distance: Infinity,
};
guestDepartments.value.forEach((department) => {
if (!locationValue?.coords) {
return;
}
const from = {
latitude: locationValue.coords.latitude,
longitude: locationValue.coords.longitude,
@@ -124,6 +161,18 @@ export function useWashDepartments(options = {}) {
if (currentNearestDepartment.id) {
nearestDepartment.value = currentNearestDepartment;
departmentSelectionStrategy.value = "distance";
return nearestDepartment.value;
}
const fallbackDepartment =
guestDepartments.value.find((department) => department.self_serve_enabled === true) || guestDepartments.value[0];
if (fallbackDepartment) {
nearestDepartment.value = toDepartmentViewModel(fallbackDepartment);
departmentSelectionStrategy.value = "fallback";
} else {
nearestDepartment.value = null;
departmentSelectionStrategy.value = null;
}
return nearestDepartment.value;
@@ -150,7 +199,7 @@ export function useWashDepartments(options = {}) {
};
const startDepartmentSearch = () => {
if (!canAccessSuperUser()) {
if (!canAccessSuperUser() && hasLocationCoordinates()) {
return;
}
@@ -191,19 +240,30 @@ export function useWashDepartments(options = {}) {
}
};
watch(() => guestDepartments.value, () => {
evaluateLocationDepartments(locations.location.value);
}, { deep: true });
watch(() => locations.location.value, (newValue) => {
evaluateLocationDepartments(newValue);
}, { deep: true });
watch(() => isSearchingDepartments.value, (isSearching) => {
if (!isSearching) {
watch(
() => guestDepartments.value,
() => {
evaluateLocationDepartments(locations.location.value);
},
{ deep: true }
);
watch(
() => locations.location.value,
(newValue) => {
evaluateLocationDepartments(newValue);
},
{ deep: true }
);
watch(
() => isSearchingDepartments.value,
(isSearching) => {
if (!isSearching) {
evaluateLocationDepartments(locations.location.value);
}
}
});
);
onUnmounted(() => {
stopAutoRefresh();
@@ -216,6 +276,10 @@ export function useWashDepartments(options = {}) {
forceNearestDepartmentEvaluationId,
isSearchingDepartments,
lastDepartmentFetchTime,
departmentFetchError,
departmentSelectionStrategy,
isDepartmentSelectionDistanceBased,
isDepartmentSelectionFallbackBased,
availableProductIds,
doesCurrentDepartmentSelectionHaveSelfServeEnabled,
fetchDepartments,
+28 -11
View File
@@ -7,6 +7,11 @@ export const WASH_STEPS = {
COMPLETED: 5,
};
const normalizeLaneId = (value) => {
const normalized = parseInt(String(value ?? ""), 10);
return Number.isNaN(normalized) || normalized <= 0 ? null : normalized;
};
export function useWashFlowState(options) {
const {
currentStep,
@@ -14,7 +19,7 @@ export function useWashFlowState(options) {
customerNumberInput,
licensePlateInput,
vehicleTypeSelect,
availableProductIds,
isCustomerNumberRequired,
radioLaneOption,
nearestDepartment,
allVisibleQuestionsAnswered,
@@ -34,6 +39,17 @@ export function useWashFlowState(options) {
activeTasks.value.length > 0 ? steps.TASKS : steps.WASH_IN_PROGRESS
);
const getSelectedLaneId = () => normalizeLaneId(radioLaneOption.value);
const getSelectedLane = () => {
const selectedLaneId = getSelectedLaneId();
if (selectedLaneId === null) {
return null;
}
return nearestDepartment.value?.lanes.find((lane) => normalizeLaneId(lane.id) === selectedLaneId) || null;
};
const clickableSteps = {
[steps.VEHICLE]: () => {
if (washInProgress.value) {
@@ -41,16 +57,15 @@ export function useWashFlowState(options) {
}
const selectedVehicleTypeId = vehicleTypeSelect.value;
const allowedProductIds = new Set((availableProductIds.value || []).map((productId) => String(productId)));
const hasAllowedVehicleType = selectedVehicleTypeId !== null
&& selectedVehicleTypeId !== undefined
&& allowedProductIds.has(String(selectedVehicleTypeId));
const hasSelectedVehicleType = selectedVehicleTypeId !== null && selectedVehicleTypeId !== undefined;
const customerNumberValue = customerNumberInput.value;
const customerNumberValid = customerNumberValue === null
const requiresCustomerNumber = isCustomerNumberRequired?.value ?? false;
const customerNumberValid = !requiresCustomerNumber
|| customerNumberValue === null
|| customerNumberValue === undefined
|| String(customerNumberValue).trim() !== "";
const licensePlateValid = !!(licensePlateInput.value && licensePlateInput.value.trim() !== "");
if (!hasAllowedVehicleType) {
if (!hasSelectedVehicleType) {
return false;
}
@@ -103,7 +118,7 @@ export function useWashFlowState(options) {
}
if (currentStep.value === steps.SELECT_LANE) {
const selectedLane = nearestDepartment.value?.lanes.find((lane) => lane.id === radioLaneOption.value);
const selectedLane = getSelectedLane();
if (!selectedLane || !isLaneAvailable(selectedLane)) {
return true;
}
@@ -133,9 +148,10 @@ export function useWashFlowState(options) {
const handleConfirmNext = async () => {
if (currentStep.value === steps.QUESTIONS) {
editAnswers.value = false;
if (radioLaneOption.value && radioLaneOption.value !== "Any") {
const selectedLaneId = getSelectedLaneId();
if (selectedLaneId !== null) {
try {
await updateLaneAllowedServices(radioLaneOption.value);
await updateLaneAllowedServices(selectedLaneId);
} catch {
return;
}
@@ -150,8 +166,9 @@ export function useWashFlowState(options) {
return;
}
const selectedLaneId = getSelectedLaneId() ?? radioLaneOption.value;
await onStartWash(
radioLaneOption.value,
selectedLaneId,
licensePlateInput.value,
customerNumberInput.value,
targetStepForStart()
+50 -12
View File
@@ -70,7 +70,10 @@ export function useWashSessionActions(options) {
const isAlreadyStoppedStopError = (message) => {
const normalized = String(message || "").toLowerCase();
return normalized.includes("cannot stop lane") && normalized.includes("not occupied");
return (
normalized.includes("not occupied") &&
(normalized.includes("cannot stop lane") || normalized.includes("cannot invoice"))
);
};
const clearActiveWashState = () => {
@@ -170,10 +173,15 @@ export function useWashSessionActions(options) {
return null;
}
return await executeSelfServeCommand(resolvedLaneId, command, {
customer_number: null,
license_plate: null,
}, options);
return await executeSelfServeCommand(
resolvedLaneId,
command,
{
customer_number: null,
license_plate: null,
},
options
);
} finally {
loadingRef.value = false;
}
@@ -185,6 +193,24 @@ export function useWashSessionActions(options) {
const openPropertyExitGate = async (laneId = null, options = {}) =>
executePropertyGateCommand("OPEN_PROPERTY_EXIT_GATE", laneId, openingPropertyExitGate, options);
const rollbackStartedWash = async (laneId) => {
const stopResponse = await executeSelfServeCommand(
laneId,
"STOP",
{
customer_number: null,
license_plate: null,
},
{ suppressAlert: true }
);
if (!stopResponse) {
console.error(`Failed to roll back started wash on lane ${laneId}:`, lastCommandErrorMessage.value);
}
return stopResponse;
};
const onStartWash = async (laneId, licensePlate, customerNumber, targetStep = steps.WASH_IN_PROGRESS) => {
if (isStartingWash.value) {
return false;
@@ -233,6 +259,25 @@ export function useWashSessionActions(options) {
return false;
}
if (radioWashType.value === "Machine" && isServiceAllowed("MACHINE")) {
let machineRelayResponse = null;
try {
machineRelayResponse = await enableMachineRelay(laneId);
} catch (error) {
console.error("Error enabling machine relay after wash start:", error);
await rollbackStartedWash(laneId);
alertFn(extractCommandErrorMessage(error, "Kunne ikke starte maskinen. Prøv igen."));
return false;
}
const machineRelaySuccessValue = machineRelayResponse?.data?.success ?? machineRelayResponse?.success;
if (machineRelaySuccessValue === false) {
await rollbackStartedWash(laneId);
alertFn(extractCommandErrorMessage(machineRelayResponse, "Kunne ikke starte maskinen. Prøv igen."));
return false;
}
}
washLaneId.value = laneId;
washStartTime.value = Date.now();
completedDurationMs.value = null;
@@ -246,13 +291,6 @@ export function useWashSessionActions(options) {
reg: licensePlate.trim().toUpperCase(),
});
if (radioWashType.value === "Machine" && isServiceAllowed("MACHINE")) {
try {
await enableMachineRelay(laneId);
} catch (error) {
console.error("Error enabling machine relay after wash start:", error);
}
}
return true;
} catch (error) {
console.error("Error starting self-serve wash:", error);
+12
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,11 +51,22 @@ export const RELEASE_MANAGER_CONTROL_API_FALLBACK_URLS = String(
.split(",")
.map((url) => normalizeApiUrl(url.trim()))
.filter(Boolean);
export const RELEASE_TRUSTED_ORIGINS = String(import.meta.env.VITE_RELEASE_TRUSTED_ORIGINS || "")
.split(",")
.map((origin) => origin.trim().replace(/\/+$/, ""))
.filter(Boolean);
export const SELF_SERVE_ATTACHMENT_TRUSTED_ORIGINS = String(
import.meta.env.VITE_SELF_SERVE_ATTACHMENT_TRUSTED_ORIGINS || ""
)
.split(",")
.map((origin) => origin.trim().replace(/\/+$/, ""))
.filter(Boolean);
// Allowed origins
export const ALLOWED_ORIGINS = [
"https://truckwash.io",
"https://www.truckwash.io",
new URL(DEFAULT_PUBLIC_GATEWAY_API_URL).origin,
"http://localhost:5173",
"http://localhost:4173",
"http://localhost:4174",
@@ -36,7 +36,73 @@ const entryContext = (entry = {}) => {
return context && typeof context === "object" && !Array.isArray(context) ? context : {};
};
const formatDetailValue = (value) => {
const REDACTED_CONTEXT_VALUE = "[REDACTED]";
const SENSITIVE_CONTEXT_KEY_PATTERN = /(?:token|secret|password|credential|api[_-]?key|authorization|auth|signature)/i;
const SENSITIVE_CONTEXT_QUERY_PATTERN = SENSITIVE_CONTEXT_KEY_PATTERN;
const redactSensitiveUrlParams = (value) => {
const text = String(value || "");
if (!text) {
return text;
}
const redactParams = (url) => {
let redacted = false;
url.searchParams.forEach((_paramValue, key) => {
if (SENSITIVE_CONTEXT_QUERY_PATTERN.test(key)) {
url.searchParams.set(key, REDACTED_CONTEXT_VALUE);
redacted = true;
}
});
return redacted ? url.toString() : text;
};
try {
return redactParams(new URL(text));
} catch {
return text.replace(/([?&])([^=&#]+)=([^&#]*)/g, (match, separator, rawKey) => {
let key = rawKey;
try {
key = decodeURIComponent(rawKey.replace(/\+/g, " "));
} catch {
key = rawKey;
}
return SENSITIVE_CONTEXT_QUERY_PATTERN.test(key) ? `${separator}${rawKey}=${REDACTED_CONTEXT_VALUE}` : match;
});
}
};
const redactEdgeGatewayLogContext = (value, seen = new WeakSet()) => {
if (value === null || typeof value === "undefined") {
return value;
}
if (typeof value === "string") {
return redactSensitiveUrlParams(value);
}
if (typeof value !== "object") {
return value;
}
if (seen.has(value)) {
return "[Circular]";
}
seen.add(value);
if (Array.isArray(value)) {
return value.map((item) => redactEdgeGatewayLogContext(item, seen));
}
return Object.fromEntries(
Object.entries(value).map(([key, item]) => [
key,
SENSITIVE_CONTEXT_KEY_PATTERN.test(key) ? REDACTED_CONTEXT_VALUE : redactEdgeGatewayLogContext(item, seen),
])
);
};
const formatDetailValue = (value, { redactContext = false } = {}) => {
if (value === null || typeof value === "undefined" || value === "") {
return "None";
}
@@ -44,13 +110,14 @@ const formatDetailValue = (value) => {
return value ? "yes" : "no";
}
if (typeof value === "object") {
const displayValue = redactContext ? redactEdgeGatewayLogContext(value) : value;
try {
return JSON.stringify(value, null, 2);
return JSON.stringify(displayValue, null, 2);
} catch {
return String(value);
return String(displayValue);
}
}
return String(value);
return redactContext && typeof value === "string" ? redactSensitiveUrlParams(value) : String(value);
};
const detailRow = (label, value) => ({
@@ -61,7 +128,7 @@ const detailRow = (label, value) => ({
const contextDetailRow = (value) => ({
label: "Context",
value: formatDetailValue(value),
value: formatDetailValue(value, { redactContext: true }),
multiline: true,
actionsOnly: true,
});
@@ -208,7 +275,9 @@ const relayActorLabel = (entry = {}) => {
};
const normalizeRelayRole = (role = "") => {
const normalized = String(role || "").trim().toUpperCase();
const normalized = String(role || "")
.trim()
.toUpperCase();
const aliases = {
ENTRANCE: "ENTRY",
IN: "ENTRY",
@@ -260,7 +329,8 @@ const firstRelayText = (values = []) => {
const relayDisplayName = (entry = {}) => {
const context = entryContext(entry);
const actionContext = context.action_context && typeof context.action_context === "object" ? context.action_context : {};
const actionContext =
context.action_context && typeof context.action_context === "object" ? context.action_context : {};
const binding = context.binding && typeof context.binding === "object" ? context.binding : {};
const request = relayRequestPayload(entry);
return firstRelayText([
@@ -284,7 +354,8 @@ const relayIdentifier = (entry = {}) => {
const relaySignalLabel = (entry = {}) => {
const context = entryContext(entry);
const actionContext = context.action_context && typeof context.action_context === "object" ? context.action_context : {};
const actionContext =
context.action_context && typeof context.action_context === "object" ? context.action_context : {};
const signal = context.signal && typeof context.signal === "object" ? context.signal : {};
const role = normalizeRelayRole(context.relay_role || actionContext.relay_role || actionContext.role);
const action = String(context.action || signal.command_type || "RELAY").toUpperCase();
@@ -2,6 +2,7 @@
import { computed, onMounted, onUnmounted, ref, watch } from "vue";
import {
cancelEdgeGatewayOperation,
clearEdgeGatewayWorkspaceCache,
createEdgeGatewayInstallToken,
createEdgeGatewayOperation,
deleteEdgeGateway,
@@ -12,16 +13,18 @@ import {
getEdgeGatewayStatistics,
getEdgeGatewayTasks,
listEdgeGatewayDepartments,
isEdgeGatewayAuthorizationError,
listEdgeGateways,
peekCachedEdgeGateway,
peekCachedEdgeGatewayDepartments,
peekCachedEdgeGatewayList,
removeEdgeGatewayCache,
rotateEdgeGatewayCredentials,
saveEdgeGatewayBindings,
setDepartmentGatewayCutover,
updateEdgeGateway,
} from "@/services/edgeGateways.js";
import { normalizeEdgeGatewayError, normalizeGatewayWebSocketClose } from "@/features/edgeGateways/edgeGatewayErrors.js";
import { normalizeEdgeGatewayError, normalizeGatewayWebSocketClose, redactEdgeGatewayDiagnostic } from "@/features/edgeGateways/edgeGatewayErrors.js";
import EdgeGatewayOverviewPage from "@/features/edgeGateways/EdgeGatewayOverviewPage.vue";
import EdgeGatewayInventoryPage from "@/features/edgeGateways/EdgeGatewayInventoryPage.vue";
import EdgeGatewayTasksPage from "@/features/edgeGateways/EdgeGatewayTasksPage.vue";
@@ -183,7 +186,7 @@ const terminalGatewayBrokerDiagnostics = (gateway = selectedGatewayView.value) =
if (value === null || value === undefined || value === "") {
return;
}
lines.push(`${label}: ${value}`);
lines.push(`${label}: ${redactEdgeGatewayDiagnostic(value)}`);
};
push("Gateway ID", gateway?.id);
@@ -1215,6 +1218,15 @@ const refreshSelected = async (gatewayId = activeGatewayId.value, { forceRefresh
}
return gateway;
} catch (error) {
if (isEdgeGatewayAuthorizationError(error)) {
removeEdgeGatewayCache(gatewayId);
unavailableGatewayId.value = String(gatewayId);
selectedGateway.value = null;
resetGatewayViewSnapshots();
fail(error);
return null;
}
const fallbackGateway = findLocalGatewaySnapshot(gatewayId);
if (fallbackGateway) {
setSelectedGatewaySnapshot(fallbackGateway);
@@ -1561,6 +1573,16 @@ const load = async () => {
departments.value = unwrap(departmentsResponse, []);
await syncSelection();
} catch (error) {
if (isEdgeGatewayAuthorizationError(error)) {
clearEdgeGatewayWorkspaceCache();
gateways.value = [];
fleetUsage.value = buildFleetUsageFromRows([]);
if (activeGatewayId.value) {
unavailableGatewayId.value = String(activeGatewayId.value);
}
selectedGateway.value = null;
resetGatewayViewSnapshots();
}
fail(error);
} finally {
loading.value.init = false;
@@ -1,4 +1,6 @@
<script setup>
import { redactEdgeGatewayDiagnostic } from "@/features/edgeGateways/edgeGatewayErrors.js";
const props = defineProps({
status: {
type: String,
@@ -44,7 +46,10 @@ const shortcuts = [
{ id: "docker ps", label: "docker ps" },
];
const diagnosticsText = () => (Array.isArray(props.diagnostics) ? props.diagnostics.join("\n") : "");
const diagnosticsText = () =>
Array.isArray(props.diagnostics)
? props.diagnostics.map((entry) => redactEdgeGatewayDiagnostic(entry)).join("\n")
: "";
const copyDiagnostics = async () => {
const text = diagnosticsText();
@@ -0,0 +1,79 @@
import { RELEASE_TRUSTED_ORIGINS } from "@/config.js";
const splitOriginList = (value) =>
String(value || "")
.split(",")
.map((origin) => origin.trim().replace(/\/+$/, ""))
.filter(Boolean);
export const ALLOWED_PUBLIC_BROKER_PROTOCOLS = Object.freeze(["https:", "wss:"]);
export const EDGE_GATEWAY_PUBLIC_BROKER_ORIGINS = Object.freeze([
"https://api.truckwash.io:4433",
"wss://api.truckwash.io:4433",
...RELEASE_TRUSTED_ORIGINS,
...splitOriginList(import.meta.env.VITE_EDGE_GATEWAY_PUBLIC_BROKER_ORIGINS),
]);
export const canUseStubBrokerAuthMode = () => import.meta.env.DEV || import.meta.env.MODE === "test";
export const sanitizeBrokerAuthMode = (value) => {
if (value === "stub" && canUseStubBrokerAuthMode()) {
return "stub";
}
return "manager";
};
export const redactBrokerSharedSecret = () => "";
export const allowedPublicBrokerOrigins = () => {
const origins = new Set(EDGE_GATEWAY_PUBLIC_BROKER_ORIGINS);
if (typeof window !== "undefined" && window.location?.origin) {
const currentOrigin = window.location.origin.replace(/\/+$/, "");
origins.add(currentOrigin);
if (window.location.hostname) {
const host = window.location.port
? `${window.location.hostname}:${window.location.port}`
: window.location.hostname;
origins.add(`https://${host}`);
origins.add(`wss://${host}`);
}
}
return origins;
};
export const validatePublicBrokerUrl = (value) => {
const normalized = String(value || "").trim();
if (!normalized) {
return { ok: true, value: "" };
}
let parsed;
try {
parsed = new URL(normalized);
} catch (_error) {
return {
ok: false,
message: "Public broker URL must be an absolute HTTPS or WSS URL on an approved origin.",
};
}
if (!ALLOWED_PUBLIC_BROKER_PROTOCOLS.includes(parsed.protocol)) {
return {
ok: false,
message: "Public broker URL must use HTTPS or WSS.",
};
}
if (!allowedPublicBrokerOrigins().has(parsed.origin)) {
return {
ok: false,
message: "Public broker URL origin is not approved for browser edge gateway sessions.",
};
}
return { ok: true, value: parsed.href };
};
+58 -9
View File
@@ -55,6 +55,36 @@ const WEBSOCKET_CLOSE_MESSAGES = {
socket_error: "Terminal websocket connection failed.",
};
const SENSITIVE_DIAGNOSTIC_KEYS = new Set([
"accesstoken",
"agenttoken",
"apikey",
"authorization",
"auth",
"clientsecret",
"jwt",
"key",
"password",
"secret",
"signature",
"token",
]);
const normalizeDiagnosticKey = (key) =>
String(key || "")
.replace(/[^a-z0-9]/gi, "")
.toLowerCase();
const isSensitiveDiagnosticKey = (key) => SENSITIVE_DIAGNOSTIC_KEYS.has(normalizeDiagnosticKey(key));
const redactSensitiveAssignments = (value) =>
String(value)
.replace(/\b(authorization|auth)\s*[:=]\s*(?:Bearer|Basic)\s+[^\s,;&]+/gi, (_match, key) => `${key}=***`)
.replace(
/([?&;,\s]?(access[_-]?token|agent[_-]?token|api[_-]?key|authorization|auth|client[_-]?secret|jwt|key|password|secret|signature|token)\s*[:=]\s*)("[^"\s,;&]*"|'[^'\s,;&]*'|[^\s,;&]+)/gi,
(_match, prefix) => `${prefix}***`
);
export function redactEdgeGatewayUrl(value) {
if (!value) {
return null;
@@ -62,17 +92,33 @@ export function redactEdgeGatewayUrl(value) {
try {
const url = new URL(String(value), typeof window !== "undefined" ? window.location.origin : "http://localhost");
["token", "agentToken", "agent_token"].forEach((key) => {
if (url.searchParams.has(key)) {
if (url.username) {
url.username = "***";
}
if (url.password) {
url.password = "***";
}
Array.from(url.searchParams.keys()).forEach((key) => {
if (isSensitiveDiagnosticKey(key)) {
url.searchParams.set(key, "***");
}
});
return url.toString();
} catch (_error) {
return String(value).replace(/([?&](?:token|agentToken|agent_token)=)[^&]+/gi, "$1***");
return redactSensitiveAssignments(String(value));
}
}
export function redactEdgeGatewayDiagnostic(value) {
if (value === null || value === undefined) {
return value;
}
return redactSensitiveAssignments(
String(value).replace(/\b(?:wss?|https?|mqtts?):\/\/[^\s<>"')]+/gi, (match) => redactEdgeGatewayUrl(match))
);
}
const formatDiagnosticLines = (diagnostics, prefix = "Diagnostic") => {
if (!diagnostics || typeof diagnostics !== "object" || Array.isArray(diagnostics)) {
return [];
@@ -83,20 +129,23 @@ const formatDiagnosticLines = (diagnostics, prefix = "Diagnostic") => {
if (value === null || value === undefined || value === "") {
return;
}
lines.push(`${label}: ${value}`);
lines.push(`${label}: ${redactEdgeGatewayDiagnostic(value)}`);
};
push(`${prefix} code`, diagnostics.reason_code || diagnostics.error_code);
push("Broker URL", diagnostics.broker_url);
push("WebSocket URL", redactEdgeGatewayUrl(diagnostics.ws_url));
push("WebSocket URL", diagnostics.ws_url);
push("Derived URL warning", diagnostics.derived_warning);
if (diagnostics.broker_presence && typeof diagnostics.broker_presence === "object") {
push("Broker connected", diagnostics.broker_presence.connected === true ? "yes" : "no");
push("Broker connection", diagnostics.broker_presence.connection_id);
push("Broker last seen", diagnostics.broker_presence.last_seen_at);
push("Broker age", Number.isFinite(Number(diagnostics.broker_presence.age_seconds))
? `${Number(diagnostics.broker_presence.age_seconds)}s`
: null);
push(
"Broker age",
Number.isFinite(Number(diagnostics.broker_presence.age_seconds))
? `${Number(diagnostics.broker_presence.age_seconds)}s`
: null
);
push("Broker last error", diagnostics.broker_presence.last_error);
push("Broker disconnect reason", diagnostics.broker_presence.disconnect_reason);
}
@@ -180,7 +229,7 @@ export function normalizeGatewayWebSocketClose(event = {}, context = {}) {
details.push(`WebSocket code: ${code}`);
}
if (reason) {
details.push(`Close reason: ${reason}`);
details.push(`Close reason: ${redactEdgeGatewayDiagnostic(reason)}`);
}
if (wasClean !== null) {
details.push(`Clean close: ${wasClean ? "yes" : "no"}`);
@@ -10,6 +10,66 @@ const MOCK_SHELL_PROMPT = "edge@truckwash:/opt/truckwash-edge-agent$ ";
const isMockSocketUrl = (value) => String(value || "").startsWith(MOCK_SOCKET_PREFIX);
const SENSITIVE_SOCKET_QUERY_KEYS = new Set([
"access_token",
"auth",
"authorization",
"bearer",
"jwt",
"session",
"session_token",
"token",
]);
const getBrowserLocation = () => (typeof window !== "undefined" && window.location ? window.location : null);
const getConfiguredTrustedSocketOrigins = () =>
String(import.meta.env?.VITE_EDGE_GATEWAY_WS_ALLOWED_ORIGINS || "")
.split(",")
.map((entry) => entry.trim())
.filter(Boolean);
const toSocketOrigin = (value) => {
const location = getBrowserLocation();
const base = location?.origin || "http://localhost";
const url = new URL(value, base);
if (url.protocol === "https:") {
url.protocol = "wss:";
} else if (url.protocol === "http:") {
url.protocol = "ws:";
}
return url.origin;
};
const isLocalSocketHost = (hostname) => ["localhost", "127.0.0.1", "::1", "[::1]"].includes(String(hostname || ""));
const getTrustedSocketOrigins = () => {
const origins = new Set();
const location = getBrowserLocation();
if (location?.origin) {
origins.add(toSocketOrigin(location.origin));
}
getConfiguredTrustedSocketOrigins().forEach((origin) => origins.add(toSocketOrigin(origin)));
return origins;
};
const assertTrustedSocketUrl = (url) => {
if (!["ws:", "wss:"].includes(url.protocol)) {
throw new Error("Unsupported websocket URL protocol");
}
if (url.protocol !== "wss:" && !isLocalSocketHost(url.hostname)) {
throw new Error("Gateway websocket URL must use wss");
}
const trustedOrigins = getTrustedSocketOrigins();
if (trustedOrigins.size > 0 && !trustedOrigins.has(url.origin)) {
throw new Error("Gateway websocket URL origin is not trusted");
}
};
const toNativeSocketUrl = (socketUrl, query = {}) => {
if (!socketUrl) {
throw new Error("Missing websocket URL");
@@ -19,16 +79,48 @@ const toNativeSocketUrl = (socketUrl, query = {}) => {
return socketUrl;
}
const url = new URL(socketUrl, typeof window !== "undefined" ? window.location.origin : "http://localhost");
const url = new URL(socketUrl, getBrowserLocation()?.origin || "http://localhost");
assertTrustedSocketUrl(url);
Array.from(url.searchParams.keys()).forEach((key) => {
if (SENSITIVE_SOCKET_QUERY_KEYS.has(String(key).toLowerCase())) {
url.searchParams.delete(key);
}
});
Object.entries(query || {}).forEach(([key, value]) => {
if (value === null || value === undefined || value === "") {
return;
}
if (SENSITIVE_SOCKET_QUERY_KEYS.has(String(key).toLowerCase())) {
return;
}
url.searchParams.set(key, String(value));
});
return url.toString();
};
const createSessionAuthenticationPayload = (session, gatewayId, kind) => {
if (!session?.token) {
return null;
}
return {
type: "AUTH",
gatewayId: String(gatewayId || session.gateway_id || ""),
token: String(session.token),
sessionId: session.id || session.session_id || null,
kind,
};
};
const sendSessionAuthentication = (send, session, gatewayId, kind) => {
const authPayload = createSessionAuthenticationPayload(session, gatewayId, kind);
if (authPayload) {
send(authPayload);
}
};
const safeJsonParse = (payload) => {
if (payload === null || payload === undefined || payload === "") {
return null;
@@ -292,10 +384,10 @@ export async function createGatewayStreamClient(gatewayId, scopes = [], handlers
const session = unwrapEdgeGatewayResponse(response, {});
const socketUrl = toNativeSocketUrl(session.ws_url, {
gatewayId,
token: session.token,
});
const connection = createConnection(socketUrl, session, "stream", handlers, ({ send }) => {
sendSessionAuthentication(send, session, gatewayId, "stream");
send({
type: "SUBSCRIBE",
gatewayId: String(gatewayId),
@@ -318,9 +410,10 @@ export async function createGatewayShellClient(gatewayId, options = {}, handlers
const session = unwrapEdgeGatewayResponse(response, {});
const socketUrl = toNativeSocketUrl(session.ws_url, {
gatewayId,
token: session.token,
});
const connection = createConnection(socketUrl, session, "shell", handlers);
const connection = createConnection(socketUrl, session, "shell", handlers, ({ send }) => {
sendSessionAuthentication(send, session, gatewayId, "shell");
});
return {
session,
-1
View File
@@ -71,7 +71,6 @@ const logBuildBanner = () => {
'',
`Build: ${formatCommit(VITE_COMMIT_HASH)} @ ${formatDateTime(VITE_BUILD_DATE)} (${IS_DEV ? 'development' : 'production'})`,
`Remote API: ${getReleaseRuntimeApiBaseUrl()}`,
`Debug: Is running beta FE.`,
].join('\n'));
};
+16 -1
View File
@@ -84,8 +84,23 @@ export const sanitizeRedirectPath = (redirectPath) => {
return redirectPath;
};
export const buildMigrationUrl = (migrationOrigin, redirectPath) => {
export const releaseFrontendPathBase = (pathname = '') => {
const match = String(pathname || '').match(/^\/[^/]+\/frontend(?:\/|$)/);
return match ? match[0].replace(/\/+$/, '') : '';
};
export const applyReleaseFrontendBase = (redirectPath, currentPath = '') => {
const safePath = sanitizeRedirectPath(redirectPath);
const releaseBase = releaseFrontendPathBase(currentPath);
if (!releaseBase || safePath === releaseBase || safePath.startsWith(`${releaseBase}/`)) {
return safePath;
}
return safePath === '/' ? `${releaseBase}/` : `${releaseBase}${safePath}`;
};
export const buildMigrationUrl = (migrationOrigin, redirectPath, currentPath = '') => {
const safePath = applyReleaseFrontendBase(redirectPath, currentPath);
try {
return new URL(safePath, migrationOrigin).toString();
} catch (_error) {
+6 -1
View File
@@ -190,9 +190,14 @@ const EconomicQueuePlaywrightHarness = lazyView('@/views/testing/EconomicQueuePl
* departmentSelection: true if the department selection should be possible for the route
*/
export const releaseFrontendRouterBase = (pathname = globalThis?.location?.pathname || '/') => {
const match = String(pathname || '/').match(/^\/[^/]+\/frontend(?:\/|$)/);
return match ? `${match[0].replace(/\/+$/, '')}/` : '/';
};
// Export the router as router
export const router = createRouter({
history: createWebHistory(),
history: createWebHistory(releaseFrontendRouterBase()),
routes: [
...((import.meta.env.DEV || import.meta.env.MODE === 'test') ? [
{
+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;
};
+16 -3
View File
@@ -1,10 +1,12 @@
import { API_URL, IS_DEV, RELEASE_MANAGER_CONTROL_API_URL, RELEASE_SOURCE, RELEASE_SOURCE_ENV } from "@/config.js";
import { buildReleaseHeaders } from "@/services/releaseHeaders.js";
import { isTrustedReleaseUrl, sameOriginReleaseUrl } from "@/services/releaseTrust.js";
export const RELEASE_RUNTIME_GLOBAL_KEY = "__TRUCKWASH_RELEASE_RUNTIME__";
export const RELEASE_CHANNEL_SELECTION_STORAGE_KEY = "release_channel_selected_slug";
export const RELEASE_SOURCE_OVERRIDE_STORAGE_KEY = "release_source_override";
export const RELEASE_ENTRY_FILENAME = "release-entry.json";
export const RELEASE_BOOTSTRAP_REVISION = "2026-06-03-dev-asset-republish";
export const RELEASE_SOURCE_MODES = Object.freeze({
LOCAL: "local",
DEPLOYMENT: "deployment",
@@ -172,7 +174,8 @@ export const fetchReleaseRuntime = async ({
const runtimeFrontendBaseUrl = (runtime = {}) => {
const urls = runtime?.urls && typeof runtime.urls === "object" ? runtime.urls : {};
return normalizeBaseUrl(runtime?.frontend_base_url || urls.frontend_base_url || "");
const frontendBaseUrl = normalizeBaseUrl(runtime?.frontend_base_url || urls.frontend_base_url || "");
return isTrustedReleaseUrl(frontendBaseUrl, { allowRelative: false }) ? frontendBaseUrl : "";
};
const runtimeChannel = (runtime = {}) => runtime?.channel || {};
@@ -190,8 +193,14 @@ export const shouldLoadRemoteRelease = (runtime = {}) => {
export const releaseEntryUrl = (frontendBaseUrl) =>
new URL(RELEASE_ENTRY_FILENAME, `${normalizeBaseUrl(frontendBaseUrl)}/`).href;
const resolveReleaseAssetUrl = (frontendBaseUrl, value) =>
new URL(String(value || "").replace(/^\/+/, ""), `${normalizeBaseUrl(frontendBaseUrl)}/`).href;
const resolveReleaseAssetUrl = (frontendBaseUrl, value) => {
const baseUrl = normalizeBaseUrl(frontendBaseUrl);
const resolvedUrl = new URL(String(value || "").replace(/^\/+/, ""), `${baseUrl}/`).href;
if (!sameOriginReleaseUrl(resolvedUrl, baseUrl)) {
throw new Error("Release entry asset URL is not on the trusted release frontend origin.");
}
return resolvedUrl;
};
export const loadRemoteReleaseEntry = async ({
runtime,
@@ -200,6 +209,9 @@ export const loadRemoteReleaseEntry = async ({
importModule = (url) => import(/* @vite-ignore */ url),
} = {}) => {
const frontendBaseUrl = runtimeFrontendBaseUrl(runtime);
if (!frontendBaseUrl) {
throw new Error("Release frontend URL is not trusted.");
}
const response = await fetchFn(releaseEntryUrl(frontendBaseUrl), {
method: "GET",
cache: "no-store",
@@ -254,6 +266,7 @@ const localReleaseRuntime = ({ requestedSource = RELEASE_SOURCE_MODES.LOCAL, mis
return {
source: RELEASE_SOURCE_MODES.LOCAL,
requested_source: requestedSource,
bootstrap_revision: RELEASE_BOOTSTRAP_REVISION,
generated_at: new Date().toISOString(),
channel: {
slug: "stable",
+95 -19
View File
@@ -1,5 +1,6 @@
import { reactive, readonly } from "vue";
import { API_URL } from "@/config.js";
import { isTrustedReleaseUrl } from "@/services/releaseTrust.js";
import { buildReleaseHeaders, RELEASE_TRACE_STORAGE_KEY } from "@/services/releaseHeaders.js";
const TRACE_STORAGE_KEY = RELEASE_TRACE_STORAGE_KEY;
@@ -97,9 +98,9 @@ const normalizeReadinessMissingValues = (missing = []) =>
)
);
const normalizeRuntimeBaseUrl = (value) => {
const normalizeRuntimeBaseUrl = (value, { allowRelative = true } = {}) => {
const raw = String(value || "").trim().replace(/\/+$/, "");
if (!raw) {
if (!raw || !isTrustedReleaseUrl(raw, { allowRelative })) {
return null;
}
if (raw.startsWith("/") && !raw.startsWith("//")) {
@@ -114,7 +115,9 @@ const normalizeRuntimeBaseUrl = (value) => {
const runtimeUrls = (runtime = {}) => {
const urls = runtime?.urls && typeof runtime.urls === "object" ? runtime.urls : {};
return {
frontendBaseUrl: normalizeRuntimeBaseUrl(runtime.frontend_base_url ?? urls.frontend_base_url),
frontendBaseUrl: normalizeRuntimeBaseUrl(runtime.frontend_base_url ?? urls.frontend_base_url, {
allowRelative: false,
}),
apiBaseUrl: normalizeRuntimeBaseUrl(runtime.api_base_url ?? urls.api_base_url),
};
};
@@ -253,6 +256,47 @@ export const rewriteReleaseApiUrl = (url = "") => {
return value;
};
const SENSITIVE_PAYLOAD_KEY_PATTERN =
/authorization|cookie|password|passwd|secret|token|api[_-]?key|session|credential|card|cpr|ssn|recaptcha/i;
const CAPTURE_DISABLED_BODY_PLACEHOLDER = "[capture-disabled]";
const truncateReleasePayloadString = (value) =>
value.length > 4000 ? `${value.slice(0, 4000)}\n... [truncated]` : value;
const redactSerializedReleasePayload = (value, depth) => {
const trimmed = value.trim();
if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
try {
return truncateReleasePayloadString(JSON.stringify(redactReleasePayload(JSON.parse(value), depth + 1)));
} catch {
// Fall through to form-encoded and truncation handling.
}
}
if (trimmed.includes("=") && !trimmed.includes("\n")) {
const params = new URLSearchParams(trimmed);
const entries = Array.from(params.entries());
if (entries.length > 0) {
let changed = false;
const redactedParams = new URLSearchParams();
for (const [key, item] of entries.slice(0, 80)) {
if (SENSITIVE_PAYLOAD_KEY_PATTERN.test(key)) {
redactedParams.append(key, "[redacted]");
changed = true;
} else {
redactedParams.append(key, truncateReleasePayloadString(item));
}
}
if (changed) {
return truncateReleasePayloadString(redactedParams.toString());
}
}
}
return truncateReleasePayloadString(value);
};
export const redactReleasePayload = (value, depth = 0) => {
if (depth > 8) {
return "[depth-limit]";
@@ -267,9 +311,7 @@ export const redactReleasePayload = (value, depth = 0) => {
Object.entries(value)
.slice(0, 80)
.map(([key, item]) => {
if (
/authorization|cookie|password|passwd|secret|token|api[_-]?key|session|credential|card|cpr|ssn/i.test(key)
) {
if (SENSITIVE_PAYLOAD_KEY_PATTERN.test(key)) {
return [key, "[redacted]"];
}
return [key, redactReleasePayload(item, depth + 1)];
@@ -277,8 +319,8 @@ export const redactReleasePayload = (value, depth = 0) => {
);
}
if (typeof value === "string" && value.length > 4000) {
return `${value.slice(0, 4000)}\n... [truncated]`;
if (typeof value === "string") {
return redactSerializedReleasePayload(value, depth);
}
return value;
@@ -303,6 +345,30 @@ const shouldSendEvent = (type, severity) => {
return releaseRuntimeStateMutable.capturePolicy.enabled === true;
};
const omitCapturedBodiesWhenDisabled = (payload) => {
if (!payload || typeof payload !== "object") {
return payload;
}
const nextPayload = { ...payload };
for (const key of ["request", "response"]) {
if (nextPayload[key] && typeof nextPayload[key] === "object" && hasOwn(nextPayload[key], "data")) {
nextPayload[key] = {
...nextPayload[key],
data: CAPTURE_DISABLED_BODY_PLACEHOLDER,
};
}
}
return nextPayload;
};
const sanitizeReleaseTimelinePayload = (type, severity, payload) => {
if (isFailureEvent(type, severity) && !releaseRuntimeStateMutable.capturePolicy.enabled) {
return omitCapturedBodiesWhenDisabled(payload);
}
return payload;
};
export const recordReleaseTimelineEvent = (type, payload = {}, options = {}) => {
const severity = options.severity || payload?.severity || "info";
if (!shouldSendEvent(type, severity)) {
@@ -317,7 +383,7 @@ export const recordReleaseTimelineEvent = (type, payload = {}, options = {}) =>
component: options.component || payload?.component || null,
request_id: options.requestId || payload?.request_id || null,
occurred_at: new Date().toISOString(),
payload: redactReleasePayload(payload),
payload: redactReleasePayload(sanitizeReleaseTimelinePayload(type, severity, payload)),
};
if (FRONTEND_FAILURE_EVENT_TYPES.has(type)) {
@@ -421,7 +487,9 @@ const firstFilledString = (...values) => {
const releaseRuntimeUrlsForDisplay = (runtime = {}) => {
const urls = isPlainRecord(runtime?.urls) ? runtime.urls : {};
return {
frontend: normalizeRuntimeBaseUrl(runtime.frontendBaseUrl ?? runtime.frontend_base_url ?? urls.frontend_base_url),
frontend: normalizeRuntimeBaseUrl(runtime.frontendBaseUrl ?? runtime.frontend_base_url ?? urls.frontend_base_url, {
allowRelative: false,
}),
api: normalizeRuntimeBaseUrl(runtime.apiBaseUrl ?? runtime.api_base_url ?? urls.api_base_url),
};
};
@@ -585,7 +653,7 @@ const releaseServiceForKey = (serviceSet = null, key = "") => {
return isPlainRecord(service) ? service : null;
};
export const buildReleaseSessionSummary = (runtime = releaseRuntimeStateMutable) => {
export const buildReleaseSessionSummary = (runtime = releaseRuntimeStateMutable, options = {}) => {
const versions = isPlainRecord(runtime?.versions) ? runtime.versions : {};
const channel = isPlainRecord(runtime?.channel) ? runtime.channel : null;
const availability = isPlainRecord(runtime?.availability) ? runtime.availability : {};
@@ -595,16 +663,18 @@ export const buildReleaseSessionSummary = (runtime = releaseRuntimeStateMutable)
channel?.default_channel === true
|| channel?.default_channel === 1
|| String(channel?.slug || "").toLowerCase() === "stable";
const urls = releaseRuntimeUrlsForDisplay(runtime);
const includeInfrastructureDetails = options?.includeInfrastructureDetails === true;
const urls = includeInfrastructureDetails ? releaseRuntimeUrlsForDisplay(runtime) : { frontend: "", api: "" };
const frontendVersion = isPlainRecord(versions.frontend) ? versions.frontend : null;
const apiVersion = isPlainRecord(versions.api) ? versions.api : null;
const bundle = isPlainRecord(versions.bundle) ? versions.bundle : null;
const bundleId = versions.bundle_id || bundle?.id || null;
const serviceSet = isPlainRecord(versions.service_set)
const rawServiceSet = isPlainRecord(versions.service_set)
? versions.service_set
: isPlainRecord(bundle?.service_set)
? bundle.service_set
: null;
const serviceSet = includeInfrastructureDetails ? rawServiceSet : null;
const defaultSharedLabel = "Default/shared runtime";
const missingLabels = missing.map((key) => RELEASE_MISSING_LABELS[key] || key.replace(/_/g, " "));
@@ -623,15 +693,19 @@ export const buildReleaseSessionSummary = (runtime = releaseRuntimeStateMutable)
? "shared"
: firstFilledString(version?.status, url ? "active" : "unknown");
const primaryText = releaseVersionPrimaryText(version, fallback);
const secondaryText = includeInfrastructureDetails ? releaseVersionSecondaryText(version) : "";
const displayUrl = includeInfrastructureDetails ? url || "" : "";
return {
key,
label,
status,
tone: missingKey ? "warning" : releaseStatusTone(status),
primaryText: releaseVersionPrimaryText(version, fallback),
secondaryText: releaseVersionSecondaryText(version),
url: url || "",
title: [releaseVersionPrimaryText(version, fallback), releaseVersionSecondaryText(version), url]
primaryText,
secondaryText,
url: displayUrl,
title: [primaryText, secondaryText, displayUrl]
.filter(Boolean)
.join(" - "),
missingLabel: missingKey ? RELEASE_MISSING_LABELS[missingKey] || missingKey : "",
@@ -687,13 +761,15 @@ export const buildReleaseSessionSummary = (runtime = releaseRuntimeStateMutable)
bundleStatus: firstFilledString(bundle?.status, bundleId ? "active" : "shared"),
serviceSetLabel: serviceSet
? firstFilledString(serviceSet.name, serviceSet.slug, serviceSet.id ? `#${serviceSet.id}` : "Connected")
: defaultSharedLabel,
: rawServiceSet
? "Restricted to release operators"
: defaultSharedLabel,
missingLabels,
appRows: [
buildAppRow("frontend", "Frontend", frontendVersion, urls.frontend),
buildAppRow("api", "API", apiVersion, urls.api),
],
serviceRows: RELEASE_SERVICE_DEFINITIONS.map(buildServiceRow),
serviceRows: includeInfrastructureDetails || !rawServiceSet ? RELEASE_SERVICE_DEFINITIONS.map(buildServiceRow) : [],
};
};
+71
View File
@@ -0,0 +1,71 @@
import {
ALLOWED_ORIGINS,
API_URL,
DEFAULT_LEGACY_API_URL,
DEFAULT_PUBLIC_GATEWAY_API_URL,
DEFAULT_STABLE_API_URL,
RELEASE_MANAGER_CONTROL_API_URL,
RELEASE_MANAGER_CONTROL_API_FALLBACK_URLS,
RELEASE_PUBLIC_GATEWAY_API_URL,
RELEASE_TRUSTED_ORIGINS,
} from "@/config.js";
const normalizeOrigin = (value) => {
const raw = String(value || "").trim();
if (!raw) {
return "";
}
try {
return new URL(raw).origin;
} catch {
return "";
}
};
const browserOrigin = () => {
if (typeof window !== "undefined" && window.location?.origin) {
return window.location.origin;
}
return "";
};
const configuredTrustedOrigins = () =>
[
browserOrigin(),
...ALLOWED_ORIGINS,
...RELEASE_TRUSTED_ORIGINS,
API_URL,
RELEASE_MANAGER_CONTROL_API_URL,
...RELEASE_MANAGER_CONTROL_API_FALLBACK_URLS,
RELEASE_PUBLIC_GATEWAY_API_URL,
DEFAULT_LEGACY_API_URL,
DEFAULT_PUBLIC_GATEWAY_API_URL,
DEFAULT_STABLE_API_URL,
]
.map(normalizeOrigin)
.filter(Boolean);
export const trustedReleaseOrigins = () => Array.from(new Set(configuredTrustedOrigins()));
export const isTrustedReleaseOrigin = (value) => {
const origin = normalizeOrigin(value);
return Boolean(origin) && trustedReleaseOrigins().includes(origin);
};
export const isRelativeReleaseUrl = (value) => {
const raw = String(value || "").trim();
return Boolean(raw) && raw.startsWith("/") && !raw.startsWith("//");
};
export const isTrustedReleaseUrl = (value, { allowRelative = true } = {}) => {
if (allowRelative && isRelativeReleaseUrl(value)) {
return true;
}
return isTrustedReleaseOrigin(value);
};
export const sameOriginReleaseUrl = (value, expectedBaseUrl) => {
const resolvedOrigin = normalizeOrigin(value);
const expectedOrigin = normalizeOrigin(expectedBaseUrl);
return Boolean(resolvedOrigin && expectedOrigin && resolvedOrigin === expectedOrigin);
};
+10 -4
View File
@@ -2,6 +2,7 @@ import axios from "axios";
import { API_URL, RELEASE_MANAGER_CONTROL_API_FALLBACK_URLS, RELEASE_MANAGER_CONTROL_API_URL } from "@/config.js";
import { enqueueRequest } from "@/services/requestQueue.js";
import { getReleaseRuntimeApiBaseUrl, releaseRuntimeState } from "@/services/releaseTimeline.js";
import { isTrustedReleaseUrl } from "@/services/releaseTrust.js";
export const RELEASE_MANAGER_CONTROL_API_STORAGE_KEY = "release_manager_control_api_url";
const RELEASE_MANAGER_LAST_WORKING_CONTROL_API_STORAGE_KEY = "release_manager_last_working_control_api_url";
@@ -19,9 +20,14 @@ const normalizeApiUrl = (value) => {
return url;
};
const isTrustedApiUrl = (value) => {
const url = normalizeApiUrl(value);
return Boolean(url && isTrustedReleaseUrl(url));
};
const isLocalApiUrl = (value) => {
const url = normalizeApiUrl(value);
if (!url) {
if (!isTrustedApiUrl(url)) {
return false;
}
if (url.startsWith("/") && !url.startsWith("//")) {
@@ -91,14 +97,14 @@ export const releaseManagerControlApiCandidates = () => {
storedUrl,
...deploymentConfigUrls,
...RELEASE_MANAGER_CONTROL_API_FALLBACK_URLS,
]);
]).filter(isTrustedApiUrl);
};
export const getReleaseManagerControlApiUrl = () => releaseManagerControlApiCandidates()[0] || API_URL;
export const setReleaseManagerControlApiUrl = (url) => {
const normalized = normalizeApiUrl(url);
if (!normalized) {
if (!isTrustedApiUrl(normalized)) {
return "";
}
releaseManagerLocalStorage()?.setItem(RELEASE_MANAGER_CONTROL_API_STORAGE_KEY, normalized);
@@ -113,7 +119,7 @@ export const clearReleaseManagerControlApiUrl = () => {
const rememberWorkingControlApiUrl = (url) => {
const normalized = normalizeApiUrl(url);
if (normalized) {
if (isTrustedApiUrl(normalized)) {
releaseManagerSessionStorage()?.setItem(RELEASE_MANAGER_LAST_WORKING_CONTROL_API_STORAGE_KEY, normalized);
}
};
+2
View File
@@ -12,6 +12,7 @@ import { QrcodeStream, QrcodeDropZone, QrcodeCapture } from 'vue-qrcode-reader'
import { sounds } from "@/components/displays/department/pos/steps/mobile/objects/PosDepartmentStepMobileFlow.vue";
import PageLoader from "@/components/global/PageLoader.vue";
import Swal from "sweetalert2";
import { clearEdgeGatewayWorkspaceCache } from "@/services/edgeGateways.js";
const isProcessingQRCode = ref(false); // State to indicate if QR code is being processed
const playCaptureSound = () => {
@@ -20,6 +21,7 @@ const playCaptureSound = () => {
const applyToken = (token: string) => {
isProcessingQRCode.value = true; // Start processing
clearEdgeGatewayWorkspaceCache();
localStorage.setItem("token", token);
window.location.href = "/";
}
+2
View File
@@ -1,12 +1,14 @@
<script setup lang="ts">
import { onMounted, onUnmounted, ref, computed } from "vue";
import PageLoader from "@/components/global/PageLoader.vue";
import { clearEdgeGatewayWorkspaceCache } from "@/services/edgeGateways.js";
const isProcessing = ref(false);
const hasToken = computed(() => localStorage.getItem("superuser_token") !== null);
const applyToken = (token: string) => {
isProcessing.value = true; // Start processing
clearEdgeGatewayWorkspaceCache();
localStorage.setItem("token", token);
localStorage.removeItem("superuser_token");
window.location.href = "/";
@@ -18,6 +18,7 @@ import {
isSearching as isSearchingCustomers,
} from "@/components/search/economic/customerSearch.vue";
import { resolveReleaseApiUrl } from "@/services/releaseTimeline.js";
import { safeAttachmentDownloadLink } from "@/services/attachmentDownloadLinks.js";
import {
buildSelfServeDynamicImageUrl,
getSelfServeCompletedDynamicImageStep,
@@ -482,6 +483,18 @@ const graphStats = computed(() => {
});
const permissions = computed(() => graphPayload.value?.permissions || {});
const canEditVehicleTypeProduct = computed(() =>
Boolean(permissions.value.can_edit) && SessionUser.canAccessSuperUser()
);
const destructiveGatewayActions = new Set(["rotate_credentials", "uninstall"]);
const canManageGateways = computed(() => Boolean(permissions.value.can_manage_gateways));
const canRunGatewayDestructiveActions = computed(() =>
Boolean(permissions.value.can_run_gateway_destructive_actions)
);
const gatewayActionRequiresConfirmation = (action) => destructiveGatewayActions.has(action);
const canRunGatewayAction = (action) =>
canManageGateways.value &&
(!gatewayActionRequiresConfirmation(action) || canRunGatewayDestructiveActions.value);
const selectedNode = computed(() => flowNodes.value.find((node) => node.id === selectedNodeId.value) || null);
const selectedNodeKind = computed(() => selectedNode.value?.data?.kind || "");
const editableNodeKinds = ["question", "condition", "task", "action", "lane"];
@@ -3149,14 +3162,14 @@ const inspectorTaskDynamicImageUnavailableReason = computed(() => {
});
const openTaskAttachment = async (attachment, taskId = null) => {
let downloadLink = String(attachment?.download_link || "").trim();
let downloadLink = safeAttachmentDownloadLink(attachment?.download_link);
if (!downloadLink && taskId && attachment?.id) {
try {
const payload = await requestGet("/department/selfserve/tasks/attachments/download", {
task_id: taskId,
attachment_id: attachment.id,
});
downloadLink = String(payload?.download_link || "").trim();
downloadLink = safeAttachmentDownloadLink(payload?.download_link);
} catch (error) {
withErrorToast(error, "Attachment download could not be prepared.");
return;
@@ -4677,6 +4690,11 @@ const deleteSelected = async () => {
};
const saveVehicleTypeProduct = async () => {
if (!canEditVehicleTypeProduct.value) {
toast.error("Only superusers can update vehicle type product catalog fields.");
return;
}
const id = parseNullableInt(
inspectorForm.value.id || selectedRaw.value.product || selectedRaw.value.product_id || selectedRaw.value.id
);
@@ -4762,7 +4780,26 @@ const rollbackVersion = async (versionId) => {
}
};
const runGatewayAction = async (gatewayId, action, confirm = false) => {
const confirmGatewayAction = (action) => {
if (!gatewayActionRequiresConfirmation(action)) {
return false;
}
const actionLabel = action === "rotate_credentials" ? "rotate gateway credentials" : "uninstall this gateway";
return window.confirm(`Are you sure you want to ${actionLabel}? This operation can disrupt live edge hardware.`);
};
const runGatewayAction = async (gatewayId, action) => {
if (!canRunGatewayAction(action)) {
toast.error("You do not have permission to run this gateway action.");
return;
}
const confirm = confirmGatewayAction(action);
if (gatewayActionRequiresConfirmation(action) && !confirm) {
return;
}
try {
await requestPost("/department/selfserve/studio/gateway-action", {
department: departmentId.value,
@@ -7026,7 +7063,7 @@ onBeforeUnmount(() => {
class="studio-debug-attachment"
:disabled="!attachment.download_link"
:title="taskAttachmentLabel(attachment)"
@click.stop="openTaskAttachment(attachment)"
@click.stop="openTaskAttachment(attachment, task.id)"
>
<span class="icon is-small"><i :class="taskAttachmentIcon(attachment)"></i></span>
<span>{{ taskAttachmentLabel(attachment) }}</span>
@@ -7484,7 +7521,7 @@ onBeforeUnmount(() => {
class="studio-node-attachment"
:disabled="!attachment.download_link"
:title="taskAttachmentLabel(attachment)"
@click.stop="openTaskAttachment(attachment)"
@click.stop="openTaskAttachment(attachment, data.object_id)"
@mousedown.stop
>
<span class="icon is-small"><i :class="taskAttachmentIcon(attachment)"></i></span>
@@ -8025,7 +8062,7 @@ onBeforeUnmount(() => {
/>
<span>Wash product</span>
</label>
<button class="button is-primary" type="submit" :disabled="!permissions.can_edit">
<button class="button is-primary" type="submit" :disabled="!canEditVehicleTypeProduct">
<span class="icon"><i class="fas fa-floppy-disk"></i></span>
<span>Save vehicle type</span>
</button>
@@ -8102,6 +8139,7 @@ onBeforeUnmount(() => {
type="button"
title="Discover gateway"
@click="runGatewayAction(inspectorForm.key || selectedGatewayId, 'discovery')"
:disabled="!canRunGatewayAction('discovery')"
>
<span class="icon"><i class="fas fa-radar"></i></span>
<span>Discover</span>
@@ -8111,6 +8149,7 @@ onBeforeUnmount(() => {
type="button"
title="Update gateway"
@click="runGatewayAction(inspectorForm.key || selectedGatewayId, 'update')"
:disabled="!canRunGatewayAction('update')"
>
<span class="icon"><i class="fas fa-upload"></i></span>
<span>Update</span>
@@ -8119,7 +8158,8 @@ onBeforeUnmount(() => {
class="button is-small is-warning is-light"
type="button"
title="Rotate gateway credentials"
@click="runGatewayAction(inspectorForm.key || selectedGatewayId, 'rotate_credentials', true)"
@click="runGatewayAction(inspectorForm.key || selectedGatewayId, 'rotate_credentials')"
:disabled="!canRunGatewayAction('rotate_credentials')"
>
<span class="icon"><i class="fas fa-key"></i></span>
<span>Rotate</span>
@@ -8128,7 +8168,8 @@ onBeforeUnmount(() => {
class="button is-small is-danger is-light"
type="button"
title="Uninstall gateway"
@click="runGatewayAction(inspectorForm.key || selectedGatewayId, 'uninstall', true)"
@click="runGatewayAction(inspectorForm.key || selectedGatewayId, 'uninstall')"
:disabled="!canRunGatewayAction('uninstall')"
>
<span class="icon"><i class="fas fa-trash"></i></span>
<span>Uninstall</span>
@@ -9316,6 +9357,7 @@ onBeforeUnmount(() => {
class="button is-small is-link is-light"
title="Discover gateway"
@click="runGatewayAction(gateway.id, 'discovery')"
:disabled="!canRunGatewayAction('discovery')"
>
<span class="icon"><i class="fas fa-radar"></i></span>
</button>
@@ -9323,20 +9365,23 @@ onBeforeUnmount(() => {
class="button is-small is-light"
title="Update gateway"
@click="runGatewayAction(gateway.id, 'update')"
:disabled="!canRunGatewayAction('update')"
>
<span class="icon"><i class="fas fa-upload"></i></span>
</button>
<button
class="button is-small is-warning is-light"
title="Rotate gateway credentials"
@click="runGatewayAction(gateway.id, 'rotate_credentials', true)"
@click="runGatewayAction(gateway.id, 'rotate_credentials')"
:disabled="!canRunGatewayAction('rotate_credentials')"
>
<span class="icon"><i class="fas fa-key"></i></span>
</button>
<button
class="button is-small is-danger is-light"
title="Uninstall gateway"
@click="runGatewayAction(gateway.id, 'uninstall', true)"
@click="runGatewayAction(gateway.id, 'uninstall')"
:disabled="!canRunGatewayAction('uninstall')"
>
<span class="icon"><i class="fas fa-trash"></i></span>
</button>
@@ -9802,7 +9847,7 @@ onBeforeUnmount(() => {
class="studio-debug-attachment"
:disabled="!attachment.download_link"
:title="taskAttachmentLabel(attachment)"
@click.stop="openTaskAttachment(attachment)"
@click.stop="openTaskAttachment(attachment, task.id)"
>
<span class="icon is-small"><i :class="taskAttachmentIcon(attachment)"></i></span>
<span>{{ taskAttachmentLabel(attachment) }}</span>
@@ -1,5 +1,5 @@
<script setup>
import {ref, watch} from "vue";
import {computed, ref, watch} from "vue";
import { BLoading } from "buefy";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
@@ -26,6 +26,12 @@ const washes = ref(0);
const outsideHours = ref(createEmptyOutsideHours());
const laneToggles = ref([]);
const laneToggleError = ref("");
const canManageMachineStatusToggles = computed(() =>
SessionUser.hasPermission("admin") || SessionUser.hasPermission("modules_selfserve_lane_status_set")
);
const canManageDognvaskToggles = computed(() =>
SessionUser.hasPermission("admin") || SessionUser.hasPermission("edit_department_lane")
);
const normalizeBoolean = (value, defaultValue = false) => {
if (typeof value === "boolean") {
@@ -214,7 +220,7 @@ const dognvaskToggleId = (lane) => `dognvask-${props.department_id}-${lane.id}`;
const laneDisplayName = (lane, index = 0) => lane.name || `Bane ${index + 1}`;
const isMachineToggleChecked = (lane) => Boolean(lane.machine_status_enabled);
const dognvaskHasWarning = (lane) => !lane.dognvask_configured;
const isDognvaskToggleChecked = (lane) => Boolean(lane.selfserve_enabled) && !dognvaskHasWarning(lane);
const isDognvaskToggleChecked = (lane) => Boolean(lane.selfserve_enabled);
const dognvaskWarnings = (lane) => Array.isArray(lane.dognvask_configuration_warnings) ? lane.dognvask_configuration_warnings : [];
const formatMachineStatusModifiedAt = (value) => {
if (!value) {
@@ -249,6 +255,11 @@ const applyLaneUpdate = (laneId, currentLane, update, savingPatch = {}) => {
};
const toggleMachineStatus = async (lane, event) => {
if (!canManageMachineStatusToggles.value) {
event.target.checked = isMachineToggleChecked(lane);
return;
}
const enabled = event.target.checked;
const previousLane = { ...lane };
@@ -278,6 +289,11 @@ const toggleMachineStatus = async (lane, event) => {
};
const toggleDognvask = async (lane, event) => {
if (!canManageDognvaskToggles.value) {
event.target.checked = isDognvaskToggleChecked(lane);
return;
}
const enabled = event.target.checked;
const previousLane = { ...lane };
@@ -335,7 +351,7 @@ watch([selected_date, selected_date_to], () => {
type="checkbox"
:id="machineToggleId(lane)"
:checked="isMachineToggleChecked(lane)"
:disabled="lane.isSavingMachineStatus"
:disabled="!canManageMachineStatusToggles || lane.isSavingMachineStatus"
@change="toggleMachineStatus(lane, $event)"
>
<div class="status-toggle-top">
@@ -371,7 +387,7 @@ watch([selected_date, selected_date_to], () => {
type="checkbox"
:id="dognvaskToggleId(lane)"
:checked="isDognvaskToggleChecked(lane)"
:disabled="lane.isSavingDognvask"
:disabled="!canManageDognvaskToggles || lane.isSavingDognvask"
@change="toggleDognvask(lane, $event)"
>
<div class="status-toggle-top">
@@ -6,6 +6,12 @@ import RestrictedPageWrapper from "@/components/page/wrappers/RestrictedPageWrap
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import ConfigurationSubPageWrapper from "@/views/dashboards/superUserDashboard/configuration/ConfigurationSubPageWrapper.vue";
import { normalizeEdgeGatewayError } from "@/features/edgeGateways/edgeGatewayErrors.js";
import {
canUseStubBrokerAuthMode,
redactBrokerSharedSecret,
sanitizeBrokerAuthMode,
validatePublicBrokerUrl,
} from "@/features/edgeGateways/edgeGatewayBrokerConfigSecurity.js";
const moduleConfigEntries = ref([]);
const moduleConfigMap = ref({
@@ -35,6 +41,15 @@ const brokerDiagnosticKeys = {
secret: "broker_shared_secret",
};
const allowStubBrokerAuthMode = canUseStubBrokerAuthMode();
const setModuleError = (message) => {
moduleState.error = {
title: "Invalid edge gateway broker configuration",
message,
};
};
const loadModuleConfig = async () => {
moduleState.loading = true;
moduleState.error = null;
@@ -49,8 +64,8 @@ const loadModuleConfig = async () => {
default_update_window: response?.data?.config?.default_update_window || "02:00-04:00",
broker_url: response?.data?.config?.broker_url || "http://edge-broker:4300",
public_broker_url: response?.data?.config?.public_broker_url || "",
broker_auth_mode: response?.data?.config?.broker_auth_mode || "manager",
broker_shared_secret: response?.data?.config?.broker_shared_secret || "",
broker_auth_mode: sanitizeBrokerAuthMode(response?.data?.config?.broker_auth_mode),
broker_shared_secret: redactBrokerSharedSecret(),
};
} catch (error) {
moduleState.error = normalizeEdgeGatewayError(error);
@@ -65,15 +80,26 @@ const saveModuleConfig = async () => {
moduleState.error = null;
try {
await SessionUser.superUser.modules.edgegateway.config.set_config({
const publicBrokerUrl = validatePublicBrokerUrl(moduleConfigMap.value.public_broker_url);
if (!publicBrokerUrl.ok) {
setModuleError(publicBrokerUrl.message);
return;
}
const payload = {
enabled: Boolean(moduleConfigMap.value.enabled),
default_release_channel: moduleConfigMap.value.default_release_channel || "stable",
default_update_window: moduleConfigMap.value.default_update_window || "02:00-04:00",
broker_url: moduleConfigMap.value.broker_url || "",
public_broker_url: moduleConfigMap.value.public_broker_url || "",
broker_auth_mode: moduleConfigMap.value.broker_auth_mode || "manager",
broker_shared_secret: moduleConfigMap.value.broker_shared_secret || "",
});
public_broker_url: publicBrokerUrl.value,
broker_auth_mode: sanitizeBrokerAuthMode(moduleConfigMap.value.broker_auth_mode),
};
if (moduleConfigMap.value.broker_shared_secret) {
payload.broker_shared_secret = moduleConfigMap.value.broker_shared_secret;
}
await SessionUser.superUser.modules.edgegateway.config.set_config(payload);
await loadModuleConfig();
} catch (error) {
moduleState.error = normalizeEdgeGatewayError(error);
@@ -82,13 +108,20 @@ const saveModuleConfig = async () => {
}
};
const brokerDiagnosticPayload = (target) => ({
target,
broker_url: moduleConfigMap.value.broker_url || "",
public_broker_url: moduleConfigMap.value.public_broker_url || "",
broker_auth_mode: moduleConfigMap.value.broker_auth_mode || "manager",
broker_shared_secret: moduleConfigMap.value.broker_shared_secret || "",
});
const brokerDiagnosticPayload = (target) => {
const payload = {
target,
broker_url: moduleConfigMap.value.broker_url || "",
public_broker_url: moduleConfigMap.value.public_broker_url || "",
broker_auth_mode: sanitizeBrokerAuthMode(moduleConfigMap.value.broker_auth_mode),
};
if (moduleConfigMap.value.broker_shared_secret) {
payload.broker_shared_secret = moduleConfigMap.value.broker_shared_secret;
}
return payload;
};
const testBrokerConfig = async (target) => {
const resultKey = brokerDiagnosticKeys[target];
@@ -100,6 +133,15 @@ const testBrokerConfig = async (target) => {
brokerDiagnostics.error = null;
try {
if (target === "public") {
const publicBrokerUrl = validatePublicBrokerUrl(moduleConfigMap.value.public_broker_url);
if (!publicBrokerUrl.ok) {
setModuleError(publicBrokerUrl.message);
return;
}
moduleConfigMap.value.public_broker_url = publicBrokerUrl.value;
}
const response = await SessionUser.superUser.modules.edgegateway.config.test_broker(
brokerDiagnosticPayload(target)
);
@@ -275,7 +317,7 @@ onMounted(async () => {
<div class="select is-fullwidth">
<select v-model="moduleConfigMap.broker_auth_mode" data-testid="gateway-module-broker-auth-mode">
<option value="manager">manager</option>
<option value="stub">stub</option>
<option v-if="allowStubBrokerAuthMode" value="stub">stub (development only)</option>
</select>
</div>
</label>
@@ -288,7 +330,7 @@ onMounted(async () => {
autocomplete="off"
class="input"
data-testid="gateway-module-broker-shared-secret"
placeholder="Shared secret"
placeholder="Leave blank to keep the existing secret"
type="password"
/>
<button
@@ -48,7 +48,11 @@ export const periodPageCount = computed(() => {
});
const normalizeLimit = (value) => {
if (String(value || "").trim().toLowerCase() === ALL_LIMIT) {
if (
String(value || "")
.trim()
.toLowerCase() === ALL_LIMIT
) {
return ALL_LIMIT;
}
@@ -76,6 +80,45 @@ const safeSessionStorage = () => {
}
};
const getStorageValue = (key) => {
try {
if (typeof window === "undefined" || !window.localStorage) {
return "";
}
return window.localStorage.getItem(key) || "";
} catch {
return "";
}
};
const hashCacheScopePart = (value) => {
let hash = 5381;
for (let index = 0; index < value.length; index += 1) {
hash = ((hash << 5) + hash) ^ value.charCodeAt(index);
}
return (hash >>> 0).toString(36);
};
const getAuthenticatedCacheScope = () => {
const token = getStorageValue("token");
if (!token) {
return "";
}
return [
hashCacheScopePart(token),
getStorageValue("is_subuser") === "true" ? "subuser" : "user",
getStorageValue("selected_customer_number"),
]
.map((part) => encodeURIComponent(String(part ?? "")))
.join("|");
};
const getScopedCacheKey = (cacheKey) => {
const scope = getAuthenticatedCacheScope();
return scope ? `${scope}:${cacheKey}` : "";
};
const memoryCache = new Map();
const selfWashCountsMemoryCache = new Map();
@@ -151,13 +194,18 @@ const isFreshCacheEntry = (entry) => {
};
export const getCachedPeriodPage = (cacheKey) => {
const memoryEntry = memoryCache.get(cacheKey);
const scopedCacheKey = getScopedCacheKey(cacheKey);
if (!scopedCacheKey) {
return null;
}
const memoryEntry = memoryCache.get(scopedCacheKey);
if (isFreshCacheEntry(memoryEntry)) {
return memoryEntry;
}
if (memoryEntry) {
memoryCache.delete(cacheKey);
memoryCache.delete(scopedCacheKey);
}
const storage = safeSessionStorage();
@@ -165,14 +213,14 @@ export const getCachedPeriodPage = (cacheKey) => {
return null;
}
const storageKey = `${CACHE_PREFIX}${cacheKey}`;
const storageKey = `${CACHE_PREFIX}${scopedCacheKey}`;
try {
const parsed = JSON.parse(storage.getItem(storageKey) || "null");
if (!isFreshCacheEntry(parsed)) {
storage.removeItem(storageKey);
return null;
}
memoryCache.set(cacheKey, parsed);
memoryCache.set(scopedCacheKey, parsed);
return parsed;
} catch {
storage.removeItem(storageKey);
@@ -181,12 +229,17 @@ export const getCachedPeriodPage = (cacheKey) => {
};
export const setCachedPeriodPage = (cacheKey, periodResult, paginationMeta) => {
const scopedCacheKey = getScopedCacheKey(cacheKey);
if (!scopedCacheKey) {
return;
}
const entry = {
storedAt: Date.now(),
data: periodResult,
pagination: paginationMeta,
};
memoryCache.set(cacheKey, entry);
memoryCache.set(scopedCacheKey, entry);
const storage = safeSessionStorage();
if (!storage) {
@@ -194,7 +247,7 @@ export const setCachedPeriodPage = (cacheKey, periodResult, paginationMeta) => {
}
try {
storage.setItem(`${CACHE_PREFIX}${cacheKey}`, JSON.stringify(entry));
storage.setItem(`${CACHE_PREFIX}${scopedCacheKey}`, JSON.stringify(entry));
} catch {
// Best-effort cache. Quota errors should not block the period view.
}
@@ -217,18 +270,21 @@ export const clearPeriodCache = () => {
};
export const buildSelfWashCountsCacheKey = ({ dateFrom, dateTo, filters }) =>
[dateFrom, dateTo, filters]
.map((part) => encodeURIComponent(String(part ?? "")))
.join("|");
[dateFrom, dateTo, filters].map((part) => encodeURIComponent(String(part ?? ""))).join("|");
export const getCachedSelfWashCounts = (cacheKey) => {
const memoryEntry = selfWashCountsMemoryCache.get(cacheKey);
const scopedCacheKey = getScopedCacheKey(cacheKey);
if (!scopedCacheKey) {
return null;
}
const memoryEntry = selfWashCountsMemoryCache.get(scopedCacheKey);
if (isFreshCacheEntry(memoryEntry)) {
return memoryEntry.data;
}
if (memoryEntry) {
selfWashCountsMemoryCache.delete(cacheKey);
selfWashCountsMemoryCache.delete(scopedCacheKey);
}
const storage = safeSessionStorage();
@@ -236,14 +292,14 @@ export const getCachedSelfWashCounts = (cacheKey) => {
return null;
}
const storageKey = `${SELF_WASH_COUNTS_CACHE_PREFIX}${cacheKey}`;
const storageKey = `${SELF_WASH_COUNTS_CACHE_PREFIX}${scopedCacheKey}`;
try {
const parsed = JSON.parse(storage.getItem(storageKey) || "null");
if (!isFreshCacheEntry(parsed)) {
storage.removeItem(storageKey);
return null;
}
selfWashCountsMemoryCache.set(cacheKey, parsed);
selfWashCountsMemoryCache.set(scopedCacheKey, parsed);
return parsed.data;
} catch {
storage.removeItem(storageKey);
@@ -252,11 +308,16 @@ export const getCachedSelfWashCounts = (cacheKey) => {
};
export const setCachedSelfWashCounts = (cacheKey, counts) => {
const scopedCacheKey = getScopedCacheKey(cacheKey);
if (!scopedCacheKey) {
return;
}
const entry = {
storedAt: Date.now(),
data: counts,
};
selfWashCountsMemoryCache.set(cacheKey, entry);
selfWashCountsMemoryCache.set(scopedCacheKey, entry);
const storage = safeSessionStorage();
if (!storage) {
@@ -264,7 +325,7 @@ export const setCachedSelfWashCounts = (cacheKey, counts) => {
}
try {
storage.setItem(`${SELF_WASH_COUNTS_CACHE_PREFIX}${cacheKey}`, JSON.stringify(entry));
storage.setItem(`${SELF_WASH_COUNTS_CACHE_PREFIX}${scopedCacheKey}`, JSON.stringify(entry));
} catch {
// Best-effort cache. Quota errors should not block the period view.
}
@@ -34,6 +34,7 @@ const showStripeStatusSummary = computed(() => isPaid.value && (isDraft.value ||
const isStripeTransferBusy = computed(() => stripeTransferQueue.disableSubmit.value);
const isStripeTransferQueuedOrProcessing = computed(() => stripeTransferQueue.isQueuedOrProcessing.value);
const isStripeTransferCompleted = computed(() => stripeTransferQueue.isCompleted.value);
const canBookStripeInvoice = computed(() => !isStripeTransferBusy.value && !isStripeTransferCompleted.value);
const isStripeTransferFailed = computed(() => stripeTransferQueue.isFailed.value);
const stripeTransferProgressPercent = computed(() => stripeTransferQueue.progressPercent.value);
const stripeTransferProgressMessage = computed(() => stripeTransferQueue.progressMessage.value);
@@ -61,6 +62,10 @@ const stripeTransferResultMessage = computed(() => {
});
const onBookStripeInvoice = async () => {
if (!canBookStripeInvoice.value) {
return;
}
try {
await stripeTransferQueue.enqueue({});
} catch (error) {
@@ -324,7 +329,7 @@ const stripePayment = computed(() => {
<div class="buttons">
<button
class="button is-small is-light"
:disabled="isStripeTransferBusy"
:disabled="!canBookStripeInvoice"
data-testid="collected-stripe-book-invoice"
@click="onBookStripeInvoice"
>
@@ -1150,8 +1150,10 @@ function normalizeReleaseStatusService(service) {
status: service?.status || service?.state || "ready",
state: service?.state || service?.status || "ready",
severity: service?.severity || "ok",
message: service?.message || trFallback("status.service_ready", "Release service is ready."),
next_action: service?.next_action || "",
message: redactSensitiveDiagnostics(
service?.message || trFallback("status.service_ready", "Release service is ready.")
),
next_action: redactSensitiveDiagnostics(service?.next_action || ""),
target_tab: service?.target_tab || "overview",
target_id: service?.target_id ?? null,
deployment_id: service?.deployment_id ?? null,
@@ -1175,8 +1177,8 @@ function normalizeReleaseStatusIssue(issue) {
channel_slug: issue?.channel_slug || "",
service_key: issue?.service_key || null,
label: issue?.label || releaseStatusServiceLabel(issue?.service_key),
message: issue?.message || "",
next_action: issue?.next_action || "",
message: redactSensitiveDiagnostics(issue?.message || ""),
next_action: redactSensitiveDiagnostics(issue?.next_action || ""),
target_tab: issue?.target_tab || "overview",
target_id: issue?.target_id ?? null,
deployment_id: issue?.deployment_id ?? null,
@@ -2888,26 +2890,6 @@ function isolatedStackRequestedName() {
return String(bundleForm.service_set_name || `${channelSlug} isolated stack`).trim();
}
function isolatedStackIsComplete(set) {
return ["frontend", "api", "database", "redis", "minio"].every((item) => serviceSetStackItem(set, item));
}
function matchingIsolatedStackServiceSet() {
const channelId = Number(bundleChannelId() || 0);
const requestedSlug = safeStackSlug(isolatedStackRequestedName());
return (
serviceSets.value.find((set) => {
if (set?.mode !== "isolated_stack") {
return false;
}
if (channelId && Number(set.channel_id || 0) !== channelId) {
return false;
}
return safeStackSlug(set.name || set.slug || "") === requestedSlug || String(set.slug || "") === requestedSlug;
}) || null
);
}
function isolatedStackServiceName(app) {
const channelSlug = selectedBundleChannel()?.slug || "release";
const stackName = safeStackSlug(bundleForm.service_set_name || `${channelSlug}-isolated-stack`);
@@ -3150,19 +3132,6 @@ async function serviceSetIdForBundle() {
return sourceId;
}
if (isIsolatedStackMode.value) {
const existingStack = matchingIsolatedStackServiceSet();
if (existingStack?.id) {
if (!isolatedStackIsComplete(existingStack) && missingIsolatedDataServices(existingStack).length > 0) {
await completeReleaseServiceSetIsolatedDataServices(existingStack.id, {
deploy_data_targets: true,
});
await load();
}
return existingStack.id;
}
}
const isolatedTargets = isIsolatedStackMode.value
? {
frontend: await createIsolatedStackDeploymentTarget("frontend"),
@@ -3310,7 +3279,7 @@ async function saveChannel() {
}
async function saveAssignment() {
if (!assignmentForm.subject_type || !assignmentForm.subject_id) {
if (!canManage.value || !assignmentForm.subject_type || !assignmentForm.subject_id) {
return;
}
@@ -4109,12 +4078,35 @@ function deploymentFailureSummary(deployment) {
return summary && typeof summary === "object" ? summary : null;
}
function redactSensitiveDiagnostics(value) {
let text = String(value || "").trim();
if (!text) {
return "";
}
text = text.replace(/(https?:\/\/)([^\s/@:]+):([^\s/@]+)@/gi, "$1[redacted]@");
text = text.replace(/\b(?:gh[opsru]_|github_pat_|glpat-|xox[baprs]-)[A-Za-z0-9_\-]{8,}\b/g, "[redacted-token]");
text = text.replace(/\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]{8,}/gi, "$1 [redacted]");
text = text.replace(
/\b((?:[A-Z][A-Z0-9_]*_)?(?:PASSWORD|PASSWD|SECRET|TOKEN|API[_-]?KEY|ACCESS[_-]?KEY|PRIVATE[_-]?KEY|AUTH)[A-Z0-9_]*)\s*=\s*(?:"[^"]*"|'[^']*'|[^\s,;]+)/gi,
"$1=[redacted]"
);
text = text.replace(
/\b(?:10\.\d{1,3}\.\d{1,3}\.\d{1,3}|172\.(?:1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3}|192\.168\.\d{1,3}\.\d{1,3})\b/g,
"[redacted-private-ip]"
);
text = text.replace(/\b[a-z0-9.-]+\.internal\b/gi, "[redacted-internal-host]");
text = text.replace(/(?:^|\s)(?:\/[A-Za-z0-9._-]+){2,}\/\.env\b/g, " [redacted-env-path]");
return text;
}
function deploymentFailureCause(deployment) {
return String(deploymentFailureSummary(deployment)?.root_cause || deployment?.error_message || "").trim();
return redactSensitiveDiagnostics(deploymentFailureSummary(deployment)?.root_cause || deployment?.error_message || "");
}
function deploymentFailureNextAction(deployment) {
return String(deploymentFailureSummary(deployment)?.next_action || "").trim();
return redactSensitiveDiagnostics(deploymentFailureSummary(deployment)?.next_action || "");
}
function canPromoteDeployment(deployment) {
@@ -4134,9 +4126,9 @@ function promotionBlockedReason(deployment) {
return tr("actions.promote");
}
return (
return redactSensitiveDiagnostics(
deployment?.promotion_blocked_reason ||
trFallback("deployments.promotion_blocked", "Only successfully deployed release deployments can be promoted.")
trFallback("deployments.promotion_blocked", "Only successfully deployed release deployments can be promoted.")
);
}
@@ -5366,7 +5358,7 @@ onMounted(async () => {
icon="fas fa-user-tag"
default-expanded
>
<div class="release-chip-row" data-testid="release-assignment-channel-suggestions">
<div v-if="canManage" class="release-chip-row" data-testid="release-assignment-channel-suggestions">
<b-button
v-for="channel in channels"
:key="channel.id"
@@ -5380,7 +5372,12 @@ onMounted(async () => {
</b-button>
</div>
<form class="release-form" data-testid="release-assignment-form" @submit.prevent="saveAssignment">
<form
v-if="canManage"
class="release-form"
data-testid="release-assignment-form"
@submit.prevent="saveAssignment"
>
<b-field :label="tr('assignments.subject')" :message="tr('assignments.subject_message')">
<ReleaseAssignmentSubjectAutocomplete
:model-value="assignmentSubjectSelection"
@@ -143,11 +143,32 @@ const onItemsClick = (event, booking) => {
showPopper(popperBox(t("bookings_table.services"), content), event.currentTarget || event.target);
};
const normalizePositiveInteger = (value) => {
const parsedValue = Number.parseInt(String(value ?? ""), 10);
return Number.isInteger(parsedValue) && parsedValue > 0 ? parsedValue : null;
};
const getCurrentCustomerNumber = () =>
normalizePositiveInteger(
SessionUser.isSubuser.value
? SessionUser.subuser.selectedGrantCustomerNumber.value
: SessionUser.getCustomerNumber()
);
const isOwnBooking = (bookingobj) =>
normalizePositiveInteger(bookingobj?.customer_number) === getCurrentCustomerNumber();
const canEditBooking = (bookingobj) => {
// Admins can always edit
if (SessionUser.canAccessAdmin() && SessionUser.hasPermission("edit_bookings")) return true;
if (!!bookingobj.order_id) return false; // Only allow editing bookings without an order
if (SessionUser.hasPermission("edit_own_bookings")) return true;
if (SessionUser.hasPermission("edit_own_bookings") && isOwnBooking(bookingobj)) return true;
};
const canDeleteBooking = (bookingobj) => {
if (!!bookingobj.order_id) return false;
if (SessionUser.canAccessAdmin() || SessionUser.canAccessSuperUser()) return true;
return SessionUser.hasPermission("edit_own_bookings") && isOwnBooking(bookingobj);
};
const sortedList = computed(() => {
@@ -467,6 +488,7 @@ const getColspan = () => {
:customer_number="booking.customer_number"
:department_id="booking.department"
:refresh-function="loadList"
:allow-booking-deletion="canDeleteBooking(booking)"
:reg_1="booking.reg_1"
:reg_2="booking.reg_2"
:icon="'fas fa-ellipsis-v'"
@@ -532,6 +554,7 @@ const getColspan = () => {
<span>{{ getDepartmentName(parseInt(booking.department)) }}</span>
</span>
<button
v-if="canDeleteBooking(booking)"
class="button is-light is-danger is-small order-booking-card-header__delete"
type="button"
aria-label="Slet booking"
@@ -598,6 +621,7 @@ const getColspan = () => {
:customer_number="booking.customer_number"
:department_id="booking.department"
:refresh-function="loadList"
:allow-booking-deletion="canDeleteBooking(booking)"
:reg_1="booking.reg_1"
:reg_2="booking.reg_2"
:displayActionsDirectly="true"
@@ -1,6 +1,6 @@
<script setup>
import { computed, onMounted } from "vue";
import { BLoading } from "buefy";
import { BLoading, BMessage } from "buefy";
import UserDashboardPageWrapper from "@/views/dashboards/userDashboard/UserDashboardPageWrapper.vue";
import PosDepartmentStepMobile1Location from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile1Location.vue";
import WhiteBoxCard from "@/components/displays/boxes/WhiteBoxCard.vue";
@@ -10,12 +10,61 @@ import { useWashDepartments } from "@/composables/useWashDepartments";
const {
guestDepartments,
nearestDepartment,
isDepartmentSelectionFallbackBased,
fetchDepartments,
evaluateLocationDepartments,
orderDepartmentsByDistance,
} = useWashDepartments();
selectDepartment,
} = useWashDepartments({ includeLanes: true });
const orderedDepartments = computed(() => orderDepartmentsByDistance(guestDepartments.value));
const hasLocationCoordinates = computed(() => !!locations.location.value?.coords);
const shouldShowChooseDepartmentMessage = computed(() => !hasLocationCoordinates.value && !nearestDepartment.value);
const isDepartmentSelfServeEnabled = (department) => department?.self_serve_enabled === true;
const isLaneSelfServeEnabled = (lane) =>
!(
lane?.selfserve_enabled === false ||
lane?.selfserve_enabled === 0 ||
lane?.selfserve_enabled === "0" ||
["false", "off", "no"].includes(
String(lane?.selfserve_enabled ?? "")
.trim()
.toLowerCase()
)
);
const isLaneReadyForSelfServe = (lane) => lane?.status === "AVAILABLE" && isLaneSelfServeEnabled(lane);
const canDepartmentStartSelfServe = (department) =>
isDepartmentSelfServeEnabled(department) && (department?.lanes || []).some(isLaneReadyForSelfServe);
const nearestDepartmentCanStartSelfServe = computed(() => canDepartmentStartSelfServe(nearestDepartment.value));
const eligibleDepartments = computed(() => guestDepartments.value.filter(canDepartmentStartSelfServe));
const eligibleAlternativeDepartments = computed(() =>
eligibleDepartments.value.filter((department) => department.id !== nearestDepartment.value?.id)
);
const hasEligibleAlternativeDepartment = computed(() => eligibleAlternativeDepartments.value.length > 0);
const selfServeUnavailableMessage = computed(() => {
if (!nearestDepartment.value) {
return "Vi kan ikke finde din nærmeste afdeling uden en placering. Vælg en selvvask-klar afdeling nedenfor.";
}
if (!isDepartmentSelfServeEnabled(nearestDepartment.value)) {
return `${nearestDepartment.value.name} er ikke aktiveret til selvvask lige nu.`;
}
if (!(nearestDepartment.value.lanes || []).some(isLaneReadyForSelfServe)) {
return `${nearestDepartment.value.name} har ingen ledige selvvask-baner lige nu.`;
}
return null;
});
const getDepartmentDistance = (department) => {
if (!locations.location.value?.coords) {
@@ -46,27 +95,65 @@ onMounted(async () => {
<div class="column is-12">
<WhiteBoxCard :force-state="true" :defaultOpen="true" :toggleable="false" :loading="false">
<template #header>
<div class="card-header-title has-text-link has-text-weight-bold" data-testid="self-serve-home-nearest-name">
{{ nearestDepartment ? nearestDepartment.name : $t("common.loading") }}
<div
class="card-header-title has-text-link has-text-weight-bold"
data-testid="self-serve-home-nearest-name"
>
{{ nearestDepartment ? nearestDepartment.name : $t("global.search_departments") }}
</div>
<div class="card-header-icon has-text-link">
<i class="fas fa-map-marker-alt"></i>
</div>
</template>
<template #content>
{{ $t("user_dashboard.wash.nearest_department", { name: nearestDepartment ? nearestDepartment.name : "-", distance: nearestDepartment ? nearestDepartment.distance.toFixed(2) : "-" }) }}
<b-loading :is-full-page="false" :model-value="!nearestDepartment" :can-cancel="true" />
<template v-if="nearestDepartment && isDepartmentSelectionFallbackBased">
Vælg en afdeling for at fortsætte. Vi har valgt den første tilgængelige selvvaskeafdeling, fordi din
placering ikke er tilgængelig.
</template>
<template v-else-if="nearestDepartment">
{{
$t("user_dashboard.wash.nearest_department", {
name: nearestDepartment.name,
distance: nearestDepartment.distance !== null ? nearestDepartment.distance.toFixed(2) : "-",
})
}}
</template>
<template v-else-if="shouldShowChooseDepartmentMessage">
Vælg en afdeling for at starte vask, fordi din placering ikke er tilgængelig.
</template>
<template v-else>
{{ $t("common.loading") }}
<b-loading :is-full-page="false" :model-value="true" :can-cancel="true" />
</template>
<BMessage
v-if="selfServeUnavailableMessage"
type="is-warning"
size="is-small"
class="mt-3"
data-testid="self-serve-home-unavailable-message"
>
{{ selfServeUnavailableMessage }}
</BMessage>
</template>
<template #footer>
<div class="card-footer-item">
<router-link
v-if="nearestDepartment"
v-if="nearestDepartment && nearestDepartmentCanStartSelfServe"
to="/user/wash/start"
class="button is-link is-fullwidth"
data-testid="self-serve-home-start"
>
{{ $t("user_dashboard.wash.start_wash") }}
</router-link>
<button
v-else
type="button"
class="button is-link is-light is-fullwidth"
data-testid="self-serve-home-start-disabled"
disabled
>
Selvvask er ikke tilgængelig her
</button>
</div>
</template>
</WhiteBoxCard>
@@ -74,7 +161,7 @@ onMounted(async () => {
<template v-for="department in orderedDepartments" :key="department.id">
<div
v-if="nearestDepartment && department.id !== nearestDepartment.id"
v-if="!nearestDepartment || department.id !== nearestDepartment.id"
class="column is-12-mobile is-6-tablet is-4-desktop"
:data-testid="`self-serve-home-department-${department.id}`"
>
@@ -94,7 +181,20 @@ onMounted(async () => {
</template>
<template #footer>
<div class="card-footer-item">
<router-link :to="{ name: 'pos', params: { departmentId: department.id } }" class="button is-light is-fullwidth">
<button
v-if="canDepartmentStartSelfServe(department) && !nearestDepartmentCanStartSelfServe"
type="button"
class="button is-link is-fullwidth"
:data-testid="`self-serve-home-select-department-${department.id}`"
@click="selectDepartment(department.id)"
>
Vælg denne afdeling
</button>
<router-link
v-else
:to="{ name: 'pos', params: { departmentId: department.id } }"
class="button is-light is-fullwidth"
>
<span class="icon"><i class="fas fa-map-marker-alt"></i></span>
<span>{{ department.address }}</span>
</router-link>
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -21,7 +21,7 @@ const redirectPath = computed(() => {
});
const destinationUrl = computed(() => {
return buildMigrationUrl(MIGRATION_ORIGIN, redirectPath.value);
return buildMigrationUrl(MIGRATION_ORIGIN, redirectPath.value, window.location.pathname);
});
const clearTimers = () => {
@@ -466,10 +466,10 @@ test.describe("Admin overview night washes", () => {
await expect(machineStatusTooltip).toContainText("2026");
}
await expect(page.locator("#dognvask-1-101")).toBeChecked();
await expect(page.locator("#dognvask-2-202")).not.toBeChecked();
await expect(page.locator("#dognvask-2-202")).toBeChecked();
await expect(page.getByTestId("overview-department-2-lane-202-dognvask").locator(".switch")).toHaveCSS(
"background-color",
"rgb(238, 75, 43)"
"rgb(146, 208, 80)"
);
await expect(page.getByTestId("overview-department-3-night-washes")).not.toContainText(
/mangler|Opening hours missing|Missing Hours/
+1 -1
View File
@@ -81,7 +81,7 @@ test.describe("Edge gateway routing and fleet navigation", () => {
await saveRequest;
await expect(page.getByTestId("gateway-module-broker-url")).toHaveValue("http://edge-broker:4301");
await expect(page.getByTestId("gateway-module-broker-shared-secret")).toHaveValue("updated-broker-secret");
await expect(page.getByTestId("gateway-module-broker-shared-secret")).toHaveValue("");
});
test("tests broker module configuration values", async ({ page }) => {
+8
View File
@@ -1340,8 +1340,12 @@ test.describe("POS mobile order flow", () => {
await selectPrimaryProduct(page, 53);
await waitForMobileNextStepCooldown(page);
const orderId = Number(new URL(page.url()).searchParams.get("id"));
await page.getByTestId("pos-mobile-next-step").click();
await expect.poll(() => fixture.requestCounters.orderItemsPost, { timeout: 10_000 }).toBe(1);
await expect.poll(() => fixture.requestCounters.markAsCompleted, { timeout: 10_000 }).toBe(1);
expect(fixture.markCompletedOrderIds).toContain(orderId);
await waitForStepReset(page);
});
@@ -3687,6 +3691,8 @@ test.describe("POS mobile order flow", () => {
await expect.poll(() => fixture.requestCounters.bookingSetOrderId, { timeout: 10_000 }).toBe(1);
await expect.poll(() => fixture.requestCounters.bookingComplete, { timeout: 10_000 }).toBe(1);
await expect.poll(() => fixture.requestCounters.markAsCompleted, { timeout: 10_000 }).toBe(1);
expect(fixture.markCompletedOrderIds).toContain(orderId);
await expect.poll(() => fixture.bookingsById[DEFAULT_BOOKING_ID]?.status ?? "").toBe("completed");
await waitForStepReset(page);
});
@@ -3757,6 +3763,8 @@ test.describe("POS mobile order flow", () => {
await page.getByTestId("pos-mobile-booking-complete-with-certificate").click();
await expect.poll(() => fixture.requestCounters.bookingComplete, { timeout: 10_000 }).toBe(1);
await expect.poll(() => fixture.requestCounters.markAsCompleted, { timeout: 10_000 }).toBe(1);
expect(fixture.markCompletedOrderIds).toContain(orderId);
await expect
.poll(() => fixture.requestLog.bookingCompletions[0]?.safety_seal ?? null, { timeout: 10_000 })
.toBe(9090);
+31 -5
View File
@@ -15,6 +15,7 @@ const json = (body, status = 200) => ({
body: JSON.stringify(body),
});
const GUARD_TIMEOUT_MS = 30_000;
const isDesktopProject = (projectName) => projectName.includes("desktop");
const unavailableRuntime = {
generated_at: "2026-05-19T09:00:00.000Z",
@@ -542,7 +543,10 @@ test("release channel choices show git commit and release time when available",
});
test("ready sidebar release channel switches are confirmed through the control runtime", async ({ page }, testInfo) => {
test.skip(testInfo.project.name.includes("mobile"), "The sidebar release selector is hidden in the mobile layout.");
test.skip(
!isDesktopProject(testInfo.project.name),
"The sidebar release selector is only visible in the desktop layout."
);
const stableRuntime = runtimeWithFailingBetaSwitch();
const betaRuntime = runtimeWithReadyBetaSelected();
@@ -601,7 +605,10 @@ test("ready sidebar release channel switches are confirmed through the control r
});
test("sidebar can switch back to the local frontend runtime", async ({ page }, testInfo) => {
test.skip(testInfo.project.name.includes("mobile"), "The sidebar release selector is hidden in the mobile layout.");
test.skip(
!isDesktopProject(testInfo.project.name),
"The sidebar release selector is only visible in the desktop layout."
);
const runtime = runtimeWithFailingBetaSwitch();
await boot(page, runtime);
@@ -626,12 +633,31 @@ test("sidebar can switch back to the local frontend runtime", async ({ page }, t
await page.evaluate(() => window.localStorage.setItem("release_channel_selected_slug", "beta"));
await useLocal.click();
await expect.poll(() => page.evaluate(() => window.localStorage.getItem("release_source_override"))).toBe("local");
await expect.poll(() => page.evaluate(() => window.localStorage.getItem("release_channel_selected_slug"))).toBeNull();
await expect
.poll(async () => {
try {
return await page.evaluate(() => window.localStorage.getItem("release_source_override"));
} catch (error) {
return null;
}
})
.toBe("local");
await expect
.poll(async () => {
try {
return await page.evaluate(() => window.localStorage.getItem("release_channel_selected_slug"));
} catch (error) {
return "navigating";
}
})
.toBeNull();
});
test("failed sidebar release channel switches keep the previous channel active", async ({ page }, testInfo) => {
test.skip(testInfo.project.name.includes("mobile"), "The sidebar release selector is hidden in the mobile layout.");
test.skip(
!isDesktopProject(testInfo.project.name),
"The sidebar release selector is only visible in the desktop layout."
);
const runtime = runtimeWithFailingBetaSwitch();
await boot(page, runtime);
+134 -4
View File
@@ -930,13 +930,13 @@ function completeIsolatedDataServices(state, serviceSet) {
return serviceSet;
}
async function boot(page, state = createReleaseState()) {
async function boot(page, state = createReleaseState(), options = {}) {
await page.addInitScript(() => {
window.localStorage.setItem("locale", "en");
});
await mockApi(page, {
authenticated: true,
permissions,
permissions: options.permissions || permissions,
sessionData: {
runtime_config: {
release: {
@@ -992,6 +992,8 @@ async function installReleaseMocks(page, state) {
if (pathname.endsWith("/superuser/releases/assignment-subjects") && method === "GET") {
const query = (url.searchParams.get("search") || "").toLowerCase();
state.assignmentSubjectRequests = state.assignmentSubjectRequests || [];
state.assignmentSubjectRequests.push(query);
const limit = Math.max(1, Math.min(10, Number(url.searchParams.get("limit") || 5)));
const subjects = [
{
@@ -2215,6 +2217,49 @@ test("overview filters channel health and issues by selected channel and app", a
await expect(issuePanel).not.toContainText("Composer install failed");
});
test("view-only release managers cannot search assignment subjects", async ({ page }) => {
const state = await boot(page, createReleaseState(), {
permissions: ["superuser_release_manager_view"],
});
await page.goto("/superuser/configuration/releases/assignments", { waitUntil: "domcontentloaded" });
await expectReleaseManagerReady(page);
await expect(page.getByTestId("release-section-assignments")).toBeVisible();
await expect(page.getByTestId("release-assignment-form")).toHaveCount(0);
await expect(page.getByTestId("release-assignment-channel-suggestions")).toHaveCount(0);
await expect(page.getByTestId("release-assignment-subject-search")).toHaveCount(0);
expect(state.assignmentSubjectRequests || []).toEqual([]);
});
test("view-only release managers see redacted deployment failure diagnostics", async ({ page }) => {
const state = createReleaseState();
state.deployments[0].error_message =
"Clone failed for https://deploy:ghp_SECRET_TOKEN_123456789@github.internal/acme/api.git DB_PASSWORD=s3cr3t internal-host=10.0.4.12";
state.deployments[0].failure_summary.root_cause = state.deployments[0].error_message;
state.deployments[0].failure_summary.next_action =
"Rotate ghp_SECRET_TOKEN_123456789 and inspect /var/lib/coolify/apps/api/.env before retrying.";
await boot(page, state, {
permissions: ["superuser_release_manager_view"],
});
await page.goto("/superuser/configuration/releases/overview?channel=canary&app=api&branch=master", {
waitUntil: "domcontentloaded",
});
await expectReleaseManagerReady(page);
await expect(page.getByTestId("release-manager-page")).not.toContainText("ghp_SECRET_TOKEN_123456789");
await expect(page.getByTestId("release-manager-page")).not.toContainText("DB_PASSWORD=s3cr3t");
await expect(page.getByTestId("release-manager-page")).not.toContainText("10.0.4.12");
await expect(page.getByTestId("release-manager-page")).not.toContainText("github.internal");
await selectReleaseTab(page, "Deployments");
await expect(page.getByTestId("release-deployment-failure-2")).toContainText("[redacted]");
await expect(page.getByTestId("release-deployment-failure-2")).toContainText("DB_PASSWORD=[redacted]");
await expect(page.getByTestId("release-deployment-failure-2")).toContainText("[redacted-private-ip]");
await expect(page.getByTestId("release-deployment-failure-2")).toContainText("[redacted-internal-host]");
await expect(page.getByTestId("release-deployment-failure-2")).toContainText("[redacted-env-path]");
});
test("superusers manage release settings, assignments, integrations, and sync operations", async ({ page }) => {
const state = await boot(page);
await page.goto("/superuser/configuration/releases/overview?channel=canary&app=api&branch=canary", {
@@ -2623,8 +2668,8 @@ test("isolated stack mode creates fresh Coolify app and data targets without att
const isolatedTargetCount = state.targets.filter((target) => target.deploy_context?.isolated_stack).length;
await page.getByTestId("release-bundle-deploy-submit").click();
await expect(page.getByTestId("release-created-bundle")).toContainText("Bundle deployed");
expect(state.serviceSets.filter((set) => set.mode === "isolated_stack")).toHaveLength(isolatedServiceSetCount);
expect(state.targets.filter((target) => target.deploy_context?.isolated_stack)).toHaveLength(isolatedTargetCount);
expect(state.serviceSets.filter((set) => set.mode === "isolated_stack")).toHaveLength(isolatedServiceSetCount + 1);
expect(state.targets.filter((target) => target.deploy_context?.isolated_stack)).toHaveLength(isolatedTargetCount + 2);
page.once("dialog", (dialog) => {
expect(dialog.message()).toContain("soft-deleted");
@@ -2636,6 +2681,91 @@ test("isolated stack mode creates fresh Coolify app and data targets without att
expect(state.serviceSets.find((set) => Number(set.id) === Number(isolatedSet.id))).toBeUndefined();
});
test("isolated stack mode creates a fresh stack even when an active stack has the requested name", async ({ page }) => {
const state = await boot(page);
state.targets[0].deploy_context = {
coolify_project_uuid: "project-internal",
};
state.targets.push({
id: state.nextTargetId++,
channel_id: 2,
channel_slug: "canary",
app: "api",
repository: "truckwash/backend-php",
branch: "canary",
coolify_instance_id: 3,
coolify_instance_label: "Production Coolify",
coolify_service_uuid: "api-canary-service",
health_url: "https://api-canary.example.test/ping",
auto_deploy: true,
deploy_context: {
coolify_project_uuid: "project-internal",
coolify_build_pack: "dockerfile",
},
});
const activeStack = {
id: state.nextServiceSetId++,
channel_id: 2,
channel_slug: "canary",
name: "Internal safe stack",
slug: "internal-safe-stack",
mode: "isolated_stack",
status: "isolated_stack",
active: true,
targets: {
frontend: {
id: 201,
channel_id: 2,
channel_slug: "canary",
app: "frontend",
repository: "truckwash/front-end-vue",
branch: DEFAULT_RELEASE_BRANCH,
deploy_context: { isolated_stack: true, production_data_attached: false },
},
api: {
id: 202,
channel_id: 2,
channel_slug: "canary",
app: "api",
repository: "truckwash/backend-php",
branch: "canary",
deploy_context: { isolated_stack: true, production_data_attached: false },
},
},
data_services: { database: null, redis: null, minio: null },
attached_bundles: [],
};
completeIsolatedDataServices(state, activeStack);
state.serviceSets.unshift(activeStack);
await page.goto("/superuser/configuration/releases/overview?channel=canary&app=all&branch=canary", {
waitUntil: "domcontentloaded",
});
await selectReleaseTab(page, "Deployments");
await expandActiveReleaseCategory(page);
const bundleFlow = page.getByTestId("release-bundle-flow");
await bundleFlow.getByTestId("release-bundle-channel").selectOption("2");
await bundleFlow.getByTestId("release-dataset-mode-isolated_stack").click();
await bundleFlow.getByPlaceholder("canary fresh data").fill("Internal safe stack");
await bundleFlow.getByRole("button", { name: "Next" }).click();
await bundleFlow.getByRole("button", { name: "Next" }).click();
await page.getByTestId("release-bundle-deploy-submit").click();
await expect(page.getByTestId("release-created-bundle")).toContainText("Bundle deployed");
const deployedServiceSetId = state.bundlePayloads[0].service_set_id;
expect(deployedServiceSetId).not.toBe(activeStack.id);
expect(state.serviceSets.find((set) => Number(set.id) === Number(activeStack.id))?.active).toBe(true);
expect(
state.serviceSets.filter((set) => set.mode === "isolated_stack" && set.slug === "internal-safe-stack")
).toHaveLength(2);
expect(
state.targets.filter((target) => target.deploy_context?.isolated_stack && target.id !== 201 && target.id !== 202)
).toHaveLength(2);
});
test("existing isolated stacks can add missing data services safely", async ({ page }) => {
const state = createReleaseState();
state.serviceSets.unshift({
@@ -13,6 +13,7 @@ const NGINX_CONFIG_PATH = path.join(PROJECT_ROOT, "nginx.coolify-frontend.conf")
const STATIC_DIRECTORIES = ["assets", "resources", "favicons", "icons", "img", "sounds", ".well-known"];
const STATIC_FILES = [
"index.html",
"manifest.json",
"manifest.webmanifest",
"favicon.ico",
@@ -23,12 +24,13 @@ const STATIC_FILES = [
"registerSW.js",
"sw.js",
];
const RELEASE_FRONTEND_BASE_PATH = "/master/frontend";
const GENERATED_HTML_STATIC_PATHS = [
"/assets/favicons/favicon-96x96.png",
"/assets/favicons/favicon.svg",
"/assets/favicons/favicon.ico",
"/assets/favicons/apple-touch-icon.png",
"/assets/manifest.webmanifest",
"./assets/favicons/favicon-96x96.png",
"./assets/favicons/favicon.svg",
"./assets/favicons/favicon.ico",
"./assets/favicons/apple-touch-icon.png",
"./assets/manifest.webmanifest",
];
const MIME_TYPES = new Map([
@@ -89,6 +91,22 @@ function safePublicPath(relativePath: string) {
return safePath(PUBLIC_DIR, relativePath);
}
function releaseRootStaticPath(staticPath: string) {
if (staticPath === RELEASE_FRONTEND_BASE_PATH) {
return "/";
}
if (staticPath.startsWith(`${RELEASE_FRONTEND_BASE_PATH}/`)) {
return staticPath.slice(RELEASE_FRONTEND_BASE_PATH.length);
}
if (staticPath.startsWith("./")) {
return `/${staticPath.slice(2)}`;
}
if (!staticPath.startsWith("/") && !/^[a-z][a-z\d+.-]*:/i.test(staticPath)) {
return `/${staticPath}`;
}
return staticPath;
}
async function fileExists(filePath: string) {
try {
const stat = await fs.stat(filePath);
@@ -187,7 +205,7 @@ async function distStaticPaths() {
const cssFile = bundledStaticFiles.find((fileName) => fileName.endsWith(".css"));
const indexStaticPaths = Array.from(indexHtml.matchAll(/\b(?:href|src)="([^"]+)"/g))
.map((match) => match[1])
.filter((url) => url.startsWith("/") && !url.startsWith("//"));
.filter((url) => (url.startsWith("/") && !url.startsWith("//")) || url.startsWith("./"));
const manifestIconPaths = (pwaManifest.icons || []).map((icon: { src?: string }) => {
if (!icon.src) {
return "";
@@ -199,8 +217,8 @@ async function distStaticPaths() {
return Array.from(
new Set(
[
...indexStaticPaths,
...GENERATED_HTML_STATIC_PATHS,
...indexStaticPaths.map(releaseRootStaticPath),
...GENERATED_HTML_STATIC_PATHS.map(releaseRootStaticPath),
...manifestIconPaths,
"/manifest.json",
"/manifest.webmanifest",
@@ -214,13 +232,13 @@ async function distStaticPaths() {
workboxFile ? `/${workboxFile}` : "",
releaseEntry.entry ? `/${releaseEntry.entry}` : "",
...(releaseEntry.css || []).map((fileName: string) => `/${fileName}`),
...(releaseManifest.index_asset_urls || []),
...(releaseManifest.pwa_asset_urls || []),
...(releaseManifest.asset_urls || []),
...(releaseManifest.index_asset_urls || []).map(releaseRootStaticPath),
...(releaseManifest.pwa_asset_urls || []).map(releaseRootStaticPath),
...(releaseManifest.asset_urls || []).map(releaseRootStaticPath),
cssFile || "",
].filter(Boolean)
)
).filter((staticPath) => staticPath !== "/index.html");
);
}
test.beforeAll(async () => {
@@ -287,6 +305,7 @@ test.describe("public .htaccess static fallback", () => {
test("emits favicon and manifest links from generated assets", async () => {
const indexHtml = await fs.readFile(path.join(DIST_DIR, "index.html"), "utf8");
const releaseManifest = JSON.parse(await fs.readFile(path.join(DIST_DIR, "release-manifest.json"), "utf8"));
const legacyManifest = JSON.parse(await fs.readFile(path.join(DIST_DIR, "manifest.json"), "utf8"));
const rootWebManifest = JSON.parse(await fs.readFile(path.join(DIST_DIR, "manifest.webmanifest"), "utf8"));
@@ -294,17 +313,42 @@ test.describe("public .htaccess static fallback", () => {
expect(indexHtml).toContain(`href="${staticPath}"`);
}
expect(indexHtml).toContain('src="./assets/');
expect(indexHtml).toContain('src="./registerSW.js"');
expect(indexHtml).toContain("window.location.pathname.match(/^\\/[^/]+\\/frontend");
expect(indexHtml).not.toContain('href="/assets/');
expect(indexHtml).not.toContain('src="/assets/');
expect(indexHtml).not.toContain('src="/registerSW.js"');
expect(indexHtml).not.toContain(`href="${RELEASE_FRONTEND_BASE_PATH}/assets/`);
expect(indexHtml).not.toContain(`src="${RELEASE_FRONTEND_BASE_PATH}/assets/`);
expect(indexHtml).not.toContain(`src="${RELEASE_FRONTEND_BASE_PATH}/registerSW.js"`);
expect(indexHtml).not.toContain('href="/favicons/');
expect(indexHtml).not.toContain('href="/favicon.ico"');
expect(indexHtml).not.toContain('href="/manifest.json"');
for (const icon of [...(legacyManifest.icons || []), ...(rootWebManifest.icons || [])]) {
expect(icon.src).toMatch(/^assets\/favicons\//);
expect(icon.src).toMatch(/^(?:assets|\/master\/frontend\/assets)\/favicons\//);
}
for (const assetPath of [
releaseManifest.entry,
...(releaseManifest.css || []),
...(releaseManifest.index_asset_urls || []),
...(releaseManifest.pwa_asset_urls || []),
...(releaseManifest.asset_urls || []),
].filter(Boolean)) {
expect(assetPath, "release manifest asset paths must be base-relative").not.toMatch(/^\/(?!master\/frontend\/)/);
}
});
test("serves the app shell from root, deep links, and release-prefixed paths", async ({ request }) => {
for (const appPath of ["/", "/guest/book/wash", "/canary/frontend", "/canary/frontend/guest/book/wash"]) {
for (const appPath of [
"/",
"/guest/book/wash",
"/canary/frontend",
"/canary/frontend/index.html",
"/canary/frontend/guest/book/wash",
]) {
const response = await request.get(`${serverBaseUrl}${appPath}`);
expect(response.status(), appPath).toBe(200);
expect(response.headers()["content-type"], appPath).toContain("text/html");
@@ -319,7 +363,12 @@ test.describe("public .htaccess static fallback", () => {
for (const prefix of ["", "/canary/frontend", "/canary/frontend/guest/book/wash"]) {
const response = await request.get(`${serverBaseUrl}${prefix}${staticPath}`);
expect(response.status(), `${prefix}${staticPath}`).toBe(200);
expect(response.headers()["content-type"], `${prefix}${staticPath}`).not.toContain("text/html");
if (staticPath === "/index.html") {
expect(response.headers()["content-type"], `${prefix}${staticPath}`).toContain("text/html");
expect(await response.text(), `${prefix}${staticPath}`).toContain('<div id="app"></div>');
} else {
expect(response.headers()["content-type"], `${prefix}${staticPath}`).not.toContain("text/html");
}
}
}
});
+7 -5
View File
@@ -14,9 +14,11 @@ type ReleaseManifest = {
};
const baseURL = process.env.PLAYWRIGHT_BASE_URL || "";
const staticBaseURL = process.env.PLAYWRIGHT_RELEASE_STATIC_BASE_URL || process.env.RELEASE_BASE_URL || baseURL;
const appBaseURL = process.env.PLAYWRIGHT_APP_BASE_URL || baseURL;
function liveUrl(assetPath: string) {
const normalizedBase = baseURL.endsWith("/") ? baseURL : `${baseURL}/`;
function liveUrl(assetPath: string, base = staticBaseURL) {
const normalizedBase = base.endsWith("/") ? base : `${base}/`;
return new URL(assetPath.replace(/^\/+/, ""), normalizedBase).toString();
}
@@ -126,7 +128,7 @@ test("@public-live release manifest, shell, and static assets are available", as
for (const assetPath of assetPaths) {
const hashKey = assetPath.startsWith("/") ? assetPath : `/${assetPath}`;
await expectStaticAsset(request, assetPath, manifest.asset_hashes?.[hashKey]);
await expectStaticAsset(request, assetPath, manifest.asset_hashes?.[assetPath] || manifest.asset_hashes?.[hashKey]);
}
});
@@ -142,9 +144,9 @@ test("@public-live api-v2 gateway and channel API prefixes serve JSON ping respo
});
test("@public-live guest flow renders on the deployed environment", async ({ page }) => {
const guards = attachPageHealthGuards(page, baseURL);
const guards = attachPageHealthGuards(page, appBaseURL);
await page.goto("/guest/book/wash");
await page.goto(liveUrl("/guest/book/wash", appBaseURL));
await settlePage(page);
await expect(page).toHaveURL(/\/guest\/book\/wash(?:\?.*)?$/);
await expectBodyHasContent(page);
@@ -15,7 +15,7 @@ const requireLiveCredentials = process.env.PLAYWRIGHT_REQUIRE_LIVE_CREDENTIALS =
function getLiveSettings() {
return {
baseURL: requiredEnv("PLAYWRIGHT_BASE_URL"),
baseURL: process.env.PLAYWRIGHT_APP_BASE_URL || requiredEnv("PLAYWRIGHT_BASE_URL"),
customerCredentials: {
customerNumber: requiredEnv("PLAYWRIGHT_USER_CUSTOMER_NUMBER"),
password: requiredEnv("PLAYWRIGHT_USER_PASSWORD"),
+129 -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,
@@ -243,6 +264,53 @@ test.describe("Self-serve wash", () => {
await expect(page.getByTestId("self-serve-nav-complete")).toBeHidden();
});
test("start route lets regular users choose a department when geolocation permission is denied", async ({
page,
context,
}) => {
await context.clearPermissions();
await page.addInitScript(() => {
Object.defineProperty(navigator, "geolocation", {
configurable: true,
value: {
getCurrentPosition: (_success, error) =>
error?.({ code: 1, message: "User denied Geolocation", PERMISSION_DENIED: 1 }),
watchPosition: (_success, error) => {
error?.({ code: 1, message: "User denied Geolocation", PERMISSION_DENIED: 1 });
return 1;
},
clearWatch: () => {},
},
});
});
await mockApi(page, {
authenticated: true,
permissions: ["user"],
sessionData: {
customer_number: 12345679,
},
selfServe: true,
});
await primeSession(page, {
token: "self-serve-denied-geolocation-token",
permissions: ["user"],
});
await page.goto("/user/wash/start", { waitUntil: "domcontentloaded" });
await expect(page.getByTestId("self-serve-start-page")).toBeVisible({ timeout: 15_000 });
await expect(page.getByTestId("self-serve-department-name")).toContainText("Roskilde");
await expect(page.getByTestId("self-serve-vehicle-step")).toBeVisible({ timeout: 15_000 });
await page.getByTestId("self-serve-department-name").click();
await expect(page.getByTestId("self-serve-department-search")).toBeVisible({ timeout: 10_000 });
await page.getByTestId("self-serve-department-search").locator("input").fill("Odense");
await page.getByText("Odense", { exact: true }).click();
await expect(page.getByTestId("self-serve-department-name")).toContainText("Odense");
});
test("start route resumes the authenticated customer's active server wash from another device", async ({ page }) => {
const requests = captureSelfServeGatewayRequests(page);
@@ -1123,7 +1191,9 @@ test.describe("Self-serve wash", () => {
expect(requests.commands).toHaveLength(0);
});
test("tasks flow renders dynamic image, requires completion, and restores after reload", async ({ page }) => {
test("@dynamic-image tasks flow renders dynamic image, requires completion, and restores after reload", async ({
page,
}) => {
const requests = captureSelfServeGatewayRequests(page);
await seedSavedProgress(page, {
@@ -1489,6 +1559,56 @@ test.describe("Self-serve wash", () => {
await expect(page.getByTestId("self-serve-lane-step")).toBeVisible();
});
test("mocked polling marks active wash completed when backend reports it ended", async ({ page }) => {
await seedSavedProgress(page, {
washInProgress: true,
washLaneId: 7,
washStartTime: Date.now() - 30_000,
currentStep: 4,
licensePlateInput: "AB12345",
vehicleTypeSelect: 2,
radioLaneOption: 7,
radioWashType: "Manual",
customerNumberInput: "12345679",
});
await mockApi(page, {
authenticated: true,
permissions: ["user"],
sessionData: {
customer_number: 12345679,
},
selfServe: {
inProgressByLaneId: {
7: {
lane_id: 7,
in_progress: false,
session: {
id: 801,
lane_id: 7,
reg: "AB12345",
customer_number: 12345679,
vehicle_type_id: 2,
status: "COMPLETED",
},
customer: { customer_number: 12345679 },
vehicle: { reg: "AB12345", type: 2 },
},
},
},
});
await primeSession(page, {
token: "self-serve-polling-completed-token",
permissions: ["user"],
});
await page.goto("/user/wash/start");
await expect(page.getByTestId("self-serve-completed-step")).toBeVisible({ timeout: 15_000 });
await expect(page.getByTestId("self-serve-live-elapsed")).toBeHidden();
await expect.poll(async () => page.evaluate(() => window.localStorage.getItem("mywash_progress_v6"))).toBeNull();
});
test("admin preview modal reuses shared question/task rendering", async ({ page }) => {
await mockApi(page, {
authenticated: true,
+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;
+378
View File
@@ -0,0 +1,378 @@
import { expect, test } from "@playwright/test";
import { mockApi, primeMockSession } from "./support/network.js";
async function suppressVueDevtoolsOverlay(page) {
await page.addInitScript(() => {
const appendStyle = () => {
const parent = document.documentElement || document.head || document.body;
if (!parent) {
document.addEventListener("DOMContentLoaded", appendStyle, { once: true });
return;
}
const style = document.createElement("style");
style.textContent =
"#__vue-devtools-container__, .vue-devtools__anchor-btn, .vue-devtools__panel-content { display: none !important; visibility: hidden !important; pointer-events: none !important; }";
parent.appendChild(style);
};
appendStyle();
});
}
async function seedGeolocation(context) {
await context.grantPermissions(["geolocation"]);
await context.setGeolocation({ latitude: 55.6415, longitude: 12.0803 });
}
function buildSavedProgress(overrides = {}) {
return {
washInProgress: false,
washLaneId: null,
washStartTime: null,
currentStep: 0,
licensePlateInput: "AB12345",
vehicleTypeSelect: 2,
radioWashType: "Manual",
radioLaneOption: 7,
customerNumberInput: "12345679",
answers: {},
completedTasks: {},
nearestDepartmentId: 6,
forceNearestDepartmentEvaluationId: 0,
isForcingNearestDepartment: false,
savedAt: Date.now(),
...overrides,
};
}
async function seedSavedProgress(page, overrides = {}) {
await page.addInitScript((savedProgress) => {
window.localStorage.setItem("mywash_progress_v6", JSON.stringify(savedProgress));
}, buildSavedProgress(overrides));
}
async function fillRegistration(page, value = "ab12345") {
const registration = page.locator('input[placeholder*="registreringsnummer"]').first();
await registration.fill(value);
await expect(registration).toHaveValue(value.toUpperCase());
await registration.press("Escape");
}
async function fillRegistrationWithoutClosingAutocomplete(page, value = "ab12345") {
const registration = page.locator('input[placeholder*="registreringsnummer"]').first();
await registration.fill(value);
await expect(registration).toHaveValue(value.toUpperCase());
}
async function selectVehicleType(page, vehicleTypeId = 2) {
await page.getByTestId(`self-serve-vehicle-type-${vehicleTypeId}`).click();
await expect(page.getByTestId("self-serve-vehicle-selector-description")).toContainText(/Truck|Van|Car/);
}
async function answerRequiredQuestions(page) {
await expect(page.getByTestId("self-serve-question-11")).toBeVisible({ timeout: 10_000 });
await page.getByTestId("self-serve-question-11-yes").click();
await expect(page.getByTestId("self-serve-nav-confirm")).toBeEnabled({ timeout: 10_000 });
}
async function completeGuidedWash(page) {
for (let index = 0; index < 5; index += 1) {
await page.getByTestId("self-serve-guided-next").click();
}
}
function collectSelfServeRequests(page) {
const requests = {
departments: [],
vehicleTypeProducts: [],
customerVehicles: [],
allowed: [],
conditions: [],
commands: [],
relays: [],
summaries: [],
activeWash: [],
inProgress: [],
};
page.on("request", (request) => {
const url = new URL(request.url());
const entry = {
method: request.method(),
url,
body: request.method() === "GET" ? null : request.postDataJSON?.() || null,
};
if (url.pathname.endsWith("/guest/departments")) requests.departments.push(entry);
if (url.pathname.endsWith("/products")) requests.vehicleTypeProducts.push(entry);
if (url.pathname.endsWith("/vehicles")) requests.customerVehicles.push(entry);
if (url.pathname.endsWith("/department/selfserve/vehicle/allowed")) requests.allowed.push(entry);
if (url.pathname.endsWith("/department/selfserve/vehicle/conditions")) requests.conditions.push(entry);
if (url.pathname.endsWith("/modules/self-serve/lane/command")) requests.commands.push(entry);
if (url.pathname.endsWith("/modules/self-serve/lane/relay/machine/enable")) requests.relays.push(entry);
if (url.pathname.endsWith("/department/selfserve/washes/summary")) requests.summaries.push(entry);
if (url.pathname.endsWith("/modules/self-serve/lane/wash/my-active-wash")) requests.activeWash.push(entry);
if (url.pathname.endsWith("/modules/self-serve/lane/wash/in-progress")) requests.inProgress.push(entry);
});
return requests;
}
test.describe("User MyWashStart mocked flow", () => {
test.describe.configure({ timeout: 120_000 });
test.skip(({ browserName }) => browserName !== "chromium", "MyWashStart flow coverage is maintained on Chromium.");
test.beforeEach(async ({ page, context }) => {
await suppressVueDevtoolsOverlay(page);
await seedGeolocation(context);
});
test("manual wash selects vehicle, answers questions, starts, stops, and shows completed step", async ({ page }) => {
const requests = collectSelfServeRequests(page);
await mockApi(page, {
authenticated: true,
permissions: ["user"],
sessionData: { customer_number: 12345679 },
selfServe: true,
});
await primeMockSession(page, { token: "user-my-wash-manual-token", bootPath: "/user/wash/start" });
await expect(page.getByTestId("self-serve-department-name")).toContainText("Roskilde", { timeout: 15_000 });
await fillRegistration(page);
await selectVehicleType(page, 2);
await page.getByTestId("self-serve-nav-next").click();
await answerRequiredQuestions(page);
await page.getByTestId("self-serve-nav-confirm").click();
await expect(page.getByTestId("self-serve-lane-step")).toBeVisible({ timeout: 10_000 });
await page.getByTestId("self-serve-lane-option-7").click();
await page.getByTestId("self-serve-wash-type-manual").click();
await page.getByTestId("self-serve-nav-confirm").click();
await expect(page.getByTestId("self-serve-live-elapsed")).toBeVisible({ timeout: 10_000 });
await completeGuidedWash(page);
await expect(page.getByTestId("self-serve-nav-complete")).toBeVisible();
await page.getByTestId("self-serve-nav-complete").click();
await expect(page.getByTestId("self-serve-completed-step")).toBeVisible({ timeout: 15_000 });
expect(requests.departments[0]?.url.searchParams.get("include_lanes")).toBe("true");
expect(requests.vehicleTypeProducts.length).toBeGreaterThan(0);
expect(requests.customerVehicles.length).toBeGreaterThan(0);
expect(requests.allowed.length).toBeGreaterThan(0);
expect(requests.conditions.some((request) => request.body?.question === 11 && request.body?.value === true)).toBe(
true
);
expect(requests.summaries.length).toBeGreaterThan(0);
expect(requests.activeWash.length).toBeGreaterThan(0);
expect(requests.inProgress.length).toBeGreaterThan(0);
expect(requests.commands.map((request) => request.body?.command)).toEqual(
expect.arrayContaining(["START", "STOP", "OPEN_PROPERTY_EXIT_GATE"])
);
});
test("registration autocomplete does not cover vehicle type selection", async ({ page }) => {
await mockApi(page, {
authenticated: true,
permissions: ["user"],
sessionData: { customer_number: 12345679 },
selfServe: true,
});
await primeMockSession(page, { token: "user-my-wash-registration-overlay-token", bootPath: "/user/wash/start" });
await expect(page.getByTestId("self-serve-department-name")).toContainText("Roskilde", { timeout: 15_000 });
await fillRegistrationWithoutClosingAutocomplete(page);
await selectVehicleType(page, 2);
await expect(page.getByTestId("self-serve-nav-next")).toBeEnabled();
});
test("machine wash rolls back when relay enable fails after START", async ({ page }) => {
const requests = collectSelfServeRequests(page);
await seedSavedProgress(page, {
currentStep: 2,
licensePlateInput: "AB12345",
vehicleTypeSelect: 2,
radioLaneOption: 7,
radioWashType: "Machine",
customerNumberInput: "12345679",
answers: { 11: true },
});
await mockApi(page, {
authenticated: true,
permissions: ["user"],
sessionData: { customer_number: 12345679 },
selfServe: {
relayResponses: [{ success: false, data: { message: "Machine relay unavailable" } }],
},
});
await primeMockSession(page, { token: "user-my-wash-machine-token", bootPath: "/user/wash/start" });
const needsQuestionReview = await page
.getByTestId("self-serve-question-11")
.waitFor({ state: "visible", timeout: 10_000 })
.then(() => true)
.catch(() => false);
if (needsQuestionReview) {
await page.getByTestId("self-serve-question-11-yes").click();
await expect(page.getByTestId("self-serve-nav-confirm")).toBeEnabled({ timeout: 10_000 });
await page.getByTestId("self-serve-nav-confirm").click();
}
await expect(page.getByTestId("self-serve-lane-step")).toBeVisible({ timeout: 10_000 });
await page.getByTestId("self-serve-wash-type-machine").click();
await page.getByTestId("self-serve-nav-confirm").click();
await expect(page.getByTestId("self-serve-action-error")).toContainText("Machine relay unavailable");
await expect(page.getByTestId("self-serve-lane-step")).toBeVisible();
await expect(page.getByTestId("self-serve-live-elapsed")).toBeHidden();
expect(requests.relays).toHaveLength(1);
expect(requests.commands.map((request) => request.body?.command)).toEqual(
expect.arrayContaining(["START", "STOP"])
);
});
test("active wash restore reloads start route and resumes at WASH_IN_PROGRESS", async ({ page }) => {
const requests = collectSelfServeRequests(page);
await mockApi(page, {
authenticated: true,
permissions: ["user"],
sessionData: { customer_number: 12345679 },
selfServe: {
inProgressByLaneId: {
7: {
lane_id: 7,
in_progress: true,
session: {
id: 801,
lane_id: 7,
department_id: 6,
reg: "AB12345",
customer_number: 12345679,
vehicle_type_id: 2,
machine_relay_enabled: false,
wash_started_at: "2026-06-02 08:00:00",
status: "IN_PROGRESS",
},
customer: { customer_number: 12345679 },
vehicle: { reg: "AB12345", type: 2 },
},
},
summaryBySessionId: {
801: {
session: { id: 801, lane_id: 7, reg: "AB12345", status: "IN_PROGRESS", vehicle_type_id: 2 },
lane: { id: 7, name: "7", department: 6 },
questions: [],
conditions: [],
rules: [],
tasks: [],
events: [{ id: 8011, type: "STARTED", created_at: "2026-06-02T08:00:00.000Z" }],
},
},
},
});
await primeMockSession(page, { token: "user-my-wash-active-token", bootPath: "/user/wash/start" });
await page.reload({ waitUntil: "domcontentloaded" });
await expect(page.getByTestId("self-serve-live-elapsed")).toBeVisible({ timeout: 15_000 });
await expect(page.getByTestId("self-serve-department-name")).toContainText("Roskilde");
await expect(page.getByTestId("self-serve-vehicle-step")).toBeHidden();
expect(requests.activeWash.length).toBeGreaterThan(0);
expect(requests.commands).toHaveLength(0);
});
test("server-completed wash refresh keeps local in-progress state until customer completion", async ({ page }) => {
const requests = collectSelfServeRequests(page);
await seedSavedProgress(page, {
washInProgress: true,
washLaneId: 7,
washStartTime: Date.now() - 60_000,
currentStep: 4,
licensePlateInput: "AB12345",
vehicleTypeSelect: 2,
radioLaneOption: 7,
radioWashType: "Manual",
customerNumberInput: "12345679",
});
await mockApi(page, {
authenticated: true,
permissions: ["user"],
sessionData: { customer_number: 12345679 },
selfServe: {
inProgressByLaneId: {
7: {
lane_id: 7,
in_progress: false,
session: null,
customer: null,
vehicle: null,
},
},
},
});
await primeMockSession(page, { token: "user-my-wash-server-completed-token", bootPath: "/user/wash/start" });
await expect.poll(() => requests.inProgress.length, { timeout: 15_000 }).toBeGreaterThan(0);
await expect(page.getByTestId("self-serve-live-elapsed")).toBeVisible();
await expect(page.getByTestId("self-serve-completed-step")).toBeHidden();
});
test("answer-sync failure shows an error and blocks progression", async ({ page }) => {
await mockApi(page, {
authenticated: true,
permissions: ["user"],
sessionData: { customer_number: 12345679 },
selfServe: true,
});
await page.route(/\/department\/selfserve\/vehicle\/conditions$/i, async (route) => {
if (route.request().method() === "POST") {
await route.fulfill({
status: 500,
contentType: "application/json",
body: JSON.stringify({ message: "Answer sync failed" }),
});
return;
}
await route.fallback();
});
await primeMockSession(page, { token: "user-my-wash-answer-sync-token", bootPath: "/user/wash/start" });
await fillRegistration(page);
await selectVehicleType(page, 2);
await page.getByTestId("self-serve-nav-next").click();
await expect(page.getByTestId("self-serve-question-11")).toBeVisible({ timeout: 10_000 });
await page.getByTestId("self-serve-question-11-yes").click();
await expect(page.getByText("Answer sync failed").first()).toBeVisible();
await expect(page.getByTestId("self-serve-nav-confirm")).toBeDisabled();
await expect(page.getByTestId("self-serve-lane-step")).toBeHidden();
});
test("geolocation unavailable still provides a department-selection fallback", async ({ browser }) => {
const context = await browser.newContext({ permissions: [] });
const page = await context.newPage();
await suppressVueDevtoolsOverlay(page);
const requests = collectSelfServeRequests(page);
await mockApi(page, {
authenticated: true,
permissions: ["user"],
sessionData: { customer_number: 12345679 },
selfServe: true,
});
await primeMockSession(page, { token: "user-my-wash-no-geolocation-token", bootPath: "/user/wash/start" });
await expect(page.getByTestId("self-serve-department-name")).toContainText("Roskilde", { timeout: 15_000 });
await fillRegistration(page);
await selectVehicleType(page, 2);
await expect(page.getByTestId("self-serve-nav-next")).toBeEnabled();
expect(requests.departments[0]?.url.searchParams.get("include_lanes")).toBe("true");
await context.close();
});
});
@@ -0,0 +1,16 @@
import { describe, expect, it } from "vitest";
import { isSafeAttachmentDownloadLink, safeAttachmentDownloadLink } from "@/services/attachmentDownloadLinks.js";
describe("attachmentDownloadLinks", () => {
it("allows relative and trusted HTTPS attachment URLs", () => {
expect(isSafeAttachmentDownloadLink("/department/selfserve/tasks/attachments/download?token=abc")).toBe(true);
expect(isSafeAttachmentDownloadLink("https://api-v2.truckwash.io/master/api/attachments/123")).toBe(true);
});
it("rejects scriptable, protocol-relative, and untrusted attachment URLs", () => {
expect(safeAttachmentDownloadLink("javascript:alert(document.domain)")).toBe("");
expect(safeAttachmentDownloadLink("data:text/html,<script>alert(document.domain)</script>")).toBe("");
expect(safeAttachmentDownloadLink("//evil.example/attachment.pdf")).toBe("");
expect(safeAttachmentDownloadLink("https://evil.example/attachment.pdf")).toBe("");
});
});
+89
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,93 @@ describe("authenticatedRequest", () => {
expect(catchCallable).toHaveBeenCalledWith(error);
});
it("does not send bearer credentials to untrusted absolute request URLs", async () => {
const response = { status: 200, data: { ok: true } };
axiosMock.mockResolvedValueOnce(response);
localStorage.setItem("is_subuser", "true");
localStorage.setItem("selected_customer_number", "1234");
configureReleaseRuntime({ api_base_url: "https://attacker.example/api" });
await authenticatedRequest("https://attacker.example/api/orders", "GET");
expect(axiosMock).toHaveBeenCalledWith(
expect.objectContaining({
url: "https://attacker.example/api/orders",
headers: expect.not.objectContaining({
Authorization: "Bearer token",
"X-Customer-Number": 1234,
}),
})
);
});
it("does not expose preview-only active wash reservations as in-progress washes", async () => {
const response = {
status: 200,
data: {
data: {
lane_id: 3,
in_progress: true,
status: "READY_FOR_MACHINE_START",
session: {
id: 379,
status: "READY_FOR_MACHINE_START",
machine_relay_enabled: false,
machine_start_triggered: false,
wash_started_at: null,
machine_start_triggered_at: null,
machine_relay_enabled_at: null,
},
},
},
};
const thenCallable = vi.fn();
axiosMock.mockResolvedValueOnce(response);
const result = await authenticatedRequest(
"/modules/self-serve/lane/wash/my-active-wash",
"GET",
undefined,
null,
thenCallable
);
expect(result).not.toBe(response);
expect(result.data.data).toMatchObject({
lane_id: 3,
in_progress: false,
status: "READY_FOR_MACHINE_START",
});
expect(result.data.data.session).toBe(response.data.data.session);
expect(thenCallable).toHaveBeenCalledWith(result);
});
it("keeps relay-enabled active wash responses in progress", async () => {
const response = {
status: 200,
data: {
data: {
lane_id: 3,
in_progress: true,
status: "MACHINE_RELAY_ENABLED",
session: {
id: 380,
status: "MACHINE_RELAY_ENABLED",
machine_relay_enabled: true,
},
},
},
};
axiosMock.mockResolvedValueOnce(response);
const result = await authenticatedRequest("/modules/self-serve/lane/wash/my-active-wash", "GET");
expect(result).toBe(response);
expect(result.data.data.in_progress).toBe(true);
});
it("queues requests and executes them sequentially", async () => {
const first = createDeferred();
const second = createDeferred();
+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");
});
});
@@ -64,6 +64,22 @@ vi.mock("@/components/pagination/departmentTabs.vue", async () => {
};
});
vi.mock("@/components/page/wrappers/RestrictedPageWrapper.vue", () => ({
default: { template: "<div><slot /></div>" },
}));
vi.mock("@/components/page/wrappers/NotFoundFallBackPageWrapper.vue", () => ({
default: { template: "<div><slot /></div>" },
}));
vi.mock("@/components/displays/boxes/ElementTabsBox.vue", () => ({
default: { template: "<div><slot name='form' /><slot name='history' /></div>" },
}));
vi.mock("@/views/dashboards/departmentDashboard/DepartmentDashboardPageWrapper.vue", () => ({
default: { template: "<div><slot /></div>" },
}));
vi.mock("@/views/dashboards/departmentDashboard/modules/daily-report/DepartmentDailyReportObject.vue", async () => {
const { ref } = await import("vue");
@@ -104,6 +120,52 @@ vi.mock("@/views/dashboards/departmentDashboard/modules/daily-report/DepartmentD
};
});
vi.mock("@/views/dashboards/departmentDashboard/modules/daily-report/DepartmentWeather.vue", () => ({
default: { template: "<div />" },
}));
vi.mock(
"@/views/dashboards/departmentDashboard/modules/daily-report/displays/DepartmentDashboardDailyReportCount.vue",
() => ({
default: { template: "<div />" },
})
);
vi.mock(
"@/views/dashboards/departmentDashboard/modules/daily-report/displays/DepartmentDashboardDailyReportComplaints.vue",
() => ({
default: { template: "<div />" },
})
);
vi.mock(
"@/views/dashboards/departmentDashboard/modules/daily-report/displays/DepartmentDashboardDailyReportHistory.vue",
() => ({
default: { template: "<div />" },
})
);
vi.mock(
"@/views/dashboards/departmentDashboard/modules/daily-report/displays/DepartmentDashboardDailyReportNavigation.vue",
() => ({
default: { template: "<div />" },
})
);
vi.mock(
"@/views/dashboards/departmentDashboard/modules/daily-report/displays/DepartmentDashboardDailyReportProductSales.vue",
() => ({
default: { template: "<div />" },
})
);
vi.mock(
"@/views/dashboards/departmentDashboard/modules/daily-report/displays/DepartmentDashboardDailyReportTodayForm.vue",
() => ({
default: { template: "<div />" },
})
);
import DepartmentDailyReport from "@/views/dashboards/departmentDashboard/modules/daily-report/DepartmentDailyReport.vue";
const flushAll = async () => {
@@ -8,6 +8,7 @@ const getDepartmentMock = vi.hoisted(() => vi.fn());
const getLaneStatusTogglesMock = vi.hoisted(() => vi.fn());
const setMachineStatusEnabledMock = vi.hoisted(() => vi.fn());
const setLaneSelfServeEnabledMock = vi.hoisted(() => vi.fn());
const hasPermissionMock = vi.hoisted(() => vi.fn());
const laneHelpers = vi.hoisted(() => {
const requiredFields = [
"relay_in_id",
@@ -67,6 +68,7 @@ vi.mock("@/components/session/token/SessionUser.vue", () => ({
},
},
},
hasPermission: hasPermissionMock,
functions: {
date: {
isToday: () => false,
@@ -141,6 +143,10 @@ describe("Department overview period sync behavior", () => {
getLaneStatusTogglesMock.mockReset();
setMachineStatusEnabledMock.mockReset();
setLaneSelfServeEnabledMock.mockReset();
hasPermissionMock.mockReset();
hasPermissionMock.mockImplementation((permission) =>
["modules_selfserve_lane_status_set", "edit_department_lane"].includes(permission)
);
getDepartmentMock.mockResolvedValue({ id: 20, name: "Dept 20" });
getLaneStatusTogglesMock.mockResolvedValue({ data: { data: [] } });
@@ -367,16 +373,27 @@ describe("Department overview period sync behavior", () => {
},
},
});
setLaneSelfServeEnabledMock.mockResolvedValueOnce({
data: {
setLaneSelfServeEnabledMock
.mockResolvedValueOnce({
data: {
lane: {
...laneWithCompleteSetup,
selfserve_enabled: false,
data: {
lane: {
...laneWithCompleteSetup,
selfserve_enabled: false,
},
},
},
},
});
})
.mockResolvedValueOnce({
data: {
data: {
lane: {
...laneWithMissingSetup,
selfserve_enabled: false,
},
},
},
});
const wrapper = mount(DepartmentDailyReportSmall, {
props: {
@@ -405,7 +422,7 @@ describe("Department overview period sync behavior", () => {
expect(wrapper.find('[data-testid="overview-department-20-lane-21-dognvask-info"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="overview-department-20-lane-22-dognvask-warning"]').exists()).toBe(true);
expect(wrapper.find("#dognvask-20-21").element.checked).toBe(true);
expect(wrapper.find("#dognvask-20-22").element.checked).toBe(false);
expect(wrapper.find("#dognvask-20-22").element.checked).toBe(true);
await wrapper.find("#machine-status-20-22").setValue(true);
await flushAll();
@@ -416,9 +433,129 @@ describe("Department overview period sync behavior", () => {
expect(setLaneSelfServeEnabledMock).toHaveBeenCalledWith(21, false);
expect(wrapper.find("#dognvask-20-21").element.checked).toBe(false);
await wrapper.find("#dognvask-20-22").setValue(false);
await flushAll();
expect(setLaneSelfServeEnabledMock).toHaveBeenCalledWith(22, false);
expect(setLaneSelfServeEnabledMock).toHaveBeenCalledTimes(2);
expect(wrapper.find("#dognvask-20-22").element.checked).toBe(false);
await wrapper.find("#dognvask-20-22").setValue(true);
await flushAll();
expect(setLaneSelfServeEnabledMock).toHaveBeenCalledTimes(1);
expect(setLaneSelfServeEnabledMock).toHaveBeenCalledTimes(2);
expect(wrapper.find("#dognvask-20-22").element.checked).toBe(false);
});
it("disables lane mutation toggles for users without lane mutation permissions", async () => {
hasPermissionMock.mockImplementation((permission) => permission === "list_department_daily_reports");
getLaneStatusTogglesMock.mockResolvedValueOnce({
data: {
data: [
{
id: 21,
department: 20,
name: "T1",
status: "AVAILABLE",
machine_status_enabled: true,
selfserve_enabled: true,
relay_in_id: "in-1",
relay_out_id: "out-1",
relay_machine_id: "machine-1",
relay_machine_program_picker_id: "picker-1",
relay_machine_cleaner_id: "cleaner-1",
dynamic_image_id: 1,
machine_type_id: 1,
dognvask_configured: true,
dognvask_configuration_warnings: [],
},
],
},
});
const wrapper = mount(DepartmentDailyReportSmall, {
props: {
department_id: 20,
},
global: {
mocks: {
$t: (value) => value,
},
stubs: {
BLoading: true,
},
},
});
await flushAll();
await flushAll();
const machineToggle = wrapper.find("#machine-status-20-21");
const dognvaskToggle = wrapper.find("#dognvask-20-21");
expect(machineToggle.element.disabled).toBe(true);
expect(dognvaskToggle.element.disabled).toBe(true);
await machineToggle.trigger("change");
await dognvaskToggle.trigger("change");
await flushAll();
expect(setMachineStatusEnabledMock).not.toHaveBeenCalled();
expect(setLaneSelfServeEnabledMock).not.toHaveBeenCalled();
});
it("does not allow wash-lane list permission to mutate lane toggles", async () => {
hasPermissionMock.mockImplementation((permission) => permission === "list_department_wash_lanes");
getLaneStatusTogglesMock.mockResolvedValueOnce({
data: {
data: [
{
id: 21,
department: 20,
name: "T1",
status: "AVAILABLE",
machine_status_enabled: true,
selfserve_enabled: true,
relay_in_id: "in-1",
relay_out_id: "out-1",
relay_machine_id: "machine-1",
relay_machine_program_picker_id: "picker-1",
relay_machine_cleaner_id: "cleaner-1",
dynamic_image_id: 1,
machine_type_id: 1,
dognvask_configured: true,
dognvask_configuration_warnings: [],
},
],
},
});
const wrapper = mount(DepartmentDailyReportSmall, {
props: {
department_id: 20,
},
global: {
mocks: {
$t: (value) => value,
},
stubs: {
BLoading: true,
},
},
});
await flushAll();
await flushAll();
const machineToggle = wrapper.find("#machine-status-20-21");
const dognvaskToggle = wrapper.find("#dognvask-20-21");
expect(machineToggle.element.disabled).toBe(true);
expect(dognvaskToggle.element.disabled).toBe(true);
await machineToggle.trigger("change");
await dognvaskToggle.trigger("change");
await flushAll();
expect(setMachineStatusEnabledMock).not.toHaveBeenCalled();
expect(setLaneSelfServeEnabledMock).not.toHaveBeenCalled();
});
});

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