diff --git a/.github/workflows/ember.yml b/.github/workflows/ember.yml index df7d1fa1b..cbdfe270f 100644 --- a/.github/workflows/ember.yml +++ b/.github/workflows/ember.yml @@ -2,7 +2,7 @@ name: Ember.js CI on: push: - branches: [ main, 'dev-v*' ] + branches: [ main ] tags: - 'v*' pull_request: diff --git a/.github/workflows/postman.yml b/.github/workflows/postman.yml index 04fc271e7..702cd6437 100644 --- a/.github/workflows/postman.yml +++ b/.github/workflows/postman.yml @@ -16,7 +16,7 @@ name: API Contract (Postman) on: push: - branches: [main, 'dev-v*'] + branches: [main] pull_request: branches: [main, 'dev-v*'] workflow_dispatch: diff --git a/.github/workflows/server.yml b/.github/workflows/server.yml index 736f25296..ffba178e9 100644 --- a/.github/workflows/server.yml +++ b/.github/workflows/server.yml @@ -2,7 +2,7 @@ name: PHP CI on: push: - branches: [ main, 'dev-v*' ] + branches: [ main ] tags: - 'v*' pull_request: diff --git a/.gitignore b/.gitignore index c840f4126..b490dd1d3 100644 --- a/.gitignore +++ b/.gitignore @@ -3,7 +3,7 @@ /declarations/ # dependencies -/node_modules/ +/node_modules /scripts/node_modules/ # misc diff --git a/.template-lintrc.js b/.template-lintrc.js index 46748176d..09a3d8350 100644 --- a/.template-lintrc.js +++ b/.template-lintrc.js @@ -10,5 +10,7 @@ module.exports = { 'link-href-attributes': 'off', 'require-input-label': 'off', 'no-array-prototype-extensions': 'off', + // `leaflet-tile-url` is a zero-argument helper resolving the configured tile provider + 'no-implicit-this': { allow: ['leaflet-tile-url'] }, }, }; diff --git a/RELEASE.md b/RELEASE.md index 29af9ef76..8e0db0602 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,34 +1,23 @@ -> v0.6.60 ~ "Closes an authentication bypass, and clears a run of API 500s" +> v0.6.61 ~ "A grid view for drivers, a sequenceable order flow, and two silent API no-ops closed" --- ## Highlights -A security fix and a broad sweep of public API defects surfaced by running the official Postman collection against a live stack. Several endpoints answered `500` where a `404` or `422` belonged, and a few were unreachable entirely. +Two API defects in this release were silent: both answered `200` and neither did what the caller asked. A vehicle odometer update was discarded, and a driver-scoped list came back unscoped. Alongside them, the order config flow now publishes enough of its shape to be sequenced, and Drivers Management gains a card layout. --- -## Security -- **Closed a verify-code authentication bypass in the driver flow.** Please upgrade. -- The non-production verification-code bypass is now scoped to explicitly designated review accounts, so a bypass code alone is not enough — the identity has to be on the allowlist too. +## Features +- **Drivers Management has a card view.** A layout toggle switches the index between the table and a card grid, and the choice is remembered across visits. +- **The order config flow publishes its graph.** `activities`, `sequence` and `logic` now ride along with each activity, so a consumer can order the flow and offer a next step instead of rendering an unordered set. Transitions are normalised to a list of codes regardless of which of the two stored shapes a flow was authored in. --- ## Bug Fixes -- **Driver `register-device` was unreachable on both driver routes.** Laravel never injects a class-typed parameter that declares a default, so the injected request was always null. -- **Geofence driver history asked for a UUID the API never issues.** It now resolves the driver by the public id callers actually hold. -- **`/from-qr` returned a 500**, and the QR code's content is now published in debug mode so the flow can be exercised. -- **Fuel reports could not be created without a location**, and could not be updated. -- **A sensor could not be created at all** — `last_position` had no default. -- **Customer signup with a place failed** — the Place location now defaults. -- Unknown onboard organization answers `404` instead of `500`. -- Duplicate part SKU and fuel transaction answer `422` instead of `500`. -- Restored the vehicle maintenance schedule workflows. +- **`PUT /v1/vehicles/{id}` silently discarded the odometer.** The field is fillable on the model but was missing from the controller's input projection, so a driver app recording mileage received a `200` and a correct-looking body while the reading was dropped. `odometer` and `odometer_unit` are now accepted and validated. +- **Issue and fuel-report lists scoped by `driver_uuid` came back scoped by nothing.** The base filter silently ignores a query parameter it cannot match to a method, so the filter was dropped and the response was narrowed only by company — every driver's records, with no sign the request had been narrowed at all. `driver_uuid`, `driver_assigned` and `vehicle_uuid` are now recognised on both filters. +- **Leaflet marker icons are served from the leaflet package** rather than resolving to a broken URL. --- ## Testing -- Coverage restored to 100% across the QR, geofence, driver auth, customer request and navigator changes. - ---- -## Continuous Integration -- The server, Ember and Postman workflows now run on `dev-v*` release branches. -- The contract run tests this branch's API code rather than the published package. +- Coverage held at 100% across the vehicle input projection, both driver-scoped filters, and the order config flow projection. --- ## Need help? diff --git a/addon/components/ai/route-preview-map.js b/addon/components/ai/route-preview-map.js index be7e145f0..c5f3ee3dd 100644 --- a/addon/components/ai/route-preview-map.js +++ b/addon/components/ai/route-preview-map.js @@ -168,11 +168,7 @@ export default class AiRoutePreviewMapComponent extends Component { get tileUrl() { const theme = document.body?.dataset?.theme; - if (theme === 'dark') { - return 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png'; - } - - return 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png'; + return this.mapSettings.getLeafletTileUrl(theme === 'dark' ? 'dark' : 'light'); } get emptyText() { diff --git a/addon/components/customer/orders.js b/addon/components/customer/orders.js index c20df8d33..1de736c0b 100644 --- a/addon/components/customer/orders.js +++ b/addon/components/customer/orders.js @@ -9,6 +9,7 @@ import { debug } from '@ember/debug'; import { task, timeout } from 'ember-concurrency'; import { Control as RoutingControl } from '@fleetbase/leaflet-routing-machine'; import engineService from '@fleetbase/ember-core/decorators/engine-service'; +import { DEFAULT_LEAFLET_TILE_URL } from '../../utils/leaflet-tile-url'; import registerComponent from '@fleetbase/ember-core/utils/register-component'; import OrderProgressCardComponent from '../order-progress-card'; import DisplayPlaceComponent from '../display-place'; @@ -38,7 +39,7 @@ export default class CustomerOrdersComponent extends Component { @tracked longitude; @tracked route; @tracked query; - @tracked tileSourceUrl = 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png'; + @tracked tileSourceUrl = DEFAULT_LEAFLET_TILE_URL; @tracked scheduledAt; @tracked deliveryInstructions = {}; diff --git a/addon/components/driver/card.hbs b/addon/components/driver/card.hbs new file mode 100644 index 000000000..4b3059080 --- /dev/null +++ b/addon/components/driver/card.hbs @@ -0,0 +1,49 @@ + + + + + +
+
{{or @resource.name @resource.display_name}}
+ +
+
{{n-a @resource.phone @resource.public_id}}
+ {{#if (has-block "header")}} + {{yield to="header"}} + {{/if}} + + + +
+ + + {{#if (has-block "body")}} + {{yield to="body"}} + {{/if}} + + + + + +
+ {{this.statusLabel}} +
+
+
+ + + + {{#if (has-block "footer")}} + {{yield to="footer"}} + {{/if}} +
+
+ {{n-a this.assignedVehicleLabel}} +
+
Last Modified: {{@resource.updatedAt}}
+
+
+
\ No newline at end of file diff --git a/addon/components/driver/card.js b/addon/components/driver/card.js new file mode 100644 index 000000000..829b921ad --- /dev/null +++ b/addon/components/driver/card.js @@ -0,0 +1,30 @@ +import Component from '@glimmer/component'; +import { inject as service } from '@ember/service'; +import { get } from '@ember/object'; +import config from 'ember-get-config'; + +export default class DriverCardComponent extends Component { + @service driverActions; + + get resource() { + return this.args.resource; + } + + get fallbackImage() { + return config?.defaultValues?.driverImage; + } + + get statusValue() { + return get(this.resource, 'status') ?? ''; + } + + get statusLabel() { + return this.statusValue || '-'; + } + + get assignedVehicleLabel() { + const driver = this.resource; + + return get(driver, 'vehicle_assigned.display_name') ?? get(driver, 'vehicle.display_name') ?? get(driver, 'vehicle_name'); + } +} diff --git a/addon/components/map/leaflet-live-map.js b/addon/components/map/leaflet-live-map.js index fd4b44d86..ae7727f8a 100644 --- a/addon/components/map/leaflet-live-map.js +++ b/addon/components/map/leaflet-live-map.js @@ -48,7 +48,6 @@ export default class MapLeafletLiveMapComponent extends Component { @tracked latitude = this.location.getLatitude(); @tracked longitude = this.location.getLongitude(); @tracked contextmenuItems = []; - @tracked tileUrl = 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png'; @tracked theme = 'light'; @tracked routes = []; @tracked drivers = []; @@ -57,6 +56,10 @@ export default class MapLeafletLiveMapComponent extends Component { @tracked leafletPluginsReady = hasLeafletPluginsReady(); _viewportReloadLocks = new Set(); + get tileUrl() { + return this.mapSettings.getLeafletTileUrl(this.theme); + } + constructor() { super(...arguments); @@ -888,22 +891,4 @@ export default class MapLeafletLiveMapComponent extends Component { // Fallback to default Singapore longitude return 103.8864; } - - #changeTileSource(source) { - switch (source) { - case 'dark': - this.theme = 'dark'; - this.tileUrl = 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png'; - break; - case 'custom': - this.theme = 'custom'; - this.tileUrl = source.startsWith('https://') ? source : 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png'; - break; - case 'light': - default: - this.theme = 'light'; - this.tileUrl = 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png'; - break; - } - } } diff --git a/addon/components/modals/place-details.hbs b/addon/components/modals/place-details.hbs index 4b49a002b..6be51d8a1 100644 --- a/addon/components/modals/place-details.hbs +++ b/addon/components/modals/place-details.hbs @@ -3,7 +3,7 @@
- + diff --git a/addon/components/modals/point-map.hbs b/addon/components/modals/point-map.hbs index 0b78d5a35..308f5e2b7 100644 --- a/addon/components/modals/point-map.hbs +++ b/addon/components/modals/point-map.hbs @@ -1,7 +1,7 @@
- + diff --git a/addon/components/orchestrator-workbench.js b/addon/components/orchestrator-workbench.js index d748789c0..2c63fedd8 100644 --- a/addon/components/orchestrator-workbench.js +++ b/addon/components/orchestrator-workbench.js @@ -36,6 +36,7 @@ export default class OrchestratorWorkbenchComponent extends Component { @service modalsManager; @service location; @service mapManager; + @service mapSettings; @service routeEngine; @service('order-allocation') allocationService; @@ -661,7 +662,7 @@ export default class OrchestratorWorkbenchComponent extends Component { get tileSourceUrl() { const isDark = document.documentElement.classList.contains('dark'); - return isDark ? 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png' : 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png'; + return this.mapSettings.getLeafletTileUrl(isDark ? 'dark' : 'light'); } // ── Computed helpers ────────────────────────────────────────────────────── diff --git a/addon/components/order-tracking-lookup.js b/addon/components/order-tracking-lookup.js index f341833e1..8067095f1 100644 --- a/addon/components/order-tracking-lookup.js +++ b/addon/components/order-tracking-lookup.js @@ -10,6 +10,7 @@ import { task } from 'ember-concurrency'; import { OSRMv1, Control as RoutingControl } from '@fleetbase/leaflet-routing-machine'; import getRoutingHost from '@fleetbase/ember-core/utils/get-routing-host'; import engineService from '@fleetbase/ember-core/decorators/engine-service'; +import { DEFAULT_LEAFLET_TILE_URL } from '../utils/leaflet-tile-url'; export default class OrderTrackingLookupComponent extends Component { @service urlSearchParams; @@ -28,7 +29,7 @@ export default class OrderTrackingLookupComponent extends Component { @tracked latitude; @tracked longitude; @tracked route; - @tracked tileSourceUrl = 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png'; + @tracked tileSourceUrl = DEFAULT_LEAFLET_TILE_URL; constructor() { super(...arguments); diff --git a/addon/components/order/form/route.js b/addon/components/order/form/route.js index 0185ccde6..f95f3f793 100644 --- a/addon/components/order/form/route.js +++ b/addon/components/order/form/route.js @@ -5,6 +5,7 @@ import { action, get } from '@ember/object'; import { task } from 'ember-concurrency'; import { colorForId, routeColorForStatus, routeStyleForStatus } from '../../../utils/route-colors'; import { buildRoutePointMarkerPresentation, buildRoutePointsFromPayload, describeRoutePoint } from '../../../utils/route-visualization'; +import preparePlaceForSave from '../../../utils/prepare-place-for-save'; const ORDER_ROUTE_PREVIEW_PADDING_BOTTOM_RIGHT = [420, 0]; const ORDER_ROUTE_PREVIEW_MAX_ZOOM_TWO_POINTS = 13; @@ -153,6 +154,7 @@ export default class OrderFormRouteComponent extends Component { @action setWaypointPlace(index, place) { if (!this.args.resource.payload.waypoints[index]) return; + place = preparePlaceForSave(this.store, place); this.args.resource.payload.waypoints[index].place = place; this.args.resource.payload.waypoints[index]?.setProperties({ street1: place.street1, @@ -186,6 +188,7 @@ export default class OrderFormRouteComponent extends Component { } @action setPayloadPlace(prop, place) { + place = preparePlaceForSave(this.store, place); this.args.resource.payload[prop] = place; this.previewRoute(); this.requestServiceQuoteRefresh(`route.${prop}.changed`); diff --git a/addon/components/place/details.hbs b/addon/components/place/details.hbs index bb057a8f8..cb2d9e7e4 100644 --- a/addon/components/place/details.hbs +++ b/addon/components/place/details.hbs @@ -75,7 +75,7 @@ @zoomControl={{false}} as |layers| > - + - + this.load.perform(), RECONCILE_INTERVAL_MS); diff --git a/addon/components/zone/details.hbs b/addon/components/zone/details.hbs index d7af87681..284582073 100644 --- a/addon/components/zone/details.hbs +++ b/addon/components/zone/details.hbs @@ -22,7 +22,7 @@ @zoomControl={{false}} as |layers| > - + this.changeLayout('table'), + }, + { + label: this.intl.t('common.grid-view'), + icon: 'grip', + onClick: () => this.changeLayout('grid'), + }, + ], + renderInPlace: true, + helpText: 'Change the layout', + }, { icon: 'refresh', onClick: this.driverActions.refresh, @@ -377,6 +397,7 @@ export default class ManagementDriversIndexController extends Controller { @action changeLayout(layout) { this.layout = layout; + this.appCache.set('fleetops:drivers:layout', layout); } @action createIssue(driver) { diff --git a/addon/controllers/settings/map.js b/addon/controllers/settings/map.js index 2ac378e26..7639bdf3d 100644 --- a/addon/controllers/settings/map.js +++ b/addon/controllers/settings/map.js @@ -33,6 +33,8 @@ export default class SettingsMapController extends Controller { * @var {String} */ @tracked mapProvider = 'leaflet'; + @tracked leafletTileUrl = ''; + @tracked leafletDarkTileUrl = ''; @tracked googleMapsMapType = 'roadmap'; @tracked showGoogleMapsTrafficLayer = false; @tracked showGoogleMapsTransitLayer = false; @@ -62,6 +64,16 @@ export default class SettingsMapController extends Controller { return this.mapProvider === 'google'; } + /** + * True when the user has selected Leaflet as the provider. + * + * @memberof SettingsMapController + * @return {Boolean} + */ + get isLeafletSelected() { + return this.mapProvider === 'leaflet'; + } + // ─── Actions ─────────────────────────────────────────────────────────────── /** @@ -77,6 +89,8 @@ export default class SettingsMapController extends Controller { @task *saveSettings() { const settings = { mapProvider: this.mapProvider, + leafletTileUrl: (this.leafletTileUrl ?? '').trim(), + leafletDarkTileUrl: (this.leafletDarkTileUrl ?? '').trim(), googleMapsMapType: this.googleMapsMapType, showGoogleMapsTrafficLayer: this.showGoogleMapsTrafficLayer, showGoogleMapsTransitLayer: this.showGoogleMapsTransitLayer, @@ -105,6 +119,8 @@ export default class SettingsMapController extends Controller { const response = yield this.mapSettings.load({ force: true }); this.mapProvider = response?.mapProvider ?? 'leaflet'; + this.leafletTileUrl = response?.leafletTileUrl ?? ''; + this.leafletDarkTileUrl = response?.leafletDarkTileUrl ?? ''; this.googleMapsMapType = response?.googleMapsMapType ?? 'roadmap'; this.showGoogleMapsTrafficLayer = Boolean(response?.showGoogleMapsTrafficLayer); this.showGoogleMapsTransitLayer = Boolean(response?.showGoogleMapsTransitLayer); diff --git a/addon/helpers/leaflet-tile-url.js b/addon/helpers/leaflet-tile-url.js new file mode 100644 index 000000000..681cee375 --- /dev/null +++ b/addon/helpers/leaflet-tile-url.js @@ -0,0 +1,19 @@ +import Helper from '@ember/component/helper'; +import { inject as service } from '@ember/service'; + +/** + * Resolves the Leaflet tile URL from Fleet-Ops map settings. + * + * Usage: + * + * + * + * Recomputes automatically when map settings load or change. + */ +export default class LeafletTileUrlHelper extends Helper { + @service mapSettings; + + compute(_params, { theme = 'light' } = {}) { + return this.mapSettings.getLeafletTileUrl(theme); + } +} diff --git a/addon/services/map-settings.js b/addon/services/map-settings.js index 4267c0b67..fae705bbd 100644 --- a/addon/services/map-settings.js +++ b/addon/services/map-settings.js @@ -1,9 +1,12 @@ import Service from '@ember/service'; import { tracked } from '@glimmer/tracking'; import { inject as service } from '@ember/service'; +import { getLeafletTileUrl } from '../utils/leaflet-tile-url'; const DEFAULT_SETTINGS = { mapProvider: 'leaflet', + leafletTileUrl: '', + leafletDarkTileUrl: '', googleMapsApiKey: '', googleMapsMapId: '', googleMapsMapType: 'roadmap', @@ -45,6 +48,25 @@ export default class MapSettingsService extends Service { return this.mapProvider === 'google'; } + get leafletTileUrl() { + return this.getLeafletTileUrl('light'); + } + + get leafletDarkTileUrl() { + return this.getLeafletTileUrl('dark'); + } + + /** + * Resolve the Leaflet tile URL for a theme, preferring the company's + * configured custom tile provider and falling back to the keyless default. + * + * @param {String} theme 'light' or 'dark' + * @return {String} + */ + getLeafletTileUrl(theme = 'light') { + return getLeafletTileUrl(this.settings, theme); + } + async load({ force = false } = {}) { if (!force && this.isLoaded) { return this.settings; diff --git a/addon/templates/management/drivers/index.hbs b/addon/templates/management/drivers/index.hbs index 33ec8a6a9..6d2313e4d 100644 --- a/addon/templates/management/drivers/index.hbs +++ b/addon/templates/management/drivers/index.hbs @@ -1,33 +1,67 @@ - -{{outlet}} +{{#if (eq this.layout "table")}} + +{{else}} + + + + + + + + + +{{/if}} +{{outlet}} \ No newline at end of file diff --git a/addon/templates/settings/map.hbs b/addon/templates/settings/map.hbs index 065d99393..fe65273b5 100644 --- a/addon/templates/settings/map.hbs +++ b/addon/templates/settings/map.hbs @@ -31,6 +31,16 @@ /> + {{#if this.isLeafletSelected}} + + + + + + + + {{/if}} + {{#if this.isGoogleMapsSelected}}