From eab6d8eed062cf7095b120c117af9d0f0d2cd977 Mon Sep 17 00:00:00 2001 From: Aaron Brethorst Date: Sun, 23 Aug 2026 23:58:42 -0700 Subject: [PATCH 01/29] docs: add admin web UI v1 design spec --- .../specs/2026-08-23-admin-web-ui-design.md | 269 ++++++++++++++++++ 1 file changed, 269 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-23-admin-web-ui-design.md diff --git a/docs/superpowers/specs/2026-08-23-admin-web-ui-design.md b/docs/superpowers/specs/2026-08-23-admin-web-ui-design.md new file mode 100644 index 0000000..0c4424e --- /dev/null +++ b/docs/superpowers/specs/2026-08-23-admin-web-ui-design.md @@ -0,0 +1,269 @@ +# Admin Web UI v1 — Design Spec + +**Date:** 2026-08-23 +**Status:** Approved for implementation +**Milestone:** 4 (Admin Interface + End-to-End Polish) from the project README + +## 1. Overview + +The repo contains a proof-of-concept admin UI (PR: `e49ea27`) behind the +`ADMIN_UI_ENABLED` flag: five server-rendered pages (dashboard, live map, +vehicles, users, trips) styled with Tailwind, populated entirely with +hardcoded mock data, and served with **no authentication**. This project +replaces the mockup with a functional, authenticated admin interface backed +by real data, fulfilling the Milestone 4 admin UI deliverable: + +- Dashboard: active vehicle count, feed health, last update times +- Vehicle map: Leaflet/OSM showing current vehicle positions +- Vehicle management: CRUD +- User management: CRUD + vehicle assignment +- Trip history: searchable list with location trail visualization + +## 2. Goals + +1. Session-authenticated admin UI (admin-role users only). +2. Every page shows real data from the store and the in-memory tracker. +3. Vehicle and user CRUD, user–vehicle assignment management, trip history + with filters, and per-trip location trails on the map. +4. Live map that polls current vehicle positions. +5. Admin UI works without internet access to CDNs (assets vendored/compiled + into the binary via `go:embed`) — target agencies have intermittent + connectivity. +6. New JSON endpoints added for the UI are proper additions to the admin + REST API (trips listing was promised in Milestone 2 and never built). + +## 3. Non-Goals + +- API-key auth for the GTFS-RT feed (separate work item). +- Dark mode, localization of the admin UI. +- Password self-service / reset emails. +- Signup flow — the mockup's signup page is removed; admins create users. + (`/api/v1/auth/signup` never existed on the server.) +- Audit logging, multi-agency tenancy. +- Charts/analytics beyond the dashboard counters. + +## 4. Architecture + +Server-rendered Go templates (existing `web/templates` structure) with a +session cookie for authentication. Pages pull data from the store and +tracker directly in their handlers — no internal HTTP hop. A small amount +of vanilla JS handles the Leaflet map (live polling, trip trails). + +### 4.1 Authentication: session cookie carrying the existing JWT + +- `POST /admin/login` — HTML form (email + password). Validates credentials + with the same logic as the API login (bcrypt compare, timing-safe on + unknown email), requires `role == "admin"`, then sets a cookie: + - Name `vp_session`, value = the same HS256 JWT `generateJWT` produces. + - `HttpOnly`, `SameSite=Lax`, `Path=/`, `Secure` when the request is TLS + (or `X-Forwarded-Proto: https`), `Max-Age` matching the 24h token TTL. + - Non-admin users get the login page back with "admin access required". +- `POST /admin/logout` — clears the cookie, redirects to `/admin/login`. +- `GET /admin/login` — renders the login page; if already authenticated as + admin, redirects to `/admin/dashboard`. +- New middleware `requireAdminPage(jwtSecret)` guards every `/admin/*` page + except login: parses/validates the cookie JWT, requires `role == "admin"`, + and on failure redirects (`303 See Other`) to `/admin/login` instead of + returning JSON. +- **Cookie fallback in `requireAuth`:** the existing API middleware checks + the `Authorization: Bearer` header first and, when absent, falls back to + the `vp_session` cookie. This lets the browser session use the existing + admin JSON endpoints (e.g. the CSV location-history download) without a + second auth system. Bearer, when present, wins; an invalid Bearer is + rejected without falling back to the cookie. + +### 4.2 CSRF protection + +All state-changing routes (admin forms and the now cookie-reachable API +mutations) are protected by Go 1.25's `http.CrossOriginProtection`, wrapped +around the whole mux in `main.go` (and in `newMux` tests). It rejects +browser-originated cross-origin non-safe requests using `Sec-Fetch-Site` / +`Origin`; non-browser clients (the Android app's Retrofit, curl) send +neither header and are unaffected. All admin forms use `POST`; no +state-changing `GET` routes exist. + +### 4.3 Route map + +Pages (server-rendered, cookie-authed unless noted): + +| Route | Purpose | +|---|---| +| `GET /admin/login` | Login form (public) | +| `POST /admin/login` | Authenticate, set cookie (public, rate limited) | +| `POST /admin/logout` | Clear session | +| `GET /admin/dashboard` | Real stats + recent activity | +| `GET /admin/map` | Live map (also renders a single trip trail via `?trip_id=N`) | +| `GET /admin/vehicles` | Vehicle list | +| `GET /admin/vehicles/new`, `POST /admin/vehicles` | Create vehicle | +| `GET /admin/vehicles/{id}/edit`, `POST /admin/vehicles/{id}` | Edit vehicle (label, agency tag, active) | +| `POST /admin/vehicles/{id}/deactivate` | Deactivate (confirm dialog in UI) | +| `GET /admin/users` | User list | +| `GET /admin/users/new`, `POST /admin/users` | Create user (name, email, password, role) | +| `GET /admin/users/{id}/edit`, `POST /admin/users/{id}` | Edit user (name, email, role, optional new password) + manage vehicle assignments | +| `POST /admin/users/{id}/deactivate` | Deactivate user | +| `POST /admin/users/{id}/vehicles` | Assign a vehicle | +| `POST /admin/users/{id}/vehicles/{vehicleID}/remove` | Unassign a vehicle | +| `GET /admin/trips` | Trip history with filters | + +The mockup's `GET /admin/signup` route and signup template mode are deleted. +`GET /admin` redirects to `/admin/dashboard`. + +New/changed JSON API endpoints (Bearer or cookie, admin role): + +| Route | Purpose | +|---|---| +| `GET /api/v1/admin/vehicles/live` | Current tracker state joined with vehicle labels and active-trip info; feeds the live map | +| `GET /api/v1/admin/trips` | List trips: filters `status` (active/completed), `vehicle_id`, `limit`/`offset` (default 50, max 200), newest first | +| `GET /api/v1/admin/trips/{id}/locations` | Ordered location points for one trip; feeds the trail view | + +`/api/v1/admin/vehicles/live` response entries: `vehicle_id`, `label`, +`latitude`, `longitude`, `bearing`, `speed` (nullable), `trip_id`, +`route_id`, `driver_name` (nullable when no active trip matches), and +`recorded_at`. Only vehicles currently in the tracker (within the staleness +threshold) appear. + +### 4.4 Forms and error handling + +- Classic POST → redirect (PRG). Success redirects carry a flash message via + a short-lived (60s) non-HttpOnly `vp_flash` cookie rendered once by the + layout and cleared. +- Validation failures re-render the form with a page-level error message and + the submitted values (except passwords), status 422. +- Store errors render a friendly 500 page section; details go to `slog`. +- Unknown IDs → 404 page. + +### 4.5 Pages: data contracts + +**Dashboard** — stat cards: total vehicles (store count, active flag true), +active now (tracker), registered drivers (store count of role `driver`, +active), active trips (store count `status = 'active'`). Feed health strip: +tracker last-update time and staleness threshold. Recent activity table: +tracker's active vehicles joined with labels/route, newest first, capped at +10, with humanized "last update" times. Empty state when nothing is active. + +**Live map** — JS fetches `/api/v1/admin/vehicles/live` every 10 s (visible +tab only), renders the existing marker/popup design with real fields, fits +bounds on first load, and shows an empty-state banner when no vehicles are +active. Mock corridor polylines are deleted. The fleet sidebar lists the +same live vehicles. With `?trip_id=N` the page instead fetches the trail +endpoint once and draws a polyline with start/end markers plus trip +metadata. + +**Vehicles** — table: id, label, agency tag, active badge, live "last seen" +(from tracker when present), current driver (from active trip when +present). Row actions: edit, deactivate (POST form with JS confirm). +"Include deactivated" toggle via `?include_inactive=1` (the existing +`ListVehicles` store method already supports this). + +**Users** — table: name, email, role badge, active badge, assigned-vehicle +count. Create form (name, email, password ≥ 8 chars, role select). Edit +form adds optional password change and an assignments section: current +vehicles with remove buttons and a select of active unassigned vehicles to +add. Deactivate with confirm. The mockup's fake "Last Seen" column is +dropped. + +**Trips** — table: trip id, vehicle label, driver name, route id, GTFS trip +id, start/end times (local server TZ), status badge, duration. Filter bar: +status select, vehicle select, applied via GET query params. Pagination +with next/prev links (50/page). Row action: "View trail" → `/admin/map?trip_id=N`. + +### 4.6 Store additions + +New methods on `*Store` (each behind a narrow interface consumed by its +handler, matching existing convention): + +- `CountVehicles(ctx, activeOnly bool) (int, error)` +- `CountUsersByRole(ctx, role string, activeOnly bool) (int, error)` +- `CountActiveTrips(ctx) (int, error)` +- `ListTrips(ctx, TripFilter) ([]TripSummary, error)` — joins users + (driver name) and vehicles (label); filter: status, vehicleID, + limit/offset; ordered by `start_time DESC` +- `GetTripSummary(ctx, id int64) (*TripSummary, error)` +- `ListLocationsByTrip(ctx, tripID int64) ([]LocationPoint, error)` — + ordered by `recorded_at ASC`, capped at 10,000 points +- `ListActiveTripsByVehicle(ctx) (map[string]ActiveTripInfo, error)` — one + query powering the live endpoint's driver/route join + +No schema migrations are required; all data exists in current tables. An +index on `location_points (trip_id, recorded_at)` is added in a new +migration to make trail queries cheap. + +### 4.7 Static assets: no CDNs + +- Vendor Leaflet 1.9.4 (`leaflet.js`, `leaflet.css`, marker images) into + `web/static/vendor/leaflet/`. +- Replace the `cdn.tailwindcss.com` runtime script with a Tailwind CSS file + compiled once via the standalone Tailwind CLI from the templates and + committed at `web/static/css/admin.css`. A `Makefile` target (`make css`) + documents regeneration. If the CLI proves unavailable during + implementation, fall back to a hand-written CSS file reproducing the + used utility classes — the visual design is preserved either way. +- Drop Google Fonts; use a system font stack (`system-ui, -apple-system, + Segoe UI, Roboto, ...`). The `display-font` class keeps a distinct weight + treatment instead of a webfont. +- CARTO basemap tiles remain a runtime network dependency of the map page + only (unavoidable for map imagery; documented). + +### 4.8 Flag and rollout + +`ADMIN_UI_ENABLED` now defaults to **true** (the UI is authenticated); +setting it to a false value disables registration of all `/admin` routes. +The startup warning about an unauthenticated demo UI is removed. The +README/development docs gain a short "Admin UI" section (URL, default flag, +how to create the first admin user via `seed_dev.sql` or the users API). + +### 4.9 Login rate limiting + +A small fixed-window per-IP limiter (10 attempts/minute) guards +`POST /admin/login` and `POST /api/v1/auth/login`, returning 429 (page: +"too many attempts, try again shortly"). Implementation mirrors the +existing `VehicleRateLimiter` style; client IP is taken from the direct +connection (`r.RemoteAddr`), not spoofable proxy headers, and documented +as such. + +## 5. Component boundaries + +- `admin_session.go` — cookie issue/clear/parse, `requireAdminPage` + middleware, flash helpers. No knowledge of specific pages. +- `admin_page_handlers.go` (replaces the mock handlers in + `admin_handlers.go`) — page rendering, form processing. Depends on store + interfaces + tracker + templates. +- `admin_live_handlers.go` — `/api/v1/admin/vehicles/live`, trips list, + trip locations JSON handlers. +- `store_admin_stats.go`, additions to `store_trips.go` — new queries. +- `web/templates/**`, `web/static/**` — templates and vendored assets. +- `auth.go` — gains the cookie fallback in `requireAuth` only. + +Each unit is testable in isolation (httptest + fake stores for handlers, +`newTestStore` + `DATABASE_URL` for store methods, following existing +conventions). + +## 6. Testing + +- **Store tests** (Postgres-backed, skip without `DATABASE_URL`): each new + method — counts, trip listing filters/pagination/order, trail ordering, + active-trip join. +- **Handler tests** (httptest, fake stores): login form success/failure/ + non-admin/ratelimit; cookie attributes; logout; redirect-to-login on + every protected page when unauthenticated/expired/tampered cookie; + Bearer-wins-over-cookie and invalid-Bearer-does-not-fall-back in + `requireAuth`; CSRF rejection of cross-origin form POST; each page + renders real data (golden substrings, not full-page snapshots); form + validation errors re-render with 422; PRG redirects; 404s. +- **Route wiring test** (`route_wiring_test.go` pattern): every new route + present with correct middleware; `/admin/*` pages redirect when + unauthenticated; JSON endpoints return 401 JSON. +- **End-to-end smoke** (manual, documented): docker-compose up → seed → + log in → create vehicle/user → assign → simulate locations → vehicle on + map → trip in history → trail renders → CSV downloads. + +## 7. Risks + +- **Tailwind compile step**: one-time; fallback to handwritten CSS is + planned and acceptable. +- **Cookie fallback on the API** widens the browser-reachable surface; + mitigated by `CrossOriginProtection`, `SameSite=Lax`, HttpOnly, and + admin-role checks already on every admin endpoint. +- **Tracker/DB label mismatch** (a tracked vehicle deleted from the DB): + live endpoint returns the vehicle with a `label` equal to its id rather + than erroring. From 7af641a42ec230aea677d0e0d3b97d9c22cf544f Mon Sep 17 00:00:00 2001 From: Aaron Brethorst Date: Mon, 24 Aug 2026 00:23:23 -0700 Subject: [PATCH 02/29] docs: revise admin UI spec per design review (24 findings) --- .../specs/2026-08-23-admin-web-ui-design.md | 432 ++++++++++++------ 1 file changed, 292 insertions(+), 140 deletions(-) diff --git a/docs/superpowers/specs/2026-08-23-admin-web-ui-design.md b/docs/superpowers/specs/2026-08-23-admin-web-ui-design.md index 0c4424e..421d046 100644 --- a/docs/superpowers/specs/2026-08-23-admin-web-ui-design.md +++ b/docs/superpowers/specs/2026-08-23-admin-web-ui-design.md @@ -1,12 +1,12 @@ # Admin Web UI v1 — Design Spec -**Date:** 2026-08-23 +**Date:** 2026-08-23 (revised 2026-08-24 after design review) **Status:** Approved for implementation **Milestone:** 4 (Admin Interface + End-to-End Polish) from the project README ## 1. Overview -The repo contains a proof-of-concept admin UI (PR: `e49ea27`) behind the +The repo contains a proof-of-concept admin UI (commit `e49ea27`) behind the `ADMIN_UI_ENABLED` flag: five server-rendered pages (dashboard, live map, vehicles, users, trips) styled with Tailwind, populated entirely with hardcoded mock data, and served with **no authentication**. This project @@ -18,13 +18,15 @@ by real data, fulfilling the Milestone 4 admin UI deliverable: - Vehicle management: CRUD - User management: CRUD + vehicle assignment - Trip history: searchable list with location trail visualization +- CSV download of location data ## 2. Goals 1. Session-authenticated admin UI (admin-role users only). 2. Every page shows real data from the store and the in-memory tracker. 3. Vehicle and user CRUD, user–vehicle assignment management, trip history - with filters, and per-trip location trails on the map. + with filters and free-text search, per-trip location trails on the map, + and CSV download of per-vehicle location history. 4. Live map that polls current vehicle positions. 5. Admin UI works without internet access to CDNs (assets vendored/compiled into the binary via `go:embed`) — target agencies have intermittent @@ -40,7 +42,13 @@ by real data, fulfilling the Milestone 4 admin UI deliverable: - Signup flow — the mockup's signup page is removed; admins create users. (`/api/v1/auth/signup` never existed on the server.) - Audit logging, multi-agency tenancy. -- Charts/analytics beyond the dashboard counters. +- Charts/analytics beyond the dashboard counters. Feed **error rates** + (README §3.3) are explicitly deferred: the server does not yet count + ingest errors, and adding metrics plumbing is out of scope for v1. +- Server-side token revocation on logout (clearing the cookie is enough for + v1; tokens expire in ≤ 24 h). +- A `next`/return-to parameter on the login redirect (v1 always lands on + the dashboard after login). ## 4. Architecture @@ -49,40 +57,66 @@ session cookie for authentication. Pages pull data from the store and tracker directly in their handlers — no internal HTTP hop. A small amount of vanilla JS handles the Leaflet map (live polling, trip trails). -### 4.1 Authentication: session cookie carrying the existing JWT +### 4.1 Handler composition: `newHandler` + +`newMux` currently returns `*http.ServeMux` and the admin UI is bolted on +separately in `main()`. A new constructor becomes the single composition +point used by both `main()` and tests: + +```go +func newHandler(store appStore, tracker *Tracker, rateLimiter *VehicleRateLimiter, + loginLimiter *LoginRateLimiter, jwtSecret []byte, startTime time.Time, + cfg adminUIConfig) (http.Handler, error) +``` + +It builds the API mux via `newMux`, registers the admin UI routes when +enabled (passing store/tracker/secret/templates into the page handlers), +and wraps the whole thing in `http.CrossOriginProtection` (Go 1.25) and +the request logger. `newMux` remains for existing JSON-only tests; new +wiring tests target `newHandler`. `appStore` gains the new store +interfaces, and the `noopStore` in `route_wiring_test.go` is extended to +match. + +### 4.2 Authentication: session cookie carrying the existing JWT - `POST /admin/login` — HTML form (email + password). Validates credentials with the same logic as the API login (bcrypt compare, timing-safe on unknown email), requires `role == "admin"`, then sets a cookie: - Name `vp_session`, value = the same HS256 JWT `generateJWT` produces. - - `HttpOnly`, `SameSite=Lax`, `Path=/`, `Secure` when the request is TLS - (or `X-Forwarded-Proto: https`), `Max-Age` matching the 24h token TTL. - - Non-admin users get the login page back with "admin access required". + - `HttpOnly`, `SameSite=Lax`, `Path=/`, `Max-Age` matching the 24 h token + TTL. `Secure` is set when the request arrived over TLS, or when + `TRUST_PROXY_HEADERS=true` and `X-Forwarded-Proto: https` (see §4.10). + - Non-admin users get the login page back with "admin access required" + (403). Deactivated users are treated as invalid credentials. - `POST /admin/logout` — clears the cookie, redirects to `/admin/login`. - `GET /admin/login` — renders the login page; if already authenticated as - admin, redirects to `/admin/dashboard`. + admin, redirects to `/admin/dashboard`. `GET /admin` and `GET /admin/` + redirect to the dashboard (or login when unauthenticated). - New middleware `requireAdminPage(jwtSecret)` guards every `/admin/*` page except login: parses/validates the cookie JWT, requires `role == "admin"`, and on failure redirects (`303 See Other`) to `/admin/login` instead of returning JSON. -- **Cookie fallback in `requireAuth`:** the existing API middleware checks - the `Authorization: Bearer` header first and, when absent, falls back to - the `vp_session` cookie. This lets the browser session use the existing - admin JSON endpoints (e.g. the CSV location-history download) without a - second auth system. Bearer, when present, wins; an invalid Bearer is - rejected without falling back to the cookie. - -### 4.2 CSRF protection +- **Cookie fallback in `requireAuth`:** the existing API middleware falls + back to the `vp_session` cookie **only when the `Authorization` header is + entirely absent**. A present-but-malformed or invalid `Authorization` + header is rejected with 401 without consulting the cookie. This lets the + browser session use the existing admin JSON endpoints (notably the CSV + location-history download) without a second auth system. +- **Login gate for deactivated users:** both login paths (`/admin/login` + and `/api/v1/auth/login`) reject users whose `active` flag is false with + the same "invalid email or password" response used for wrong passwords. + +### 4.3 CSRF protection All state-changing routes (admin forms and the now cookie-reachable API -mutations) are protected by Go 1.25's `http.CrossOriginProtection`, wrapped -around the whole mux in `main.go` (and in `newMux` tests). It rejects -browser-originated cross-origin non-safe requests using `Sec-Fetch-Site` / -`Origin`; non-browser clients (the Android app's Retrofit, curl) send -neither header and are unaffected. All admin forms use `POST`; no -state-changing `GET` routes exist. +mutations) are protected by Go 1.25's `http.CrossOriginProtection`, applied +in `newHandler` around the composed handler. It rejects browser-originated +cross-origin non-safe requests using `Sec-Fetch-Site` / `Origin`-vs-`Host`; +non-browser clients (the Android app's Retrofit, curl) send neither header +and are unaffected. All admin forms use `POST`; no state-changing `GET` +routes exist. -### 4.3 Route map +### 4.4 Route map Pages (server-rendered, cookie-authed unless noted): @@ -91,148 +125,256 @@ Pages (server-rendered, cookie-authed unless noted): | `GET /admin/login` | Login form (public) | | `POST /admin/login` | Authenticate, set cookie (public, rate limited) | | `POST /admin/logout` | Clear session | +| `GET /admin` | Redirect to dashboard | | `GET /admin/dashboard` | Real stats + recent activity | | `GET /admin/map` | Live map (also renders a single trip trail via `?trip_id=N`) | | `GET /admin/vehicles` | Vehicle list | | `GET /admin/vehicles/new`, `POST /admin/vehicles` | Create vehicle | -| `GET /admin/vehicles/{id}/edit`, `POST /admin/vehicles/{id}` | Edit vehicle (label, agency tag, active) | +| `GET /admin/vehicles/{id}/edit`, `POST /admin/vehicles/{id}` | Edit vehicle (label, agency tag) | | `POST /admin/vehicles/{id}/deactivate` | Deactivate (confirm dialog in UI) | +| `POST /admin/vehicles/{id}/activate` | Reactivate | | `GET /admin/users` | User list | | `GET /admin/users/new`, `POST /admin/users` | Create user (name, email, password, role) | | `GET /admin/users/{id}/edit`, `POST /admin/users/{id}` | Edit user (name, email, role, optional new password) + manage vehicle assignments | | `POST /admin/users/{id}/deactivate` | Deactivate user | +| `POST /admin/users/{id}/activate` | Reactivate user | | `POST /admin/users/{id}/vehicles` | Assign a vehicle | | `POST /admin/users/{id}/vehicles/{vehicleID}/remove` | Unassign a vehicle | | `GET /admin/trips` | Trip history with filters | -The mockup's `GET /admin/signup` route and signup template mode are deleted. -`GET /admin` redirects to `/admin/dashboard`. +The mockup's `GET /admin/signup` route and signup template mode are +deleted. -New/changed JSON API endpoints (Bearer or cookie, admin role): +New JSON API endpoints (Bearer or cookie, admin role): | Route | Purpose | |---|---| | `GET /api/v1/admin/vehicles/live` | Current tracker state joined with vehicle labels and active-trip info; feeds the live map | -| `GET /api/v1/admin/trips` | List trips: filters `status` (active/completed), `vehicle_id`, `limit`/`offset` (default 50, max 200), newest first | -| `GET /api/v1/admin/trips/{id}/locations` | Ordered location points for one trip; feeds the trail view | - -`/api/v1/admin/vehicles/live` response entries: `vehicle_id`, `label`, -`latitude`, `longitude`, `bearing`, `speed` (nullable), `trip_id`, -`route_id`, `driver_name` (nullable when no active trip matches), and -`recorded_at`. Only vehicles currently in the tracker (within the staleness -threshold) appear. - -### 4.4 Forms and error handling - -- Classic POST → redirect (PRG). Success redirects carry a flash message via - a short-lived (60s) non-HttpOnly `vp_flash` cookie rendered once by the - layout and cleared. -- Validation failures re-render the form with a page-level error message and - the submitted values (except passwords), status 422. +| `GET /api/v1/admin/trips` | List trips (API-only addition fulfilling the Milestone 2 promise; the trips *page* queries the store directly). Filters: `status` (active/completed), `vehicle_id`, `q`, `limit`/`offset` (default 50, max 200), newest first | +| `GET /api/v1/admin/trips/{id}/locations` | Trip metadata + ordered location points for one trip; feeds the trail view (single fetch for the map page) | + +`/api/v1/admin/vehicles/live` response entries: + +```json +{ + "vehicle_id": "bus-1", + "label": "Bus 1", + "latitude": -1.29, "longitude": 36.82, + "bearing": 180.0, // nullable + "speed": 8.5, // nullable + "gtfs_trip_id": "route_5_0830", // string from the tracker (client-reported) + "trip_db_id": 42, // nullable int64: trips.id of the vehicle's active trip + "route_id": "5", // nullable, from the active-trip join + "driver_name": "Asha", // nullable, from the active-trip join + "reported_at": 1752566400, // device-reported unix epoch + "updated_at": "2026-08-24T04:00:00Z" // server receipt time (staleness basis) +} +``` + +Only vehicles currently in the tracker (within the staleness threshold) +appear. A tracked vehicle missing from the `vehicles` table (edge case) +is returned with `label` equal to its id rather than erroring. + +`/api/v1/admin/trips/{id}/locations` response: `{"trip": {…TripSummary…}, +"points": [{latitude, longitude, bearing, speed, accuracy, reported_at, +received_at}, …]}` ordered by `received_at ASC`, capped at 10,000 points. + +### 4.5 Trip trail derivation (no ingest change) + +`location_points.trip_id` is a client-supplied GTFS/route **string**, not a +`trips.id` reference, so trails are derived instead from columns the server +controls: points where `vehicle_id = trips.vehicle_id` AND `driver_id = +trips.user_id::text` (`driver_id` is set server-side from the JWT `sub`) +AND `received_at` between `trips.start_time` and +`COALESCE(trips.end_time, NOW())`. The partial unique index +`idx_trips_one_active_per_user` makes this window exact per driver. The +existing `idx_location_points_vehicle_received_at (vehicle_id, +received_at DESC)` index covers the query; **no new index or ingest-path +change is required**. + +### 4.6 Forms and error handling + +- Classic POST → redirect (PRG). Success redirects carry a flash via a + short-lived (60 s) **HttpOnly** `vp_flash` cookie whose value is an + opaque code (e.g. `vehicle_created`); the layout maps known codes to + fixed message strings server-side and clears the cookie. Unknown codes + render nothing. Flash values are never rendered as raw markup. +- Validation failures re-render the form with a page-level error message + and the submitted values (except passwords), status 422. +- Create-vehicle collisions (id already exists) re-render at 422 with a + "vehicle id already exists" error — no silent overwrite. - Store errors render a friendly 500 page section; details go to `slog`. - Unknown IDs → 404 page. -### 4.5 Pages: data contracts - -**Dashboard** — stat cards: total vehicles (store count, active flag true), -active now (tracker), registered drivers (store count of role `driver`, -active), active trips (store count `status = 'active'`). Feed health strip: -tracker last-update time and staleness threshold. Recent activity table: -tracker's active vehicles joined with labels/route, newest first, capped at -10, with humanized "last update" times. Empty state when nothing is active. - -**Live map** — JS fetches `/api/v1/admin/vehicles/live` every 10 s (visible -tab only), renders the existing marker/popup design with real fields, fits -bounds on first load, and shows an empty-state banner when no vehicles are -active. Mock corridor polylines are deleted. The fleet sidebar lists the -same live vehicles. With `?trip_id=N` the page instead fetches the trail -endpoint once and draws a polyline with start/end markers plus trip -metadata. - -**Vehicles** — table: id, label, agency tag, active badge, live "last seen" -(from tracker when present), current driver (from active trip when -present). Row actions: edit, deactivate (POST form with JS confirm). -"Include deactivated" toggle via `?include_inactive=1` (the existing -`ListVehicles` store method already supports this). +### 4.7 Pages: data contracts + +**Dashboard** — stat cards: total active vehicles (store count), active +now + feed last-update time (both from `Tracker.Status()`, the same source +as `/api/v1/admin/status` — no re-derived "active" definition), registered +active drivers (store count, role `driver`), active trips (store count). +Feed health strip: `Status().LastUpdate` and the staleness threshold. +Recent activity table: `Tracker.ActiveVehicles()` joined with labels and +active-trip route, newest first, capped at 10, humanized "last update" +ages. Empty state when nothing is active. + +**Live map** — JS fetches `/api/v1/admin/vehicles/live` every 10 s +(visible tab only), fits bounds on first load, and shows an empty-state +banner when no vehicles are active. Mock corridor polylines and the +active/idle marker distinction are deleted: every tracked vehicle is by +definition fresh, so there is a single marker style. Popups show label, +vehicle id, route id, driver name, speed (when present), and last-update +age. The fleet sidebar lists the same live vehicles; the mockup's +"route count" stat becomes the count of distinct non-empty `route_id` +values in the live data. With `?trip_id=N` the page instead makes a +single fetch of the trail endpoint and draws a polyline with start/end +markers plus the returned trip metadata. + +**Vehicles** — table: id, label, agency tag, active badge, live "last +seen" (from tracker when present), current driver (from active-trip join +when present). Row actions: edit, deactivate/reactivate (POST forms with +JS confirm), and **Download CSV** linking to +`GET /api/v1/admin/vehicles/{id}/locations?format=csv` (existing endpoint, +reachable via the session cookie; default range is the endpoint's default +last 24 h). The page shows active vehicles by default; `?include_inactive=1` +shows all. Filtering happens in the page handler over the existing +`ListVehicles` result (fleet sizes are small); the store method is not +changed. The create form's `id` field enforces the same rules as the API +(`^[a-zA-Z0-9._-]+$`, ≤ 50 chars) with the same error text. **Users** — table: name, email, role badge, active badge, assigned-vehicle count. Create form (name, email, password ≥ 8 chars, role select). Edit form adds optional password change and an assignments section: current vehicles with remove buttons and a select of active unassigned vehicles to -add. Deactivate with confirm. The mockup's fake "Last Seen" column is -dropped. - -**Trips** — table: trip id, vehicle label, driver name, route id, GTFS trip -id, start/end times (local server TZ), status badge, duration. Filter bar: -status select, vehicle select, applied via GET query params. Pagination -with next/prev links (50/page). Row action: "View trail" → `/admin/map?trip_id=N`. - -### 4.6 Store additions +add. Deactivate/reactivate with confirm. The mockup's fake "Last Seen" +column is dropped. The existing API hard-delete +(`DELETE /api/v1/admin/users/{id}`) is left unchanged; the UI only +soft-deactivates. + +**Trips** — table: trip id, vehicle label, driver name, route id, GTFS +trip id, start/end times (rendered in UTC with an explicit "UTC" label), +status badge, duration. Filter bar: status select, vehicle select, and a +free-text `q` input matching driver name, `route_id`, or `gtfs_trip_id` +(ILIKE substring), applied via GET query params — this satisfies the +README's "searchable list". Pagination with next/prev links (50/page), +using the existing `limit+1 → hasMore` idiom from +`handleGetLocationHistory`. Row action: "View trail" → +`/admin/map?trip_id={trips.id}`. + +### 4.8 Store additions + +One schema migration is required: **`000010_add_user_active`** — +`ALTER TABLE users ADD COLUMN active BOOLEAN NOT NULL DEFAULT true;` +(down: drop column). Note the migration sequence skips `000007`; the next +number is `000010`. New methods on `*Store` (each behind a narrow interface consumed by its -handler, matching existing convention): - -- `CountVehicles(ctx, activeOnly bool) (int, error)` -- `CountUsersByRole(ctx, role string, activeOnly bool) (int, error)` -- `CountActiveTrips(ctx) (int, error)` -- `ListTrips(ctx, TripFilter) ([]TripSummary, error)` — joins users - (driver name) and vehicles (label); filter: status, vehicleID, - limit/offset; ordered by `start_time DESC` -- `GetTripSummary(ctx, id int64) (*TripSummary, error)` -- `ListLocationsByTrip(ctx, tripID int64) ([]LocationPoint, error)` — - ordered by `recorded_at ASC`, capped at 10,000 points +handler, matching existing convention). Fixed-shape queries go through +sqlc (`db/query.sql`, regenerate with `make generate`); dynamically +filtered ones are hand-written pgx in the store file: + +- `CountActiveVehicles(ctx) (int, error)` — sqlc +- `CountActiveUsersByRole(ctx, role string) (int, error)` — sqlc +- `CountActiveTrips(ctx) (int, error)` — sqlc +- `SetVehicleActive(ctx, id string, active bool) error` — sqlc; powers + deactivate/reactivate. The edit form updates only label/agency tag via a + new `UpdateVehicleInfo(ctx, id, label, agencyTag string) error` (sqlc) + that never touches `active` — the existing upsert's `active = true` + behavior made editing a deactivated vehicle silently reactivate it. +- `SetUserActive(ctx, id int64, active bool) error` — sqlc. + `GetUserByEmail` gains an `active` column in its result; login handlers + check it. `UserResponse` gains an `Active` field. +- `ListTrips(ctx, TripFilter) ([]TripSummary, error)` — hand-written pgx + (dynamic filters: status, vehicleID, q, limit/offset), joins users + (driver name) and vehicles (label), ordered `start_time DESC`, called + with `limit+1` for `hasMore` +- `GetTripSummary(ctx, id int64) (*TripSummary, error)` — sqlc; caller is + the trail endpoint (its `trip` object) +- `ListTripLocations(ctx, tripID int64) ([]LocationPoint, error)` — + implements §4.5's windowed query, `received_at ASC`, cap 10,000 - `ListActiveTripsByVehicle(ctx) (map[string]ActiveTripInfo, error)` — one - query powering the live endpoint's driver/route join - -No schema migrations are required; all data exists in current tables. An -index on `location_points (trip_id, recorded_at)` is added in a new -migration to make trail queries cheap. + `DISTINCT ON (vehicle_id) … ORDER BY vehicle_id, start_time DESC` query + powering the live endpoint's and vehicle page's driver/route join. The + schema only guarantees one active trip per *user*, so the newest active + trip per vehicle is the defined tiebreak (no new unique index; changing + `StartTrip` semantics is out of scope). -### 4.7 Static assets: no CDNs +### 4.9 Static assets: no CDNs - Vendor Leaflet 1.9.4 (`leaflet.js`, `leaflet.css`, marker images) into `web/static/vendor/leaflet/`. -- Replace the `cdn.tailwindcss.com` runtime script with a Tailwind CSS file - compiled once via the standalone Tailwind CLI from the templates and - committed at `web/static/css/admin.css`. A `Makefile` target (`make css`) - documents regeneration. If the CLI proves unavailable during - implementation, fall back to a hand-written CSS file reproducing the - used utility classes — the visual design is preserved either way. +- Replace the `cdn.tailwindcss.com` runtime script with a committed, + pre-compiled stylesheet at `web/static/css/admin.css`, generated by the + **standalone Tailwind CLI (v4.x, hard prerequisite — already installed + in the dev environment)** scanning `web/templates/**`. Any inline + `tailwind.config` blocks in the mockup templates (custom colors, fonts, + shadows, arbitrary values) are ported to a CSS-first `@theme` block in + the Tailwind input file. `make css` regenerates; CI runs `make css` and + fails if the committed output is stale. There is no hand-written-CSS + fallback — the CLI is required tooling, same as `sqlc`. - Drop Google Fonts; use a system font stack (`system-ui, -apple-system, - Segoe UI, Roboto, ...`). The `display-font` class keeps a distinct weight - treatment instead of a webfont. + Segoe UI, Roboto, ...`). The `display-font` class keeps a distinct + weight treatment instead of a webfont. - CARTO basemap tiles remain a runtime network dependency of the map page only (unavoidable for map imagery; documented). -### 4.8 Flag and rollout - -`ADMIN_UI_ENABLED` now defaults to **true** (the UI is authenticated); -setting it to a false value disables registration of all `/admin` routes. -The startup warning about an unauthenticated demo UI is removed. The -README/development docs gain a short "Admin UI" section (URL, default flag, -how to create the first admin user via `seed_dev.sql` or the users API). - -### 4.9 Login rate limiting - -A small fixed-window per-IP limiter (10 attempts/minute) guards -`POST /admin/login` and `POST /api/v1/auth/login`, returning 429 (page: -"too many attempts, try again shortly"). Implementation mirrors the -existing `VehicleRateLimiter` style; client IP is taken from the direct -connection (`r.RemoteAddr`), not spoofable proxy headers, and documented -as such. +### 4.10 Proxy awareness + +A single `TRUST_PROXY_HEADERS` env var (default `false`) governs all proxy +header trust consistently: + +- **Client IP** (rate limiting): `X-Forwarded-For` rightmost hop when + trusted, else `r.RemoteAddr` host. +- **Secure cookie flag**: `X-Forwarded-Proto: https` when trusted, else + `r.TLS != nil`. + +The production deployment guide documents setting it to `true` behind the +reverse proxy. + +### 4.11 Login rate limiting + +A dedicated `LoginRateLimiter` guards `POST /admin/login` and +`POST /api/v1/auth/login`, with two dimensions: per-client-IP (10 +attempts/min, §4.10 IP extraction) and per-submitted-email (5 +attempts/min) so a shared-IP bucket can't lock out all admins and a +single-target attack is still bounded. Implementation mirrors +`VehicleRateLimiter`'s fixed-window style but **fails closed** at capacity +(the existing limiter's fail-open default is wrong for an auth endpoint). +Limited requests get 429 (page: "too many attempts, try again shortly"). + +### 4.12 Flag, bootstrap, and rollout + +- `ADMIN_UI_ENABLED` now defaults to **true** (the UI is authenticated); + a false value disables registration of all `/admin` routes. The startup + warning about an unauthenticated demo UI is removed. The change is + called out in the README (upgrading operators will newly serve + `/admin/login`; it is session-gated). +- **First-admin bootstrap** (both): `seed_dev.sql` gains an admin user for + dev, and the server honors one-shot `ADMIN_BOOTSTRAP_EMAIL` / + `ADMIN_BOOTSTRAP_PASSWORD` env vars at startup — creating that admin + only when the users table contains **zero admin users**, logging what it + did. Without either, a fresh production install cannot mint an admin + (the users API requires an admin token). +- Docs: README and `docs/development.md` gain a short "Admin UI" section + (URL, flag, bootstrap, `TRUST_PROXY_HEADERS`). ## 5. Component boundaries - `admin_session.go` — cookie issue/clear/parse, `requireAdminPage` - middleware, flash helpers. No knowledge of specific pages. + middleware, flash read/write helpers, login/logout handlers. No + knowledge of specific pages. - `admin_page_handlers.go` (replaces the mock handlers in - `admin_handlers.go`) — page rendering, form processing. Depends on store - interfaces + tracker + templates. + `admin_handlers.go`) — page rendering and form processing. Handlers are + built by a constructor that receives the parsed templates, store + interfaces, and tracker — `loadTemplates`' result is passed in + explicitly; the package-level `templates` global is removed. - `admin_live_handlers.go` — `/api/v1/admin/vehicles/live`, trips list, trip locations JSON handlers. -- `store_admin_stats.go`, additions to `store_trips.go` — new queries. +- `store_admin_stats.go`, additions to `store_trips.go`, + `store_vehicles.go`, `store_users.go` — new queries per §4.8. +- `ratelimit_login.go` — `LoginRateLimiter`. +- `auth.go` — cookie fallback in `requireAuth`; active-user check in + login. - `web/templates/**`, `web/static/**` — templates and vendored assets. -- `auth.go` — gains the cookie fallback in `requireAuth` only. Each unit is testable in isolation (httptest + fake stores for handlers, `newTestStore` + `DATABASE_URL` for store methods, following existing @@ -241,29 +383,39 @@ conventions). ## 6. Testing - **Store tests** (Postgres-backed, skip without `DATABASE_URL`): each new - method — counts, trip listing filters/pagination/order, trail ordering, - active-trip join. + method — counts, trip listing filters/search/pagination/order, trail + windowing (points inside/outside the trip window, other drivers' + points on the same vehicle excluded), active-trip-per-vehicle tiebreak, + user/vehicle activate/deactivate round-trips, migration 000010. - **Handler tests** (httptest, fake stores): login form success/failure/ - non-admin/ratelimit; cookie attributes; logout; redirect-to-login on - every protected page when unauthenticated/expired/tampered cookie; - Bearer-wins-over-cookie and invalid-Bearer-does-not-fall-back in - `requireAuth`; CSRF rejection of cross-origin form POST; each page - renders real data (golden substrings, not full-page snapshots); form - validation errors re-render with 422; PRG redirects; 404s. -- **Route wiring test** (`route_wiring_test.go` pattern): every new route - present with correct middleware; `/admin/*` pages redirect when - unauthenticated; JSON endpoints return 401 JSON. -- **End-to-end smoke** (manual, documented): docker-compose up → seed → - log in → create vehicle/user → assign → simulate locations → vehicle on - map → trip in history → trail renders → CSV downloads. + non-admin/deactivated-user/rate-limit (both dimensions, fail-closed); + cookie attributes incl. Secure under `TRUST_PROXY_HEADERS`; logout; + redirect-to-login on every protected page when unauthenticated/expired/ + tampered cookie; Bearer-wins and absent-vs-malformed-header fallback + rules in `requireAuth`; CSRF rejection of cross-origin form POST through + `newHandler`; each page renders real data (golden substrings, not + full-page snapshots); form validation errors re-render with 422; + create-vehicle collision 422; PRG redirects + flash codes; 404s; live + endpoint JSON shape incl. nullable fields and label fallback. +- **Route wiring test** (`route_wiring_test.go` pattern, extended + `noopStore`, targeting `newHandler`): every new route present with + correct middleware; `/admin/*` pages redirect when unauthenticated; JSON + endpoints return 401 JSON; admin UI disabled ⇒ `/admin/*` 404s. +- **End-to-end smoke** (manual, documented): docker-compose up → bootstrap + admin → log in → create vehicle/user → assign → simulate locations → + vehicle on map → trip in history → trail renders → CSV downloads. ## 7. Risks -- **Tailwind compile step**: one-time; fallback to handwritten CSS is - planned and acceptable. +- **Tailwind CLI availability**: pinned-version standalone CLI is a build + prerequisite; CI verifies the committed CSS so runtime never depends on + it. - **Cookie fallback on the API** widens the browser-reachable surface; mitigated by `CrossOriginProtection`, `SameSite=Lax`, HttpOnly, and admin-role checks already on every admin endpoint. -- **Tracker/DB label mismatch** (a tracked vehicle deleted from the DB): - live endpoint returns the vehicle with a `label` equal to its id rather - than erroring. +- **Trail accuracy** depends on the §4.5 window derivation; points sent by + a driver outside any trip window are simply not part of any trail + (acceptable — the same is true of the GTFS-RT feed's trip association). +- **Two drivers, one vehicle**: the live endpoint shows the newest active + trip's driver (defined tiebreak in §4.8); the underlying double-active + possibility is unchanged driver-API behavior. From 22ac26791860ad0bb8a564c8f4348423e5316ccf Mon Sep 17 00:00:00 2001 From: Aaron Brethorst Date: Mon, 24 Aug 2026 00:32:22 -0700 Subject: [PATCH 03/29] docs: add admin web UI implementation plan (18 tasks) --- .../plans/2026-08-24-admin-web-ui.md | 2092 +++++++++++++++++ 1 file changed, 2092 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-24-admin-web-ui.md diff --git a/docs/superpowers/plans/2026-08-24-admin-web-ui.md b/docs/superpowers/plans/2026-08-24-admin-web-ui.md new file mode 100644 index 0000000..a96405f --- /dev/null +++ b/docs/superpowers/plans/2026-08-24-admin-web-ui.md @@ -0,0 +1,2092 @@ +# Admin Web UI v1 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the unauthenticated, mock-data admin UI proof-of-concept with a session-authenticated admin interface backed by real store/tracker data, plus the new admin JSON endpoints it needs. + +**Architecture:** Server-rendered Go templates with an HttpOnly session cookie carrying the existing HS256 JWT. Pages query the store/tracker directly in their handlers. A `newHandler` constructor composes the API mux, admin UI routes, `http.CrossOriginProtection` (CSRF), and request logging for both `main()` and tests. Vanilla JS drives the Leaflet live map and trip trails against two new admin JSON endpoints. + +**Tech Stack:** Go 1.25 (stdlib `net/http`, `html/template`), pgx/sqlc/golang-migrate, Leaflet 1.9.4 (vendored), Tailwind CSS v4 standalone CLI (build-time only), testify. + +**Spec:** `docs/superpowers/specs/2026-08-23-admin-web-ui-design.md` — read it before starting any task. + +## Global Constraints + +- All server code lives in package `main` at the repo root, one concern per file. Follow existing file/test naming (`foo.go` / `foo_test.go`). +- Store-backed tests: `require`/`assert` from testify, `newTestStore(t)` helper, and they skip without `DATABASE_URL`. Start the DB with `docker compose up -d db`, then run tests with `DATABASE_URL='postgres://postgres:postgres@localhost:5432/vehicle_positions?sslmode=disable' go test ./...`. Handler-only tests must pass with plain `go test ./...`. +- sqlc: queries in `db/query.sql`, regenerate with `make generate` (sqlc is at `/opt/homebrew/bin/sqlc`). Never hand-edit `db/*.sql.go`. +- Fixed-shape queries go through sqlc; dynamically-filtered queries are hand-written pgx in the store file (spec §4.8). +- Session cookie name: `vp_session`. Flash cookie name: `vp_flash`. Both HttpOnly, SameSite=Lax, Path=/. +- Every new `/api/v1/admin/*` route: `authMiddleware(adminMiddleware(...))`. Every `/admin/*` page except login: `requireAdminPage`. +- JSON errors use the existing `writeJSON(w, status, map[string]string{"error": ...})` shape. +- Migration numbering: the next migration is `000010` (the sequence intentionally skips 000007). +- Do not change driver-API behavior (`/api/v1/locations`, `/api/v1/trips/*`, `/api/v1/vehicles`, `/api/v1/auth/login` request/response shapes). The only login change is rejecting deactivated users (indistinguishable from wrong password) and rate limiting. +- Commit after each task with a conventional-commit message. Run `go vet ./...` before each commit. + +--- + +### Task 1: users.active migration + store plumbing + +**Files:** +- Create: `migrations/000010_add_user_active.up.sql`, `migrations/000010_add_user_active.down.sql` +- Modify: `db/query.sql` (add `active` to user queries; add `SetUserActive`, `CountUsersByRole`, `CountActiveUsersByRole`), `user.go`, `user_store.go`, `store_users.go` +- Test: `store_users_test.go`, `user_store_test.go` (whichever holds store user tests — check both; add where `newTestStore` user tests already live) + +**Interfaces:** +- Produces: `User.Active bool`, `UserResponse.Active bool` (`json:"active"`), `(*Store) SetUserActive(ctx context.Context, id int64, active bool) error` (returns `ErrUserNotFound` when no row), `(*Store) CountUsersByRole(ctx context.Context, role string) (int, error)`, `(*Store) CountActiveUsersByRole(ctx context.Context, role string) (int, error)`. + +- [ ] **Step 1: Write the migration** + +`migrations/000010_add_user_active.up.sql`: +```sql +ALTER TABLE users ADD COLUMN active BOOLEAN NOT NULL DEFAULT true; +``` +`migrations/000010_add_user_active.down.sql`: +```sql +ALTER TABLE users DROP COLUMN active; +``` + +- [ ] **Step 2: Update sqlc queries** + +In `db/query.sql`, add `active` to the SELECT/RETURNING column lists of `ListUsers`, `GetUserByID`, `CreateUser`, `UpdateUser`, and append: + +```sql +-- name: SetUserActive :execrows +UPDATE users SET active = $2 WHERE id = $1; + +-- name: CountUsersByRole :one +SELECT COUNT(*) FROM users WHERE role = $1; + +-- name: CountActiveUsersByRole :one +SELECT COUNT(*) FROM users WHERE role = $1 AND active = true; +``` + +Run `make generate`. + +- [ ] **Step 3: Write failing store tests** + +In the file holding existing user store tests, add (adapting helper names to what exists there): + +```go +func TestSetUserActive(t *testing.T) { + store := newTestStore(t) + u, err := store.CreateUser(context.Background(), "Deact Me", uniqueEmail(t), "password123", "driver") + require.NoError(t, err) + require.True(t, u.Active) + + require.NoError(t, store.SetUserActive(context.Background(), u.ID, false)) + got, err := store.GetUser(context.Background(), u.ID) + require.NoError(t, err) + assert.False(t, got.Active) + + require.NoError(t, store.SetUserActive(context.Background(), u.ID, true)) + got, err = store.GetUser(context.Background(), u.ID) + require.NoError(t, err) + assert.True(t, got.Active) + + assert.ErrorIs(t, store.SetUserActive(context.Background(), 999999999, false), ErrUserNotFound) +} + +func TestGetUserByEmailIncludesActive(t *testing.T) { + store := newTestStore(t) + email := uniqueEmail(t) + u, err := store.CreateUser(context.Background(), "Flag Check", email, "password123", "driver") + require.NoError(t, err) + require.NoError(t, store.SetUserActive(context.Background(), u.ID, false)) + + fetched, err := store.GetUserByEmail(context.Background(), email) + require.NoError(t, err) + assert.False(t, fetched.Active) +} + +func TestCountUsersByRole(t *testing.T) { + store := newTestStore(t) + u, err := store.CreateUser(context.Background(), "Count Me", uniqueEmail(t), "password123", "driver") + require.NoError(t, err) + + total, err := store.CountUsersByRole(context.Background(), "driver") + require.NoError(t, err) + active, err := store.CountActiveUsersByRole(context.Background(), "driver") + require.NoError(t, err) + assert.GreaterOrEqual(t, total, 1) + assert.GreaterOrEqual(t, active, 1) + + require.NoError(t, store.SetUserActive(context.Background(), u.ID, false)) + active2, err := store.CountActiveUsersByRole(context.Background(), "driver") + require.NoError(t, err) + assert.Equal(t, active-1, active2) +} +``` + +If no `uniqueEmail(t)` helper exists, add one: `func uniqueEmail(t *testing.T) string { return fmt.Sprintf("u-%d-%s@test.com", time.Now().UnixNano(), t.Name()) }` (lowercase/sanitize `t.Name()` if it contains `/`). Reuse an existing pattern if the test files already generate unique emails another way. + +- [ ] **Step 4: Run tests to verify they fail** — `DATABASE_URL=... go test ./... -run 'TestSetUserActive|TestGetUserByEmailIncludesActive|TestCountUsersByRole' -v` → compile errors / FAIL. + +- [ ] **Step 5: Implement** + +`user.go`: add `Active bool` to `User`. `user_store.go`: add `Active bool \`json:"active"\`` to `UserResponse`; populate `Active: row.Active` in `ListUsers`, `GetUser`, `CreateUser`, `UpdateUser`; add: + +```go +// SetUserActive flips a user's active flag. Deactivated users cannot log in. +func (s *Store) SetUserActive(ctx context.Context, id int64, active bool) error { + rows, err := s.queries.SetUserActive(ctx, db.SetUserActiveParams{ID: id, Active: active}) + if err != nil { + return fmt.Errorf("set user active: %w", err) + } + if rows == 0 { + return ErrUserNotFound + } + return nil +} + +func (s *Store) CountUsersByRole(ctx context.Context, role string) (int, error) { + n, err := s.queries.CountUsersByRole(ctx, role) + if err != nil { + return 0, fmt.Errorf("count users by role: %w", err) + } + return int(n), nil +} + +func (s *Store) CountActiveUsersByRole(ctx context.Context, role string) (int, error) { + n, err := s.queries.CountActiveUsersByRole(ctx, role) + if err != nil { + return 0, fmt.Errorf("count active users by role: %w", err) + } + return int(n), nil +} +``` + +`store_users.go` (`GetUserByEmail`): add `active` to the SELECT list and `&u.Active` to the Scan, keeping column order aligned. + +- [ ] **Step 6: Run the new tests → PASS; run full suite** `DATABASE_URL=... go test ./...` → PASS. + +- [ ] **Step 7: Commit** — `git add -A && git commit -m "feat: add users.active flag with soft-deactivate store support"` + +--- + +### Task 2: reject deactivated users at login + +**Files:** +- Modify: `auth.go` (handleLogin) +- Test: `auth_test.go` + +**Interfaces:** +- Consumes: `User.Active` from Task 1. +- Produces: `handleLogin` returns 401 `{"error": "invalid email or password"}` for inactive users. + +- [ ] **Step 1: Write failing test** in `auth_test.go`, following its existing fake-`UserFetcher` pattern (there is one for the login tests — reuse it): + +```go +func TestLoginRejectsDeactivatedUser(t *testing.T) { + hash, err := bcrypt.GenerateFromPassword([]byte("password123"), bcryptCost) + require.NoError(t, err) + fetcher := &fakeUserFetcher{user: &User{ID: 7, Email: "gone@test.com", PasswordHash: string(hash), Role: "driver", Active: false}} + + body := strings.NewReader(`{"email":"gone@test.com","password":"password123"}`) + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", body) + w := httptest.NewRecorder() + handleLogin(fetcher, testSecret).ServeHTTP(w, req) + + assert.Equal(t, http.StatusUnauthorized, w.Code) + assert.Contains(t, w.Body.String(), "invalid email or password") +} +``` + +Adapt `fakeUserFetcher` to whatever stub `auth_test.go` already defines (add an `Active: true` to existing fixtures so current tests keep passing). + +- [ ] **Step 2: Run → FAIL** (currently 200). + +- [ ] **Step 3: Implement** — in `handleLogin`, after the bcrypt compare succeeds, add: + +```go + if !user.Active { + writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid email or password"}) + return + } +``` + +(After bcrypt, not before, to keep timing identical to the wrong-password path.) + +- [ ] **Step 4: Run auth tests → PASS.** `go test ./... -run TestLogin -v` +- [ ] **Step 5: Commit** — `git commit -am "fix: reject deactivated users at login"` + +--- + +### Task 3: vehicle + count store additions + +**Files:** +- Modify: `db/query.sql`, `store_vehicles.go`, `store_trips.go` (or a new `store_admin_stats.go` for counts) +- Test: `store_vehicles_test.go`, `store_trips_test.go` + +**Interfaces:** +- Produces: `(*Store) UpdateVehicleInfo(ctx, id, label, agencyTag string) error` (ErrNoRows-wrapped when missing; never touches `active`), `(*Store) SetVehicleActive(ctx, id string, active bool) error`, `(*Store) CountActiveVehicles(ctx) (int, error)`, `(*Store) CountActiveTrips(ctx) (int, error)`. + +- [ ] **Step 1: sqlc queries** — append to `db/query.sql`: + +```sql +-- name: UpdateVehicleInfo :execrows +UPDATE vehicles SET label = $2, agency_tag = $3, updated_at = NOW() WHERE id = $1; + +-- name: SetVehicleActive :execrows +UPDATE vehicles SET active = $2, updated_at = NOW() WHERE id = $1; + +-- name: CountActiveVehicles :one +SELECT COUNT(*) FROM vehicles WHERE active = true; + +-- name: CountActiveTrips :one +SELECT COUNT(*) FROM trips WHERE status = 'active'; +``` + +Run `make generate`. + +- [ ] **Step 2: Failing store tests** (in `store_vehicles_test.go`, reusing its existing unique-vehicle-id helpers/patterns): + +```go +func TestUpdateVehicleInfoDoesNotReactivate(t *testing.T) { + store := newTestStore(t) + id := uniqueVehicleID(t) // reuse/create helper matching existing tests + _, err := store.UpsertVehicle(context.Background(), id, "Old", "tag") + require.NoError(t, err) + require.NoError(t, store.DeactivateVehicle(context.Background(), id)) + + require.NoError(t, store.UpdateVehicleInfo(context.Background(), id, "New Label", "newtag")) + v, err := store.GetVehicle(context.Background(), id) + require.NoError(t, err) + assert.Equal(t, "New Label", v.Label) + assert.False(t, v.Active, "editing must not reactivate a deactivated vehicle") + + err = store.UpdateVehicleInfo(context.Background(), "no-such-vehicle-xyz", "x", "y") + assert.ErrorIs(t, err, pgx.ErrNoRows) +} + +func TestSetVehicleActive(t *testing.T) { + store := newTestStore(t) + id := uniqueVehicleID(t) + _, err := store.UpsertVehicle(context.Background(), id, "Bus", "") + require.NoError(t, err) + + require.NoError(t, store.SetVehicleActive(context.Background(), id, false)) + v, _ := store.GetVehicle(context.Background(), id) + assert.False(t, v.Active) + require.NoError(t, store.SetVehicleActive(context.Background(), id, true)) + v, _ = store.GetVehicle(context.Background(), id) + assert.True(t, v.Active) + assert.ErrorIs(t, store.SetVehicleActive(context.Background(), "no-such-vehicle-xyz", true), pgx.ErrNoRows) +} + +func TestCountActiveVehiclesAndTrips(t *testing.T) { + store := newTestStore(t) + before, err := store.CountActiveVehicles(context.Background()) + require.NoError(t, err) + id := uniqueVehicleID(t) + _, err = store.UpsertVehicle(context.Background(), id, "Bus", "") + require.NoError(t, err) + after, err := store.CountActiveVehicles(context.Background()) + require.NoError(t, err) + assert.Equal(t, before+1, after) + + _, err = store.CountActiveTrips(context.Background()) + require.NoError(t, err) // exact value covered by trip tests; here just exercises the query +} +``` + +- [ ] **Step 3: Run → FAIL (compile).** +- [ ] **Step 4: Implement** in `store_vehicles.go` (counts may live in a new `store_admin_stats.go` together with Task 1's counts if you prefer one stats file — pick one and be consistent): + +```go +// UpdateVehicleInfo updates label/agency tag WITHOUT touching the active flag, +// unlike UpsertVehicle which force-reactivates. +func (s *Store) UpdateVehicleInfo(ctx context.Context, id, label, agencyTag string) error { + rows, err := s.queries.UpdateVehicleInfo(ctx, db.UpdateVehicleInfoParams{ID: id, Label: label, AgencyTag: agencyTag}) + if err != nil { + return fmt.Errorf("update vehicle info: %w", err) + } + if rows == 0 { + return fmt.Errorf("update vehicle info: %w", pgx.ErrNoRows) + } + return nil +} + +// SetVehicleActive flips a vehicle's active flag (deactivate/reactivate). +func (s *Store) SetVehicleActive(ctx context.Context, id string, active bool) error { + rows, err := s.queries.SetVehicleActive(ctx, db.SetVehicleActiveParams{ID: id, Active: active}) + if err != nil { + return fmt.Errorf("set vehicle active: %w", err) + } + if rows == 0 { + return fmt.Errorf("set vehicle active: %w", pgx.ErrNoRows) + } + return nil +} + +func (s *Store) CountActiveVehicles(ctx context.Context) (int, error) { + n, err := s.queries.CountActiveVehicles(ctx) + if err != nil { + return 0, fmt.Errorf("count active vehicles: %w", err) + } + return int(n), nil +} + +func (s *Store) CountActiveTrips(ctx context.Context) (int, error) { + n, err := s.queries.CountActiveTrips(ctx) + if err != nil { + return 0, fmt.Errorf("count active trips: %w", err) + } + return int(n), nil +} +``` + +- [ ] **Step 5: Run → PASS, full suite PASS.** +- [ ] **Step 6: Commit** — `git commit -am "feat: add vehicle activate/edit-without-reactivate and admin count queries"` + +--- + +### Task 4: trips store — summaries, trails, active-trip join + +**Files:** +- Modify: `db/query.sql`, `store_trips.go` +- Test: `store_trips_test.go` + +**Interfaces:** +- Produces: + +```go +type TripSummary struct { + ID int64 `json:"id"` + VehicleID string `json:"vehicle_id"` + VehicleLabel string `json:"vehicle_label"` + UserID int64 `json:"user_id"` + DriverName string `json:"driver_name"` + RouteID string `json:"route_id"` + GtfsTripID string `json:"gtfs_trip_id"` + StartTime time.Time `json:"start_time"` + EndTime *time.Time `json:"end_time,omitempty"` + Status string `json:"status"` +} + +type TripFilter struct { + Status string // "", "active", "completed" + VehicleID string // "" = all + Q string // ILIKE substring on driver name, route_id, gtfs_trip_id + Limit int // callers pass limit+1 to detect hasMore + Offset int +} + +type ActiveTripInfo struct { + TripID int64 + RouteID string + GtfsTripID string + UserID int64 + DriverName string +} + +func (s *Store) ListTrips(ctx context.Context, f TripFilter) ([]TripSummary, error) +func (s *Store) GetTripSummary(ctx context.Context, id int64) (*TripSummary, error) // ErrTripNotFound when missing +func (s *Store) ListTripLocations(ctx context.Context, tripID int64) ([]LocationPoint, error) +func (s *Store) ListActiveTripsByVehicle(ctx context.Context) (map[string]ActiveTripInfo, error) +var ErrTripNotFound = errors.New("trip not found") +``` + +- [ ] **Step 1: sqlc queries** — append to `db/query.sql`: + +```sql +-- name: GetTripSummary :one +SELECT t.id, t.vehicle_id, v.label AS vehicle_label, t.user_id, u.name AS driver_name, + t.route_id, t.gtfs_trip_id, t.start_time, t.end_time, t.status +FROM trips t +JOIN users u ON u.id = t.user_id +JOIN vehicles v ON v.id = t.vehicle_id +WHERE t.id = $1; + +-- name: ListTripLocations :many +-- Trail derivation per spec §4.5: location_points.trip_id is a client string, +-- not trips.id, so trail points are matched by vehicle + driver + time window. +SELECT lp.latitude, lp.longitude, lp.bearing, lp.speed, lp.accuracy, + lp.timestamp, lp.trip_id, lp.received_at +FROM location_points lp +JOIN trips t ON t.id = $1 +WHERE lp.vehicle_id = t.vehicle_id + AND lp.driver_id = t.user_id::text + AND lp.received_at >= t.start_time + AND lp.received_at <= COALESCE(t.end_time, NOW()) +ORDER BY lp.received_at ASC +LIMIT 10000; + +-- name: ListActiveTripsByVehicle :many +-- Schema guarantees one active trip per USER, not per vehicle; newest active +-- trip per vehicle is the defined tiebreak (spec §4.8). +SELECT DISTINCT ON (t.vehicle_id) + t.vehicle_id, t.id, t.route_id, t.gtfs_trip_id, t.user_id, u.name AS driver_name +FROM trips t +JOIN users u ON u.id = t.user_id +WHERE t.status = 'active' +ORDER BY t.vehicle_id, t.start_time DESC; +``` + +Run `make generate`. + +- [ ] **Step 2: Failing store tests** in `store_trips_test.go`. Use its existing helpers for creating users/vehicles/assignments (it has them for StartTrip tests — reuse). Cover: + +```go +func TestListTripsFiltersAndOrder(t *testing.T) { + store := newTestStore(t) + // create driver+vehicle+assignment, StartTrip, EndTrip → one completed trip + // create second driver+vehicle+assignment, StartTrip → one active trip + // (follow the existing setup pattern in this file) + ... + all, err := store.ListTrips(context.Background(), TripFilter{Limit: 200}) + require.NoError(t, err) + require.GreaterOrEqual(t, len(all), 2) + // newest first + for i := 1; i < len(all); i++ { + assert.True(t, !all[i-1].StartTime.Before(all[i].StartTime)) + } + + active, err := store.ListTrips(context.Background(), TripFilter{Status: "active", Limit: 200}) + require.NoError(t, err) + for _, tr := range active { + assert.Equal(t, "active", tr.Status) + } + + byVehicle, err := store.ListTrips(context.Background(), TripFilter{VehicleID: vehicleID1, Limit: 200}) + require.NoError(t, err) + for _, tr := range byVehicle { + assert.Equal(t, vehicleID1, tr.VehicleID) + } + + byQ, err := store.ListTrips(context.Background(), TripFilter{Q: driver1NameFragment, Limit: 200}) + require.NoError(t, err) + assert.NotEmpty(t, byQ) + + page1, err := store.ListTrips(context.Background(), TripFilter{Limit: 1}) + require.NoError(t, err) + assert.Len(t, page1, 1) + page2, err := store.ListTrips(context.Background(), TripFilter{Limit: 1, Offset: 1}) + require.NoError(t, err) + require.Len(t, page2, 1) + assert.NotEqual(t, page1[0].ID, page2[0].ID) +} + +func TestGetTripSummary(t *testing.T) { + // start a trip; GetTripSummary returns matching labels/names; + // unknown id → ErrTripNotFound +} + +func TestListTripLocationsWindow(t *testing.T) { + store := newTestStore(t) + // driver A + vehicle V assigned; StartTrip → trip + // SaveLocation with DriverID = strconv.FormatInt(driverA.ID, 10), VehicleID = V → IN window + // SaveLocation with DriverID = other user's id, VehicleID = V → excluded + // EndTrip; SaveLocation for driver A after end → excluded + pts, err := store.ListTripLocations(context.Background(), tripID) + require.NoError(t, err) + require.Len(t, pts, 1) +} + +func TestListActiveTripsByVehicleTiebreak(t *testing.T) { + store := newTestStore(t) + // two drivers assigned to the SAME vehicle; both StartTrip (allowed by schema) + m, err := store.ListActiveTripsByVehicle(context.Background()) + require.NoError(t, err) + info, ok := m[sharedVehicleID] + require.True(t, ok) + assert.Equal(t, secondTrip.ID, info.TripID, "newest active trip wins") +} +``` + +Write these fully (no `...` in the committed test) using the concrete setup helpers present in `store_trips_test.go`. + +- [ ] **Step 3: Run → FAIL.** +- [ ] **Step 4: Implement** in `store_trips.go`. `ListTrips` is hand-written pgx (dynamic filter): + +```go +var ErrTripNotFound = errors.New("trip not found") + +// ListTrips returns trip summaries newest-first with optional filters. +// Dynamic WHERE clauses make this a hand-written query rather than sqlc. +func (s *Store) ListTrips(ctx context.Context, f TripFilter) ([]TripSummary, error) { + query := ` + SELECT t.id, t.vehicle_id, v.label, t.user_id, u.name, + t.route_id, t.gtfs_trip_id, t.start_time, t.end_time, t.status + FROM trips t + JOIN users u ON u.id = t.user_id + JOIN vehicles v ON v.id = t.vehicle_id` + var conds []string + var args []any + arg := func(v any) string { args = append(args, v); return fmt.Sprintf("$%d", len(args)) } + if f.Status != "" { + conds = append(conds, "t.status = "+arg(f.Status)) + } + if f.VehicleID != "" { + conds = append(conds, "t.vehicle_id = "+arg(f.VehicleID)) + } + if f.Q != "" { + p := arg("%" + f.Q + "%") + conds = append(conds, fmt.Sprintf("(u.name ILIKE %s OR t.route_id ILIKE %s OR t.gtfs_trip_id ILIKE %s)", p, p, p)) + } + if len(conds) > 0 { + query += " WHERE " + strings.Join(conds, " AND ") + } + query += " ORDER BY t.start_time DESC LIMIT " + arg(f.Limit) + " OFFSET " + arg(f.Offset) + + rows, err := s.pool.Query(ctx, query, args...) + if err != nil { + return nil, fmt.Errorf("list trips: %w", err) + } + defer rows.Close() + + var trips []TripSummary + for rows.Next() { + var tr TripSummary + var endTime pgtype.Timestamptz + if err := rows.Scan(&tr.ID, &tr.VehicleID, &tr.VehicleLabel, &tr.UserID, &tr.DriverName, + &tr.RouteID, &tr.GtfsTripID, &tr.StartTime, &endTime, &tr.Status); err != nil { + return nil, fmt.Errorf("scan trip: %w", err) + } + if endTime.Valid { + t := endTime.Time + tr.EndTime = &t + } + trips = append(trips, tr) + } + return trips, rows.Err() +} +``` + +`GetTripSummary`, `ListTripLocations`, `ListActiveTripsByVehicle` wrap the sqlc-generated calls, mapping row types to `TripSummary`/`LocationPoint`/`map[string]ActiveTripInfo` the same way existing methods do (`pgtype.Float8` → `*float64` etc.; see `GetLocationHistory` in `location_history_store.go` for the pattern). `GetTripSummary` maps `pgx.ErrNoRows` to `ErrTripNotFound`. + +- [ ] **Step 5: Run → PASS, full suite PASS.** +- [ ] **Step 6: Commit** — `git commit -am "feat: add trip summaries, trail query, and active-trip-by-vehicle store methods"` + +--- + +### Task 5: proxy helpers + login rate limiter + +**Files:** +- Create: `proxy.go`, `ratelimit_login.go` +- Test: `proxy_test.go`, `ratelimit_login_test.go` + +**Interfaces:** +- Produces: + +```go +func clientIP(r *http.Request, trustProxy bool) string // proxy.go +func requestIsSecure(r *http.Request, trustProxy bool) bool // proxy.go +type LoginRateLimiter struct{ ... } +func NewLoginRateLimiter() *LoginRateLimiter +func (l *LoginRateLimiter) Allow(ip, email string) bool // false when either dimension exceeded OR at capacity (fail closed) +func (l *LoginRateLimiter) Stop() +``` + +- [ ] **Step 1: Failing tests** + +`proxy_test.go`: +```go +func TestClientIP(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.RemoteAddr = "203.0.113.9:4567" + req.Header.Set("X-Forwarded-For", "198.51.100.1, 192.0.2.7") + + assert.Equal(t, "203.0.113.9", clientIP(req, false), "untrusted: RemoteAddr host wins") + assert.Equal(t, "192.0.2.7", clientIP(req, true), "trusted: rightmost XFF hop") + + req2 := httptest.NewRequest(http.MethodGet, "/", nil) + req2.RemoteAddr = "203.0.113.9:4567" + assert.Equal(t, "203.0.113.9", clientIP(req2, true), "trusted but no header: RemoteAddr") +} + +func TestRequestIsSecure(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + assert.False(t, requestIsSecure(req, false)) + req.Header.Set("X-Forwarded-Proto", "https") + assert.False(t, requestIsSecure(req, false), "untrusted header ignored") + assert.True(t, requestIsSecure(req, true)) + reqTLS := httptest.NewRequest(http.MethodGet, "https://example.com/", nil) + assert.True(t, requestIsSecure(reqTLS, false), "real TLS always secure") +} +``` + +`ratelimit_login_test.go`: +```go +func TestLoginRateLimiterPerIP(t *testing.T) { + l := NewLoginRateLimiter() + defer l.Stop() + for i := 0; i < 10; i++ { + assert.True(t, l.Allow("1.2.3.4", fmt.Sprintf("u%d@test.com", i)), "attempt %d", i) + } + assert.False(t, l.Allow("1.2.3.4", "another@test.com"), "11th attempt from same IP blocked") + assert.True(t, l.Allow("5.6.7.8", "fresh@test.com"), "other IP unaffected") +} + +func TestLoginRateLimiterPerEmail(t *testing.T) { + l := NewLoginRateLimiter() + defer l.Stop() + for i := 0; i < 5; i++ { + assert.True(t, l.Allow(fmt.Sprintf("10.0.0.%d", i), "target@test.com")) + } + assert.False(t, l.Allow("10.0.0.99", "target@test.com"), "6th attempt on same email blocked across IPs") +} +``` + +- [ ] **Step 2: Run → FAIL (compile).** +- [ ] **Step 3: Implement** + +`proxy.go`: +```go +package main + +import ( + "net" + "net/http" + "strings" +) + +// clientIP extracts the caller's IP. With trustProxy (TRUST_PROXY_HEADERS=true) +// the rightmost X-Forwarded-For hop is used — the value appended by our own +// reverse proxy. Without it, only the direct connection is trusted (spec §4.10). +func clientIP(r *http.Request, trustProxy bool) string { + if trustProxy { + if xff := r.Header.Get("X-Forwarded-For"); xff != "" { + parts := strings.Split(xff, ",") + if ip := strings.TrimSpace(parts[len(parts)-1]); ip != "" { + return ip + } + } + } + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + return r.RemoteAddr + } + return host +} + +// requestIsSecure reports whether the request arrived over HTTPS, honoring +// X-Forwarded-Proto only when proxy headers are trusted. +func requestIsSecure(r *http.Request, trustProxy bool) bool { + if r.TLS != nil { + return true + } + return trustProxy && r.Header.Get("X-Forwarded-Proto") == "https" +} +``` + +`ratelimit_login.go` — fixed-window, dual-dimension, fail-closed: +```go +package main + +import ( + "log/slog" + "sync" + "time" +) + +const ( + loginIPLimit = 10 + loginEmailLimit = 5 + loginWindow = time.Minute + maxTrackedLogins = 10_000 +) + +type loginWindowEntry struct { + count int + windowStart time.Time +} + +// LoginRateLimiter guards login endpoints with per-IP and per-email fixed +// windows. Unlike VehicleRateLimiter it FAILS CLOSED at capacity — an auth +// endpoint must not become unlimited under memory pressure (spec §4.11). +type LoginRateLimiter struct { + mu sync.Mutex + byIP map[string]*loginWindowEntry + byEmail map[string]*loginWindowEntry + stop chan struct{} + once sync.Once +} + +func NewLoginRateLimiter() *LoginRateLimiter { + l := &LoginRateLimiter{ + byIP: make(map[string]*loginWindowEntry), + byEmail: make(map[string]*loginWindowEntry), + stop: make(chan struct{}), + } + go l.cleanup() + return l +} + +func (l *LoginRateLimiter) Stop() { l.once.Do(func() { close(l.stop) }) } + +func (l *LoginRateLimiter) Allow(ip, email string) bool { + l.mu.Lock() + defer l.mu.Unlock() + now := time.Now() + okIP := allowInWindow(l.byIP, ip, loginIPLimit, now) + okEmail := allowInWindow(l.byEmail, email, loginEmailLimit, now) + return okIP && okEmail +} + +func allowInWindow(m map[string]*loginWindowEntry, key string, limit int, now time.Time) bool { + e, ok := m[key] + if !ok { + if len(m) >= maxTrackedLogins { + slog.Warn("login rate limiter at capacity, failing closed", "capacity", maxTrackedLogins) + return false + } + m[key] = &loginWindowEntry{count: 1, windowStart: now} + return true + } + if now.Sub(e.windowStart) >= loginWindow { + e.count = 1 + e.windowStart = now + return true + } + e.count++ + return e.count <= limit +} + +func (l *LoginRateLimiter) cleanup() { + ticker := time.NewTicker(time.Minute) + defer ticker.Stop() + for { + select { + case <-ticker.C: + cutoff := time.Now().Add(-2 * loginWindow) + l.mu.Lock() + for k, e := range l.byIP { + if e.windowStart.Before(cutoff) { + delete(l.byIP, k) + } + } + for k, e := range l.byEmail { + if e.windowStart.Before(cutoff) { + delete(l.byEmail, k) + } + } + l.mu.Unlock() + case <-l.stop: + return + } + } +} +``` + +Note: counting both dimensions on every call means a blocked attempt still consumes window budget; that is intended (attempts, not successes, are limited). + +- [ ] **Step 4: Run → PASS.** +- [ ] **Step 5: Commit** — `git commit -am "feat: add proxy-aware client IP/secure helpers and fail-closed login rate limiter"` + +--- + +### Task 6: requireAuth cookie fallback + +**Files:** +- Modify: `auth.go` +- Test: `auth_test.go` + +**Interfaces:** +- Consumes: nothing new. +- Produces: `requireAuth` accepts the JWT from cookie `vp_session` **only when the `Authorization` header is entirely absent**. Constant `sessionCookieName = "vp_session"` defined in `auth.go` (Task 7's session helpers reuse it). + +- [ ] **Step 1: Failing tests** in `auth_test.go` (reuse `testSecret` and a protected probe handler like existing requireAuth tests do): + +```go +func TestRequireAuthCookieFallback(t *testing.T) { + token, err := generateJWT(&User{ID: 3, Email: "admin@test.com", Role: "admin", Active: true}, testSecret) + require.NoError(t, err) + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }) + h := requireAuth(testSecret)(next) + + t.Run("cookie only → 200", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: token}) + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + assert.Equal(t, http.StatusOK, w.Code) + }) + t.Run("invalid bearer + valid cookie → 401, no fallback", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Set("Authorization", "Bearer garbage") + req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: token}) + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + assert.Equal(t, http.StatusUnauthorized, w.Code) + }) + t.Run("malformed header + valid cookie → 401, no fallback", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Set("Authorization", "Basic abc") + req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: token}) + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + assert.Equal(t, http.StatusUnauthorized, w.Code) + }) + t.Run("bad cookie only → 401", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: "garbage"}) + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + assert.Equal(t, http.StatusUnauthorized, w.Code) + }) +} +``` + +- [ ] **Step 2: Run → FAIL.** +- [ ] **Step 3: Implement** — in `auth.go` add `const sessionCookieName = "vp_session"`. In `requireAuth`, replace the header-only extraction: + +```go + authHeader := r.Header.Get("Authorization") + var tokenString string + switch { + case authHeader == "": + // Cookie fallback for the admin UI's browser session + // (spec §4.2). Applies ONLY when the header is entirely + // absent — a present-but-bad header never falls back. + c, err := r.Cookie(sessionCookieName) + if err != nil || c.Value == "" { + writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "missing or invalid authorization header"}) + return + } + tokenString = c.Value + case strings.HasPrefix(authHeader, "Bearer "): + tokenString = strings.TrimPrefix(authHeader, "Bearer ") + default: + writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "missing or invalid authorization header"}) + return + } +``` + +The rest of the parse/validation is unchanged. + +- [ ] **Step 4: Run auth tests + full suite → PASS.** +- [ ] **Step 5: Commit** — `git commit -am "feat: accept session cookie in requireAuth when Authorization header is absent"` + +--- + +### Task 7: session layer — cookies, flash, requireAdminPage + +**Files:** +- Create: `admin_session.go` +- Test: `admin_session_test.go` + +**Interfaces:** +- Consumes: `sessionCookieName`, `generateJWT`, `clientIP`/`requestIsSecure` (Task 5). +- Produces: + +```go +const flashCookieName = "vp_flash" +func setSessionCookie(w http.ResponseWriter, r *http.Request, token string, trustProxy bool) // 24h Max-Age, HttpOnly, Lax, Secure per requestIsSecure +func clearSessionCookie(w http.ResponseWriter) +func adminClaimsFromCookie(r *http.Request, secret []byte) (jwt.MapClaims, bool) // valid cookie JWT with role==admin +func requireAdminPage(secret []byte) func(http.Handler) http.Handler // 303 → /admin/login on failure +func setFlash(w http.ResponseWriter, code string) // HttpOnly, 60s Max-Age +func takeFlash(w http.ResponseWriter, r *http.Request) string // returns mapped message ("" if none/unknown) and clears cookie +var flashMessages = map[string]string{ ... } +``` + +- [ ] **Step 1: Failing tests** — `admin_session_test.go`: + +```go +func TestSetSessionCookieAttributes(t *testing.T) { + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/admin/login", nil) + setSessionCookie(w, req, "tok123", false) + res := w.Result() + require.Len(t, res.Cookies(), 1) + c := res.Cookies()[0] + assert.Equal(t, sessionCookieName, c.Name) + assert.Equal(t, "tok123", c.Value) + assert.True(t, c.HttpOnly) + assert.Equal(t, http.SameSiteLaxMode, c.SameSite) + assert.Equal(t, "/", c.Path) + assert.Equal(t, 24*60*60, c.MaxAge) + assert.False(t, c.Secure, "plain HTTP without trusted proxy") + + w2 := httptest.NewRecorder() + req2 := httptest.NewRequest(http.MethodPost, "/admin/login", nil) + req2.Header.Set("X-Forwarded-Proto", "https") + setSessionCookie(w2, req2, "tok123", true) + assert.True(t, w2.Result().Cookies()[0].Secure, "trusted proxy + https → Secure") +} + +func TestRequireAdminPage(t *testing.T) { + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }) + h := requireAdminPage(testSecret)(next) + + cases := []struct { + name string + cookie *http.Cookie + want int + }{ + {"no cookie", nil, http.StatusSeeOther}, + {"garbage cookie", &http.Cookie{Name: sessionCookieName, Value: "garbage"}, http.StatusSeeOther}, + {"driver role", cookieFor(t, "driver"), http.StatusSeeOther}, + {"admin role", cookieFor(t, "admin"), http.StatusOK}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/admin/dashboard", nil) + if tc.cookie != nil { + req.AddCookie(tc.cookie) + } + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + assert.Equal(t, tc.want, w.Code) + if tc.want == http.StatusSeeOther { + assert.Equal(t, "/admin/login", w.Header().Get("Location")) + } + }) + } +} + +func cookieFor(t *testing.T, role string) *http.Cookie { + t.Helper() + tok, err := generateJWT(&User{ID: 9, Email: role + "@test.com", Role: role, Active: true}, testSecret) + require.NoError(t, err) + return &http.Cookie{Name: sessionCookieName, Value: tok} +} + +func TestFlashRoundTrip(t *testing.T) { + w := httptest.NewRecorder() + setFlash(w, "vehicle_created") + c := w.Result().Cookies()[0] + assert.Equal(t, flashCookieName, c.Name) + assert.True(t, c.HttpOnly) + + req := httptest.NewRequest(http.MethodGet, "/admin/vehicles", nil) + req.AddCookie(c) + w2 := httptest.NewRecorder() + msg := takeFlash(w2, req) + assert.Equal(t, "Vehicle created.", msg) + // clearing set-cookie present + require.NotEmpty(t, w2.Result().Cookies()) + assert.Equal(t, -1, w2.Result().Cookies()[0].MaxAge) + + // unknown code renders nothing + req3 := httptest.NewRequest(http.MethodGet, "/", nil) + req3.AddCookie(&http.Cookie{Name: flashCookieName, Value: ""}) + assert.Equal(t, "", takeFlash(httptest.NewRecorder(), req3)) +} +``` + +- [ ] **Step 2: Run → FAIL.** +- [ ] **Step 3: Implement** `admin_session.go`: + +```go +package main + +import ( + "fmt" + "log/slog" + "net/http" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +const flashCookieName = "vp_flash" + +// flashMessages maps opaque flash codes to the fixed strings the layout +// renders. Cookie values are attacker-writable, so free text is never +// rendered — unknown codes yield nothing (spec §4.6). +var flashMessages = map[string]string{ + "vehicle_created": "Vehicle created.", + "vehicle_updated": "Vehicle updated.", + "vehicle_deactivated": "Vehicle deactivated.", + "vehicle_activated": "Vehicle reactivated.", + "user_created": "User created.", + "user_updated": "User updated.", + "user_deactivated": "User deactivated.", + "user_activated": "User reactivated.", + "vehicle_assigned": "Vehicle assigned.", + "vehicle_unassigned": "Vehicle unassigned.", +} + +func setSessionCookie(w http.ResponseWriter, r *http.Request, token string, trustProxy bool) { + http.SetCookie(w, &http.Cookie{ + Name: sessionCookieName, + Value: token, + Path: "/", + MaxAge: int((24 * time.Hour).Seconds()), + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + Secure: requestIsSecure(r, trustProxy), + }) +} + +func clearSessionCookie(w http.ResponseWriter) { + http.SetCookie(w, &http.Cookie{ + Name: sessionCookieName, + Value: "", + Path: "/", + MaxAge: -1, + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + }) +} + +// adminClaimsFromCookie validates the session cookie's JWT and requires the +// admin role. It mirrors requireAuth's validation exactly (HS256, issuer). +func adminClaimsFromCookie(r *http.Request, secret []byte) (jwt.MapClaims, bool) { + c, err := r.Cookie(sessionCookieName) + if err != nil || c.Value == "" { + return nil, false + } + token, err := jwt.Parse(c.Value, func(t *jwt.Token) (interface{}, error) { + if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"]) + } + return secret, nil + }, jwt.WithValidMethods([]string{"HS256"}), jwt.WithIssuer("vehicle-positions-api")) + if err != nil || !token.Valid { + return nil, false + } + claims, ok := token.Claims.(jwt.MapClaims) + if !ok { + return nil, false + } + if role, _ := claims["role"].(string); role != "admin" { + return nil, false + } + return claims, true +} + +// requireAdminPage guards HTML admin pages: unauthenticated or non-admin +// visitors are redirected to the login page (303) rather than given JSON. +func requireAdminPage(secret []byte) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + claims, ok := adminClaimsFromCookie(r, secret) + if !ok { + http.Redirect(w, r, "/admin/login", http.StatusSeeOther) + return + } + ctx := contextWithClaims(r.Context(), claims) + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} + +func setFlash(w http.ResponseWriter, code string) { + http.SetCookie(w, &http.Cookie{ + Name: flashCookieName, Value: code, Path: "/", MaxAge: 60, + HttpOnly: true, SameSite: http.SameSiteLaxMode, + }) +} + +// takeFlash reads, clears, and resolves the flash cookie to its message. +func takeFlash(w http.ResponseWriter, r *http.Request) string { + c, err := r.Cookie(flashCookieName) + if err != nil || c.Value == "" { + return "" + } + http.SetCookie(w, &http.Cookie{ + Name: flashCookieName, Value: "", Path: "/", MaxAge: -1, + HttpOnly: true, SameSite: http.SameSiteLaxMode, + }) + msg, ok := flashMessages[c.Value] + if !ok { + slog.Debug("unknown flash code ignored", "code", c.Value) + return "" + } + return msg +} +``` + +Add to `auth.go` a tiny helper so both middlewares share claim wiring: +```go +func contextWithClaims(ctx context.Context, claims jwt.MapClaims) context.Context { + return context.WithValue(ctx, claimsKey, claims) +} +``` +and use it in `requireAuth` too (replacing the inline `context.WithValue`). + +- [ ] **Step 4: Run → PASS, full suite PASS.** +- [ ] **Step 5: Commit** — `git commit -am "feat: add admin session cookie layer, flash messages, and requireAdminPage middleware"` + +--- + +### Task 8: admin UI restructure — injected deps, login/logout, signup removal + +**Files:** +- Create: `admin_page_handlers.go` +- Modify: `admin_handlers.go` (shrinks to template loading + `adminUIEnabled`), `web/templates/views/login.html` (rewrite as form-POST login only), `web/templates/layout/header.html` (add logout button + flash slot), `web/templates/layout/base.html` (flash render) +- Delete: signup route/mode remnants +- Test: `admin_handlers_test.go` (rework), new tests in `admin_page_handlers_test.go` + +**Interfaces:** +- Consumes: Tasks 5–7 helpers; `UserFetcher`; `LoginRateLimiter`. +- Produces: + +```go +type adminUIConfig struct { + enabled bool + trustProxy bool +} + +// adminUI owns the parsed templates and dependencies for all admin pages. +type adminUI struct { + tmpl *embeddedTemplates + users UserFetcher + jwtSecret []byte + loginLimiter *LoginRateLimiter + cfg adminUIConfig + // page-data deps grow in later tasks (tracker, stats, trips, vehicles...) +} + +func registerAdminUI(mux *http.ServeMux, ui *adminUI) // registers /admin routes with requireAdminPage +func newAdminUI(...) (*adminUI, error) // loads templates, wires deps +``` + +The package-level `templates` global is deleted; `render`/`renderAdmin`/`renderPublic` become methods on `adminUI` (same bodies, `ui.tmpl` instead of the global). + +- [ ] **Step 1: Failing tests** — in `admin_page_handlers_test.go`: + +```go +func newTestAdminUI(t *testing.T) *adminUI { + t.Helper() + ui, err := newAdminUI(&noopStore{}, testSecret, NewLoginRateLimiter(), adminUIConfig{enabled: true}) + require.NoError(t, err) + t.Cleanup(ui.loginLimiter.Stop) + return ui +} + +func TestAdminLoginPageRenders(t *testing.T) { + ui := newTestAdminUI(t) + mux := http.NewServeMux() + registerAdminUI(mux, ui) + req := httptest.NewRequest(http.MethodGet, "/admin/login", nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + require.Equal(t, http.StatusOK, w.Code) + assert.Contains(t, w.Body.String(), `method="post"`) + assert.Contains(t, w.Body.String(), `action="/admin/login"`) + assert.NotContains(t, w.Body.String(), "signup") +} + +func TestAdminLoginFlow(t *testing.T) { + hash, _ := bcrypt.GenerateFromPassword([]byte("password123"), bcryptCost) + admin := &User{ID: 1, Email: "boss@test.com", PasswordHash: string(hash), Role: "admin", Active: true} + driver := &User{ID: 2, Email: "drv@test.com", PasswordHash: string(hash), Role: "driver", Active: true} + ui := newTestAdminUI(t) + ui.users = &fakeUserFetcher{users: map[string]*User{admin.Email: admin, driver.Email: driver}} + mux := http.NewServeMux() + registerAdminUI(mux, ui) + + post := func(email, pw string) *httptest.ResponseRecorder { + form := url.Values{"email": {email}, "password": {pw}} + req := httptest.NewRequest(http.MethodPost, "/admin/login", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + return w + } + + t.Run("success sets cookie and redirects", func(t *testing.T) { + w := post(admin.Email, "password123") + assert.Equal(t, http.StatusSeeOther, w.Code) + assert.Equal(t, "/admin/dashboard", w.Header().Get("Location")) + require.NotEmpty(t, w.Result().Cookies()) + assert.Equal(t, sessionCookieName, w.Result().Cookies()[0].Name) + }) + t.Run("wrong password re-renders 401", func(t *testing.T) { + w := post(admin.Email, "nope") + assert.Equal(t, http.StatusUnauthorized, w.Code) + assert.Contains(t, w.Body.String(), "Invalid email or password") + }) + t.Run("driver role gets 403 admin-required", func(t *testing.T) { + w := post(driver.Email, "password123") + assert.Equal(t, http.StatusForbidden, w.Code) + assert.Contains(t, w.Body.String(), "Admin access required") + }) + t.Run("rate limited after repeated attempts", func(t *testing.T) { + var last *httptest.ResponseRecorder + for i := 0; i < 12; i++ { + last = post("x@test.com", "nope") + } + assert.Equal(t, http.StatusTooManyRequests, last.Code) + }) +} + +func TestAdminLogout(t *testing.T) { + ui := newTestAdminUI(t) + mux := http.NewServeMux() + registerAdminUI(mux, ui) + req := httptest.NewRequest(http.MethodPost, "/admin/logout", nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + assert.Equal(t, http.StatusSeeOther, w.Code) + assert.Equal(t, "/admin/login", w.Header().Get("Location")) + require.NotEmpty(t, w.Result().Cookies()) + assert.Equal(t, -1, w.Result().Cookies()[0].MaxAge) +} + +func TestAdminPagesRedirectWithoutSession(t *testing.T) { + ui := newTestAdminUI(t) + mux := http.NewServeMux() + registerAdminUI(mux, ui) + for _, path := range []string{"/admin", "/admin/dashboard", "/admin/map", "/admin/vehicles", "/admin/users", "/admin/trips"} { + req := httptest.NewRequest(http.MethodGet, path, nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + assert.Equal(t, http.StatusSeeOther, w.Code, path) + assert.Equal(t, "/admin/login", w.Header().Get("Location"), path) + } +} +``` + +`fakeUserFetcher` here: `type fakeUserFetcher struct{ users map[string]*User }` with `GetUserByEmail` returning `ErrUserNotFound` when missing (merge with the Task 2 fake if names collide — one fake, both shapes). + +- [ ] **Step 2: Run → FAIL.** +- [ ] **Step 3: Implement.** + +`admin_handlers.go` keeps: `adminUIEnabled()` (unchanged for now; Task 9 flips the default), `embeddedTemplates`, `loadTemplates()` (drop the signup entry: the public map still holds only `login.html`), `render` (unchanged logic but taking the template set as a parameter — no global). + +`admin_page_handlers.go` — the structure plus login/logout/redirect handlers: + +```go +package main + +import ( + "log/slog" + "net/http" + + "golang.org/x/crypto/bcrypt" +) + +type adminUIConfig struct { + enabled bool + trustProxy bool +} + +type adminUI struct { + tmpl *embeddedTemplates + users UserFetcher + jwtSecret []byte + loginLimiter *LoginRateLimiter + cfg adminUIConfig +} + +func newAdminUI(users UserFetcher, jwtSecret []byte, limiter *LoginRateLimiter, cfg adminUIConfig) (*adminUI, error) { + tmpl, err := loadTemplates() + if err != nil { + return nil, err + } + return &adminUI{tmpl: tmpl, users: users, jwtSecret: jwtSecret, loginLimiter: limiter, cfg: cfg}, nil +} + +func registerAdminUI(mux *http.ServeMux, ui *adminUI) { + protect := requireAdminPage(ui.jwtSecret) + + mux.HandleFunc("GET /admin/login", ui.loginPage) + mux.HandleFunc("POST /admin/login", ui.loginSubmit) + mux.HandleFunc("POST /admin/logout", ui.logout) + mux.HandleFunc("GET /admin", ui.rootRedirect) + mux.HandleFunc("GET /admin/{$}", ui.rootRedirect) + mux.Handle("GET /admin/dashboard", protect(http.HandlerFunc(ui.dashboardPage))) + mux.Handle("GET /admin/map", protect(http.HandlerFunc(ui.mapPage))) + mux.Handle("GET /admin/vehicles", protect(http.HandlerFunc(ui.vehiclesPage))) + mux.Handle("GET /admin/users", protect(http.HandlerFunc(ui.usersPage))) + mux.Handle("GET /admin/trips", protect(http.HandlerFunc(ui.tripsPage))) + // CRUD form routes are added by later tasks. +} + +func (ui *adminUI) rootRedirect(w http.ResponseWriter, r *http.Request) { + if _, ok := adminClaimsFromCookie(r, ui.jwtSecret); ok { + http.Redirect(w, r, "/admin/dashboard", http.StatusSeeOther) + return + } + http.Redirect(w, r, "/admin/login", http.StatusSeeOther) +} + +func (ui *adminUI) loginPage(w http.ResponseWriter, r *http.Request) { + if _, ok := adminClaimsFromCookie(r, ui.jwtSecret); ok { + http.Redirect(w, r, "/admin/dashboard", http.StatusSeeOther) + return + } + ui.renderLogin(w, http.StatusOK, "", "") +} + +func (ui *adminUI) loginSubmit(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + ui.renderLogin(w, http.StatusBadRequest, "Invalid form submission.", "") + return + } + email := r.PostFormValue("email") + password := r.PostFormValue("password") + if !ui.loginLimiter.Allow(clientIP(r, ui.cfg.trustProxy), email) { + ui.renderLogin(w, http.StatusTooManyRequests, "Too many attempts, try again shortly.", email) + return + } + if email == "" || password == "" { + ui.renderLogin(w, http.StatusUnprocessableEntity, "Email and password are required.", email) + return + } + user, err := ui.users.GetUserByEmail(r.Context(), email) + if err != nil { + if errors.Is(err, ErrUserNotFound) { + _ = bcrypt.CompareHashAndPassword(dummyHash, []byte(password)) + ui.renderLogin(w, http.StatusUnauthorized, "Invalid email or password.", email) + return + } + slog.Error("admin login: database error", "error", err) + ui.renderLogin(w, http.StatusInternalServerError, "Something went wrong. Try again.", email) + return + } + if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil { + ui.renderLogin(w, http.StatusUnauthorized, "Invalid email or password.", email) + return + } + if !user.Active { + ui.renderLogin(w, http.StatusUnauthorized, "Invalid email or password.", email) + return + } + if user.Role != "admin" { + ui.renderLogin(w, http.StatusForbidden, "Admin access required.", email) + return + } + token, err := generateJWT(user, ui.jwtSecret) + if err != nil { + slog.Error("admin login: token generation failed", "error", err) + ui.renderLogin(w, http.StatusInternalServerError, "Something went wrong. Try again.", email) + return + } + setSessionCookie(w, r, token, ui.cfg.trustProxy) + http.Redirect(w, r, "/admin/dashboard", http.StatusSeeOther) +} + +func (ui *adminUI) renderLogin(w http.ResponseWriter, status int, errMsg, email string) { + w.WriteHeader(status) + // render() writes the body; status must be set first for non-200s. + renderInto(w, ui.tmpl.public, "login.html", "login.html", map[string]interface{}{ + "Title": "Sign In", "Error": errMsg, "Email": email, + }) +} + +func (ui *adminUI) logout(w http.ResponseWriter, r *http.Request) { + clearSessionCookie(w) + http.Redirect(w, r, "/admin/login", http.StatusSeeOther) +} +``` + +Note on rendering with non-200 status: the existing `render` calls `http.Error` on failure after possibly not writing a header. Refactor `render` into `renderInto(w, set, view, root, data)` that buffers, then writes the body WITHOUT calling `WriteHeader` itself (caller sets status first; default 200 applies otherwise), and on template failure logs + writes a plain 500 only if the header isn't committed — keep it simple: buffer first, and only `WriteHeader(500)+error body` when the buffer fails AND status wasn't already set. Simplest correct shape: + +```go +func renderInto(w http.ResponseWriter, set map[string]*template.Template, view, rootName string, data map[string]interface{}) { + tmpl, ok := set[path.Base(view)] + if !ok { + slog.Error("template render failed", "view", view, "error", "no such template") + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + var buf bytes.Buffer + if err := tmpl.ExecuteTemplate(&buf, rootName, data); err != nil { + slog.Error("template render failed", "view", view, "error", err) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + if _, err := buf.WriteTo(w); err != nil { + slog.Error("template response write failed", "view", view, "error", err) + } +} +``` + +(When the caller pre-sets a non-200 status and the template then fails, `http.Error`'s WriteHeader is a no-op with a log line — acceptable.) For error-status page renders, call `w.WriteHeader(status)` before `renderInto` only when status != 200. The five page methods (`dashboardPage`, `mapPage`, `vehiclesPage`, `usersPage`, `tripsPage`) keep this task's scope: same mock data bodies as today, but as `adminUI` methods calling `ui.renderAdmin(...)`, where: + +```go +func (ui *adminUI) renderAdmin(w http.ResponseWriter, r *http.Request, view string, data map[string]interface{}) { + data["Flash"] = takeFlash(w, r) + renderInto(w, ui.tmpl.admin, view, "base.html", data) +} +``` + +`web/templates/views/login.html` — replace entirely with a standalone form page (keep the existing visual style/classes from the old file where convenient): + +```html + + + + + +{{.Title}} — Transit Tracker + + + +
+

Transit Tracker

+ + {{if .Error}}{{end}} +
+ + + + + +
+
+ + +``` + +(Styling: reuse the old login.html's Tailwind classes for these elements if they translate cleanly; the semantic structure above is the contract. Until Task 10 lands `admin.css`, the CDN link may remain in this file temporarily — Task 10 removes every CDN reference.) + +`web/templates/layout/base.html`: inside `
` before `{{template "content" .}}` add: +```html +{{if .Flash}}
{{.Flash}}
{{end}} +``` + +`web/templates/layout/header.html`: replace the static "Admin" pill with: +```html +
+ +
+``` + +Rework `admin_handlers_test.go`: its existing tests exercised unauthenticated page renders; update them to construct an `adminUI` via `newTestAdminUI(t)` and include an admin session cookie (use `cookieFor(t, "admin")` from Task 7's test file) where they hit protected pages. Delete signup-related tests. + +- [ ] **Step 4: Run → PASS, full suite PASS.** +- [ ] **Step 5: Commit** — `git commit -am "feat: session-authenticated admin UI shell with form login, logout, and flash"` + +--- + +### Task 9: newHandler composition, CSRF, bootstrap, flag default + +**Files:** +- Modify: `main.go`, `admin_handlers.go` (`adminUIEnabled` default true), `seed_dev.sql`, `route_wiring_test.go` (noopStore additions), `handlers.go` (only if `newMux` signature grows — it should not) +- Create: `bootstrap.go`, `bootstrap_test.go`, `handler_composition_test.go` + +**Interfaces:** +- Consumes: everything above. +- Produces: + +```go +func newHandler(store appStore, tracker *Tracker, rateLimiter *VehicleRateLimiter, + loginLimiter *LoginRateLimiter, jwtSecret []byte, startTime time.Time, + cfg adminUIConfig) (http.Handler, error) +// bootstrap.go: +func bootstrapAdmin(ctx context.Context, store adminBootstrapStore, email, password string) error +type adminBootstrapStore interface { + CountUsersByRole(ctx context.Context, role string) (int, error) + CreateUser(ctx context.Context, name, email, password, role string) (*UserResponse, error) +} +``` + +`appStore` gains: `SetUserActive`, `CountUsersByRole`, `CountActiveUsersByRole`, `UpdateVehicleInfo`, `SetVehicleActive`, `CountActiveVehicles`, `CountActiveTrips`, `ListTrips`, `GetTripSummary`, `ListTripLocations`, `ListActiveTripsByVehicle` — declare matching narrow interfaces next to each store method group and embed them in `appStore`; extend `noopStore` with zero-value stubs for all of them. + +- [ ] **Step 1: Failing tests** — `handler_composition_test.go`: + +```go +func newTestHandler(t *testing.T, enabled bool) http.Handler { + t.Helper() + tracker := NewTracker(5 * time.Minute) + t.Cleanup(tracker.Stop) + ll := NewLoginRateLimiter() + t.Cleanup(ll.Stop) + h, err := newHandler(&noopStore{}, tracker, nil, ll, testSecret, time.Now(), adminUIConfig{enabled: enabled}) + require.NoError(t, err) + return h +} + +func TestNewHandlerServesAPIAndAdmin(t *testing.T) { + h := newTestHandler(t, true) + + req := httptest.NewRequest(http.MethodGet, "/health", nil) + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + assert.Equal(t, http.StatusOK, w.Code) + + req = httptest.NewRequest(http.MethodGet, "/admin/dashboard", nil) + w = httptest.NewRecorder() + h.ServeHTTP(w, req) + assert.Equal(t, http.StatusSeeOther, w.Code, "admin page redirects to login when unauthenticated") +} + +func TestNewHandlerAdminDisabled(t *testing.T) { + h := newTestHandler(t, false) + req := httptest.NewRequest(http.MethodGet, "/admin/login", nil) + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + assert.Equal(t, http.StatusNotFound, w.Code) +} + +func TestNewHandlerCSRFRejectsCrossOriginPost(t *testing.T) { + h := newTestHandler(t, true) + form := url.Values{"email": {"a@b.c"}, "password": {"x"}} + req := httptest.NewRequest(http.MethodPost, "/admin/login", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Sec-Fetch-Site", "cross-site") + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + assert.Equal(t, http.StatusForbidden, w.Code, "cross-site browser POST must be rejected") +} + +func TestNewHandlerCSRFAllowsHeaderlessClients(t *testing.T) { + h := newTestHandler(t, true) + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", strings.NewReader(`{"email":"a@b.c","password":"x"}`)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + assert.NotEqual(t, http.StatusForbidden, w.Code, "non-browser clients (no Sec-Fetch-Site/Origin) pass CSRF") +} +``` + +`bootstrap_test.go` (store-backed): + +```go +func TestBootstrapAdmin(t *testing.T) { + store := newTestStore(t) + email := uniqueEmail(t) + require.NoError(t, bootstrapAdmin(context.Background(), store, email, "supersecret123")) + u, err := store.GetUserByEmail(context.Background(), email) + require.NoError(t, err) + assert.Equal(t, "admin", u.Role) + + // second call: an admin now exists → no-op, no duplicate + err = bootstrapAdmin(context.Background(), store, uniqueEmail(t), "supersecret123") + require.NoError(t, err) +} +``` + +(Precondition: the shared dev DB may already contain admins, making the first assertion unreliable — instead structure the test with a fake `adminBootstrapStore` for the "creates when zero" and "skips when nonzero" branches, plus one real-store smoke call. Write the fake-based version as primary.) + +- [ ] **Step 2: Run → FAIL.** +- [ ] **Step 3: Implement.** + +`newHandler` (place in `main.go` next to `newMux`): + +```go +func newHandler(store appStore, tracker *Tracker, rateLimiter *VehicleRateLimiter, + loginLimiter *LoginRateLimiter, jwtSecret []byte, startTime time.Time, + cfg adminUIConfig) (http.Handler, error) { + + mux := newMux(store, tracker, rateLimiter, jwtSecret, startTime) + + if cfg.enabled { + ui, err := newAdminUI(store, jwtSecret, loginLimiter, cfg) + if err != nil { + return nil, fmt.Errorf("init admin UI: %w", err) + } + staticFiles, err := fs.Sub(files, "web/static") + if err != nil { + return nil, fmt.Errorf("prepare static files: %w", err) + } + mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticFiles)))) + registerAdminUI(mux, ui) + } + + // CSRF: rejects browser cross-origin non-safe requests; clients without + // Sec-Fetch-Site/Origin headers (Retrofit, curl) are unaffected (spec §4.3). + csrf := http.NewCrossOriginProtection() + return csrf.Handler(mux), nil +} +``` + +(Move the static-file mounting out of `registerAdminUI` into here, or keep it in `registerAdminUI` — one place only; update Task 8's code accordingly. Check the actual Go 1.25 constructor name with `go doc net/http.NewCrossOriginProtection` — if it is `http.CrossOriginProtection{}` zero-value usable, use that form.) + +`bootstrap.go`: +```go +package main + +import ( + "context" + "fmt" + "log/slog" +) + +type adminBootstrapStore interface { + CountUsersByRole(ctx context.Context, role string) (int, error) + CreateUser(ctx context.Context, name, email, password, role string) (*UserResponse, error) +} + +// bootstrapAdmin creates the first admin account from ADMIN_BOOTSTRAP_* env +// vars, but only when the users table holds zero admins (spec §4.12). +func bootstrapAdmin(ctx context.Context, store adminBootstrapStore, email, password string) error { + n, err := store.CountUsersByRole(ctx, "admin") + if err != nil { + return fmt.Errorf("bootstrap admin: count: %w", err) + } + if n > 0 { + slog.Info("admin bootstrap skipped: admin users already exist", "count", n) + return nil + } + if len(password) < 8 { + return fmt.Errorf("bootstrap admin: password must be at least 8 characters") + } + if _, err := store.CreateUser(ctx, "Administrator", email, password, "admin"); err != nil { + return fmt.Errorf("bootstrap admin: create: %w", err) + } + slog.Info("bootstrapped initial admin user", "email", email) + return nil +} +``` + +`main()` changes: +- After migrations: `if be, bp := os.Getenv("ADMIN_BOOTSTRAP_EMAIL"), os.Getenv("ADMIN_BOOTSTRAP_PASSWORD"); be != "" && bp != "" { if err := bootstrapAdmin(ctx, store, be, bp); err != nil { slog.Error(...); os.Exit(1) } }` +- Build `loginLimiter := NewLoginRateLimiter(); defer loginLimiter.Stop()`. +- Replace the `newMux` + `registerAdminUI` block with `handler, err := newHandler(store, tracker, rateLimiter, loginLimiter, jwtSecret, startTime, adminUIConfig{enabled: adminUIEnabled(), trustProxy: trustProxyHeaders()})`, exit on error; `srv.Handler = requestLogger(handler)`. Remove the old warning log line. +- Add `func trustProxyHeaders() bool { v, _ := strconv.ParseBool(os.Getenv("TRUST_PROXY_HEADERS")); return v }` (in `proxy.go`). +- Wire the API login's rate limiting: in `newMux`, change the login registration to `mux.Handle("POST /api/v1/auth/login", handleLogin(store, jwtSecret))` → the limiter check goes INSIDE `handleLogin` via a new parameter: `handleLogin(store, jwtSecret, loginLimiter, trustProxy)` returning 429 JSON `{"error":"too many attempts"}` before touching the store; `newMux` therefore gains `loginLimiter *LoginRateLimiter` and `trustProxy bool` params (update all `newMux` callers in tests — pass `nil`-safe: guard `if limiter != nil` inside handleLogin so existing tests passing nil still work). + +`adminUIEnabled()` in `admin_handlers.go` becomes default-true: +```go +func adminUIEnabled() bool { + v := os.Getenv("ADMIN_UI_ENABLED") + if v == "" { + return true + } + enabled, err := strconv.ParseBool(v) + if err != nil { + return true + } + return enabled +} +``` + +`seed_dev.sql` — append: +```sql +-- Seed a test admin for local development +-- Email: admin@test.com | Password: password +INSERT INTO users (name, email, password_hash, role) +VALUES ( + 'Test Admin', + 'admin@test.com', + '$2a$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', + 'admin' +) +ON CONFLICT (email) DO NOTHING; +``` + +Extend `noopStore` in `route_wiring_test.go` with stubs for every new interface method (zero values, same style as existing stubs). + +- [ ] **Step 4: Run new tests + full suite → PASS.** +- [ ] **Step 5: Commit** — `git commit -am "feat: compose handler with CSRF protection, admin bootstrap, and default-on admin UI"` + +--- + +### Task 10: static assets — vendor Leaflet, compile Tailwind, drop CDNs + +**Files:** +- Create: `web/static/vendor/leaflet/leaflet.js`, `web/static/vendor/leaflet/leaflet.css`, `web/static/vendor/leaflet/images/*` (marker-icon.png, marker-icon-2x.png, marker-shadow.png, layers.png, layers-2x.png), `web/static/css/admin.css` (generated, committed), `web/styles/input.css` (Tailwind source) +- Modify: `web/templates/layout/base.html`, `web/templates/views/login.html`, `Makefile`, CI workflow (`.github/workflows/*` — the Go one) + +**Interfaces:** +- Produces: `make css` target; templates reference only `/static/...` URLs (Google Fonts, cdn.tailwindcss.com, unpkg removed). + +- [ ] **Step 1: Vendor Leaflet 1.9.4** + +```bash +mkdir -p web/static/vendor/leaflet/images +curl -fsSL https://unpkg.com/leaflet@1.9.4/dist/leaflet.js -o web/static/vendor/leaflet/leaflet.js +curl -fsSL https://unpkg.com/leaflet@1.9.4/dist/leaflet.css -o web/static/vendor/leaflet/leaflet.css +for f in marker-icon.png marker-icon-2x.png marker-shadow.png layers.png layers-2x.png; do + curl -fsSL "https://unpkg.com/leaflet@1.9.4/dist/images/$f" -o "web/static/vendor/leaflet/images/$f" +done +``` + +Verify sizes are plausible (`leaflet.js` ≈ 140 KB). + +- [ ] **Step 2: Tailwind source + build** + +`web/styles/input.css` (Tailwind v4 CSS-first config; port the custom look): +```css +@import "tailwindcss"; +@source "../templates/**/*.html"; +@source "../static/js/**/*.js"; + +@theme { + --font-sans: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + --font-display: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; +} +``` + +Then move every rule currently inside base.html's ` + + @@ -275,7 +30,7 @@
- + diff --git a/web/templates/views/login.html b/web/templates/views/login.html index fd7e579..c8dac02 100644 --- a/web/templates/views/login.html +++ b/web/templates/views/login.html @@ -4,44 +4,16 @@ {{.Title}} — Transit Tracker - - - - - - - -
+ +

Access

Transit Tracker

- {{if .Error}}{{end}} + {{if .Error}}{{end}}
From b0a591fa17cb93f5b51582f2b881b5ec9e30c3c6 Mon Sep 17 00:00:00 2001 From: Aaron Brethorst Date: Mon, 24 Aug 2026 01:45:15 -0700 Subject: [PATCH 15/29] feat: add live vehicles admin endpoint joining tracker, labels, and active trips --- admin_live_handlers.go | 101 +++++++++++++++++ admin_live_handlers_test.go | 213 ++++++++++++++++++++++++++++++++++++ main.go | 1 + route_wiring_test.go | 34 ++++++ 4 files changed, 349 insertions(+) create mode 100644 admin_live_handlers.go create mode 100644 admin_live_handlers_test.go diff --git a/admin_live_handlers.go b/admin_live_handlers.go new file mode 100644 index 0000000..f0e0480 --- /dev/null +++ b/admin_live_handlers.go @@ -0,0 +1,101 @@ +package main + +import ( + "context" + "log/slog" + "net/http" + "sort" + "time" +) + +// ActiveTripLister returns the current active trip for each vehicle that +// has one, keyed by vehicle ID. +type ActiveTripLister interface { + ListActiveTripsByVehicle(ctx context.Context) (map[string]ActiveTripInfo, error) +} + +// liveVehicleEntry is the JSON representation of a single vehicle's live +// position, joined with its label and (if any) active trip. +type liveVehicleEntry struct { + VehicleID string `json:"vehicle_id"` + Label string `json:"label"` + Latitude float64 `json:"latitude"` + Longitude float64 `json:"longitude"` + Bearing *float64 `json:"bearing"` + Speed *float64 `json:"speed"` + GtfsTripID string `json:"gtfs_trip_id"` + TripDBID *int64 `json:"trip_db_id"` + RouteID *string `json:"route_id"` + DriverName *string `json:"driver_name"` + ReportedAt int64 `json:"reported_at"` + UpdatedAt string `json:"updated_at"` // RFC3339 UTC +} + +type liveVehiclesResponse struct { + Count int `json:"count"` + Vehicles []liveVehicleEntry `json:"vehicles"` +} + +// handleLiveVehicles returns the current positions of all actively-reporting +// vehicles, joined with their DB label (falling back to the vehicle id when +// unknown) and their current active trip, if any. +func handleLiveVehicles(tracker *Tracker, vehicles VehicleManager, trips ActiveTripLister) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + vehicleList, err := vehicles.ListVehicles(r.Context()) + if err != nil { + slog.Error("failed to list vehicles for live view", "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to list vehicles"}) + return + } + labels := make(map[string]string, len(vehicleList)) + for _, v := range vehicleList { + labels[v.ID] = v.Label + } + + activeTrips, err := trips.ListActiveTripsByVehicle(r.Context()) + if err != nil { + slog.Error("failed to list active trips for live view", "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to list active trips"}) + return + } + + active := tracker.ActiveVehicles() + entries := make([]liveVehicleEntry, 0, len(active)) + for _, state := range active { + label := state.VehicleID + if l, ok := labels[state.VehicleID]; ok { + label = l + } + + entry := liveVehicleEntry{ + VehicleID: state.VehicleID, + Label: label, + Latitude: state.Latitude, + Longitude: state.Longitude, + Bearing: state.Bearing, + Speed: state.Speed, + GtfsTripID: state.TripID, + ReportedAt: state.Timestamp, + UpdatedAt: state.UpdatedAt.UTC().Format(time.RFC3339), + } + + if trip, ok := activeTrips[state.VehicleID]; ok { + tripID := trip.TripID + entry.TripDBID = &tripID + routeID := trip.RouteID + entry.RouteID = &routeID + driverName := trip.DriverName + entry.DriverName = &driverName + } + + entries = append(entries, entry) + } + + sort.Slice(entries, func(i, j int) bool { return entries[i].VehicleID < entries[j].VehicleID }) + + writeJSON(w, http.StatusOK, liveVehiclesResponse{ + Count: len(entries), + Vehicles: entries, + }) + } +} diff --git a/admin_live_handlers_test.go b/admin_live_handlers_test.go new file mode 100644 index 0000000..40da285 --- /dev/null +++ b/admin_live_handlers_test.go @@ -0,0 +1,213 @@ +package main + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type fakeVehicleLister struct{ vehicles []VehicleResponse } + +func (f *fakeVehicleLister) ListVehicles(_ context.Context) ([]VehicleResponse, error) { + return f.vehicles, nil +} +func (f *fakeVehicleLister) GetVehicle(_ context.Context, _ string) (*VehicleResponse, error) { + panic("not implemented") +} +func (f *fakeVehicleLister) UpsertVehicle(_ context.Context, _, _, _ string) (*VehicleResponse, error) { + panic("not implemented") +} +func (f *fakeVehicleLister) DeactivateVehicle(_ context.Context, _ string) error { + panic("not implemented") +} + +type fakeActiveTrips struct{ m map[string]ActiveTripInfo } + +func (f *fakeActiveTrips) ListActiveTripsByVehicle(_ context.Context) (map[string]ActiveTripInfo, error) { + return f.m, nil +} + +type errVehicleLister struct{} + +func (f *errVehicleLister) ListVehicles(_ context.Context) ([]VehicleResponse, error) { + return nil, assert.AnError +} +func (f *errVehicleLister) GetVehicle(_ context.Context, _ string) (*VehicleResponse, error) { + panic("not implemented") +} +func (f *errVehicleLister) UpsertVehicle(_ context.Context, _, _, _ string) (*VehicleResponse, error) { + panic("not implemented") +} +func (f *errVehicleLister) DeactivateVehicle(_ context.Context, _ string) error { + panic("not implemented") +} + +type errActiveTrips struct{} + +func (f *errActiveTrips) ListActiveTripsByVehicle(_ context.Context) (map[string]ActiveTripInfo, error) { + return nil, assert.AnError +} + +func TestHandleLiveVehicles(t *testing.T) { + tracker := NewTracker(5 * time.Minute) + defer tracker.Stop() + speed := 8.5 + tracker.Update(&LocationReport{VehicleID: "bus-1", TripID: "gtfs-77", Latitude: -1.29, Longitude: 36.82, Speed: &speed, Timestamp: 1752566400}) + tracker.Update(&LocationReport{VehicleID: "ghost-9", TripID: "", Latitude: 0.1, Longitude: 0.2, Timestamp: 1752566400}) + + vehicles := &fakeVehicleLister{vehicles: []VehicleResponse{{ID: "bus-1", Label: "Bus One", Active: true}}} + trips := &fakeActiveTrips{m: map[string]ActiveTripInfo{ + "bus-1": {TripID: 42, RouteID: "5", GtfsTripID: "gtfs-77", UserID: 3, DriverName: "Asha"}, + }} + + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/vehicles/live", nil) + w := httptest.NewRecorder() + handleLiveVehicles(tracker, vehicles, trips).ServeHTTP(w, req) + require.Equal(t, http.StatusOK, w.Code) + + var resp struct { + Count int `json:"count"` + Vehicles []liveVehicleEntry `json:"vehicles"` + } + require.NoError(t, json.NewDecoder(w.Body).Decode(&resp)) + require.Equal(t, 2, resp.Count) + + byID := map[string]liveVehicleEntry{} + for _, v := range resp.Vehicles { + byID[v.VehicleID] = v + } + b1 := byID["bus-1"] + assert.Equal(t, "Bus One", b1.Label) + require.NotNil(t, b1.TripDBID) + assert.EqualValues(t, 42, *b1.TripDBID) + assert.Equal(t, "Asha", *b1.DriverName) + assert.Equal(t, "5", *b1.RouteID) + assert.Nil(t, b1.Bearing) + assert.Equal(t, 8.5, *b1.Speed) + assert.EqualValues(t, 1752566400, b1.ReportedAt) + + g := byID["ghost-9"] + assert.Equal(t, "ghost-9", g.Label, "label falls back to id when vehicle unknown") + assert.Nil(t, g.TripDBID) + assert.Nil(t, g.DriverName) +} + +// TestHandleLiveVehicles_Sorted verifies vehicles are sorted by vehicle_id +// for stable output, regardless of tracker map iteration order. +func TestHandleLiveVehicles_Sorted(t *testing.T) { + tracker := NewTracker(5 * time.Minute) + defer tracker.Stop() + for _, id := range []string{"zebra", "alpha", "mike"} { + tracker.Update(&LocationReport{VehicleID: id, Latitude: 1, Longitude: 1, Timestamp: 1752566400}) + } + + vehicles := &fakeVehicleLister{} + trips := &fakeActiveTrips{m: map[string]ActiveTripInfo{}} + + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/vehicles/live", nil) + w := httptest.NewRecorder() + handleLiveVehicles(tracker, vehicles, trips).ServeHTTP(w, req) + require.Equal(t, http.StatusOK, w.Code) + + var resp struct { + Count int `json:"count"` + Vehicles []liveVehicleEntry `json:"vehicles"` + } + require.NoError(t, json.NewDecoder(w.Body).Decode(&resp)) + require.Len(t, resp.Vehicles, 3) + assert.Equal(t, []string{"alpha", "mike", "zebra"}, []string{ + resp.Vehicles[0].VehicleID, resp.Vehicles[1].VehicleID, resp.Vehicles[2].VehicleID, + }) +} + +// TestHandleLiveVehicles_Empty verifies an empty tracker returns an empty +// (non-null) vehicles array and a zero count. +func TestHandleLiveVehicles_Empty(t *testing.T) { + tracker := NewTracker(5 * time.Minute) + defer tracker.Stop() + + vehicles := &fakeVehicleLister{} + trips := &fakeActiveTrips{m: map[string]ActiveTripInfo{}} + + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/vehicles/live", nil) + w := httptest.NewRecorder() + handleLiveVehicles(tracker, vehicles, trips).ServeHTTP(w, req) + require.Equal(t, http.StatusOK, w.Code) + + var resp struct { + Count int `json:"count"` + Vehicles []liveVehicleEntry `json:"vehicles"` + } + require.NoError(t, json.NewDecoder(w.Body).Decode(&resp)) + assert.Equal(t, 0, resp.Count) + assert.NotNil(t, resp.Vehicles) + assert.Len(t, resp.Vehicles, 0) +} + +// TestHandleLiveVehicles_UpdatedAtFormat verifies UpdatedAt is RFC3339 UTC. +func TestHandleLiveVehicles_UpdatedAtFormat(t *testing.T) { + tracker := NewTracker(5 * time.Minute) + defer tracker.Stop() + tracker.Update(&LocationReport{VehicleID: "bus-1", Latitude: 1, Longitude: 1, Timestamp: 1752566400}) + + vehicles := &fakeVehicleLister{} + trips := &fakeActiveTrips{m: map[string]ActiveTripInfo{}} + + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/vehicles/live", nil) + w := httptest.NewRecorder() + handleLiveVehicles(tracker, vehicles, trips).ServeHTTP(w, req) + require.Equal(t, http.StatusOK, w.Code) + + var resp struct { + Vehicles []liveVehicleEntry `json:"vehicles"` + } + require.NoError(t, json.NewDecoder(w.Body).Decode(&resp)) + require.Len(t, resp.Vehicles, 1) + parsed, err := time.Parse(time.RFC3339, resp.Vehicles[0].UpdatedAt) + require.NoError(t, err, "updated_at must be RFC3339") + assert.Equal(t, time.UTC, parsed.Location()) +} + +// TestHandleLiveVehicles_VehicleStoreError verifies a ListVehicles error +// produces a 500 JSON error response. +func TestHandleLiveVehicles_VehicleStoreError(t *testing.T) { + tracker := NewTracker(5 * time.Minute) + defer tracker.Stop() + tracker.Update(&LocationReport{VehicleID: "bus-1", Latitude: 1, Longitude: 1, Timestamp: 1752566400}) + + trips := &fakeActiveTrips{m: map[string]ActiveTripInfo{}} + + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/vehicles/live", nil) + w := httptest.NewRecorder() + handleLiveVehicles(tracker, &errVehicleLister{}, trips).ServeHTTP(w, req) + require.Equal(t, http.StatusInternalServerError, w.Code) + + var resp map[string]string + require.NoError(t, json.NewDecoder(w.Body).Decode(&resp)) + assert.NotEmpty(t, resp["error"]) +} + +// TestHandleLiveVehicles_TripStoreError verifies a ListActiveTripsByVehicle +// error produces a 500 JSON error response. +func TestHandleLiveVehicles_TripStoreError(t *testing.T) { + tracker := NewTracker(5 * time.Minute) + defer tracker.Stop() + tracker.Update(&LocationReport{VehicleID: "bus-1", Latitude: 1, Longitude: 1, Timestamp: 1752566400}) + + vehicles := &fakeVehicleLister{} + + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/vehicles/live", nil) + w := httptest.NewRecorder() + handleLiveVehicles(tracker, vehicles, &errActiveTrips{}).ServeHTTP(w, req) + require.Equal(t, http.StatusInternalServerError, w.Code) + + var resp map[string]string + require.NoError(t, json.NewDecoder(w.Body).Decode(&resp)) + assert.NotEmpty(t, resp["error"]) +} diff --git a/main.go b/main.go index 37a0857..c4792c8 100644 --- a/main.go +++ b/main.go @@ -63,6 +63,7 @@ func newMux(store appStore, tracker *Tracker, rateLimiter *VehicleRateLimiter, j mux.HandleFunc("GET /gtfs-rt/vehicle-positions", handleGetFeed(tracker)) mux.Handle("GET /api/v1/admin/status", authMiddleware(adminMiddleware(handleAdminStatus(tracker, startTime)))) mux.Handle("GET /api/v1/admin/vehicles", authMiddleware(adminMiddleware(handleListVehicles(store)))) + mux.Handle("GET /api/v1/admin/vehicles/live", authMiddleware(adminMiddleware(handleLiveVehicles(tracker, store, store)))) mux.Handle("GET /api/v1/admin/vehicles/{id}", authMiddleware(adminMiddleware(handleGetVehicle(store)))) mux.Handle("POST /api/v1/admin/vehicles", authMiddleware(adminMiddleware(handleUpsertVehicle(store)))) mux.Handle("DELETE /api/v1/admin/vehicles/{id}", authMiddleware(adminMiddleware(handleDeactivateVehicle(store)))) diff --git a/route_wiring_test.go b/route_wiring_test.go index 0820ac6..9cce141 100644 --- a/route_wiring_test.go +++ b/route_wiring_test.go @@ -133,6 +133,7 @@ func TestAdminRoutes_DriverTokenRejected(t *testing.T) { }{ {"GET", "/api/v1/admin/status"}, {"GET", "/api/v1/admin/vehicles"}, + {"GET", "/api/v1/admin/vehicles/live"}, {"GET", "/api/v1/admin/vehicles/bus-1"}, {"POST", "/api/v1/admin/vehicles"}, {"DELETE", "/api/v1/admin/vehicles/bus-1"}, @@ -185,6 +186,7 @@ func TestAdminRoutes_AdminTokenAllowed(t *testing.T) { }{ {"GET", "/api/v1/admin/status"}, {"GET", "/api/v1/admin/vehicles"}, + {"GET", "/api/v1/admin/vehicles/live"}, {"GET", "/api/v1/admin/vehicles/bus-1"}, {"POST", "/api/v1/admin/vehicles"}, {"DELETE", "/api/v1/admin/vehicles/bus-1"}, @@ -213,6 +215,38 @@ func TestAdminRoutes_AdminTokenAllowed(t *testing.T) { } } +// TestLiveVehiclesRoute_DoesNotHitGetVehicle verifies that Go's 1.22+ mux +// routes GET /api/v1/admin/vehicles/live to handleLiveVehicles rather than +// treating "live" as the {id} path parameter of GET +// /api/v1/admin/vehicles/{id} (handleGetVehicle). A noopStore GetVehicle +// stub returns (nil, nil), which handleGetVehicle would serialize as a +// literal JSON "null" body with 200 OK; handleLiveVehicles always returns an +// object with "count" and "vehicles" keys, so decoding into that shape is +// enough to distinguish the two handlers. +func TestLiveVehiclesRoute_DoesNotHitGetVehicle(t *testing.T) { + adminToken, err := generateJWT(&User{ID: 2, Email: "admin@test.com", Role: "admin"}, testSecret) + require.NoError(t, err) + + tracker := NewTracker(5 * time.Minute) + defer tracker.Stop() + + mux := newMux(&noopStore{}, tracker, nil, testSecret, time.Time{}, nil, false) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/vehicles/live", nil) + req.Header.Set("Authorization", "Bearer "+adminToken) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) + + var resp map[string]json.RawMessage + require.NoError(t, json.NewDecoder(w.Body).Decode(&resp)) + _, hasCount := resp["count"] + _, hasVehicles := resp["vehicles"] + assert.True(t, hasCount, "response must have a \"count\" key, proving handleLiveVehicles served the request, not handleGetVehicle") + assert.True(t, hasVehicles, "response must have a \"vehicles\" key, proving handleLiveVehicles served the request, not handleGetVehicle") +} + // TestDriverVehiclesRoute_Wiring verifies GET /api/v1/vehicles requires // authentication (401 with no token) and accepts any authenticated driver // (200 with a driver-role token) — no admin role required, unlike the From 6ba03381f3373d1ca99da5ff1e5f710256a90bd4 Mon Sep 17 00:00:00 2001 From: Aaron Brethorst Date: Mon, 24 Aug 2026 01:50:27 -0700 Subject: [PATCH 16/29] feat: add admin trips list and trip trail endpoints --- admin_live_handlers.go | 150 +++++++++++++++++++++ admin_live_handlers_test.go | 251 ++++++++++++++++++++++++++++++++++++ main.go | 2 + route_wiring_test.go | 4 + 4 files changed, 407 insertions(+) diff --git a/admin_live_handlers.go b/admin_live_handlers.go index f0e0480..b233be3 100644 --- a/admin_live_handlers.go +++ b/admin_live_handlers.go @@ -2,9 +2,12 @@ package main import ( "context" + "errors" + "fmt" "log/slog" "net/http" "sort" + "strconv" "time" ) @@ -99,3 +102,150 @@ func handleLiveVehicles(tracker *Tracker, vehicles VehicleManager, trips ActiveT }) } } + +const ( + defaultTripListLimit = 50 + maxTripListLimit = 200 +) + +// TripTrailStore is the store interface required to serve a single trip's +// summary and location trail, for the admin trip detail/map view. +type TripTrailStore interface { + GetTripSummary(ctx context.Context, id int64) (*TripSummary, error) + ListTripLocations(ctx context.Context, tripID int64) ([]LocationPoint, error) +} + +type tripListResponse struct { + Count int `json:"count"` + HasMore bool `json:"has_more"` + Trips []TripSummary `json:"trips"` +} + +// tripTrailPoint is the JSON representation of a single point in a trip's +// location trail. Field names are consumed directly by the admin map JS. +type tripTrailPoint struct { + Latitude float64 `json:"latitude"` + Longitude float64 `json:"longitude"` + Bearing *float64 `json:"bearing"` + Speed *float64 `json:"speed"` + Accuracy *float64 `json:"accuracy"` + ReportedAt int64 `json:"reported_at"` + ReceivedAt string `json:"received_at"` // RFC3339 UTC +} + +type tripTrailResponse struct { + Trip TripSummary `json:"trip"` + Points []tripTrailPoint `json:"points"` +} + +// handleListTrips returns trip summaries for the admin trips list, filtered +// by status/vehicle_id/q and paginated by limit/offset. It fetches limit+1 +// rows from the store to detect has_more without a separate count query. +func handleListTrips(store TripLister) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + + status := q.Get("status") + if status != "" && status != "active" && status != "completed" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": `status must be "", "active", or "completed"`}) + return + } + + limit, err := parseOptionalInt(q.Get("limit"), defaultTripListLimit) + if err != nil || limit < 1 || limit > maxTripListLimit { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": fmt.Sprintf("limit must be between 1 and %d", maxTripListLimit)}) + return + } + + offset, err := parseOptionalInt(q.Get("offset"), 0) + if err != nil || offset < 0 { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "offset must be a non-negative integer"}) + return + } + + filter := TripFilter{ + Status: status, + VehicleID: q.Get("vehicle_id"), + Q: q.Get("q"), + // Fetch one extra row to detect whether results were truncated at limit. + Limit: limit + 1, + Offset: offset, + } + + trips, err := store.ListTrips(r.Context(), filter) + if err != nil { + slog.Error("failed to list trips", "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal server error"}) + return + } + + hasMore := len(trips) > limit + if hasMore { + trips = trips[:limit] + } + if trips == nil { + trips = []TripSummary{} + } + + writeJSON(w, http.StatusOK, tripListResponse{ + Count: len(trips), + HasMore: hasMore, + Trips: trips, + }) + } +} + +// handleTripLocations returns a single trip's summary joined with its +// location trail, for the admin trip detail/map view. A non-numeric or +// unknown {id} both produce 404, since neither identifies a real trip. +func handleTripLocations(store TripTrailStore) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "trip not found"}) + return + } + + trip, err := store.GetTripSummary(r.Context(), id) + if err != nil { + if errors.Is(err, ErrTripNotFound) { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "trip not found"}) + return + } + slog.Error("failed to get trip summary", "trip_id", id, "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal server error"}) + return + } + if trip == nil { + // Defensive: a well-behaved store returns ErrTripNotFound rather + // than (nil, nil), but guard against it to avoid a nil dereference. + writeJSON(w, http.StatusNotFound, map[string]string{"error": "trip not found"}) + return + } + + points, err := store.ListTripLocations(r.Context(), id) + if err != nil { + slog.Error("failed to list trip locations", "trip_id", id, "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal server error"}) + return + } + + entries := make([]tripTrailPoint, 0, len(points)) + for _, p := range points { + entries = append(entries, tripTrailPoint{ + Latitude: p.Latitude, + Longitude: p.Longitude, + Bearing: p.Bearing, + Speed: p.Speed, + Accuracy: p.Accuracy, + ReportedAt: p.Timestamp, + ReceivedAt: p.ReceivedAt.UTC().Format(time.RFC3339), + }) + } + + writeJSON(w, http.StatusOK, tripTrailResponse{ + Trip: *trip, + Points: entries, + }) + } +} diff --git a/admin_live_handlers_test.go b/admin_live_handlers_test.go index 40da285..5697e40 100644 --- a/admin_live_handlers_test.go +++ b/admin_live_handlers_test.go @@ -211,3 +211,254 @@ func TestHandleLiveVehicles_TripStoreError(t *testing.T) { require.NoError(t, json.NewDecoder(w.Body).Decode(&resp)) assert.NotEmpty(t, resp["error"]) } + +// fakeTripLister is a TripLister test double that captures the TripFilter it +// was called with, so tests can assert query params were translated +// correctly. +type fakeTripLister struct { + trips []TripSummary + err error + captured TripFilter +} + +func (f *fakeTripLister) ListTrips(_ context.Context, filter TripFilter) ([]TripSummary, error) { + f.captured = filter + if f.err != nil { + return nil, f.err + } + return f.trips, nil +} + +// fakeTripTrailStore is a TripTrailStore test double. +type fakeTripTrailStore struct { + trip *TripSummary + tripErr error + points []LocationPoint + pointsErr error +} + +func (f *fakeTripTrailStore) GetTripSummary(_ context.Context, _ int64) (*TripSummary, error) { + return f.trip, f.tripErr +} + +func (f *fakeTripTrailStore) ListTripLocations(_ context.Context, _ int64) ([]LocationPoint, error) { + return f.points, f.pointsErr +} + +// TestHandleListTrips_StatusFilterPassthrough verifies status, vehicle_id, +// and q query params are translated into the TripFilter passed to the store, +// with limit widened to limit+1 for hasMore detection. +func TestHandleListTrips_StatusFilterPassthrough(t *testing.T) { + fake := &fakeTripLister{} + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/trips?status=active&vehicle_id=bus-1&q=asha", nil) + w := httptest.NewRecorder() + handleListTrips(fake).ServeHTTP(w, req) + require.Equal(t, http.StatusOK, w.Code) + + assert.Equal(t, TripFilter{Status: "active", VehicleID: "bus-1", Q: "asha", Limit: 51, Offset: 0}, fake.captured) +} + +// TestHandleListTrips_HasMore verifies that when the store returns limit+1 +// rows, the response reports has_more:true and trims to exactly limit rows. +func TestHandleListTrips_HasMore(t *testing.T) { + trips := make([]TripSummary, 3) + for i := range trips { + trips[i] = TripSummary{ID: int64(i + 1)} + } + fake := &fakeTripLister{trips: trips} + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/trips?limit=2", nil) + w := httptest.NewRecorder() + handleListTrips(fake).ServeHTTP(w, req) + require.Equal(t, http.StatusOK, w.Code) + + var resp struct { + Count int `json:"count"` + HasMore bool `json:"has_more"` + Trips []TripSummary `json:"trips"` + } + require.NoError(t, json.NewDecoder(w.Body).Decode(&resp)) + assert.True(t, resp.HasMore) + assert.Equal(t, 2, resp.Count) + require.Len(t, resp.Trips, 2) + assert.Equal(t, 3, fake.captured.Limit, "store must be called with limit+1") +} + +// TestHandleListTrips_NoMore verifies has_more is false when the store +// returns fewer rows than limit+1. +func TestHandleListTrips_NoMore(t *testing.T) { + fake := &fakeTripLister{trips: []TripSummary{{ID: 1}}} + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/trips?limit=5", nil) + w := httptest.NewRecorder() + handleListTrips(fake).ServeHTTP(w, req) + require.Equal(t, http.StatusOK, w.Code) + + var resp struct { + HasMore bool `json:"has_more"` + Trips []TripSummary `json:"trips"` + } + require.NoError(t, json.NewDecoder(w.Body).Decode(&resp)) + assert.False(t, resp.HasMore) + assert.Len(t, resp.Trips, 1) +} + +// TestHandleListTrips_BadLimit verifies a non-numeric limit is rejected. +func TestHandleListTrips_BadLimit(t *testing.T) { + fake := &fakeTripLister{} + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/trips?limit=abc", nil) + w := httptest.NewRecorder() + handleListTrips(fake).ServeHTTP(w, req) + assert.Equal(t, http.StatusBadRequest, w.Code) +} + +// TestHandleListTrips_LimitOutOfRange verifies limit=0 and limit>200 are +// rejected. +func TestHandleListTrips_LimitOutOfRange(t *testing.T) { + for _, limit := range []string{"0", "201", "-1"} { + t.Run("limit="+limit, func(t *testing.T) { + fake := &fakeTripLister{} + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/trips?limit="+limit, nil) + w := httptest.NewRecorder() + handleListTrips(fake).ServeHTTP(w, req) + assert.Equal(t, http.StatusBadRequest, w.Code) + }) + } +} + +// TestHandleListTrips_BadOffset verifies a non-numeric offset is rejected. +func TestHandleListTrips_BadOffset(t *testing.T) { + fake := &fakeTripLister{} + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/trips?offset=abc", nil) + w := httptest.NewRecorder() + handleListTrips(fake).ServeHTTP(w, req) + assert.Equal(t, http.StatusBadRequest, w.Code) +} + +// TestHandleListTrips_BadStatus verifies status values other than "", +// "active", or "completed" are rejected. +func TestHandleListTrips_BadStatus(t *testing.T) { + fake := &fakeTripLister{} + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/trips?status=bogus", nil) + w := httptest.NewRecorder() + handleListTrips(fake).ServeHTTP(w, req) + assert.Equal(t, http.StatusBadRequest, w.Code) +} + +// TestHandleListTrips_Defaults verifies the default limit (50) and offset +// (0) are applied when the query params are omitted. +func TestHandleListTrips_Defaults(t *testing.T) { + fake := &fakeTripLister{} + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/trips", nil) + w := httptest.NewRecorder() + handleListTrips(fake).ServeHTTP(w, req) + require.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, 51, fake.captured.Limit) + assert.Equal(t, 0, fake.captured.Offset) +} + +// TestHandleListTrips_StoreError verifies a store error produces a 500 JSON +// error response. +func TestHandleListTrips_StoreError(t *testing.T) { + fake := &fakeTripLister{err: assert.AnError} + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/trips", nil) + w := httptest.NewRecorder() + handleListTrips(fake).ServeHTTP(w, req) + assert.Equal(t, http.StatusInternalServerError, w.Code) +} + +// TestHandleTripLocations_NotFound verifies ErrTripNotFound from +// GetTripSummary produces a 404. +func TestHandleTripLocations_NotFound(t *testing.T) { + fake := &fakeTripTrailStore{tripErr: ErrTripNotFound} + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/trips/999/locations", nil) + req.SetPathValue("id", "999") + w := httptest.NewRecorder() + handleTripLocations(fake).ServeHTTP(w, req) + assert.Equal(t, http.StatusNotFound, w.Code) +} + +// TestHandleTripLocations_NonNumericID verifies a non-numeric {id} path +// value produces a 404, same as an unknown numeric id. +func TestHandleTripLocations_NonNumericID(t *testing.T) { + fake := &fakeTripTrailStore{} + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/trips/not-a-number/locations", nil) + req.SetPathValue("id", "not-a-number") + w := httptest.NewRecorder() + handleTripLocations(fake).ServeHTTP(w, req) + assert.Equal(t, http.StatusNotFound, w.Code) +} + +// TestHandleTripLocations_HappyPath verifies the trip summary and point +// trail are mapped correctly, in particular LocationPoint.Timestamp -> +// reported_at (unix int) and ReceivedAt -> RFC3339 UTC string. +func TestHandleTripLocations_HappyPath(t *testing.T) { + speed := 12.5 + bearing := 90.0 + trip := &TripSummary{ID: 5, VehicleID: "bus-1", Status: "completed"} + points := []LocationPoint{ + { + Latitude: -1.29, Longitude: 36.82, + Bearing: &bearing, Speed: &speed, + Timestamp: 1752566400, + ReceivedAt: time.Unix(1752566405, 0), + }, + } + fake := &fakeTripTrailStore{trip: trip, points: points} + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/trips/5/locations", nil) + req.SetPathValue("id", "5") + w := httptest.NewRecorder() + handleTripLocations(fake).ServeHTTP(w, req) + require.Equal(t, http.StatusOK, w.Code) + + var resp struct { + Trip TripSummary `json:"trip"` + Points []struct { + Latitude float64 `json:"latitude"` + Longitude float64 `json:"longitude"` + Bearing *float64 `json:"bearing"` + Speed *float64 `json:"speed"` + Accuracy *float64 `json:"accuracy"` + ReportedAt int64 `json:"reported_at"` + ReceivedAt string `json:"received_at"` + } `json:"points"` + } + require.NoError(t, json.NewDecoder(w.Body).Decode(&resp)) + assert.Equal(t, int64(5), resp.Trip.ID) + assert.Equal(t, "bus-1", resp.Trip.VehicleID) + require.Len(t, resp.Points, 1) + p := resp.Points[0] + assert.Equal(t, -1.29, p.Latitude) + assert.Equal(t, 36.82, p.Longitude) + require.NotNil(t, p.Bearing) + assert.Equal(t, 90.0, *p.Bearing) + require.NotNil(t, p.Speed) + assert.Equal(t, 12.5, *p.Speed) + assert.Nil(t, p.Accuracy) + assert.EqualValues(t, 1752566400, p.ReportedAt) + + parsed, err := time.Parse(time.RFC3339, p.ReceivedAt) + require.NoError(t, err, "received_at must be RFC3339") + assert.Equal(t, time.UTC, parsed.Location()) + assert.Equal(t, int64(1752566405), parsed.Unix()) +} + +// TestHandleTripLocations_StoreError verifies a GetTripSummary error other +// than ErrTripNotFound produces a 500. +func TestHandleTripLocations_StoreError(t *testing.T) { + fake := &fakeTripTrailStore{tripErr: assert.AnError} + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/trips/5/locations", nil) + req.SetPathValue("id", "5") + w := httptest.NewRecorder() + handleTripLocations(fake).ServeHTTP(w, req) + assert.Equal(t, http.StatusInternalServerError, w.Code) +} + +// TestHandleTripLocations_LocationsStoreError verifies a ListTripLocations +// error produces a 500. +func TestHandleTripLocations_LocationsStoreError(t *testing.T) { + fake := &fakeTripTrailStore{trip: &TripSummary{ID: 5}, pointsErr: assert.AnError} + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/trips/5/locations", nil) + req.SetPathValue("id", "5") + w := httptest.NewRecorder() + handleTripLocations(fake).ServeHTTP(w, req) + assert.Equal(t, http.StatusInternalServerError, w.Code) +} diff --git a/main.go b/main.go index c4792c8..829aef6 100644 --- a/main.go +++ b/main.go @@ -68,6 +68,8 @@ func newMux(store appStore, tracker *Tracker, rateLimiter *VehicleRateLimiter, j mux.Handle("POST /api/v1/admin/vehicles", authMiddleware(adminMiddleware(handleUpsertVehicle(store)))) mux.Handle("DELETE /api/v1/admin/vehicles/{id}", authMiddleware(adminMiddleware(handleDeactivateVehicle(store)))) mux.Handle("GET /api/v1/admin/vehicles/{vehicleID}/locations", authMiddleware(adminMiddleware(handleGetLocationHistory(store, store)))) + mux.Handle("GET /api/v1/admin/trips", authMiddleware(adminMiddleware(handleListTrips(store)))) + mux.Handle("GET /api/v1/admin/trips/{id}/locations", authMiddleware(adminMiddleware(handleTripLocations(store)))) mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) }) diff --git a/route_wiring_test.go b/route_wiring_test.go index 9cce141..9b4eb32 100644 --- a/route_wiring_test.go +++ b/route_wiring_test.go @@ -138,6 +138,8 @@ func TestAdminRoutes_DriverTokenRejected(t *testing.T) { {"POST", "/api/v1/admin/vehicles"}, {"DELETE", "/api/v1/admin/vehicles/bus-1"}, {"GET", "/api/v1/admin/vehicles/bus-1/locations"}, + {"GET", "/api/v1/admin/trips"}, + {"GET", "/api/v1/admin/trips/1/locations"}, {"GET", "/api/v1/admin/users"}, {"GET", "/api/v1/admin/users/1"}, {"POST", "/api/v1/admin/users"}, @@ -191,6 +193,8 @@ func TestAdminRoutes_AdminTokenAllowed(t *testing.T) { {"POST", "/api/v1/admin/vehicles"}, {"DELETE", "/api/v1/admin/vehicles/bus-1"}, {"GET", "/api/v1/admin/vehicles/bus-1/locations"}, + {"GET", "/api/v1/admin/trips"}, + {"GET", "/api/v1/admin/trips/1/locations"}, {"GET", "/api/v1/admin/users"}, {"GET", "/api/v1/admin/users/1"}, {"POST", "/api/v1/admin/users"}, From aae8606f5d33b9388abf1e239adacf3d707a7e5e Mon Sep 17 00:00:00 2001 From: Aaron Brethorst Date: Mon, 24 Aug 2026 01:56:39 -0700 Subject: [PATCH 17/29] feat: dashboard renders live counts, feed health, and recent activity --- admin_page_handlers.go | 181 +++++++++++++++++++++++++---- admin_page_handlers_test.go | 84 ++++++++++++- handler_composition_test.go | 2 +- main.go | 4 +- web/templates/views/dashboard.html | 24 ++-- 5 files changed, 258 insertions(+), 37 deletions(-) diff --git a/admin_page_handlers.go b/admin_page_handlers.go index a2a0557..6b2caa5 100644 --- a/admin_page_handlers.go +++ b/admin_page_handlers.go @@ -1,26 +1,43 @@ package main import ( + "context" "errors" + "fmt" "log/slog" "net/http" + "sort" + "time" "golang.org/x/crypto/bcrypt" ) // adminUIConfig holds the runtime knobs for the admin UI that vary by -// deployment (whether it's served at all, and whether proxy headers are -// trusted for client-IP/HTTPS detection). +// deployment (whether it's served at all, whether proxy headers are trusted +// for client-IP/HTTPS detection, and the feed staleness threshold shown on +// the dashboard's feed-health strip — mirrors main's STALENESS_THRESHOLD). type adminUIConfig struct { - enabled bool - trustProxy bool + enabled bool + trustProxy bool + stalenessThreshold time.Duration +} + +// adminStatsStore provides the aggregate counts shown on the admin +// dashboard. +type adminStatsStore interface { + CountActiveVehicles(ctx context.Context) (int, error) + CountActiveUsersByRole(ctx context.Context, role string) (int, error) + CountActiveTrips(ctx context.Context) (int, error) } // adminUI owns the parsed templates and dependencies for all admin pages. -// Page-data deps grow in later tasks (tracker, stats, trips, vehicles...). type adminUI struct { tmpl *embeddedTemplates users UserFetcher + tracker *Tracker + stats adminStatsStore + activeTrips ActiveTripLister + vehicles VehicleManager jwtSecret []byte loginLimiter *LoginRateLimiter cfg adminUIConfig @@ -28,13 +45,25 @@ type adminUI struct { // newAdminUI loads the embedded templates and wires the admin UI's // dependencies. It returns an error rather than panicking so callers can log -// it with context and exit cleanly. -func newAdminUI(users UserFetcher, jwtSecret []byte, limiter *LoginRateLimiter, cfg adminUIConfig) (*adminUI, error) { +// it with context and exit cleanly. store supplies the user, stats, active +// trips, and vehicle dependencies (it implements appStore, a superset of all +// of them). +func newAdminUI(store appStore, tracker *Tracker, jwtSecret []byte, limiter *LoginRateLimiter, cfg adminUIConfig) (*adminUI, error) { tmpl, err := loadTemplates() if err != nil { return nil, err } - return &adminUI{tmpl: tmpl, users: users, jwtSecret: jwtSecret, loginLimiter: limiter, cfg: cfg}, nil + return &adminUI{ + tmpl: tmpl, + users: store, + tracker: tracker, + stats: store, + activeTrips: store, + vehicles: store, + jwtSecret: jwtSecret, + loginLimiter: limiter, + cfg: cfg, + }, nil } // registerAdminUI registers the admin routes on mux. It does not mount @@ -157,24 +186,134 @@ func (ui *adminUI) mapPage(w http.ResponseWriter, r *http.Request) { }) } +// dashboardRow is a single row in the dashboard's recent-activity table: a +// vehicle's label, its current route (if it has an active trip), and how +// long ago it last reported. +type dashboardRow struct { + Label string + RouteID string + LastSeen string +} + +// recentActivityLimit caps the dashboard's recent-activity table so it stays +// scannable regardless of fleet size. +const recentActivityLimit = 10 + +// dashboardPage renders the admin dashboard: aggregate stats from the store, +// the tracker's live feed status, and the most recently reported vehicles +// joined with their labels and (if any) active trip's route. func (ui *adminUI) dashboardPage(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + totalVehicles, err := ui.stats.CountActiveVehicles(ctx) + if err != nil { + slog.Error("dashboard: count active vehicles", "error", err) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + + totalDrivers, err := ui.stats.CountActiveUsersByRole(ctx, "driver") + if err != nil { + slog.Error("dashboard: count active drivers", "error", err) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + + activeTripCount, err := ui.stats.CountActiveTrips(ctx) + if err != nil { + slog.Error("dashboard: count active trips", "error", err) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + + vehicleList, err := ui.vehicles.ListVehicles(ctx) + if err != nil { + slog.Error("dashboard: list vehicles", "error", err) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + labels := make(map[string]string, len(vehicleList)) + for _, v := range vehicleList { + labels[v.ID] = v.Label + } + + tripsByVehicle, err := ui.activeTrips.ListActiveTripsByVehicle(ctx) + if err != nil { + slog.Error("dashboard: list active trips by vehicle", "error", err) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + + status := ui.tracker.Status() + + active := ui.tracker.ActiveVehicles() + sort.Slice(active, func(i, j int) bool { return active[i].UpdatedAt.After(active[j].UpdatedAt) }) + if len(active) > recentActivityLimit { + active = active[:recentActivityLimit] + } + recent := make([]dashboardRow, 0, len(active)) + for _, v := range active { + label := v.VehicleID + if l, ok := labels[v.VehicleID]; ok { + label = l + } + var routeID string + if trip, ok := tripsByVehicle[v.VehicleID]; ok { + routeID = trip.RouteID + } + recent = append(recent, dashboardRow{ + Label: label, + RouteID: routeID, + LastSeen: humanizeAge(v.UpdatedAt), + }) + } + + lastUpdate := "never" + if status.LastUpdate != nil { + lastUpdate = humanizeAge(*status.LastUpdate) + } + ui.renderAdmin(w, r, "dashboard.html", map[string]interface{}{ - "Title": "Dashboard", - "Page": "dashboard", - "TotalVehicles": "24", - "ActiveVehicles": "18", - "TotalDrivers": "32", - "ActiveTrips": "15", - "RecentVehicles": []map[string]string{ - {"Name": "Bus 001", "Route": "Route A", "Status": "active", "LastSeen": "2 min ago"}, - {"Name": "Bus 002", "Route": "Route B", "Status": "active", "LastSeen": "5 min ago"}, - {"Name": "Bus 003", "Route": "Route C", "Status": "idle", "LastSeen": "12 min ago"}, - {"Name": "Bus 004", "Route": "Route A", "Status": "active", "LastSeen": "1 min ago"}, - {"Name": "Bus 005", "Route": "Route D", "Status": "active", "LastSeen": "3 min ago"}, - }, + "Title": "Dashboard", + "Page": "dashboard", + "TotalVehicles": totalVehicles, + "ActiveVehicles": status.ActiveVehicles, + "TotalDrivers": totalDrivers, + "ActiveTrips": activeTripCount, + "LastUpdate": lastUpdate, + "StalenessThreshold": humanizeDuration(ui.cfg.stalenessThreshold), + "RecentVehicles": recent, }) } +// humanizeAge renders how long ago t was, in a compact human form: "just +// now" for anything under a minute, then whole minutes, then whole hours. +func humanizeAge(t time.Time) string { + age := time.Since(t) + switch { + case age < time.Minute: + return "just now" + case age < time.Hour: + return fmt.Sprintf("%d min ago", int(age.Minutes())) + default: + return fmt.Sprintf("%d h ago", int(age.Hours())) + } +} + +// humanizeDuration renders a duration in the same compact style as +// humanizeAge, without the "ago" suffix — used for the feed-health strip's +// staleness threshold. +func humanizeDuration(d time.Duration) string { + switch { + case d < time.Minute: + return fmt.Sprintf("%ds", int(d.Seconds())) + case d < time.Hour: + return fmt.Sprintf("%d min", int(d.Minutes())) + default: + return fmt.Sprintf("%d h", int(d.Hours())) + } +} + func (ui *adminUI) vehiclesPage(w http.ResponseWriter, r *http.Request) { ui.renderAdmin(w, r, "vehicles.html", map[string]interface{}{ "Title": "Vehicles", diff --git a/admin_page_handlers_test.go b/admin_page_handlers_test.go index b529561..606628c 100644 --- a/admin_page_handlers_test.go +++ b/admin_page_handlers_test.go @@ -2,11 +2,13 @@ package main import ( "context" + "errors" "net/http" "net/http/httptest" "net/url" "strings" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -30,12 +32,28 @@ func (f *fakeUserFetcher) GetUserByEmail(_ context.Context, email string) (*User func newTestAdminUI(t *testing.T) *adminUI { t.Helper() - ui, err := newAdminUI(&noopStore{}, testSecret, NewLoginRateLimiter(), adminUIConfig{enabled: true}) + tracker := NewTracker(5 * time.Minute) + t.Cleanup(tracker.Stop) + ui, err := newAdminUI(&noopStore{}, tracker, testSecret, NewLoginRateLimiter(), adminUIConfig{enabled: true, stalenessThreshold: 5 * time.Minute}) require.NoError(t, err) t.Cleanup(ui.loginLimiter.Stop) return ui } +// fakeAdminStats is a configurable adminStatsStore double for dashboard tests +// that need specific counts, independent of the tracker's own vehicle count. +type fakeAdminStats struct { + vehicles int + drivers int + trips int +} + +func (f *fakeAdminStats) CountActiveVehicles(_ context.Context) (int, error) { return f.vehicles, nil } +func (f *fakeAdminStats) CountActiveUsersByRole(_ context.Context, _ string) (int, error) { + return f.drivers, nil +} +func (f *fakeAdminStats) CountActiveTrips(_ context.Context) (int, error) { return f.trips, nil } + func TestAdminLoginPageRenders(t *testing.T) { ui := newTestAdminUI(t) mux := http.NewServeMux() @@ -149,7 +167,7 @@ func TestAdminPagesRenderWithSession(t *testing.T) { path string want string }{ - {"dashboard", "/admin/dashboard", "Bus 001"}, + {"dashboard", "/admin/dashboard", "Active Trips"}, {"vehicles", "/admin/vehicles", "Bus 001"}, {"users", "/admin/users", "Chaitanya K"}, {"trips", "/admin/trips", "Route A"}, @@ -207,3 +225,65 @@ func TestAdminLoginPageRedirectsWhenAlreadyAuthenticated(t *testing.T) { assert.Equal(t, http.StatusSeeOther, w.Code) assert.Equal(t, "/admin/dashboard", w.Header().Get("Location")) } + +// TestDashboardRendersRealCounts verifies the dashboard renders live stats +// (from a fake stats store), the tracker's active-vehicle count/recent +// activity, and that the old mock data is gone. +func TestDashboardRendersRealCounts(t *testing.T) { + ui := newTestAdminUI(t) + ui.stats = &fakeAdminStats{vehicles: 7, drivers: 5, trips: 3} + ui.tracker.Update(&LocationReport{VehicleID: "bus-1", TripID: "g1", Latitude: 1, Longitude: 2, Timestamp: time.Now().Unix()}) + mux := http.NewServeMux() + registerAdminUI(mux, ui) + req := httptest.NewRequest(http.MethodGet, "/admin/dashboard", nil) + req.AddCookie(cookieFor(t, "admin")) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + require.Equal(t, http.StatusOK, w.Code) + body := w.Body.String() + assert.Contains(t, body, ">7<") // total vehicles stat + assert.Contains(t, body, ">5<") // drivers stat + assert.Contains(t, body, ">3<") // active trips stat + assert.Contains(t, body, "bus-1") // recent activity row (label falls back to id) + assert.NotContains(t, body, "Bus 001", "mock data must be gone") +} + +// TestDashboardRecentActivityEmptyState covers the empty-state row when the +// tracker has no active vehicles. +func TestDashboardRecentActivityEmptyState(t *testing.T) { + ui := newTestAdminUI(t) + mux := http.NewServeMux() + registerAdminUI(mux, ui) + req := httptest.NewRequest(http.MethodGet, "/admin/dashboard", nil) + req.AddCookie(cookieFor(t, "admin")) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + require.Equal(t, http.StatusOK, w.Code) + assert.Contains(t, w.Body.String(), "No vehicles have reported recently") +} + +// TestDashboardStoreErrorReturns500 verifies a stats-store failure produces a +// 500 rather than a partially-rendered page. +func TestDashboardStoreErrorReturns500(t *testing.T) { + ui := newTestAdminUI(t) + ui.stats = &erroringAdminStats{} + mux := http.NewServeMux() + registerAdminUI(mux, ui) + req := httptest.NewRequest(http.MethodGet, "/admin/dashboard", nil) + req.AddCookie(cookieFor(t, "admin")) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + assert.Equal(t, http.StatusInternalServerError, w.Code) +} + +type erroringAdminStats struct{} + +func (erroringAdminStats) CountActiveVehicles(_ context.Context) (int, error) { + return 0, errors.New("boom") +} +func (erroringAdminStats) CountActiveUsersByRole(_ context.Context, _ string) (int, error) { + return 0, errors.New("boom") +} +func (erroringAdminStats) CountActiveTrips(_ context.Context) (int, error) { + return 0, errors.New("boom") +} diff --git a/handler_composition_test.go b/handler_composition_test.go index 2c34584..8ba9cf5 100644 --- a/handler_composition_test.go +++ b/handler_composition_test.go @@ -18,7 +18,7 @@ func newTestHandler(t *testing.T, enabled bool) http.Handler { t.Cleanup(tracker.Stop) ll := NewLoginRateLimiter() t.Cleanup(ll.Stop) - h, err := newHandler(&noopStore{}, tracker, nil, ll, testSecret, time.Now(), adminUIConfig{enabled: enabled}) + h, err := newHandler(&noopStore{}, tracker, nil, ll, testSecret, time.Now(), adminUIConfig{enabled: enabled, stalenessThreshold: 5 * time.Minute}) require.NoError(t, err) return h } diff --git a/main.go b/main.go index 829aef6..57bcc9c 100644 --- a/main.go +++ b/main.go @@ -107,7 +107,7 @@ func newHandler(store appStore, tracker *Tracker, rateLimiter *VehicleRateLimite mux := newMux(store, tracker, rateLimiter, jwtSecret, startTime, loginLimiter, cfg.trustProxy) if cfg.enabled { - ui, err := newAdminUI(store, jwtSecret, loginLimiter, cfg) + ui, err := newAdminUI(store, tracker, jwtSecret, loginLimiter, cfg) if err != nil { return nil, fmt.Errorf("init admin UI: %w", err) } @@ -193,7 +193,7 @@ func main() { startTime := time.Now() handler, err := newHandler(store, tracker, rateLimiter, loginLimiter, jwtSecret, startTime, - adminUIConfig{enabled: adminUIEnabled(), trustProxy: trustProxyHeaders()}) + adminUIConfig{enabled: adminUIEnabled(), trustProxy: trustProxyHeaders(), stalenessThreshold: maxAge}) if err != nil { slog.Error("failed to build handler", "error", err) os.Exit(1) diff --git a/web/templates/views/dashboard.html b/web/templates/views/dashboard.html index 359951f..4d2d497 100644 --- a/web/templates/views/dashboard.html +++ b/web/templates/views/dashboard.html @@ -15,7 +15,7 @@

Drivers

{{.TotalDrivers}}

-

Registered

+

Active

Active Trips

@@ -24,6 +24,11 @@
+
+ Feed last updated: {{.LastUpdate}} + Staleness threshold: {{.StalenessThreshold}} +
+

Recent Activity

@@ -35,22 +40,19 @@

Recent Activity

Vehicle Route - Status Last Update + {{if not .RecentVehicles}} + + No vehicles have reported recently. + + {{end}} {{range .RecentVehicles}} - {{.Name}} - {{.Route}} - - {{if eq .Status "active"}} - Active - {{else}} - Idle - {{end}} - + {{.Label}} + {{if .RouteID}}{{.RouteID}}{{else}}—{{end}} {{.LastSeen}} {{end}} From 763e9e74444c29d648874fe1a8415f7f402044cf Mon Sep 17 00:00:00 2001 From: Aaron Brethorst Date: Mon, 24 Aug 2026 02:03:24 -0700 Subject: [PATCH 18/29] feat: live map polls real vehicle data; trip trail mode --- admin_page_handlers.go | 18 +- admin_page_handlers_test.go | 44 ++++ web/static/css/admin.css | 2 +- web/static/js/admin.js | 442 +++++++++++++++++++++-------------- web/templates/views/map.html | 114 ++------- 5 files changed, 345 insertions(+), 275 deletions(-) diff --git a/admin_page_handlers.go b/admin_page_handlers.go index 6b2caa5..4b605b2 100644 --- a/admin_page_handlers.go +++ b/admin_page_handlers.go @@ -7,6 +7,7 @@ import ( "log/slog" "net/http" "sort" + "strconv" "time" "golang.org/x/crypto/bcrypt" @@ -179,10 +180,23 @@ func (ui *adminUI) renderAdmin(w http.ResponseWriter, r *http.Request, view stri renderInto(w, ui.tmpl.admin, view, "base.html", data) } +// mapPage renders the live fleet map, or (with a ?trip_id= query param) a +// single trip's trail. trip_id must be a valid int64 when present; a +// non-numeric value produces 404 rather than silently falling back to live +// mode, since it can't identify any real trip. func (ui *adminUI) mapPage(w http.ResponseWriter, r *http.Request) { + tripID := "" + if raw := r.URL.Query().Get("trip_id"); raw != "" { + if _, err := strconv.ParseInt(raw, 10, 64); err != nil { + http.NotFound(w, r) + return + } + tripID = raw + } ui.renderAdmin(w, r, "map.html", map[string]interface{}{ - "Title": "Live Map", - "Page": "map", + "Title": "Live Map", + "Page": "map", + "TripID": tripID, }) } diff --git a/admin_page_handlers_test.go b/admin_page_handlers_test.go index 606628c..a4778bb 100644 --- a/admin_page_handlers_test.go +++ b/admin_page_handlers_test.go @@ -185,6 +185,50 @@ func TestAdminPagesRenderWithSession(t *testing.T) { } } +// TestMapPageLiveMode verifies the live-map view (no trip_id) tags the map +// container with the live-feed data attribute and omits the trail attribute. +func TestMapPageLiveMode(t *testing.T) { + ui := newTestAdminUI(t) + mux := http.NewServeMux() + registerAdminUI(mux, ui) + req := httptest.NewRequest(http.MethodGet, "/admin/map", nil) + req.AddCookie(cookieFor(t, "admin")) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + require.Equal(t, http.StatusOK, w.Code) + body := w.Body.String() + assert.Contains(t, body, `id="main-map"`) + assert.Contains(t, body, `data-live-url="/api/v1/admin/vehicles/live"`) + assert.NotContains(t, body, "data-trip-url") +} + +// TestMapPageTrailMode verifies a numeric trip_id query param tags the map +// container with the trail-locations data attribute. +func TestMapPageTrailMode(t *testing.T) { + ui := newTestAdminUI(t) + mux := http.NewServeMux() + registerAdminUI(mux, ui) + req := httptest.NewRequest(http.MethodGet, "/admin/map?trip_id=42", nil) + req.AddCookie(cookieFor(t, "admin")) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + require.Equal(t, http.StatusOK, w.Code) + assert.Contains(t, w.Body.String(), `data-trip-url="/api/v1/admin/trips/42/locations"`) +} + +// TestMapPageInvalidTripIDReturns404 verifies a non-numeric trip_id is +// rejected as 404 rather than silently falling back to live mode. +func TestMapPageInvalidTripIDReturns404(t *testing.T) { + ui := newTestAdminUI(t) + mux := http.NewServeMux() + registerAdminUI(mux, ui) + req := httptest.NewRequest(http.MethodGet, "/admin/map?trip_id=not-a-number", nil) + req.AddCookie(cookieFor(t, "admin")) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + assert.Equal(t, http.StatusNotFound, w.Code) +} + // TestAdminRootRedirect covers both branches of rootRedirect: an // authenticated visitor goes straight to the dashboard, an unauthenticated // one goes to the login page. diff --git a/web/static/css/admin.css b/web/static/css/admin.css index 4b4e2f8..2431114 100644 --- a/web/static/css/admin.css +++ b/web/static/css/admin.css @@ -1,2 +1,2 @@ /*! tailwindcss v4.2.0 | MIT License | https://tailwindcss.com */ -@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-space-y-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial}}}@layer theme{:root,:host{--font-sans:system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--color-amber-50:oklch(98.7% .022 95.277);--color-amber-400:oklch(82.8% .189 84.429);--color-amber-500:oklch(76.9% .188 70.08);--color-amber-600:oklch(66.6% .179 58.318);--color-amber-700:oklch(55.5% .163 48.998);--color-emerald-50:oklch(97.9% .021 166.113);--color-emerald-200:oklch(90.5% .093 164.15);--color-emerald-500:oklch(69.6% .17 162.48);--color-emerald-700:oklch(50.8% .118 165.612);--color-emerald-800:oklch(43.2% .095 166.913);--color-teal-600:oklch(60% .118 184.704);--color-cyan-950:oklch(30.2% .056 229.695);--color-sky-50:oklch(97.7% .013 236.62);--color-sky-100:oklch(95.1% .026 236.824);--color-sky-500:oklch(68.5% .169 237.323);--color-sky-600:oklch(58.8% .158 241.966);--color-sky-700:oklch(50% .134 242.749);--color-sky-800:oklch(44.3% .11 240.79);--color-violet-50:oklch(96.9% .016 293.756);--color-violet-700:oklch(49.1% .27 292.581);--color-slate-50:oklch(98.4% .003 247.858);--color-slate-100:oklch(96.8% .007 247.896);--color-slate-200:oklch(92.9% .013 255.508);--color-slate-300:oklch(86.9% .022 252.894);--color-slate-400:oklch(70.4% .04 256.788);--color-slate-500:oklch(55.4% .046 257.417);--color-slate-600:oklch(44.6% .043 257.281);--color-slate-700:oklch(37.2% .044 257.287);--color-slate-800:oklch(27.9% .041 260.031);--color-slate-900:oklch(20.8% .042 265.755);--color-white:#fff;--spacing:.25rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25 / 1.875);--text-4xl:2.25rem;--text-4xl--line-height:calc(2.5 / 2.25);--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--tracking-tight:-.025em;--radius-lg:.5rem;--radius-xl:.75rem;--radius-2xl:1rem;--blur-xl:24px;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono);--font-display:system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;--color-ink:#10233f;--color-line:#d7e3f1}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.pointer-events-auto{pointer-events:auto}.pointer-events-none{pointer-events:none}.absolute{position:absolute}.relative{position:relative}.inset-0{inset:calc(var(--spacing) * 0)}.top-4{top:calc(var(--spacing) * 4)}.bottom-4{bottom:calc(var(--spacing) * 4)}.left-4{left:calc(var(--spacing) * 4)}.z-\[500\]{z-index:500}.container{width:100%}@media (min-width:40rem){.container{max-width:40rem}}@media (min-width:48rem){.container{max-width:48rem}}@media (min-width:64rem){.container{max-width:64rem}}@media (min-width:80rem){.container{max-width:80rem}}@media (min-width:96rem){.container{max-width:96rem}}.mx-5{margin-inline:calc(var(--spacing) * 5)}.mt-0\.5{margin-top:calc(var(--spacing) * .5)}.mt-1{margin-top:calc(var(--spacing) * 1)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mt-6{margin-top:calc(var(--spacing) * 6)}.mb-1\.5{margin-bottom:calc(var(--spacing) * 1.5)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.block{display:block}.flex{display:flex}.grid{display:grid}.inline-flex{display:inline-flex}.h-1\.5{height:calc(var(--spacing) * 1.5)}.h-2{height:calc(var(--spacing) * 2)}.h-2\.5{height:calc(var(--spacing) * 2.5)}.h-7{height:calc(var(--spacing) * 7)}.h-8{height:calc(var(--spacing) * 8)}.h-9{height:calc(var(--spacing) * 9)}.h-12{height:calc(var(--spacing) * 12)}.min-h-screen{min-height:100vh}.w-1\.5{width:calc(var(--spacing) * 1.5)}.w-2{width:calc(var(--spacing) * 2)}.w-2\.5{width:calc(var(--spacing) * 2.5)}.w-8{width:calc(var(--spacing) * 8)}.w-9{width:calc(var(--spacing) * 9)}.w-12{width:calc(var(--spacing) * 12)}.w-full{width:100%}.w-px{width:1px}.min-w-0{min-width:calc(var(--spacing) * 0)}.min-w-full{min-width:100%}.flex-1{flex:1}.shrink-0{flex-shrink:0}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.flex-col{flex-direction:column}.items-center{align-items:center}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.gap-1\.5{gap:calc(var(--spacing) * 1.5)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}.gap-5{gap:calc(var(--spacing) * 5)}:where(.space-y-1\.5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 1.5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 1.5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 5) * calc(1 - var(--tw-space-y-reverse)))}:where(.divide-y>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px * var(--tw-divide-y-reverse));border-bottom-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)))}:where(.divide-slate-100>:not(:last-child)){border-color:var(--color-slate-100)}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.rounded-2xl{border-radius:var(--radius-2xl)}.rounded-\[20px\]{border-radius:20px}.rounded-\[24px\]{border-radius:24px}.rounded-\[28px\]{border-radius:28px}.rounded-\[30px\]{border-radius:30px}.rounded-\[32px\]{border-radius:32px}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius-lg)}.rounded-xl{border-radius:var(--radius-xl)}.border{border-style:var(--tw-border-style);border-width:1px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-emerald-200{border-color:var(--color-emerald-200)}.border-line{border-color:var(--color-line)}.border-slate-200{border-color:var(--color-slate-200)}.border-slate-200\/60{border-color:#e2e8f099}@supports (color:color-mix(in lab, red, red)){.border-slate-200\/60{border-color:color-mix(in oklab, var(--color-slate-200) 60%, transparent)}}.border-slate-200\/70{border-color:#e2e8f0b3}@supports (color:color-mix(in lab, red, red)){.border-slate-200\/70{border-color:color-mix(in oklab, var(--color-slate-200) 70%, transparent)}}.border-white\/10{border-color:#ffffff1a}@supports (color:color-mix(in lab, red, red)){.border-white\/10{border-color:color-mix(in oklab, var(--color-white) 10%, transparent)}}.border-white\/60{border-color:#fff9}@supports (color:color-mix(in lab, red, red)){.border-white\/60{border-color:color-mix(in oklab, var(--color-white) 60%, transparent)}}.border-white\/70{border-color:#ffffffb3}@supports (color:color-mix(in lab, red, red)){.border-white\/70{border-color:color-mix(in oklab, var(--color-white) 70%, transparent)}}.border-white\/80{border-color:#fffc}@supports (color:color-mix(in lab, red, red)){.border-white\/80{border-color:color-mix(in oklab, var(--color-white) 80%, transparent)}}.bg-amber-50{background-color:var(--color-amber-50)}.bg-amber-400{background-color:var(--color-amber-400)}.bg-amber-500{background-color:var(--color-amber-500)}.bg-emerald-50{background-color:var(--color-emerald-50)}.bg-emerald-50\/80{background-color:#ecfdf5cc}@supports (color:color-mix(in lab, red, red)){.bg-emerald-50\/80{background-color:color-mix(in oklab, var(--color-emerald-50) 80%, transparent)}}.bg-emerald-500{background-color:var(--color-emerald-500)}.bg-ink{background-color:var(--color-ink)}.bg-sky-50{background-color:var(--color-sky-50)}.bg-slate-50\/80{background-color:#f8fafccc}@supports (color:color-mix(in lab, red, red)){.bg-slate-50\/80{background-color:color-mix(in oklab, var(--color-slate-50) 80%, transparent)}}.bg-slate-100{background-color:var(--color-slate-100)}.bg-slate-200{background-color:var(--color-slate-200)}.bg-slate-400{background-color:var(--color-slate-400)}.bg-teal-600{background-color:var(--color-teal-600)}.bg-violet-50{background-color:var(--color-violet-50)}.bg-white{background-color:var(--color-white)}.bg-white\/10{background-color:#ffffff1a}@supports (color:color-mix(in lab, red, red)){.bg-white\/10{background-color:color-mix(in oklab, var(--color-white) 10%, transparent)}}.bg-white\/75{background-color:#ffffffbf}@supports (color:color-mix(in lab, red, red)){.bg-white\/75{background-color:color-mix(in oklab, var(--color-white) 75%, transparent)}}.bg-white\/90{background-color:#ffffffe6}@supports (color:color-mix(in lab, red, red)){.bg-white\/90{background-color:color-mix(in oklab, var(--color-white) 90%, transparent)}}.p-2\.5{padding:calc(var(--spacing) * 2.5)}.p-3{padding:calc(var(--spacing) * 3)}.p-4{padding:calc(var(--spacing) * 4)}.p-5{padding:calc(var(--spacing) * 5)}.p-6{padding:calc(var(--spacing) * 6)}.px-2\.5{padding-inline:calc(var(--spacing) * 2.5)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-3\.5{padding-inline:calc(var(--spacing) * 3.5)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-5{padding-inline:calc(var(--spacing) * 5)}.py-0\.5{padding-block:calc(var(--spacing) * .5)}.py-1{padding-block:calc(var(--spacing) * 1)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2\.5{padding-block:calc(var(--spacing) * 2.5)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-3\.5{padding-block:calc(var(--spacing) * 3.5)}.py-4{padding-block:calc(var(--spacing) * 4)}.py-5{padding-block:calc(var(--spacing) * 5)}.text-center{text-align:center}.text-left{text-align:left}.font-display{font-family:var(--font-display)}.font-mono{font-family:var(--font-mono)}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-3xl{font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height))}.text-4xl{font-size:var(--text-4xl);line-height:var(--tw-leading,var(--text-4xl--line-height))}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-\[10px\]{font-size:10px}.text-\[11px\]{font-size:11px}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-\[0\.18em\]{--tw-tracking:.18em;letter-spacing:.18em}.tracking-\[0\.22em\]{--tw-tracking:.22em;letter-spacing:.22em}.tracking-\[0\.25em\]{--tw-tracking:.25em;letter-spacing:.25em}.tracking-\[0\.28em\]{--tw-tracking:.28em;letter-spacing:.28em}.tracking-tight{--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight)}.text-amber-600{color:var(--color-amber-600)}.text-amber-700{color:var(--color-amber-700)}.text-emerald-700{color:var(--color-emerald-700)}.text-emerald-800{color:var(--color-emerald-800)}.text-ink{color:var(--color-ink)}.text-sky-600{color:var(--color-sky-600)}.text-sky-700{color:var(--color-sky-700)}.text-sky-700\/70{color:#0069a4b3}@supports (color:color-mix(in lab, red, red)){.text-sky-700\/70{color:color-mix(in oklab, var(--color-sky-700) 70%, transparent)}}.text-slate-300{color:var(--color-slate-300)}.text-slate-400{color:var(--color-slate-400)}.text-slate-500{color:var(--color-slate-500)}.text-slate-600{color:var(--color-slate-600)}.text-slate-700{color:var(--color-slate-700)}.text-slate-900{color:var(--color-slate-900)}.text-violet-700{color:var(--color-violet-700)}.text-white{color:var(--color-white)}.uppercase{text-transform:uppercase}.shadow-lg{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a), 0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-md{--tw-shadow:0 4px 6px -1px var(--tw-shadow-color,#0000001a), 0 2px 4px -2px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-soft{--tw-shadow:0 24px 70px var(--tw-shadow-color,#0f172a29);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-xl{--tw-shadow:0 20px 25px -5px var(--tw-shadow-color,#0000001a), 0 8px 10px -6px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring-1{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-cyan-950\/30{--tw-shadow-color:#0533454d}@supports (color:color-mix(in lab, red, red)){.shadow-cyan-950\/30{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-cyan-950) 30%, transparent) var(--tw-shadow-alpha), transparent)}}.ring-white\/10{--tw-ring-color:#ffffff1a}@supports (color:color-mix(in lab, red, red)){.ring-white\/10{--tw-ring-color:color-mix(in oklab, var(--color-white) 10%, transparent)}}.backdrop-blur-xl{--tw-backdrop-blur:blur(var(--blur-xl));-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.outline-none{--tw-outline-style:none;outline-style:none}@media (hover:hover){.hover\:bg-slate-50:hover{background-color:var(--color-slate-50)}.hover\:bg-slate-50\/60:hover{background-color:#f8fafc99}@supports (color:color-mix(in lab, red, red)){.hover\:bg-slate-50\/60:hover{background-color:color-mix(in oklab, var(--color-slate-50) 60%, transparent)}}.hover\:bg-slate-800:hover{background-color:var(--color-slate-800)}.hover\:bg-white\/5:hover{background-color:#ffffff0d}@supports (color:color-mix(in lab, red, red)){.hover\:bg-white\/5:hover{background-color:color-mix(in oklab, var(--color-white) 5%, transparent)}}.hover\:text-sky-800:hover{color:var(--color-sky-800)}.hover\:text-white:hover{color:var(--color-white)}}.focus\:border-sky-500:focus{border-color:var(--color-sky-500)}.focus\:ring-4:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(4px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus\:ring-sky-100:focus{--tw-ring-color:var(--color-sky-100)}@media (min-width:40rem){.sm\:p-8{padding:calc(var(--spacing) * 8)}}@media (min-width:64rem){.lg\:sticky{position:sticky}.lg\:top-5{top:calc(var(--spacing) * 5)}.lg\:max-h-\[calc\(100vh-2\.5rem\)\]{max-height:calc(100vh - 2.5rem)}.lg\:w-72{width:calc(var(--spacing) * 72)}.lg\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.lg\:grid-cols-\[1fr_280px\]{grid-template-columns:1fr 280px}.lg\:flex-row{flex-direction:row}.lg\:items-start{align-items:flex-start}.lg\:p-5{padding:calc(var(--spacing) * 5)}.lg\:px-7{padding-inline:calc(var(--spacing) * 7)}}}:root{--bg-top:#f4f9ff;--bg-bottom:#deebf7;--panel:#fffc;--panel-strong:#fffffff0;--border:#94a3b838;--text-strong:#0f172a;--text-soft:#5b6473;--sidebar-top:#0b1728;--sidebar-bottom:#13233d;--teal:#0f766e;--sky:#0284c7;--amber:#f59e0b;--rose:#e11d48}*{box-sizing:border-box}html,body{height:100%}body{font-family:var(--font-sans);color:var(--text-strong);background:radial-gradient(circle at top left, #ffffffeb, transparent 26rem), linear-gradient(180deg, var(--bg-top), var(--bg-bottom));margin:0}h1,h2,h3,.display-font{font-family:var(--font-display)}.glass-panel{background:var(--panel);-webkit-backdrop-filter:blur(18px);backdrop-filter:blur(18px)}.page-shell{background:linear-gradient(135deg,#fffffff5,#f6faffdb);box-shadow:0 18px 48px #0f172a1f,inset 0 1px #ffffffa6}.sidebar-shell{background:radial-gradient(circle at top, #38bdf824, transparent 18rem), linear-gradient(180deg, var(--sidebar-top), var(--sidebar-bottom));box-shadow:inset 0 1px #ffffff0f,0 18px 42px #080f1e5c}.sidebar-link{position:relative;overflow:hidden}.sidebar-link:before{content:"";border-radius:inherit;opacity:0;background:linear-gradient(90deg,#0ea5e929,#2dd4bf14);transition:opacity .16s;position:absolute;inset:0}.sidebar-link:hover:before,.sidebar-link[data-active=true]:before{opacity:1}.table-card{background:var(--panel-strong);-webkit-backdrop-filter:blur(16px);backdrop-filter:blur(16px);box-shadow:0 16px 36px #0f172a14}.stat-card{background:linear-gradient(#fffffff0,#f5f9ffe6);box-shadow:0 16px 32px #0f172a12}.leaflet-container{font-family:var(--font-sans);background:#dbeafe}.leaflet-control-zoom{border:0!important;box-shadow:0 10px 30px #0f172a2e!important}.leaflet-control-zoom a{width:38px!important;height:38px!important;color:var(--text-strong)!important;border:0!important;line-height:38px!important}.leaflet-popup-content-wrapper{border-radius:18px;padding:0;box-shadow:0 18px 38px #0f172a33}.leaflet-popup-content{width:260px!important;margin:0!important}.leaflet-popup-tip{box-shadow:none}.map-popup{background:linear-gradient(#fff,#f4f9ff);padding:1rem}.map-popup__header{justify-content:space-between;align-items:center;gap:.75rem;margin-bottom:.85rem;display:flex}.map-popup__title{font-family:var(--font-display);color:var(--text-strong);font-size:1rem;font-weight:700}.map-popup__badge{text-transform:capitalize;border-radius:999px;align-items:center;gap:.35rem;padding:.3rem .7rem;font-size:.72rem;font-weight:700;display:inline-flex}.map-popup__badge--active{color:var(--teal);background:#0f766e1f}.map-popup__badge--idle{color:#b45309;background:#f59e0b1f}.map-popup__meta{color:var(--text-soft);gap:.55rem;font-size:.84rem;display:grid}.map-popup__meta strong{color:var(--text-strong)}.bus-marker{border:2px solid #ffffffeb;border-radius:16px;justify-content:center;align-items:center;width:42px;height:42px;display:flex;position:relative;transform:translate(-50%,-50%);box-shadow:0 16px 28px #0f172a3d}.bus-marker--active{background:linear-gradient(#14b8a6,#0f766e)}.bus-marker--idle{background:linear-gradient(#fbbf24,#f59e0b)}.bus-marker__pulse{opacity:0;border:2px solid #14b8a647;border-radius:20px;position:absolute;inset:-8px}.bus-marker--active .bus-marker__pulse{animation:2.1s ease-out infinite pulse-ring}.bus-marker__icon{z-index:1;filter:saturate(1.05);font-size:1.15rem;line-height:1;position:relative}@keyframes pulse-ring{0%{opacity:.88;transform:scale(.86)}to{opacity:0;transform:scale(1.3)}}@media (max-width:1023px){body{overflow:auto}}.login-body{min-height:100vh;font-family:var(--font-sans);color:#0f172a;background:radial-gradient(circle at 0 0,#fffffff2,#0000 28rem),linear-gradient(#eef6ff 0%,#dceaf6 100%)}.login-card{flex-direction:column;justify-content:center;max-width:28rem;min-height:100vh;margin-left:auto;margin-right:auto;padding:2rem 1rem;display:flex}.login-sub{color:#475569}.alert{font-size:.875rem}.alert-error{color:#be123c;background:#fff1f2;border:1px solid #fecdd3}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-divide-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-backdrop-blur{syntax:"*";inherits:false}@property --tw-backdrop-brightness{syntax:"*";inherits:false}@property --tw-backdrop-contrast{syntax:"*";inherits:false}@property --tw-backdrop-grayscale{syntax:"*";inherits:false}@property --tw-backdrop-hue-rotate{syntax:"*";inherits:false}@property --tw-backdrop-invert{syntax:"*";inherits:false}@property --tw-backdrop-opacity{syntax:"*";inherits:false}@property --tw-backdrop-saturate{syntax:"*";inherits:false}@property --tw-backdrop-sepia{syntax:"*";inherits:false} \ No newline at end of file +@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-space-y-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial}}}@layer theme{:root,:host{--font-sans:system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--color-amber-50:oklch(98.7% .022 95.277);--color-amber-400:oklch(82.8% .189 84.429);--color-amber-700:oklch(55.5% .163 48.998);--color-emerald-50:oklch(97.9% .021 166.113);--color-emerald-200:oklch(90.5% .093 164.15);--color-emerald-500:oklch(69.6% .17 162.48);--color-emerald-700:oklch(50.8% .118 165.612);--color-emerald-800:oklch(43.2% .095 166.913);--color-teal-600:oklch(60% .118 184.704);--color-cyan-950:oklch(30.2% .056 229.695);--color-sky-50:oklch(97.7% .013 236.62);--color-sky-100:oklch(95.1% .026 236.824);--color-sky-500:oklch(68.5% .169 237.323);--color-sky-600:oklch(58.8% .158 241.966);--color-sky-700:oklch(50% .134 242.749);--color-sky-800:oklch(44.3% .11 240.79);--color-violet-50:oklch(96.9% .016 293.756);--color-violet-700:oklch(49.1% .27 292.581);--color-slate-50:oklch(98.4% .003 247.858);--color-slate-100:oklch(96.8% .007 247.896);--color-slate-200:oklch(92.9% .013 255.508);--color-slate-300:oklch(86.9% .022 252.894);--color-slate-400:oklch(70.4% .04 256.788);--color-slate-500:oklch(55.4% .046 257.417);--color-slate-600:oklch(44.6% .043 257.281);--color-slate-700:oklch(37.2% .044 257.287);--color-slate-800:oklch(27.9% .041 260.031);--color-slate-900:oklch(20.8% .042 265.755);--color-white:#fff;--spacing:.25rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25 / 1.875);--text-4xl:2.25rem;--text-4xl--line-height:calc(2.5 / 2.25);--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--tracking-tight:-.025em;--radius-lg:.5rem;--radius-xl:.75rem;--radius-2xl:1rem;--blur-xl:24px;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono);--font-display:system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;--color-ink:#10233f;--color-line:#d7e3f1}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.pointer-events-auto{pointer-events:auto}.pointer-events-none{pointer-events:none}.absolute{position:absolute}.relative{position:relative}.inset-0{inset:calc(var(--spacing) * 0)}.inset-x-0{inset-inline:calc(var(--spacing) * 0)}.top-4{top:calc(var(--spacing) * 4)}.bottom-4{bottom:calc(var(--spacing) * 4)}.left-4{left:calc(var(--spacing) * 4)}.z-\[500\]{z-index:500}.mx-5{margin-inline:calc(var(--spacing) * 5)}.mt-0\.5{margin-top:calc(var(--spacing) * .5)}.mt-1{margin-top:calc(var(--spacing) * 1)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mt-6{margin-top:calc(var(--spacing) * 6)}.mb-1\.5{margin-bottom:calc(var(--spacing) * 1.5)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.block{display:block}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline-flex{display:inline-flex}.h-1\.5{height:calc(var(--spacing) * 1.5)}.h-2{height:calc(var(--spacing) * 2)}.h-2\.5{height:calc(var(--spacing) * 2.5)}.h-7{height:calc(var(--spacing) * 7)}.h-8{height:calc(var(--spacing) * 8)}.h-9{height:calc(var(--spacing) * 9)}.h-12{height:calc(var(--spacing) * 12)}.min-h-screen{min-height:100vh}.w-1\.5{width:calc(var(--spacing) * 1.5)}.w-2{width:calc(var(--spacing) * 2)}.w-2\.5{width:calc(var(--spacing) * 2.5)}.w-8{width:calc(var(--spacing) * 8)}.w-9{width:calc(var(--spacing) * 9)}.w-12{width:calc(var(--spacing) * 12)}.w-full{width:100%}.w-px{width:1px}.min-w-0{min-width:calc(var(--spacing) * 0)}.min-w-full{min-width:100%}.flex-1{flex:1}.shrink-0{flex-shrink:0}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.gap-1\.5{gap:calc(var(--spacing) * 1.5)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}.gap-5{gap:calc(var(--spacing) * 5)}:where(.space-y-1\.5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 1.5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 1.5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 5) * calc(1 - var(--tw-space-y-reverse)))}.gap-x-6{column-gap:calc(var(--spacing) * 6)}.gap-y-1{row-gap:calc(var(--spacing) * 1)}:where(.divide-y>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px * var(--tw-divide-y-reverse));border-bottom-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)))}:where(.divide-slate-100>:not(:last-child)){border-color:var(--color-slate-100)}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.rounded-2xl{border-radius:var(--radius-2xl)}.rounded-\[16px\]{border-radius:16px}.rounded-\[20px\]{border-radius:20px}.rounded-\[24px\]{border-radius:24px}.rounded-\[28px\]{border-radius:28px}.rounded-\[30px\]{border-radius:30px}.rounded-\[32px\]{border-radius:32px}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius-lg)}.rounded-xl{border-radius:var(--radius-xl)}.border{border-style:var(--tw-border-style);border-width:1px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-emerald-200{border-color:var(--color-emerald-200)}.border-line{border-color:var(--color-line)}.border-slate-200{border-color:var(--color-slate-200)}.border-slate-200\/60{border-color:#e2e8f099}@supports (color:color-mix(in lab, red, red)){.border-slate-200\/60{border-color:color-mix(in oklab, var(--color-slate-200) 60%, transparent)}}.border-slate-200\/70{border-color:#e2e8f0b3}@supports (color:color-mix(in lab, red, red)){.border-slate-200\/70{border-color:color-mix(in oklab, var(--color-slate-200) 70%, transparent)}}.border-white\/10{border-color:#ffffff1a}@supports (color:color-mix(in lab, red, red)){.border-white\/10{border-color:color-mix(in oklab, var(--color-white) 10%, transparent)}}.border-white\/60{border-color:#fff9}@supports (color:color-mix(in lab, red, red)){.border-white\/60{border-color:color-mix(in oklab, var(--color-white) 60%, transparent)}}.border-white\/70{border-color:#ffffffb3}@supports (color:color-mix(in lab, red, red)){.border-white\/70{border-color:color-mix(in oklab, var(--color-white) 70%, transparent)}}.border-white\/80{border-color:#fffc}@supports (color:color-mix(in lab, red, red)){.border-white\/80{border-color:color-mix(in oklab, var(--color-white) 80%, transparent)}}.bg-amber-50{background-color:var(--color-amber-50)}.bg-amber-400{background-color:var(--color-amber-400)}.bg-emerald-50{background-color:var(--color-emerald-50)}.bg-emerald-50\/80{background-color:#ecfdf5cc}@supports (color:color-mix(in lab, red, red)){.bg-emerald-50\/80{background-color:color-mix(in oklab, var(--color-emerald-50) 80%, transparent)}}.bg-emerald-500{background-color:var(--color-emerald-500)}.bg-ink{background-color:var(--color-ink)}.bg-sky-50{background-color:var(--color-sky-50)}.bg-slate-50\/80{background-color:#f8fafccc}@supports (color:color-mix(in lab, red, red)){.bg-slate-50\/80{background-color:color-mix(in oklab, var(--color-slate-50) 80%, transparent)}}.bg-slate-100{background-color:var(--color-slate-100)}.bg-slate-200{background-color:var(--color-slate-200)}.bg-slate-400{background-color:var(--color-slate-400)}.bg-teal-600{background-color:var(--color-teal-600)}.bg-violet-50{background-color:var(--color-violet-50)}.bg-white{background-color:var(--color-white)}.bg-white\/10{background-color:#ffffff1a}@supports (color:color-mix(in lab, red, red)){.bg-white\/10{background-color:color-mix(in oklab, var(--color-white) 10%, transparent)}}.bg-white\/75{background-color:#ffffffbf}@supports (color:color-mix(in lab, red, red)){.bg-white\/75{background-color:color-mix(in oklab, var(--color-white) 75%, transparent)}}.bg-white\/90{background-color:#ffffffe6}@supports (color:color-mix(in lab, red, red)){.bg-white\/90{background-color:color-mix(in oklab, var(--color-white) 90%, transparent)}}.p-2\.5{padding:calc(var(--spacing) * 2.5)}.p-3{padding:calc(var(--spacing) * 3)}.p-4{padding:calc(var(--spacing) * 4)}.p-5{padding:calc(var(--spacing) * 5)}.p-6{padding:calc(var(--spacing) * 6)}.px-2\.5{padding-inline:calc(var(--spacing) * 2.5)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-3\.5{padding-inline:calc(var(--spacing) * 3.5)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-5{padding-inline:calc(var(--spacing) * 5)}.py-1{padding-block:calc(var(--spacing) * 1)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2\.5{padding-block:calc(var(--spacing) * 2.5)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-3\.5{padding-block:calc(var(--spacing) * 3.5)}.py-4{padding-block:calc(var(--spacing) * 4)}.py-5{padding-block:calc(var(--spacing) * 5)}.py-6{padding-block:calc(var(--spacing) * 6)}.text-center{text-align:center}.text-left{text-align:left}.font-display{font-family:var(--font-display)}.font-mono{font-family:var(--font-mono)}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-3xl{font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height))}.text-4xl{font-size:var(--text-4xl);line-height:var(--tw-leading,var(--text-4xl--line-height))}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-\[10px\]{font-size:10px}.text-\[11px\]{font-size:11px}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-\[0\.18em\]{--tw-tracking:.18em;letter-spacing:.18em}.tracking-\[0\.22em\]{--tw-tracking:.22em;letter-spacing:.22em}.tracking-\[0\.25em\]{--tw-tracking:.25em;letter-spacing:.25em}.tracking-\[0\.28em\]{--tw-tracking:.28em;letter-spacing:.28em}.tracking-tight{--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight)}.text-amber-700{color:var(--color-amber-700)}.text-emerald-700{color:var(--color-emerald-700)}.text-emerald-800{color:var(--color-emerald-800)}.text-ink{color:var(--color-ink)}.text-sky-600{color:var(--color-sky-600)}.text-sky-700{color:var(--color-sky-700)}.text-sky-700\/70{color:#0069a4b3}@supports (color:color-mix(in lab, red, red)){.text-sky-700\/70{color:color-mix(in oklab, var(--color-sky-700) 70%, transparent)}}.text-slate-300{color:var(--color-slate-300)}.text-slate-400{color:var(--color-slate-400)}.text-slate-500{color:var(--color-slate-500)}.text-slate-600{color:var(--color-slate-600)}.text-slate-700{color:var(--color-slate-700)}.text-slate-900{color:var(--color-slate-900)}.text-violet-700{color:var(--color-violet-700)}.text-white{color:var(--color-white)}.uppercase{text-transform:uppercase}.shadow-lg{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a), 0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-md{--tw-shadow:0 4px 6px -1px var(--tw-shadow-color,#0000001a), 0 2px 4px -2px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-soft{--tw-shadow:0 24px 70px var(--tw-shadow-color,#0f172a29);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-xl{--tw-shadow:0 20px 25px -5px var(--tw-shadow-color,#0000001a), 0 8px 10px -6px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring-1{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-cyan-950\/30{--tw-shadow-color:#0533454d}@supports (color:color-mix(in lab, red, red)){.shadow-cyan-950\/30{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-cyan-950) 30%, transparent) var(--tw-shadow-alpha), transparent)}}.ring-white\/10{--tw-ring-color:#ffffff1a}@supports (color:color-mix(in lab, red, red)){.ring-white\/10{--tw-ring-color:color-mix(in oklab, var(--color-white) 10%, transparent)}}.backdrop-blur-xl{--tw-backdrop-blur:blur(var(--blur-xl));-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.outline-none{--tw-outline-style:none;outline-style:none}@media (hover:hover){.hover\:bg-slate-50:hover{background-color:var(--color-slate-50)}.hover\:bg-slate-50\/60:hover{background-color:#f8fafc99}@supports (color:color-mix(in lab, red, red)){.hover\:bg-slate-50\/60:hover{background-color:color-mix(in oklab, var(--color-slate-50) 60%, transparent)}}.hover\:bg-slate-800:hover{background-color:var(--color-slate-800)}.hover\:bg-white\/5:hover{background-color:#ffffff0d}@supports (color:color-mix(in lab, red, red)){.hover\:bg-white\/5:hover{background-color:color-mix(in oklab, var(--color-white) 5%, transparent)}}.hover\:text-sky-800:hover{color:var(--color-sky-800)}.hover\:text-white:hover{color:var(--color-white)}}.focus\:border-sky-500:focus{border-color:var(--color-sky-500)}.focus\:ring-4:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(4px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus\:ring-sky-100:focus{--tw-ring-color:var(--color-sky-100)}@media (min-width:40rem){.sm\:p-8{padding:calc(var(--spacing) * 8)}}@media (min-width:64rem){.lg\:sticky{position:sticky}.lg\:top-5{top:calc(var(--spacing) * 5)}.lg\:max-h-\[calc\(100vh-2\.5rem\)\]{max-height:calc(100vh - 2.5rem)}.lg\:w-72{width:calc(var(--spacing) * 72)}.lg\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.lg\:grid-cols-\[1fr_280px\]{grid-template-columns:1fr 280px}.lg\:flex-row{flex-direction:row}.lg\:items-start{align-items:flex-start}.lg\:p-5{padding:calc(var(--spacing) * 5)}.lg\:px-7{padding-inline:calc(var(--spacing) * 7)}}}:root{--bg-top:#f4f9ff;--bg-bottom:#deebf7;--panel:#fffc;--panel-strong:#fffffff0;--border:#94a3b838;--text-strong:#0f172a;--text-soft:#5b6473;--sidebar-top:#0b1728;--sidebar-bottom:#13233d;--teal:#0f766e;--sky:#0284c7;--amber:#f59e0b;--rose:#e11d48}*{box-sizing:border-box}html,body{height:100%}body{font-family:var(--font-sans);color:var(--text-strong);background:radial-gradient(circle at top left, #ffffffeb, transparent 26rem), linear-gradient(180deg, var(--bg-top), var(--bg-bottom));margin:0}h1,h2,h3,.display-font{font-family:var(--font-display)}.glass-panel{background:var(--panel);-webkit-backdrop-filter:blur(18px);backdrop-filter:blur(18px)}.page-shell{background:linear-gradient(135deg,#fffffff5,#f6faffdb);box-shadow:0 18px 48px #0f172a1f,inset 0 1px #ffffffa6}.sidebar-shell{background:radial-gradient(circle at top, #38bdf824, transparent 18rem), linear-gradient(180deg, var(--sidebar-top), var(--sidebar-bottom));box-shadow:inset 0 1px #ffffff0f,0 18px 42px #080f1e5c}.sidebar-link{position:relative;overflow:hidden}.sidebar-link:before{content:"";border-radius:inherit;opacity:0;background:linear-gradient(90deg,#0ea5e929,#2dd4bf14);transition:opacity .16s;position:absolute;inset:0}.sidebar-link:hover:before,.sidebar-link[data-active=true]:before{opacity:1}.table-card{background:var(--panel-strong);-webkit-backdrop-filter:blur(16px);backdrop-filter:blur(16px);box-shadow:0 16px 36px #0f172a14}.stat-card{background:linear-gradient(#fffffff0,#f5f9ffe6);box-shadow:0 16px 32px #0f172a12}.leaflet-container{font-family:var(--font-sans);background:#dbeafe}.leaflet-control-zoom{border:0!important;box-shadow:0 10px 30px #0f172a2e!important}.leaflet-control-zoom a{width:38px!important;height:38px!important;color:var(--text-strong)!important;border:0!important;line-height:38px!important}.leaflet-popup-content-wrapper{border-radius:18px;padding:0;box-shadow:0 18px 38px #0f172a33}.leaflet-popup-content{width:260px!important;margin:0!important}.leaflet-popup-tip{box-shadow:none}.map-popup{background:linear-gradient(#fff,#f4f9ff);padding:1rem}.map-popup__header{justify-content:space-between;align-items:center;gap:.75rem;margin-bottom:.85rem;display:flex}.map-popup__title{font-family:var(--font-display);color:var(--text-strong);font-size:1rem;font-weight:700}.map-popup__badge{text-transform:capitalize;border-radius:999px;align-items:center;gap:.35rem;padding:.3rem .7rem;font-size:.72rem;font-weight:700;display:inline-flex}.map-popup__badge--active{color:var(--teal);background:#0f766e1f}.map-popup__badge--idle{color:#b45309;background:#f59e0b1f}.map-popup__meta{color:var(--text-soft);gap:.55rem;font-size:.84rem;display:grid}.map-popup__meta strong{color:var(--text-strong)}.bus-marker{border:2px solid #ffffffeb;border-radius:16px;justify-content:center;align-items:center;width:42px;height:42px;display:flex;position:relative;transform:translate(-50%,-50%);box-shadow:0 16px 28px #0f172a3d}.bus-marker--active{background:linear-gradient(#14b8a6,#0f766e)}.bus-marker--idle{background:linear-gradient(#fbbf24,#f59e0b)}.bus-marker__pulse{opacity:0;border:2px solid #14b8a647;border-radius:20px;position:absolute;inset:-8px}.bus-marker--active .bus-marker__pulse{animation:2.1s ease-out infinite pulse-ring}.bus-marker__icon{z-index:1;filter:saturate(1.05);font-size:1.15rem;line-height:1;position:relative}@keyframes pulse-ring{0%{opacity:.88;transform:scale(.86)}to{opacity:0;transform:scale(1.3)}}@media (max-width:1023px){body{overflow:auto}}.login-body{min-height:100vh;font-family:var(--font-sans);color:#0f172a;background:radial-gradient(circle at 0 0,#fffffff2,#0000 28rem),linear-gradient(#eef6ff 0%,#dceaf6 100%)}.login-card{flex-direction:column;justify-content:center;max-width:28rem;min-height:100vh;margin-left:auto;margin-right:auto;padding:2rem 1rem;display:flex}.login-sub{color:#475569}.alert{font-size:.875rem}.alert-error{color:#be123c;background:#fff1f2;border:1px solid #fecdd3}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-divide-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-backdrop-blur{syntax:"*";inherits:false}@property --tw-backdrop-brightness{syntax:"*";inherits:false}@property --tw-backdrop-contrast{syntax:"*";inherits:false}@property --tw-backdrop-grayscale{syntax:"*";inherits:false}@property --tw-backdrop-hue-rotate{syntax:"*";inherits:false}@property --tw-backdrop-invert{syntax:"*";inherits:false}@property --tw-backdrop-opacity{syntax:"*";inherits:false}@property --tw-backdrop-saturate{syntax:"*";inherits:false}@property --tw-backdrop-sepia{syntax:"*";inherits:false} \ No newline at end of file diff --git a/web/static/js/admin.js b/web/static/js/admin.js index 0283073..db03356 100644 --- a/web/static/js/admin.js +++ b/web/static/js/admin.js @@ -1,181 +1,277 @@ -const mockVehicles = [ - { - id: "SEA-101", - label: "Bus 101", - lat: 47.6101, - lng: -122.3426, - route: "Rapid E Line", - status: "active", - corridor: "Downtown to Ballard", - stop: "3rd Ave & Pine St", - }, - { - id: "SEA-108", - label: "Bus 108", - lat: 47.6206, - lng: -122.3201, - route: "Route 8", - status: "active", - corridor: "Capitol Hill Crosstown", - stop: "Denny Way & Broadway", - }, - { - id: "SEA-214", - label: "Bus 214", - lat: 47.5989, - lng: -122.3347, - route: "South Lake Loop", - status: "active", - corridor: "Pioneer Square Connector", - stop: "Jackson St Transit Hub", - }, - { - id: "SEA-305", - label: "Bus 305", - lat: 47.6677, - lng: -122.3826, - route: "Rapid E Line", - status: "idle", - corridor: "Northwest Layover", - stop: "Ballard Ave NW", - }, - { - id: "SEA-417", - label: "Bus 417", - lat: 47.6267, - lng: -122.3561, - route: "South Lake Loop", - status: "active", - corridor: "Seattle Center Spur", - stop: "Queen Anne Ave N", - }, - { - id: "SEA-522", - label: "Bus 522", - lat: 47.5884, - lng: -122.3023, - route: "Route 8", - status: "idle", - corridor: "Mount Baker Relief", - stop: "Rainier Ave S", - }, -]; - -const mockCorridors = [ - { - name: "Rapid E Line", - color: "#0f766e", - points: [ - [47.6101, -122.3426], - [47.6205, -122.3492], - [47.6362, -122.3563], - [47.6516, -122.3752], - [47.6677, -122.3826], - ], - }, - { - name: "Route 8", - color: "#f59e0b", - points: [ - [47.5884, -122.3023], - [47.6002, -122.3119], - [47.6117, -122.3174], - [47.6206, -122.3201], - [47.6312, -122.3225], - ], - }, - { - name: "South Lake Loop", - color: "#0284c7", - points: [ - [47.5989, -122.3347], - [47.6072, -122.3324], - [47.6202, -122.3384], - [47.6267, -122.3561], - ], - }, -]; - -function makeMarkerIcon(status) { - const markerClass = status === "active" ? "bus-marker bus-marker--active" : "bus-marker bus-marker--idle"; - return L.divIcon({ - className: "", - html: `
- - 🚌 -
`, - iconSize: [42, 42], - iconAnchor: [21, 21], - popupAnchor: [0, -18], - }); -} - -function buildPopup(vehicle) { - const badgeClass = vehicle.status === "active" - ? "map-popup__badge map-popup__badge--active" - : "map-popup__badge map-popup__badge--idle"; - - return `
-
-
-
🚌 ${vehicle.label}
-
${vehicle.id}
-
- ${vehicle.status} -
-
-
Route ${vehicle.route}
-
Corridor ${vehicle.corridor}
-
Nearest stop ${vehicle.stop}
-
-
`; -} - -function initMap() { +(function () { const el = document.getElementById("main-map"); - if (!el) return; - const map = L.map("main-map", { - zoomControl: false, - scrollWheelZoom: true, - }).setView([47.6062, -122.3321], 13); + if (!el || typeof L === "undefined") return; + const map = L.map("main-map", { zoomControl: false }).setView([0, 0], 2); L.tileLayer("https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png", { attribution: "© OpenStreetMap contributors © CARTO", maxZoom: 19, }).addTo(map); - L.control.zoom({ position: "bottomright" }).addTo(map); - mockCorridors.forEach(corridor => { - L.polyline(corridor.points, { - color: corridor.color, - weight: 5, - opacity: 0.82, - lineCap: "round", - }) - .addTo(map) - .bindTooltip(corridor.name, { - direction: "top", - offset: [0, -4], - opacity: 0.95, - }); - }); - - mockVehicles.forEach(v => { - L.marker([v.lat, v.lng], { icon: makeMarkerIcon(v.status) }) - .addTo(map) - .bindPopup(buildPopup(v)); - }); - - const activeCount = mockVehicles.filter(vehicle => vehicle.status === "active").length; - const activeCountEl = document.getElementById("fleet-active-count"); - const routeCountEl = document.getElementById("route-count"); - - if (activeCountEl) activeCountEl.textContent = String(activeCount); - if (routeCountEl) routeCountEl.textContent = String(mockCorridors.length); -} - -// Auto-initialize the map if this page has the map container -if (document.getElementById("main-map")) { - initMap(); -} + const tripUrl = el.dataset.tripUrl; + if (tripUrl) { + renderTrail(tripUrl); + } else { + startLive(el.dataset.liveUrl); + } + + // busIcon returns the shared divIcon markup used for every marker (live + // fleet vehicles and trail start/end points). There is only one visual + // style now that the map no longer distinguishes idle vehicles. + function busIcon() { + return L.divIcon({ + className: "", + html: '
' + + '' + + '🚌' + + "
", + iconSize: [42, 42], + iconAnchor: [21, 21], + popupAnchor: [0, -18], + }); + } + + // ageText renders a human-friendly "how long ago" string from a unix + // timestamp (seconds). Falls back to "unknown" for missing/invalid input. + function ageText(unixSeconds) { + if (!unixSeconds && unixSeconds !== 0) return "unknown"; + const deltaMs = Date.now() - unixSeconds * 1000; + const deltaSec = Math.max(0, Math.round(deltaMs / 1000)); + if (deltaSec < 60) return deltaSec + "s ago"; + const deltaMin = Math.round(deltaSec / 60); + if (deltaMin < 60) return deltaMin + "m ago"; + const deltaHr = Math.round(deltaMin / 60); + return deltaHr + "h ago"; + } + + // metaRow builds a single "label value" row using DOM + // APIs only, so any server-supplied text lands via textContent. + function metaRow(label, value) { + const row = document.createElement("div"); + const strong = document.createElement("strong"); + strong.textContent = label; + row.appendChild(strong); + row.appendChild(document.createTextNode(" " + value)); + return row; + } + + // popupHtml builds a vehicle marker popup's DOM tree from live-feed data. + // Every server-provided string goes through textContent/createTextNode — + // never innerHTML — so a malicious label/driver name can't inject markup. + function popupHtml(v) { + const wrap = document.createElement("div"); + wrap.className = "map-popup"; + + const header = document.createElement("div"); + header.className = "map-popup__header"; + + const titleWrap = document.createElement("div"); + const title = document.createElement("div"); + title.className = "map-popup__title"; + title.textContent = "\u{1F68C} " + (v.label || v.vehicle_id); + const subtitle = document.createElement("div"); + subtitle.style.fontSize = "12px"; + subtitle.style.color = "#64748b"; + subtitle.style.marginTop = "2px"; + subtitle.textContent = String(v.vehicle_id); + titleWrap.appendChild(title); + titleWrap.appendChild(subtitle); + header.appendChild(titleWrap); + wrap.appendChild(header); + + const meta = document.createElement("div"); + meta.className = "map-popup__meta"; + meta.appendChild(metaRow("Route", v.route_id || "—")); + meta.appendChild(metaRow("Driver", v.driver_name || "—")); + meta.appendChild(metaRow("Speed", v.speed != null ? Math.round(v.speed) + " m/s" : "—")); + meta.appendChild(metaRow("Updated", ageText(v.reported_at))); + wrap.appendChild(meta); + + return wrap; + } + + async function fetchJSON(url) { + const res = await fetch(url, { headers: { Accept: "application/json" } }); + if (!res.ok) throw new Error("HTTP " + res.status); + return res.json(); + } + + // --- live mode --- + let markers = new Map(); + let fitted = false; + let timer = null; + + async function refresh(url) { + try { + const data = await fetchJSON(url); + drawVehicles(data.vehicles || []); + updateSidebar(data.vehicles || []); + } catch (e) { + console.error("live refresh failed", e); + } + } + + function startLive(url) { + refresh(url); + timer = setInterval(() => { + if (!document.hidden) refresh(url); + }, 10000); + document.addEventListener("visibilitychange", () => { + if (!document.hidden) refresh(url); + }); + } + + function drawVehicles(vehicles) { + const seen = new Set(); + vehicles.forEach(v => { + seen.add(v.vehicle_id); + const ll = [v.latitude, v.longitude]; + if (markers.has(v.vehicle_id)) { + markers.get(v.vehicle_id).setLatLng(ll).setPopupContent(popupHtml(v)); + } else { + markers.set(v.vehicle_id, L.marker(ll, { icon: busIcon() }).addTo(map).bindPopup(popupHtml(v))); + } + }); + for (const [id, m] of markers) { + if (!seen.has(id)) { + map.removeLayer(m); + markers.delete(id); + } + } + document.getElementById("empty-banner")?.classList.toggle("hidden", vehicles.length > 0); + if (!fitted && vehicles.length) { + map.fitBounds(vehicles.map(v => [v.latitude, v.longitude]), { padding: [40, 40], maxZoom: 15 }); + fitted = true; + } + const routes = new Set(vehicles.map(v => v.route_id).filter(Boolean)); + setText("stat-active", vehicles.length); + setText("stat-routes", routes.size); + } + + // updateSidebar rebuilds the #fleet-list rows from scratch on every + // refresh. The fleet is small enough that a full rebuild is simpler (and + // safer against stale nodes) than diffing, and every field is set via + // textContent so server strings can never become markup. + function updateSidebar(vehicles) { + const list = document.getElementById("fleet-list"); + if (!list) return; + list.textContent = ""; + + if (!vehicles.length) { + const empty = document.createElement("p"); + empty.className = "text-xs text-slate-400"; + empty.textContent = "No vehicles reporting."; + list.appendChild(empty); + return; + } + + vehicles.forEach(v => { + const row = document.createElement("div"); + row.className = "flex items-center gap-3 rounded-xl bg-slate-50/80 p-2.5"; + + const icon = document.createElement("span"); + icon.className = "flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-teal-600 text-sm text-white"; + icon.textContent = "\u{1F68C}"; + row.appendChild(icon); + + const info = document.createElement("div"); + info.className = "min-w-0 flex-1"; + + const label = document.createElement("p"); + label.className = "truncate text-sm font-semibold text-slate-900"; + label.textContent = v.label || v.vehicle_id; + info.appendChild(label); + + const sub = document.createElement("p"); + sub.className = "truncate text-xs text-slate-400"; + const routeText = v.route_id || "No route"; + const driverText = v.driver_name || "Unassigned"; + sub.textContent = routeText + " · " + driverText; + info.appendChild(sub); + + row.appendChild(info); + + const dot = document.createElement("span"); + dot.className = "h-2 w-2 shrink-0 rounded-full bg-emerald-500"; + row.appendChild(dot); + + list.appendChild(row); + }); + } + + function setText(id, v) { + const n = document.getElementById(id); + if (n) n.textContent = String(v); + } + + // --- trail mode --- + async function renderTrail(url) { + try { + const data = await fetchJSON(url); + const pts = (data.points || []).map(p => [p.latitude, p.longitude]); + if (!pts.length) { + document.getElementById("empty-banner")?.classList.remove("hidden"); + return; + } + L.polyline(pts, { color: "#0f766e", weight: 5, opacity: 0.85 }).addTo(map); + L.marker(pts[0], { icon: busIcon() }).addTo(map).bindPopup(trailPopup("Start", data.trip)); + L.marker(pts[pts.length - 1], { icon: busIcon() }).addTo(map).bindPopup(trailPopup("End", data.trip)); + map.fitBounds(pts, { padding: [40, 40] }); + renderTripHeader(data.trip); + } catch (e) { + console.error("trail load failed", e); + } + } + + // trailPopup builds a Start/End marker popup from trip metadata via DOM + // APIs only. + function trailPopup(kind, trip) { + const wrap = document.createElement("div"); + wrap.className = "map-popup"; + + const header = document.createElement("div"); + header.className = "map-popup__header"; + const title = document.createElement("div"); + title.className = "map-popup__title"; + title.textContent = kind; + header.appendChild(title); + wrap.appendChild(header); + + const meta = document.createElement("div"); + meta.className = "map-popup__meta"; + if (trip) { + meta.appendChild(metaRow("Vehicle", trip.vehicle_label || trip.vehicle_id || "—")); + meta.appendChild(metaRow("Driver", trip.driver_name || "—")); + meta.appendChild(metaRow("Route", trip.route_id || "—")); + } + wrap.appendChild(meta); + + return wrap; + } + + // renderTripHeader replaces the fleet sidebar with a summary of the trip + // being viewed, built entirely via DOM APIs. + function renderTripHeader(trip) { + const list = document.getElementById("fleet-list"); + if (!list || !trip) return; + list.textContent = ""; + setText("fleet-title", "Trip Detail"); + + const card = document.createElement("div"); + card.className = "space-y-2"; + + const title = document.createElement("p"); + title.className = "text-sm font-semibold text-slate-900"; + title.textContent = trip.vehicle_label || trip.vehicle_id || "Trip"; + card.appendChild(title); + + card.appendChild(metaRow("Driver", trip.driver_name || "—")); + card.appendChild(metaRow("Route", trip.route_id || "—")); + card.appendChild(metaRow("Status", trip.status || "—")); + card.appendChild(metaRow("Started", trip.start_time || "—")); + if (trip.end_time) card.appendChild(metaRow("Ended", trip.end_time)); + + list.appendChild(card); + } +})(); diff --git a/web/templates/views/map.html b/web/templates/views/map.html index dc3a2ff..d40df82 100644 --- a/web/templates/views/map.html +++ b/web/templates/views/map.html @@ -4,24 +4,28 @@
-
+
+ + +
-

4

+

0

Active

-

2

-

Idle

-
-
-
-

6

-

Buses

+

0

+

Routes

@@ -30,108 +34,20 @@
Active - Idle
-
-

Fleet Status

- 6 buses -
-
-
- 🚌 -
-

Bus 001

-

Route A · James Mwangi

-
- -
-
- 🚌 -
-

Bus 002

-

Route B · Brad Pitt

-
- -
-
- 🚌 -
-

Bus 003

-

Route C · Bruce Wayne

-
- -
-
- 🚌 -
-

Bus 004

-

Route A · Michael Phelps

-
- -
-
- 🚌 -
-

Bus 005

-

Route D · Michael Jordan

-
- -
-
- 🚌 -
-

Bus 006

-

Route B · Unassigned

-
- -
-
-
- - -
-

Active Routes

-
-
-
-

Route A

-

CBD → Westlands

-
- 2 buses -
-
-
-

Route B

-

Ngong Rd → City

-
- 2 buses -
-
-
-

Route C

-

Thika Rd → CBD

-
- 1 bus -
-
-
-

Route D

-

Eastlands → City

-
- 1 bus -
+

Fleet Status

+
{{end}} - From 5a31425b09171204ca8756f0540eccfa6fa05e17 Mon Sep 17 00:00:00 2001 From: Aaron Brethorst Date: Mon, 24 Aug 2026 02:14:30 -0700 Subject: [PATCH 19/29] feat: vehicle management pages with create, edit, deactivate, CSV export --- admin_handlers.go | 1 + admin_page_handlers.go | 266 ++++++++++++++++++++--- admin_page_handlers_test.go | 290 +++++++++++++++++++++++++- web/static/css/admin.css | 2 +- web/templates/views/vehicle_form.html | 33 +++ web/templates/views/vehicles.html | 53 +++-- 6 files changed, 604 insertions(+), 41 deletions(-) create mode 100644 web/templates/views/vehicle_form.html diff --git a/admin_handlers.go b/admin_handlers.go index 6972bcc..a577d20 100644 --- a/admin_handlers.go +++ b/admin_handlers.go @@ -43,6 +43,7 @@ func loadTemplates() (*embeddedTemplates, error) { "trips.html", "users.html", "vehicles.html", + "vehicle_form.html", } admin := make(map[string]*template.Template, len(adminViews)) diff --git a/admin_page_handlers.go b/admin_page_handlers.go index 4b605b2..3f8ad54 100644 --- a/admin_page_handlers.go +++ b/admin_page_handlers.go @@ -10,6 +10,7 @@ import ( "strconv" "time" + "github.com/jackc/pgx/v5" "golang.org/x/crypto/bcrypt" ) @@ -31,17 +32,29 @@ type adminStatsStore interface { CountActiveTrips(ctx context.Context) (int, error) } +// vehicleEditor is the narrow interface the vehicle edit/deactivate/activate +// pages need: label/agency-tag updates and active-flag toggling. It's kept +// separate from VehicleManager because UpsertVehicle (used by the create +// page) force-reactivates a vehicle, which the edit/deactivate/activate +// flows must not do. +type vehicleEditor interface { + VehicleInfoUpdater + VehicleActivator +} + // adminUI owns the parsed templates and dependencies for all admin pages. type adminUI struct { - tmpl *embeddedTemplates - users UserFetcher - tracker *Tracker - stats adminStatsStore - activeTrips ActiveTripLister - vehicles VehicleManager - jwtSecret []byte - loginLimiter *LoginRateLimiter - cfg adminUIConfig + tmpl *embeddedTemplates + users UserFetcher + tracker *Tracker + stats adminStatsStore + activeTrips ActiveTripLister + vehicles VehicleManager + vehicleEditor vehicleEditor + vehicleChecker VehicleChecker + jwtSecret []byte + loginLimiter *LoginRateLimiter + cfg adminUIConfig } // newAdminUI loads the embedded templates and wires the admin UI's @@ -55,15 +68,17 @@ func newAdminUI(store appStore, tracker *Tracker, jwtSecret []byte, limiter *Log return nil, err } return &adminUI{ - tmpl: tmpl, - users: store, - tracker: tracker, - stats: store, - activeTrips: store, - vehicles: store, - jwtSecret: jwtSecret, - loginLimiter: limiter, - cfg: cfg, + tmpl: tmpl, + users: store, + tracker: tracker, + stats: store, + activeTrips: store, + vehicles: store, + vehicleEditor: store, + vehicleChecker: store, + jwtSecret: jwtSecret, + loginLimiter: limiter, + cfg: cfg, }, nil } @@ -81,9 +96,15 @@ func registerAdminUI(mux *http.ServeMux, ui *adminUI) { mux.Handle("GET /admin/dashboard", protect(http.HandlerFunc(ui.dashboardPage))) mux.Handle("GET /admin/map", protect(http.HandlerFunc(ui.mapPage))) mux.Handle("GET /admin/vehicles", protect(http.HandlerFunc(ui.vehiclesPage))) + mux.Handle("GET /admin/vehicles/new", protect(http.HandlerFunc(ui.vehicleNewPage))) + mux.Handle("POST /admin/vehicles", protect(http.HandlerFunc(ui.vehicleCreate))) + mux.Handle("GET /admin/vehicles/{id}/edit", protect(http.HandlerFunc(ui.vehicleEditPage))) + mux.Handle("POST /admin/vehicles/{id}", protect(http.HandlerFunc(ui.vehicleUpdate))) + mux.Handle("POST /admin/vehicles/{id}/deactivate", protect(http.HandlerFunc(ui.vehicleDeactivate))) + mux.Handle("POST /admin/vehicles/{id}/activate", protect(http.HandlerFunc(ui.vehicleActivate))) mux.Handle("GET /admin/users", protect(http.HandlerFunc(ui.usersPage))) mux.Handle("GET /admin/trips", protect(http.HandlerFunc(ui.tripsPage))) - // CRUD form routes are added by later tasks. + // Remaining CRUD form routes (users) are added by later tasks. } func (ui *adminUI) rootRedirect(w http.ResponseWriter, r *http.Request) { @@ -328,18 +349,211 @@ func humanizeDuration(d time.Duration) string { } } +// vehicleRow is a single row in the vehicle list table: the vehicle's +// stored fields plus whatever live state we can join in (last-seen from the +// tracker, current driver from the active-trips map). +type vehicleRow struct { + ID string + Label string + AgencyTag string + Active bool + LastSeen string + Driver string +} + +// vehiclesPage renders the vehicle list: real vehicles from the store, +// joined with the tracker's live last-seen data and the current driver (if +// any) from the active-trips map. Inactive vehicles are hidden unless +// ?include_inactive=1 is set — the store itself always returns everything; +// filtering happens here so the store's ListVehicles stays a plain listing. func (ui *adminUI) vehiclesPage(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + includeInactive := r.URL.Query().Get("include_inactive") == "1" + + all, err := ui.vehicles.ListVehicles(ctx) + if err != nil { + slog.Error("vehicles: list vehicles", "error", err) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + + lastSeen := make(map[string]string, len(all)) + for _, v := range ui.tracker.ActiveVehicles() { + lastSeen[v.VehicleID] = humanizeAge(v.UpdatedAt) + } + + tripsByVehicle, err := ui.activeTrips.ListActiveTripsByVehicle(ctx) + if err != nil { + slog.Error("vehicles: list active trips by vehicle", "error", err) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + + rows := make([]vehicleRow, 0, len(all)) + for _, v := range all { + if !v.Active && !includeInactive { + continue + } + row := vehicleRow{ID: v.ID, Label: v.Label, AgencyTag: v.AgencyTag, Active: v.Active} + row.LastSeen = lastSeen[v.ID] + if trip, ok := tripsByVehicle[v.ID]; ok { + row.Driver = trip.DriverName + } + rows = append(rows, row) + } + sort.Slice(rows, func(i, j int) bool { return rows[i].ID < rows[j].ID }) + ui.renderAdmin(w, r, "vehicles.html", map[string]interface{}{ - "Title": "Vehicles", - "Page": "vehicles", - "Vehicles": []map[string]string{ - {"ID": "V001", "Name": "Bus 001", "Route": "Route A", "Driver": "Chaitanya K", "Status": "active", "LastSeen": "2 min ago"}, - {"ID": "V002", "Name": "Bus 002", "Route": "Route B", "Driver": "Aron", "Status": "active", "LastSeen": "5 min ago"}, - {"ID": "V003", "Name": "Bus 003", "Route": "Route C", "Driver": "Brad Pitt", "Status": "idle", "LastSeen": "12 min ago"}, - }, + "Title": "Vehicles", + "Page": "vehicles", + "Vehicles": rows, + "IncludeInactive": includeInactive, + }) +} + +// vehicleFormData carries the vehicle_form.html template's fields for both +// the create and edit flows (distinguished by IsEdit), including any +// submitted values and validation error to re-render on failure. +type vehicleFormData struct { + IsEdit bool + ID string + Label string + AgencyTag string + Error string +} + +func (ui *adminUI) renderVehicleForm(w http.ResponseWriter, r *http.Request, status int, data vehicleFormData) { + if status != http.StatusOK { + w.WriteHeader(status) + } + title := "New Vehicle" + if data.IsEdit { + title = "Edit Vehicle" + } + ui.renderAdmin(w, r, "vehicle_form.html", map[string]interface{}{ + "Title": title, + "Page": "vehicles", + "IsEdit": data.IsEdit, + "ID": data.ID, + "Label": data.Label, + "AgencyTag": data.AgencyTag, + "Error": data.Error, }) } +// vehicleNewPage renders the blank create-vehicle form. +func (ui *adminUI) vehicleNewPage(w http.ResponseWriter, r *http.Request) { + ui.renderVehicleForm(w, r, http.StatusOK, vehicleFormData{}) +} + +// vehicleCreate validates and saves a new vehicle. It reuses +// validateVehicleID — the same helper the JSON API uses — so form and API +// validation stay in lockstep, and reports the exact same error text on +// failure. A 422 re-renders the form with the submitted values so the admin +// doesn't have to retype everything. +func (ui *adminUI) vehicleCreate(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + ui.renderVehicleForm(w, r, http.StatusBadRequest, vehicleFormData{Error: "Invalid form submission."}) + return + } + id := r.PostFormValue("id") + label := r.PostFormValue("label") + agencyTag := r.PostFormValue("agency_tag") + + if err := validateVehicleID(id); err != nil { + ui.renderVehicleForm(w, r, http.StatusUnprocessableEntity, vehicleFormData{ID: id, Label: label, AgencyTag: agencyTag, Error: err.Error()}) + return + } + + exists, err := ui.vehicleChecker.VehicleExists(r.Context(), id) + if err != nil { + slog.Error("vehicle create: check existence", "vehicle_id", id, "error", err) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + if exists { + ui.renderVehicleForm(w, r, http.StatusUnprocessableEntity, vehicleFormData{ID: id, Label: label, AgencyTag: agencyTag, Error: "vehicle id already exists"}) + return + } + + if _, err := ui.vehicles.UpsertVehicle(r.Context(), id, label, agencyTag); err != nil { + slog.Error("vehicle create: upsert vehicle", "vehicle_id", id, "error", err) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + setFlash(w, "vehicle_created") + http.Redirect(w, r, "/admin/vehicles", http.StatusSeeOther) +} + +// vehicleEditPage renders the edit form pre-filled with the vehicle's +// current label/agency tag. An unknown id 404s rather than showing a blank +// or error-banner form. +func (ui *adminUI) vehicleEditPage(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + v, err := ui.vehicles.GetVehicle(r.Context(), id) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + http.NotFound(w, r) + return + } + slog.Error("vehicle edit: get vehicle", "vehicle_id", id, "error", err) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + ui.renderVehicleForm(w, r, http.StatusOK, vehicleFormData{IsEdit: true, ID: v.ID, Label: v.Label, AgencyTag: v.AgencyTag}) +} + +// vehicleUpdate saves label/agency_tag edits for an existing vehicle. The id +// is read-only in the form (it's part of the URL, not submitted), and this +// uses UpdateVehicleInfo rather than UpsertVehicle so it never touches the +// active flag. +func (ui *adminUI) vehicleUpdate(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + if err := r.ParseForm(); err != nil { + ui.renderVehicleForm(w, r, http.StatusBadRequest, vehicleFormData{IsEdit: true, ID: id, Error: "Invalid form submission."}) + return + } + label := r.PostFormValue("label") + agencyTag := r.PostFormValue("agency_tag") + + if err := ui.vehicleEditor.UpdateVehicleInfo(r.Context(), id, label, agencyTag); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + http.NotFound(w, r) + return + } + slog.Error("vehicle update: update info", "vehicle_id", id, "error", err) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + setFlash(w, "vehicle_updated") + http.Redirect(w, r, "/admin/vehicles", http.StatusSeeOther) +} + +// vehicleDeactivate and vehicleActivate toggle a vehicle's active flag via +// setVehicleActive, sharing the same 404/error/flash/redirect handling. +func (ui *adminUI) vehicleDeactivate(w http.ResponseWriter, r *http.Request) { + ui.setVehicleActive(w, r, false, "vehicle_deactivated") +} + +func (ui *adminUI) vehicleActivate(w http.ResponseWriter, r *http.Request) { + ui.setVehicleActive(w, r, true, "vehicle_activated") +} + +func (ui *adminUI) setVehicleActive(w http.ResponseWriter, r *http.Request, active bool, flashCode string) { + id := r.PathValue("id") + if err := ui.vehicleEditor.SetVehicleActive(r.Context(), id, active); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + http.NotFound(w, r) + return + } + slog.Error("vehicle set active", "vehicle_id", id, "active", active, "error", err) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + setFlash(w, flashCode) + http.Redirect(w, r, "/admin/vehicles", http.StatusSeeOther) +} + func (ui *adminUI) usersPage(w http.ResponseWriter, r *http.Request) { ui.renderAdmin(w, r, "users.html", map[string]interface{}{ "Title": "Users", diff --git a/admin_page_handlers_test.go b/admin_page_handlers_test.go index a4778bb..9144ae2 100644 --- a/admin_page_handlers_test.go +++ b/admin_page_handlers_test.go @@ -10,6 +10,7 @@ import ( "testing" "time" + "github.com/jackc/pgx/v5" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "golang.org/x/crypto/bcrypt" @@ -168,7 +169,7 @@ func TestAdminPagesRenderWithSession(t *testing.T) { want string }{ {"dashboard", "/admin/dashboard", "Active Trips"}, - {"vehicles", "/admin/vehicles", "Bus 001"}, + {"vehicles", "/admin/vehicles", "New vehicle"}, {"users", "/admin/users", "Chaitanya K"}, {"trips", "/admin/trips", "Route A"}, {"map", "/admin/map", "Live Map"}, @@ -331,3 +332,290 @@ func (erroringAdminStats) CountActiveUsersByRole(_ context.Context, _ string) (i func (erroringAdminStats) CountActiveTrips(_ context.Context) (int, error) { return 0, errors.New("boom") } + +// fakeVehicleStore is an in-memory double implementing VehicleManager, +// vehicleEditor (UpdateVehicleInfo/SetVehicleActive), and VehicleChecker +// (VehicleExists), covering everything the vehicle pages need without a +// database. +type fakeVehicleStore struct { + vehicles map[string]*VehicleResponse +} + +func newFakeVehicleStore(vehicles ...VehicleResponse) *fakeVehicleStore { + m := make(map[string]*VehicleResponse, len(vehicles)) + for i := range vehicles { + v := vehicles[i] + m[v.ID] = &v + } + return &fakeVehicleStore{vehicles: m} +} + +func (f *fakeVehicleStore) ListVehicles(_ context.Context) ([]VehicleResponse, error) { + out := make([]VehicleResponse, 0, len(f.vehicles)) + for _, v := range f.vehicles { + out = append(out, *v) + } + return out, nil +} + +func (f *fakeVehicleStore) GetVehicle(_ context.Context, id string) (*VehicleResponse, error) { + v, ok := f.vehicles[id] + if !ok { + return nil, pgx.ErrNoRows + } + cp := *v + return &cp, nil +} + +func (f *fakeVehicleStore) UpsertVehicle(_ context.Context, id, label, agencyTag string) (*VehicleResponse, error) { + v := &VehicleResponse{ID: id, Label: label, AgencyTag: agencyTag, Active: true} + f.vehicles[id] = v + cp := *v + return &cp, nil +} + +func (f *fakeVehicleStore) DeactivateVehicle(_ context.Context, id string) error { + v, ok := f.vehicles[id] + if !ok { + return pgx.ErrNoRows + } + v.Active = false + return nil +} + +func (f *fakeVehicleStore) UpdateVehicleInfo(_ context.Context, id, label, agencyTag string) error { + v, ok := f.vehicles[id] + if !ok { + return pgx.ErrNoRows + } + v.Label = label + v.AgencyTag = agencyTag + return nil +} + +func (f *fakeVehicleStore) SetVehicleActive(_ context.Context, id string, active bool) error { + v, ok := f.vehicles[id] + if !ok { + return pgx.ErrNoRows + } + v.Active = active + return nil +} + +func (f *fakeVehicleStore) VehicleExists(_ context.Context, id string) (bool, error) { + _, ok := f.vehicles[id] + return ok, nil +} + +// wireFakeVehicleStore points every vehicle-related adminUI field at the +// same fake, mirroring how newAdminUI wires them all from a single appStore. +func wireFakeVehicleStore(ui *adminUI, f *fakeVehicleStore) { + ui.vehicles = f + ui.vehicleEditor = f + ui.vehicleChecker = f +} + +// TestVehiclesPageListsRealVehicles verifies the list page renders a seeded +// vehicle's label and a CSV export link for it, and that the old mock data +// is gone. +func TestVehiclesPageListsRealVehicles(t *testing.T) { + ui := newTestAdminUI(t) + wireFakeVehicleStore(ui, newFakeVehicleStore(VehicleResponse{ID: "bus-1", Label: "Bus One", AgencyTag: "metro", Active: true})) + mux := http.NewServeMux() + registerAdminUI(mux, ui) + + req := httptest.NewRequest(http.MethodGet, "/admin/vehicles", nil) + req.AddCookie(cookieFor(t, "admin")) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) + body := w.Body.String() + assert.Contains(t, body, "Bus One") + assert.Contains(t, body, "/api/v1/admin/vehicles/bus-1/locations?format=csv") + assert.NotContains(t, body, "Bus 001", "mock data must be gone") +} + +// TestVehiclesPageInactiveFilter verifies the list hides inactive vehicles +// by default and shows them with ?include_inactive=1. +func TestVehiclesPageInactiveFilter(t *testing.T) { + ui := newTestAdminUI(t) + wireFakeVehicleStore(ui, newFakeVehicleStore( + VehicleResponse{ID: "active-1", Label: "Active Bus", Active: true}, + VehicleResponse{ID: "inactive-1", Label: "Retired Bus", Active: false}, + )) + mux := http.NewServeMux() + registerAdminUI(mux, ui) + + get := func(path string) string { + req := httptest.NewRequest(http.MethodGet, path, nil) + req.AddCookie(cookieFor(t, "admin")) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + require.Equal(t, http.StatusOK, w.Code) + return w.Body.String() + } + + t.Run("default hides inactive", func(t *testing.T) { + body := get("/admin/vehicles") + assert.Contains(t, body, "Active Bus") + assert.NotContains(t, body, "Retired Bus") + }) + + t.Run("include_inactive shows all", func(t *testing.T) { + body := get("/admin/vehicles?include_inactive=1") + assert.Contains(t, body, "Active Bus") + assert.Contains(t, body, "Retired Bus") + }) +} + +// TestVehicleCreate covers the create form's happy path, validation-error +// re-render, and duplicate-id rejection. +func TestVehicleCreate(t *testing.T) { + post := func(ui *adminUI, mux *http.ServeMux, id, label, agencyTag string) *httptest.ResponseRecorder { + form := url.Values{"id": {id}, "label": {label}, "agency_tag": {agencyTag}} + req := httptest.NewRequest(http.MethodPost, "/admin/vehicles", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.AddCookie(cookieFor(t, "admin")) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + return w + } + + t.Run("success redirects with flash", func(t *testing.T) { + ui := newTestAdminUI(t) + wireFakeVehicleStore(ui, newFakeVehicleStore()) + mux := http.NewServeMux() + registerAdminUI(mux, ui) + + w := post(ui, mux, "bus-42", "Bus 42", "metro") + require.Equal(t, http.StatusSeeOther, w.Code) + assert.Equal(t, "/admin/vehicles", w.Header().Get("Location")) + require.NotEmpty(t, w.Result().Cookies()) + found := false + for _, c := range w.Result().Cookies() { + if c.Name == flashCookieName { + assert.Equal(t, "vehicle_created", c.Value) + found = true + } + } + assert.True(t, found, "expected vehicle_created flash cookie") + }) + + t.Run("invalid id re-renders 422 with API error text", func(t *testing.T) { + ui := newTestAdminUI(t) + wireFakeVehicleStore(ui, newFakeVehicleStore()) + mux := http.NewServeMux() + registerAdminUI(mux, ui) + + w := post(ui, mux, "bad id!", "Bad Bus", "") + assert.Equal(t, http.StatusUnprocessableEntity, w.Code) + assert.Contains(t, w.Body.String(), "vehicle id must contain only alphanumeric characters, dots, hyphens, and underscores") + assert.Contains(t, w.Body.String(), `value="bad id!"`) + }) + + t.Run("duplicate id re-renders 422", func(t *testing.T) { + ui := newTestAdminUI(t) + wireFakeVehicleStore(ui, newFakeVehicleStore(VehicleResponse{ID: "bus-1", Label: "Existing", Active: true})) + mux := http.NewServeMux() + registerAdminUI(mux, ui) + + w := post(ui, mux, "bus-1", "New Label", "") + assert.Equal(t, http.StatusUnprocessableEntity, w.Code) + assert.Contains(t, w.Body.String(), "vehicle id already exists") + }) +} + +// TestVehicleEditPage covers rendering the edit form for a known vehicle and +// 404ing for an unknown one. +func TestVehicleEditPage(t *testing.T) { + t.Run("known id renders form with values", func(t *testing.T) { + ui := newTestAdminUI(t) + wireFakeVehicleStore(ui, newFakeVehicleStore(VehicleResponse{ID: "bus-1", Label: "Bus One", AgencyTag: "metro", Active: true})) + mux := http.NewServeMux() + registerAdminUI(mux, ui) + + req := httptest.NewRequest(http.MethodGet, "/admin/vehicles/bus-1/edit", nil) + req.AddCookie(cookieFor(t, "admin")) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + require.Equal(t, http.StatusOK, w.Code) + assert.Contains(t, w.Body.String(), "Bus One") + }) + + t.Run("unknown id 404s", func(t *testing.T) { + ui := newTestAdminUI(t) + wireFakeVehicleStore(ui, newFakeVehicleStore()) + mux := http.NewServeMux() + registerAdminUI(mux, ui) + + req := httptest.NewRequest(http.MethodGet, "/admin/vehicles/ghost/edit", nil) + req.AddCookie(cookieFor(t, "admin")) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + assert.Equal(t, http.StatusNotFound, w.Code) + }) +} + +// TestVehicleUpdate verifies the edit POST updates label/agency_tag and +// redirects with a flash. +func TestVehicleUpdate(t *testing.T) { + ui := newTestAdminUI(t) + fake := newFakeVehicleStore(VehicleResponse{ID: "bus-1", Label: "Old Label", AgencyTag: "old-tag", Active: true}) + wireFakeVehicleStore(ui, fake) + mux := http.NewServeMux() + registerAdminUI(mux, ui) + + form := url.Values{"label": {"New Label"}, "agency_tag": {"new-tag"}} + req := httptest.NewRequest(http.MethodPost, "/admin/vehicles/bus-1", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.AddCookie(cookieFor(t, "admin")) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + + require.Equal(t, http.StatusSeeOther, w.Code) + assert.Equal(t, "/admin/vehicles", w.Header().Get("Location")) + assert.Equal(t, "New Label", fake.vehicles["bus-1"].Label) + assert.Equal(t, "new-tag", fake.vehicles["bus-1"].AgencyTag) +} + +// TestVehicleDeactivateActivate verifies both POST endpoints redirect with +// the correct flash and flip the active flag. +func TestVehicleDeactivateActivate(t *testing.T) { + ui := newTestAdminUI(t) + fake := newFakeVehicleStore(VehicleResponse{ID: "bus-1", Label: "Bus One", Active: true}) + wireFakeVehicleStore(ui, fake) + mux := http.NewServeMux() + registerAdminUI(mux, ui) + + postTo := func(path string) *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodPost, path, nil) + req.AddCookie(cookieFor(t, "admin")) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + return w + } + + w := postTo("/admin/vehicles/bus-1/deactivate") + require.Equal(t, http.StatusSeeOther, w.Code) + assert.Equal(t, "/admin/vehicles", w.Header().Get("Location")) + assert.False(t, fake.vehicles["bus-1"].Active) + var flashed bool + for _, c := range w.Result().Cookies() { + if c.Name == flashCookieName && c.Value == "vehicle_deactivated" { + flashed = true + } + } + assert.True(t, flashed, "expected vehicle_deactivated flash") + + w = postTo("/admin/vehicles/bus-1/activate") + require.Equal(t, http.StatusSeeOther, w.Code) + assert.True(t, fake.vehicles["bus-1"].Active) + flashed = false + for _, c := range w.Result().Cookies() { + if c.Name == flashCookieName && c.Value == "vehicle_activated" { + flashed = true + } + } + assert.True(t, flashed, "expected vehicle_activated flash") +} diff --git a/web/static/css/admin.css b/web/static/css/admin.css index 2431114..4811054 100644 --- a/web/static/css/admin.css +++ b/web/static/css/admin.css @@ -1,2 +1,2 @@ /*! tailwindcss v4.2.0 | MIT License | https://tailwindcss.com */ -@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-space-y-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial}}}@layer theme{:root,:host{--font-sans:system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--color-amber-50:oklch(98.7% .022 95.277);--color-amber-400:oklch(82.8% .189 84.429);--color-amber-700:oklch(55.5% .163 48.998);--color-emerald-50:oklch(97.9% .021 166.113);--color-emerald-200:oklch(90.5% .093 164.15);--color-emerald-500:oklch(69.6% .17 162.48);--color-emerald-700:oklch(50.8% .118 165.612);--color-emerald-800:oklch(43.2% .095 166.913);--color-teal-600:oklch(60% .118 184.704);--color-cyan-950:oklch(30.2% .056 229.695);--color-sky-50:oklch(97.7% .013 236.62);--color-sky-100:oklch(95.1% .026 236.824);--color-sky-500:oklch(68.5% .169 237.323);--color-sky-600:oklch(58.8% .158 241.966);--color-sky-700:oklch(50% .134 242.749);--color-sky-800:oklch(44.3% .11 240.79);--color-violet-50:oklch(96.9% .016 293.756);--color-violet-700:oklch(49.1% .27 292.581);--color-slate-50:oklch(98.4% .003 247.858);--color-slate-100:oklch(96.8% .007 247.896);--color-slate-200:oklch(92.9% .013 255.508);--color-slate-300:oklch(86.9% .022 252.894);--color-slate-400:oklch(70.4% .04 256.788);--color-slate-500:oklch(55.4% .046 257.417);--color-slate-600:oklch(44.6% .043 257.281);--color-slate-700:oklch(37.2% .044 257.287);--color-slate-800:oklch(27.9% .041 260.031);--color-slate-900:oklch(20.8% .042 265.755);--color-white:#fff;--spacing:.25rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25 / 1.875);--text-4xl:2.25rem;--text-4xl--line-height:calc(2.5 / 2.25);--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--tracking-tight:-.025em;--radius-lg:.5rem;--radius-xl:.75rem;--radius-2xl:1rem;--blur-xl:24px;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono);--font-display:system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;--color-ink:#10233f;--color-line:#d7e3f1}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.pointer-events-auto{pointer-events:auto}.pointer-events-none{pointer-events:none}.absolute{position:absolute}.relative{position:relative}.inset-0{inset:calc(var(--spacing) * 0)}.inset-x-0{inset-inline:calc(var(--spacing) * 0)}.top-4{top:calc(var(--spacing) * 4)}.bottom-4{bottom:calc(var(--spacing) * 4)}.left-4{left:calc(var(--spacing) * 4)}.z-\[500\]{z-index:500}.mx-5{margin-inline:calc(var(--spacing) * 5)}.mt-0\.5{margin-top:calc(var(--spacing) * .5)}.mt-1{margin-top:calc(var(--spacing) * 1)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mt-6{margin-top:calc(var(--spacing) * 6)}.mb-1\.5{margin-bottom:calc(var(--spacing) * 1.5)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.block{display:block}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline-flex{display:inline-flex}.h-1\.5{height:calc(var(--spacing) * 1.5)}.h-2{height:calc(var(--spacing) * 2)}.h-2\.5{height:calc(var(--spacing) * 2.5)}.h-7{height:calc(var(--spacing) * 7)}.h-8{height:calc(var(--spacing) * 8)}.h-9{height:calc(var(--spacing) * 9)}.h-12{height:calc(var(--spacing) * 12)}.min-h-screen{min-height:100vh}.w-1\.5{width:calc(var(--spacing) * 1.5)}.w-2{width:calc(var(--spacing) * 2)}.w-2\.5{width:calc(var(--spacing) * 2.5)}.w-8{width:calc(var(--spacing) * 8)}.w-9{width:calc(var(--spacing) * 9)}.w-12{width:calc(var(--spacing) * 12)}.w-full{width:100%}.w-px{width:1px}.min-w-0{min-width:calc(var(--spacing) * 0)}.min-w-full{min-width:100%}.flex-1{flex:1}.shrink-0{flex-shrink:0}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.gap-1\.5{gap:calc(var(--spacing) * 1.5)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}.gap-5{gap:calc(var(--spacing) * 5)}:where(.space-y-1\.5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 1.5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 1.5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 5) * calc(1 - var(--tw-space-y-reverse)))}.gap-x-6{column-gap:calc(var(--spacing) * 6)}.gap-y-1{row-gap:calc(var(--spacing) * 1)}:where(.divide-y>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px * var(--tw-divide-y-reverse));border-bottom-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)))}:where(.divide-slate-100>:not(:last-child)){border-color:var(--color-slate-100)}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.rounded-2xl{border-radius:var(--radius-2xl)}.rounded-\[16px\]{border-radius:16px}.rounded-\[20px\]{border-radius:20px}.rounded-\[24px\]{border-radius:24px}.rounded-\[28px\]{border-radius:28px}.rounded-\[30px\]{border-radius:30px}.rounded-\[32px\]{border-radius:32px}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius-lg)}.rounded-xl{border-radius:var(--radius-xl)}.border{border-style:var(--tw-border-style);border-width:1px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-emerald-200{border-color:var(--color-emerald-200)}.border-line{border-color:var(--color-line)}.border-slate-200{border-color:var(--color-slate-200)}.border-slate-200\/60{border-color:#e2e8f099}@supports (color:color-mix(in lab, red, red)){.border-slate-200\/60{border-color:color-mix(in oklab, var(--color-slate-200) 60%, transparent)}}.border-slate-200\/70{border-color:#e2e8f0b3}@supports (color:color-mix(in lab, red, red)){.border-slate-200\/70{border-color:color-mix(in oklab, var(--color-slate-200) 70%, transparent)}}.border-white\/10{border-color:#ffffff1a}@supports (color:color-mix(in lab, red, red)){.border-white\/10{border-color:color-mix(in oklab, var(--color-white) 10%, transparent)}}.border-white\/60{border-color:#fff9}@supports (color:color-mix(in lab, red, red)){.border-white\/60{border-color:color-mix(in oklab, var(--color-white) 60%, transparent)}}.border-white\/70{border-color:#ffffffb3}@supports (color:color-mix(in lab, red, red)){.border-white\/70{border-color:color-mix(in oklab, var(--color-white) 70%, transparent)}}.border-white\/80{border-color:#fffc}@supports (color:color-mix(in lab, red, red)){.border-white\/80{border-color:color-mix(in oklab, var(--color-white) 80%, transparent)}}.bg-amber-50{background-color:var(--color-amber-50)}.bg-amber-400{background-color:var(--color-amber-400)}.bg-emerald-50{background-color:var(--color-emerald-50)}.bg-emerald-50\/80{background-color:#ecfdf5cc}@supports (color:color-mix(in lab, red, red)){.bg-emerald-50\/80{background-color:color-mix(in oklab, var(--color-emerald-50) 80%, transparent)}}.bg-emerald-500{background-color:var(--color-emerald-500)}.bg-ink{background-color:var(--color-ink)}.bg-sky-50{background-color:var(--color-sky-50)}.bg-slate-50\/80{background-color:#f8fafccc}@supports (color:color-mix(in lab, red, red)){.bg-slate-50\/80{background-color:color-mix(in oklab, var(--color-slate-50) 80%, transparent)}}.bg-slate-100{background-color:var(--color-slate-100)}.bg-slate-200{background-color:var(--color-slate-200)}.bg-slate-400{background-color:var(--color-slate-400)}.bg-teal-600{background-color:var(--color-teal-600)}.bg-violet-50{background-color:var(--color-violet-50)}.bg-white{background-color:var(--color-white)}.bg-white\/10{background-color:#ffffff1a}@supports (color:color-mix(in lab, red, red)){.bg-white\/10{background-color:color-mix(in oklab, var(--color-white) 10%, transparent)}}.bg-white\/75{background-color:#ffffffbf}@supports (color:color-mix(in lab, red, red)){.bg-white\/75{background-color:color-mix(in oklab, var(--color-white) 75%, transparent)}}.bg-white\/90{background-color:#ffffffe6}@supports (color:color-mix(in lab, red, red)){.bg-white\/90{background-color:color-mix(in oklab, var(--color-white) 90%, transparent)}}.p-2\.5{padding:calc(var(--spacing) * 2.5)}.p-3{padding:calc(var(--spacing) * 3)}.p-4{padding:calc(var(--spacing) * 4)}.p-5{padding:calc(var(--spacing) * 5)}.p-6{padding:calc(var(--spacing) * 6)}.px-2\.5{padding-inline:calc(var(--spacing) * 2.5)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-3\.5{padding-inline:calc(var(--spacing) * 3.5)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-5{padding-inline:calc(var(--spacing) * 5)}.py-1{padding-block:calc(var(--spacing) * 1)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2\.5{padding-block:calc(var(--spacing) * 2.5)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-3\.5{padding-block:calc(var(--spacing) * 3.5)}.py-4{padding-block:calc(var(--spacing) * 4)}.py-5{padding-block:calc(var(--spacing) * 5)}.py-6{padding-block:calc(var(--spacing) * 6)}.text-center{text-align:center}.text-left{text-align:left}.font-display{font-family:var(--font-display)}.font-mono{font-family:var(--font-mono)}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-3xl{font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height))}.text-4xl{font-size:var(--text-4xl);line-height:var(--tw-leading,var(--text-4xl--line-height))}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-\[10px\]{font-size:10px}.text-\[11px\]{font-size:11px}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-\[0\.18em\]{--tw-tracking:.18em;letter-spacing:.18em}.tracking-\[0\.22em\]{--tw-tracking:.22em;letter-spacing:.22em}.tracking-\[0\.25em\]{--tw-tracking:.25em;letter-spacing:.25em}.tracking-\[0\.28em\]{--tw-tracking:.28em;letter-spacing:.28em}.tracking-tight{--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight)}.text-amber-700{color:var(--color-amber-700)}.text-emerald-700{color:var(--color-emerald-700)}.text-emerald-800{color:var(--color-emerald-800)}.text-ink{color:var(--color-ink)}.text-sky-600{color:var(--color-sky-600)}.text-sky-700{color:var(--color-sky-700)}.text-sky-700\/70{color:#0069a4b3}@supports (color:color-mix(in lab, red, red)){.text-sky-700\/70{color:color-mix(in oklab, var(--color-sky-700) 70%, transparent)}}.text-slate-300{color:var(--color-slate-300)}.text-slate-400{color:var(--color-slate-400)}.text-slate-500{color:var(--color-slate-500)}.text-slate-600{color:var(--color-slate-600)}.text-slate-700{color:var(--color-slate-700)}.text-slate-900{color:var(--color-slate-900)}.text-violet-700{color:var(--color-violet-700)}.text-white{color:var(--color-white)}.uppercase{text-transform:uppercase}.shadow-lg{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a), 0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-md{--tw-shadow:0 4px 6px -1px var(--tw-shadow-color,#0000001a), 0 2px 4px -2px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-soft{--tw-shadow:0 24px 70px var(--tw-shadow-color,#0f172a29);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-xl{--tw-shadow:0 20px 25px -5px var(--tw-shadow-color,#0000001a), 0 8px 10px -6px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring-1{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-cyan-950\/30{--tw-shadow-color:#0533454d}@supports (color:color-mix(in lab, red, red)){.shadow-cyan-950\/30{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-cyan-950) 30%, transparent) var(--tw-shadow-alpha), transparent)}}.ring-white\/10{--tw-ring-color:#ffffff1a}@supports (color:color-mix(in lab, red, red)){.ring-white\/10{--tw-ring-color:color-mix(in oklab, var(--color-white) 10%, transparent)}}.backdrop-blur-xl{--tw-backdrop-blur:blur(var(--blur-xl));-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.outline-none{--tw-outline-style:none;outline-style:none}@media (hover:hover){.hover\:bg-slate-50:hover{background-color:var(--color-slate-50)}.hover\:bg-slate-50\/60:hover{background-color:#f8fafc99}@supports (color:color-mix(in lab, red, red)){.hover\:bg-slate-50\/60:hover{background-color:color-mix(in oklab, var(--color-slate-50) 60%, transparent)}}.hover\:bg-slate-800:hover{background-color:var(--color-slate-800)}.hover\:bg-white\/5:hover{background-color:#ffffff0d}@supports (color:color-mix(in lab, red, red)){.hover\:bg-white\/5:hover{background-color:color-mix(in oklab, var(--color-white) 5%, transparent)}}.hover\:text-sky-800:hover{color:var(--color-sky-800)}.hover\:text-white:hover{color:var(--color-white)}}.focus\:border-sky-500:focus{border-color:var(--color-sky-500)}.focus\:ring-4:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(4px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus\:ring-sky-100:focus{--tw-ring-color:var(--color-sky-100)}@media (min-width:40rem){.sm\:p-8{padding:calc(var(--spacing) * 8)}}@media (min-width:64rem){.lg\:sticky{position:sticky}.lg\:top-5{top:calc(var(--spacing) * 5)}.lg\:max-h-\[calc\(100vh-2\.5rem\)\]{max-height:calc(100vh - 2.5rem)}.lg\:w-72{width:calc(var(--spacing) * 72)}.lg\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.lg\:grid-cols-\[1fr_280px\]{grid-template-columns:1fr 280px}.lg\:flex-row{flex-direction:row}.lg\:items-start{align-items:flex-start}.lg\:p-5{padding:calc(var(--spacing) * 5)}.lg\:px-7{padding-inline:calc(var(--spacing) * 7)}}}:root{--bg-top:#f4f9ff;--bg-bottom:#deebf7;--panel:#fffc;--panel-strong:#fffffff0;--border:#94a3b838;--text-strong:#0f172a;--text-soft:#5b6473;--sidebar-top:#0b1728;--sidebar-bottom:#13233d;--teal:#0f766e;--sky:#0284c7;--amber:#f59e0b;--rose:#e11d48}*{box-sizing:border-box}html,body{height:100%}body{font-family:var(--font-sans);color:var(--text-strong);background:radial-gradient(circle at top left, #ffffffeb, transparent 26rem), linear-gradient(180deg, var(--bg-top), var(--bg-bottom));margin:0}h1,h2,h3,.display-font{font-family:var(--font-display)}.glass-panel{background:var(--panel);-webkit-backdrop-filter:blur(18px);backdrop-filter:blur(18px)}.page-shell{background:linear-gradient(135deg,#fffffff5,#f6faffdb);box-shadow:0 18px 48px #0f172a1f,inset 0 1px #ffffffa6}.sidebar-shell{background:radial-gradient(circle at top, #38bdf824, transparent 18rem), linear-gradient(180deg, var(--sidebar-top), var(--sidebar-bottom));box-shadow:inset 0 1px #ffffff0f,0 18px 42px #080f1e5c}.sidebar-link{position:relative;overflow:hidden}.sidebar-link:before{content:"";border-radius:inherit;opacity:0;background:linear-gradient(90deg,#0ea5e929,#2dd4bf14);transition:opacity .16s;position:absolute;inset:0}.sidebar-link:hover:before,.sidebar-link[data-active=true]:before{opacity:1}.table-card{background:var(--panel-strong);-webkit-backdrop-filter:blur(16px);backdrop-filter:blur(16px);box-shadow:0 16px 36px #0f172a14}.stat-card{background:linear-gradient(#fffffff0,#f5f9ffe6);box-shadow:0 16px 32px #0f172a12}.leaflet-container{font-family:var(--font-sans);background:#dbeafe}.leaflet-control-zoom{border:0!important;box-shadow:0 10px 30px #0f172a2e!important}.leaflet-control-zoom a{width:38px!important;height:38px!important;color:var(--text-strong)!important;border:0!important;line-height:38px!important}.leaflet-popup-content-wrapper{border-radius:18px;padding:0;box-shadow:0 18px 38px #0f172a33}.leaflet-popup-content{width:260px!important;margin:0!important}.leaflet-popup-tip{box-shadow:none}.map-popup{background:linear-gradient(#fff,#f4f9ff);padding:1rem}.map-popup__header{justify-content:space-between;align-items:center;gap:.75rem;margin-bottom:.85rem;display:flex}.map-popup__title{font-family:var(--font-display);color:var(--text-strong);font-size:1rem;font-weight:700}.map-popup__badge{text-transform:capitalize;border-radius:999px;align-items:center;gap:.35rem;padding:.3rem .7rem;font-size:.72rem;font-weight:700;display:inline-flex}.map-popup__badge--active{color:var(--teal);background:#0f766e1f}.map-popup__badge--idle{color:#b45309;background:#f59e0b1f}.map-popup__meta{color:var(--text-soft);gap:.55rem;font-size:.84rem;display:grid}.map-popup__meta strong{color:var(--text-strong)}.bus-marker{border:2px solid #ffffffeb;border-radius:16px;justify-content:center;align-items:center;width:42px;height:42px;display:flex;position:relative;transform:translate(-50%,-50%);box-shadow:0 16px 28px #0f172a3d}.bus-marker--active{background:linear-gradient(#14b8a6,#0f766e)}.bus-marker--idle{background:linear-gradient(#fbbf24,#f59e0b)}.bus-marker__pulse{opacity:0;border:2px solid #14b8a647;border-radius:20px;position:absolute;inset:-8px}.bus-marker--active .bus-marker__pulse{animation:2.1s ease-out infinite pulse-ring}.bus-marker__icon{z-index:1;filter:saturate(1.05);font-size:1.15rem;line-height:1;position:relative}@keyframes pulse-ring{0%{opacity:.88;transform:scale(.86)}to{opacity:0;transform:scale(1.3)}}@media (max-width:1023px){body{overflow:auto}}.login-body{min-height:100vh;font-family:var(--font-sans);color:#0f172a;background:radial-gradient(circle at 0 0,#fffffff2,#0000 28rem),linear-gradient(#eef6ff 0%,#dceaf6 100%)}.login-card{flex-direction:column;justify-content:center;max-width:28rem;min-height:100vh;margin-left:auto;margin-right:auto;padding:2rem 1rem;display:flex}.login-sub{color:#475569}.alert{font-size:.875rem}.alert-error{color:#be123c;background:#fff1f2;border:1px solid #fecdd3}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-divide-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-backdrop-blur{syntax:"*";inherits:false}@property --tw-backdrop-brightness{syntax:"*";inherits:false}@property --tw-backdrop-contrast{syntax:"*";inherits:false}@property --tw-backdrop-grayscale{syntax:"*";inherits:false}@property --tw-backdrop-hue-rotate{syntax:"*";inherits:false}@property --tw-backdrop-invert{syntax:"*";inherits:false}@property --tw-backdrop-opacity{syntax:"*";inherits:false}@property --tw-backdrop-saturate{syntax:"*";inherits:false}@property --tw-backdrop-sepia{syntax:"*";inherits:false} \ No newline at end of file +@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-space-y-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial}}}@layer theme{:root,:host{--font-sans:system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--color-amber-50:oklch(98.7% .022 95.277);--color-amber-400:oklch(82.8% .189 84.429);--color-amber-700:oklch(55.5% .163 48.998);--color-emerald-50:oklch(97.9% .021 166.113);--color-emerald-200:oklch(90.5% .093 164.15);--color-emerald-500:oklch(69.6% .17 162.48);--color-emerald-700:oklch(50.8% .118 165.612);--color-emerald-800:oklch(43.2% .095 166.913);--color-teal-600:oklch(60% .118 184.704);--color-cyan-950:oklch(30.2% .056 229.695);--color-sky-50:oklch(97.7% .013 236.62);--color-sky-100:oklch(95.1% .026 236.824);--color-sky-500:oklch(68.5% .169 237.323);--color-sky-600:oklch(58.8% .158 241.966);--color-sky-700:oklch(50% .134 242.749);--color-sky-800:oklch(44.3% .11 240.79);--color-violet-50:oklch(96.9% .016 293.756);--color-violet-700:oklch(49.1% .27 292.581);--color-slate-50:oklch(98.4% .003 247.858);--color-slate-100:oklch(96.8% .007 247.896);--color-slate-200:oklch(92.9% .013 255.508);--color-slate-300:oklch(86.9% .022 252.894);--color-slate-400:oklch(70.4% .04 256.788);--color-slate-500:oklch(55.4% .046 257.417);--color-slate-600:oklch(44.6% .043 257.281);--color-slate-700:oklch(37.2% .044 257.287);--color-slate-800:oklch(27.9% .041 260.031);--color-slate-900:oklch(20.8% .042 265.755);--color-white:#fff;--spacing:.25rem;--container-xl:36rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25 / 1.875);--text-4xl:2.25rem;--text-4xl--line-height:calc(2.5 / 2.25);--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--tracking-tight:-.025em;--radius-lg:.5rem;--radius-xl:.75rem;--radius-2xl:1rem;--blur-xl:24px;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono);--font-display:system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;--color-ink:#10233f;--color-line:#d7e3f1}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.pointer-events-auto{pointer-events:auto}.pointer-events-none{pointer-events:none}.absolute{position:absolute}.relative{position:relative}.inset-0{inset:calc(var(--spacing) * 0)}.inset-x-0{inset-inline:calc(var(--spacing) * 0)}.top-4{top:calc(var(--spacing) * 4)}.bottom-4{bottom:calc(var(--spacing) * 4)}.left-4{left:calc(var(--spacing) * 4)}.z-\[500\]{z-index:500}.mx-5{margin-inline:calc(var(--spacing) * 5)}.mx-auto{margin-inline:auto}.mt-0\.5{margin-top:calc(var(--spacing) * .5)}.mt-1{margin-top:calc(var(--spacing) * 1)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mt-6{margin-top:calc(var(--spacing) * 6)}.mb-1\.5{margin-bottom:calc(var(--spacing) * 1.5)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.block{display:block}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline-flex{display:inline-flex}.h-1\.5{height:calc(var(--spacing) * 1.5)}.h-2{height:calc(var(--spacing) * 2)}.h-2\.5{height:calc(var(--spacing) * 2.5)}.h-7{height:calc(var(--spacing) * 7)}.h-8{height:calc(var(--spacing) * 8)}.h-9{height:calc(var(--spacing) * 9)}.h-12{height:calc(var(--spacing) * 12)}.min-h-screen{min-height:100vh}.w-1\.5{width:calc(var(--spacing) * 1.5)}.w-2{width:calc(var(--spacing) * 2)}.w-2\.5{width:calc(var(--spacing) * 2.5)}.w-8{width:calc(var(--spacing) * 8)}.w-9{width:calc(var(--spacing) * 9)}.w-12{width:calc(var(--spacing) * 12)}.w-full{width:100%}.w-px{width:1px}.max-w-xl{max-width:var(--container-xl)}.min-w-0{min-width:calc(var(--spacing) * 0)}.min-w-full{min-width:100%}.flex-1{flex:1}.shrink-0{flex-shrink:0}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.gap-1\.5{gap:calc(var(--spacing) * 1.5)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}.gap-5{gap:calc(var(--spacing) * 5)}:where(.space-y-1\.5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 1.5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 1.5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 5) * calc(1 - var(--tw-space-y-reverse)))}.gap-x-6{column-gap:calc(var(--spacing) * 6)}.gap-y-1{row-gap:calc(var(--spacing) * 1)}:where(.divide-y>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px * var(--tw-divide-y-reverse));border-bottom-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)))}:where(.divide-slate-100>:not(:last-child)){border-color:var(--color-slate-100)}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.rounded-2xl{border-radius:var(--radius-2xl)}.rounded-\[16px\]{border-radius:16px}.rounded-\[20px\]{border-radius:20px}.rounded-\[24px\]{border-radius:24px}.rounded-\[28px\]{border-radius:28px}.rounded-\[30px\]{border-radius:30px}.rounded-\[32px\]{border-radius:32px}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius-lg)}.rounded-xl{border-radius:var(--radius-xl)}.border{border-style:var(--tw-border-style);border-width:1px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-emerald-200{border-color:var(--color-emerald-200)}.border-line{border-color:var(--color-line)}.border-slate-200{border-color:var(--color-slate-200)}.border-slate-200\/60{border-color:#e2e8f099}@supports (color:color-mix(in lab, red, red)){.border-slate-200\/60{border-color:color-mix(in oklab, var(--color-slate-200) 60%, transparent)}}.border-slate-200\/70{border-color:#e2e8f0b3}@supports (color:color-mix(in lab, red, red)){.border-slate-200\/70{border-color:color-mix(in oklab, var(--color-slate-200) 70%, transparent)}}.border-white\/10{border-color:#ffffff1a}@supports (color:color-mix(in lab, red, red)){.border-white\/10{border-color:color-mix(in oklab, var(--color-white) 10%, transparent)}}.border-white\/60{border-color:#fff9}@supports (color:color-mix(in lab, red, red)){.border-white\/60{border-color:color-mix(in oklab, var(--color-white) 60%, transparent)}}.border-white\/70{border-color:#ffffffb3}@supports (color:color-mix(in lab, red, red)){.border-white\/70{border-color:color-mix(in oklab, var(--color-white) 70%, transparent)}}.border-white\/80{border-color:#fffc}@supports (color:color-mix(in lab, red, red)){.border-white\/80{border-color:color-mix(in oklab, var(--color-white) 80%, transparent)}}.bg-amber-50{background-color:var(--color-amber-50)}.bg-amber-400{background-color:var(--color-amber-400)}.bg-emerald-50{background-color:var(--color-emerald-50)}.bg-emerald-50\/80{background-color:#ecfdf5cc}@supports (color:color-mix(in lab, red, red)){.bg-emerald-50\/80{background-color:color-mix(in oklab, var(--color-emerald-50) 80%, transparent)}}.bg-emerald-500{background-color:var(--color-emerald-500)}.bg-ink{background-color:var(--color-ink)}.bg-sky-50{background-color:var(--color-sky-50)}.bg-slate-50{background-color:var(--color-slate-50)}.bg-slate-50\/80{background-color:#f8fafccc}@supports (color:color-mix(in lab, red, red)){.bg-slate-50\/80{background-color:color-mix(in oklab, var(--color-slate-50) 80%, transparent)}}.bg-slate-100{background-color:var(--color-slate-100)}.bg-slate-200{background-color:var(--color-slate-200)}.bg-slate-400{background-color:var(--color-slate-400)}.bg-teal-600{background-color:var(--color-teal-600)}.bg-violet-50{background-color:var(--color-violet-50)}.bg-white{background-color:var(--color-white)}.bg-white\/10{background-color:#ffffff1a}@supports (color:color-mix(in lab, red, red)){.bg-white\/10{background-color:color-mix(in oklab, var(--color-white) 10%, transparent)}}.bg-white\/75{background-color:#ffffffbf}@supports (color:color-mix(in lab, red, red)){.bg-white\/75{background-color:color-mix(in oklab, var(--color-white) 75%, transparent)}}.bg-white\/90{background-color:#ffffffe6}@supports (color:color-mix(in lab, red, red)){.bg-white\/90{background-color:color-mix(in oklab, var(--color-white) 90%, transparent)}}.p-2\.5{padding:calc(var(--spacing) * 2.5)}.p-3{padding:calc(var(--spacing) * 3)}.p-4{padding:calc(var(--spacing) * 4)}.p-5{padding:calc(var(--spacing) * 5)}.p-6{padding:calc(var(--spacing) * 6)}.px-2\.5{padding-inline:calc(var(--spacing) * 2.5)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-3\.5{padding-inline:calc(var(--spacing) * 3.5)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-5{padding-inline:calc(var(--spacing) * 5)}.py-1{padding-block:calc(var(--spacing) * 1)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-2\.5{padding-block:calc(var(--spacing) * 2.5)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-3\.5{padding-block:calc(var(--spacing) * 3.5)}.py-4{padding-block:calc(var(--spacing) * 4)}.py-5{padding-block:calc(var(--spacing) * 5)}.py-6{padding-block:calc(var(--spacing) * 6)}.text-center{text-align:center}.text-left{text-align:left}.font-display{font-family:var(--font-display)}.font-mono{font-family:var(--font-mono)}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-3xl{font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height))}.text-4xl{font-size:var(--text-4xl);line-height:var(--tw-leading,var(--text-4xl--line-height))}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-\[10px\]{font-size:10px}.text-\[11px\]{font-size:11px}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-\[0\.18em\]{--tw-tracking:.18em;letter-spacing:.18em}.tracking-\[0\.22em\]{--tw-tracking:.22em;letter-spacing:.22em}.tracking-\[0\.25em\]{--tw-tracking:.25em;letter-spacing:.25em}.tracking-\[0\.28em\]{--tw-tracking:.28em;letter-spacing:.28em}.tracking-tight{--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight)}.text-amber-700{color:var(--color-amber-700)}.text-emerald-700{color:var(--color-emerald-700)}.text-emerald-800{color:var(--color-emerald-800)}.text-ink{color:var(--color-ink)}.text-sky-600{color:var(--color-sky-600)}.text-sky-700{color:var(--color-sky-700)}.text-sky-700\/70{color:#0069a4b3}@supports (color:color-mix(in lab, red, red)){.text-sky-700\/70{color:color-mix(in oklab, var(--color-sky-700) 70%, transparent)}}.text-slate-300{color:var(--color-slate-300)}.text-slate-400{color:var(--color-slate-400)}.text-slate-500{color:var(--color-slate-500)}.text-slate-600{color:var(--color-slate-600)}.text-slate-700{color:var(--color-slate-700)}.text-slate-900{color:var(--color-slate-900)}.text-violet-700{color:var(--color-violet-700)}.text-white{color:var(--color-white)}.uppercase{text-transform:uppercase}.shadow-lg{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a), 0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-md{--tw-shadow:0 4px 6px -1px var(--tw-shadow-color,#0000001a), 0 2px 4px -2px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-soft{--tw-shadow:0 24px 70px var(--tw-shadow-color,#0f172a29);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-xl{--tw-shadow:0 20px 25px -5px var(--tw-shadow-color,#0000001a), 0 8px 10px -6px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring-1{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-cyan-950\/30{--tw-shadow-color:#0533454d}@supports (color:color-mix(in lab, red, red)){.shadow-cyan-950\/30{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-cyan-950) 30%, transparent) var(--tw-shadow-alpha), transparent)}}.ring-white\/10{--tw-ring-color:#ffffff1a}@supports (color:color-mix(in lab, red, red)){.ring-white\/10{--tw-ring-color:color-mix(in oklab, var(--color-white) 10%, transparent)}}.backdrop-blur-xl{--tw-backdrop-blur:blur(var(--blur-xl));-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.outline-none{--tw-outline-style:none;outline-style:none}@media (hover:hover){.hover\:bg-slate-50:hover{background-color:var(--color-slate-50)}.hover\:bg-slate-50\/60:hover{background-color:#f8fafc99}@supports (color:color-mix(in lab, red, red)){.hover\:bg-slate-50\/60:hover{background-color:color-mix(in oklab, var(--color-slate-50) 60%, transparent)}}.hover\:bg-slate-800:hover{background-color:var(--color-slate-800)}.hover\:bg-white\/5:hover{background-color:#ffffff0d}@supports (color:color-mix(in lab, red, red)){.hover\:bg-white\/5:hover{background-color:color-mix(in oklab, var(--color-white) 5%, transparent)}}.hover\:text-sky-800:hover{color:var(--color-sky-800)}.hover\:text-slate-700:hover{color:var(--color-slate-700)}.hover\:text-white:hover{color:var(--color-white)}}.focus\:border-sky-500:focus{border-color:var(--color-sky-500)}.focus\:ring-4:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(4px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus\:ring-sky-100:focus{--tw-ring-color:var(--color-sky-100)}@media (min-width:40rem){.sm\:p-8{padding:calc(var(--spacing) * 8)}}@media (min-width:64rem){.lg\:sticky{position:sticky}.lg\:top-5{top:calc(var(--spacing) * 5)}.lg\:max-h-\[calc\(100vh-2\.5rem\)\]{max-height:calc(100vh - 2.5rem)}.lg\:w-72{width:calc(var(--spacing) * 72)}.lg\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.lg\:grid-cols-\[1fr_280px\]{grid-template-columns:1fr 280px}.lg\:flex-row{flex-direction:row}.lg\:items-start{align-items:flex-start}.lg\:p-5{padding:calc(var(--spacing) * 5)}.lg\:px-7{padding-inline:calc(var(--spacing) * 7)}}}:root{--bg-top:#f4f9ff;--bg-bottom:#deebf7;--panel:#fffc;--panel-strong:#fffffff0;--border:#94a3b838;--text-strong:#0f172a;--text-soft:#5b6473;--sidebar-top:#0b1728;--sidebar-bottom:#13233d;--teal:#0f766e;--sky:#0284c7;--amber:#f59e0b;--rose:#e11d48}*{box-sizing:border-box}html,body{height:100%}body{font-family:var(--font-sans);color:var(--text-strong);background:radial-gradient(circle at top left, #ffffffeb, transparent 26rem), linear-gradient(180deg, var(--bg-top), var(--bg-bottom));margin:0}h1,h2,h3,.display-font{font-family:var(--font-display)}.glass-panel{background:var(--panel);-webkit-backdrop-filter:blur(18px);backdrop-filter:blur(18px)}.page-shell{background:linear-gradient(135deg,#fffffff5,#f6faffdb);box-shadow:0 18px 48px #0f172a1f,inset 0 1px #ffffffa6}.sidebar-shell{background:radial-gradient(circle at top, #38bdf824, transparent 18rem), linear-gradient(180deg, var(--sidebar-top), var(--sidebar-bottom));box-shadow:inset 0 1px #ffffff0f,0 18px 42px #080f1e5c}.sidebar-link{position:relative;overflow:hidden}.sidebar-link:before{content:"";border-radius:inherit;opacity:0;background:linear-gradient(90deg,#0ea5e929,#2dd4bf14);transition:opacity .16s;position:absolute;inset:0}.sidebar-link:hover:before,.sidebar-link[data-active=true]:before{opacity:1}.table-card{background:var(--panel-strong);-webkit-backdrop-filter:blur(16px);backdrop-filter:blur(16px);box-shadow:0 16px 36px #0f172a14}.stat-card{background:linear-gradient(#fffffff0,#f5f9ffe6);box-shadow:0 16px 32px #0f172a12}.leaflet-container{font-family:var(--font-sans);background:#dbeafe}.leaflet-control-zoom{border:0!important;box-shadow:0 10px 30px #0f172a2e!important}.leaflet-control-zoom a{width:38px!important;height:38px!important;color:var(--text-strong)!important;border:0!important;line-height:38px!important}.leaflet-popup-content-wrapper{border-radius:18px;padding:0;box-shadow:0 18px 38px #0f172a33}.leaflet-popup-content{width:260px!important;margin:0!important}.leaflet-popup-tip{box-shadow:none}.map-popup{background:linear-gradient(#fff,#f4f9ff);padding:1rem}.map-popup__header{justify-content:space-between;align-items:center;gap:.75rem;margin-bottom:.85rem;display:flex}.map-popup__title{font-family:var(--font-display);color:var(--text-strong);font-size:1rem;font-weight:700}.map-popup__badge{text-transform:capitalize;border-radius:999px;align-items:center;gap:.35rem;padding:.3rem .7rem;font-size:.72rem;font-weight:700;display:inline-flex}.map-popup__badge--active{color:var(--teal);background:#0f766e1f}.map-popup__badge--idle{color:#b45309;background:#f59e0b1f}.map-popup__meta{color:var(--text-soft);gap:.55rem;font-size:.84rem;display:grid}.map-popup__meta strong{color:var(--text-strong)}.bus-marker{border:2px solid #ffffffeb;border-radius:16px;justify-content:center;align-items:center;width:42px;height:42px;display:flex;position:relative;transform:translate(-50%,-50%);box-shadow:0 16px 28px #0f172a3d}.bus-marker--active{background:linear-gradient(#14b8a6,#0f766e)}.bus-marker--idle{background:linear-gradient(#fbbf24,#f59e0b)}.bus-marker__pulse{opacity:0;border:2px solid #14b8a647;border-radius:20px;position:absolute;inset:-8px}.bus-marker--active .bus-marker__pulse{animation:2.1s ease-out infinite pulse-ring}.bus-marker__icon{z-index:1;filter:saturate(1.05);font-size:1.15rem;line-height:1;position:relative}@keyframes pulse-ring{0%{opacity:.88;transform:scale(.86)}to{opacity:0;transform:scale(1.3)}}@media (max-width:1023px){body{overflow:auto}}.login-body{min-height:100vh;font-family:var(--font-sans);color:#0f172a;background:radial-gradient(circle at 0 0,#fffffff2,#0000 28rem),linear-gradient(#eef6ff 0%,#dceaf6 100%)}.login-card{flex-direction:column;justify-content:center;max-width:28rem;min-height:100vh;margin-left:auto;margin-right:auto;padding:2rem 1rem;display:flex}.login-sub{color:#475569}.alert{font-size:.875rem}.alert-error{color:#be123c;background:#fff1f2;border:1px solid #fecdd3}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-divide-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-backdrop-blur{syntax:"*";inherits:false}@property --tw-backdrop-brightness{syntax:"*";inherits:false}@property --tw-backdrop-contrast{syntax:"*";inherits:false}@property --tw-backdrop-grayscale{syntax:"*";inherits:false}@property --tw-backdrop-hue-rotate{syntax:"*";inherits:false}@property --tw-backdrop-invert{syntax:"*";inherits:false}@property --tw-backdrop-opacity{syntax:"*";inherits:false}@property --tw-backdrop-saturate{syntax:"*";inherits:false}@property --tw-backdrop-sepia{syntax:"*";inherits:false} \ No newline at end of file diff --git a/web/templates/views/vehicle_form.html b/web/templates/views/vehicle_form.html new file mode 100644 index 0000000..7c96a90 --- /dev/null +++ b/web/templates/views/vehicle_form.html @@ -0,0 +1,33 @@ +{{define "content"}} +
+
+

{{if .IsEdit}}Edit Vehicle{{else}}New Vehicle{{end}}

+

{{if .IsEdit}}Update the vehicle's label and agency tag.{{else}}Register a new vehicle in the fleet.{{end}}

+ + {{if .Error}}{{end}} + + +
+ + {{if .IsEdit}} + + {{else}} + + {{end}} +
+
+ + +
+
+ + +
+
+ + Cancel +
+ +
+
+{{end}} diff --git a/web/templates/views/vehicles.html b/web/templates/views/vehicles.html index e54dcd4..1017ddb 100644 --- a/web/templates/views/vehicles.html +++ b/web/templates/views/vehicles.html @@ -4,7 +4,15 @@

All Vehicles

-

{{len .Vehicles}} registered in fleet

+

{{len .Vehicles}} {{if .IncludeInactive}}shown{{else}}active{{end}} in fleet

+
+
+ {{if .IncludeInactive}} + Hide deactivated + {{else}} + Show deactivated + {{end}} + New vehicle
@@ -12,35 +20,54 @@

All Vehicles

ID - Vehicle - Route - Driver + Label + Agency tag Status - Last Seen + Last seen + Driver + Actions + {{if not .Vehicles}} + + No vehicles found. + + {{end}} {{range .Vehicles}} {{.ID}}
🚌 -

{{.Name}}

+

{{.Label}}

- {{.Route}} - {{.Driver}} + {{.AgencyTag}} - {{if eq .Status "active"}} + {{if .Active}} Active - {{else if eq .Status "idle"}} - Idle {{else}} - Offline + Deactivated {{end}} - {{.LastSeen}} + {{if .LastSeen}}{{.LastSeen}}{{else}}—{{end}} + {{if .Driver}}{{.Driver}}{{else}}—{{end}} + +
+ Edit + CSV + {{if .Active}} +
+ +
+ {{else}} +
+ +
+ {{end}} +
+ {{end}} From d3591459aef699b5f17c2333743b1a015bdeb11c Mon Sep 17 00:00:00 2001 From: Aaron Brethorst Date: Mon, 24 Aug 2026 02:26:27 -0700 Subject: [PATCH 20/29] feat: user management pages with CRUD, password change, and vehicle assignments --- admin_handlers.go | 1 + admin_page_handlers.go | 405 +++++++++++++++++++++++++- admin_page_handlers_test.go | 439 ++++++++++++++++++++++++++++- db/query.sql | 3 + db/query.sql.go | 17 ++ main.go | 1 + route_wiring_test.go | 3 + store_users_test.go | 26 ++ user_store.go | 27 ++ web/static/css/admin.css | 2 +- web/templates/views/user_form.html | 70 +++++ web/templates/views/users.html | 33 ++- 12 files changed, 1017 insertions(+), 10 deletions(-) create mode 100644 web/templates/views/user_form.html diff --git a/admin_handlers.go b/admin_handlers.go index a577d20..1acd897 100644 --- a/admin_handlers.go +++ b/admin_handlers.go @@ -44,6 +44,7 @@ func loadTemplates() (*embeddedTemplates, error) { "users.html", "vehicles.html", "vehicle_form.html", + "user_form.html", } admin := make(map[string]*template.Template, len(adminViews)) diff --git a/admin_page_handlers.go b/admin_page_handlers.go index 3f8ad54..84548e7 100644 --- a/admin_page_handlers.go +++ b/admin_page_handlers.go @@ -42,6 +42,26 @@ type vehicleEditor interface { VehicleActivator } +// userManager is the narrow interface the user CRUD pages need. It's kept +// separate from UserFetcher (used only by the login flow) so the login path +// doesn't depend on write methods it never calls. +type userManager interface { + UserLister + UserGetter + UserCreator + UserUpdater + UserActivator + UserPasswordUpdater +} + +// assignmentManager is the narrow interface the user edit page's +// vehicle-assignments section needs. +type assignmentManager interface { + AssignmentCreator + AssignmentDeleter + AssignmentListerByUser +} + // adminUI owns the parsed templates and dependencies for all admin pages. type adminUI struct { tmpl *embeddedTemplates @@ -52,6 +72,8 @@ type adminUI struct { vehicles VehicleManager vehicleEditor vehicleEditor vehicleChecker VehicleChecker + userManager userManager + assignments assignmentManager jwtSecret []byte loginLimiter *LoginRateLimiter cfg adminUIConfig @@ -76,6 +98,8 @@ func newAdminUI(store appStore, tracker *Tracker, jwtSecret []byte, limiter *Log vehicles: store, vehicleEditor: store, vehicleChecker: store, + userManager: store, + assignments: store, jwtSecret: jwtSecret, loginLimiter: limiter, cfg: cfg, @@ -103,8 +127,15 @@ func registerAdminUI(mux *http.ServeMux, ui *adminUI) { mux.Handle("POST /admin/vehicles/{id}/deactivate", protect(http.HandlerFunc(ui.vehicleDeactivate))) mux.Handle("POST /admin/vehicles/{id}/activate", protect(http.HandlerFunc(ui.vehicleActivate))) mux.Handle("GET /admin/users", protect(http.HandlerFunc(ui.usersPage))) + mux.Handle("GET /admin/users/new", protect(http.HandlerFunc(ui.userNewPage))) + mux.Handle("POST /admin/users", protect(http.HandlerFunc(ui.userCreate))) + mux.Handle("GET /admin/users/{id}/edit", protect(http.HandlerFunc(ui.userEditPage))) + mux.Handle("POST /admin/users/{id}", protect(http.HandlerFunc(ui.userUpdate))) + mux.Handle("POST /admin/users/{id}/deactivate", protect(http.HandlerFunc(ui.userDeactivate))) + mux.Handle("POST /admin/users/{id}/activate", protect(http.HandlerFunc(ui.userActivate))) + mux.Handle("POST /admin/users/{id}/vehicles", protect(http.HandlerFunc(ui.userAssignVehicle))) + mux.Handle("POST /admin/users/{id}/vehicles/{vehicleID}/remove", protect(http.HandlerFunc(ui.userUnassignVehicle))) mux.Handle("GET /admin/trips", protect(http.HandlerFunc(ui.tripsPage))) - // Remaining CRUD form routes (users) are added by later tasks. } func (ui *adminUI) rootRedirect(w http.ResponseWriter, r *http.Request) { @@ -554,18 +585,380 @@ func (ui *adminUI) setVehicleActive(w http.ResponseWriter, r *http.Request, acti http.Redirect(w, r, "/admin/vehicles", http.StatusSeeOther) } +// minPasswordLength is the minimum length required for a new or changed +// user password (create form and edit form's optional password field). +const minPasswordLength = 8 + +// userRow is a single row in the user list table: the user's stored fields +// plus how many vehicles are currently assigned to them. +type userRow struct { + ID int64 + Name string + Email string + Role string + Active bool + VehicleCount int +} + +// usersPage renders the user list: real users from the store, each joined +// with its assigned-vehicle count via a per-user ListAssignmentsByUser call. +// This is an N+1 query pattern, but it's fine at admin scale (dozens of +// users, not thousands) and keeps the assignment store's query surface +// simple (no bulk "counts by user" query needed just for this list). func (ui *adminUI) usersPage(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + all, err := ui.userManager.ListUsers(ctx) + if err != nil { + slog.Error("users: list users", "error", err) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + + rows := make([]userRow, 0, len(all)) + for _, u := range all { + assignments, err := ui.assignments.ListAssignmentsByUser(ctx, u.ID) + if err != nil { + slog.Error("users: list assignments", "user_id", u.ID, "error", err) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + rows = append(rows, userRow{ + ID: u.ID, + Name: u.Name, + Email: u.Email, + Role: u.Role, + Active: u.Active, + VehicleCount: len(assignments), + }) + } + ui.renderAdmin(w, r, "users.html", map[string]interface{}{ "Title": "Users", "Page": "users", - "Users": []map[string]string{ - {"Name": "Chaitanya K", "Email": "kbc@transit.co.ke", "Role": "driver", "LastSeen": "Today"}, - {"Name": "To Holland", "Email": "tom@transit.co.ke", "Role": "driver", "LastSeen": "Today"}, - {"Name": "Open transit", "Email": "brian@transit.co.ke", "Role": "driver", "LastSeen": "Yesterday"}, - }, + "Users": rows, }) } +// assignmentRow is a single currently-assigned vehicle shown in the user +// edit page's assignments section, with the vehicle's label joined in for +// display (assignments themselves only carry the vehicle id). +type assignmentRow struct { + VehicleID string + Label string +} + +// userFormData carries the user_form.html template's fields for both the +// create and edit flows (distinguished by IsEdit), including any submitted +// values and validation error to re-render on failure. Assignments and +// AvailableVehicles are only populated (and only rendered) in edit mode. +type userFormData struct { + IsEdit bool + ID string + Name string + Email string + Role string + Error string + Assignments []assignmentRow + AvailableVehicles []VehicleResponse +} + +func (ui *adminUI) renderUserForm(w http.ResponseWriter, r *http.Request, status int, data userFormData) { + if status != http.StatusOK { + w.WriteHeader(status) + } + title := "New User" + if data.IsEdit { + title = "Edit User" + } + ui.renderAdmin(w, r, "user_form.html", map[string]interface{}{ + "Title": title, + "Page": "users", + "IsEdit": data.IsEdit, + "ID": data.ID, + "Name": data.Name, + "Email": data.Email, + "Role": data.Role, + "Error": data.Error, + "Assignments": data.Assignments, + "AvailableVehicles": data.AvailableVehicles, + }) +} + +// validUserRole reports whether role is one of the two roles the form +// offers. Anything else (including empty) is rejected server-side even +// though the +
+
+ + +
+
+ + +
+
+ + +
+
+ + Cancel +
+ + + {{if .IsEdit}} +
+

Assigned vehicles

+

{{len .Assignments}} assigned

+ +
    + {{if not .Assignments}} +
  • No vehicles assigned.
  • + {{end}} + {{range .Assignments}} +
  • + {{.Label}} +
    + +
    +
  • + {{end}} +
+ + {{if .AvailableVehicles}} +
+ + +
+ {{else}} +

No active vehicles available to assign.

+ {{end}} +
+ {{end}} + + +{{end}} diff --git a/web/templates/views/users.html b/web/templates/views/users.html index 67b6e13..6ea4763 100644 --- a/web/templates/views/users.html +++ b/web/templates/views/users.html @@ -6,6 +6,7 @@

All Users

{{len .Users}} accounts

+ New user
@@ -14,10 +15,17 @@

All Users

- + + + + {{if not .Users}} + + + + {{end}} {{range .Users}} - + + + {{end}} From e1a0e8393aa09b8efd7a5bccaf8fd508b083aadd Mon Sep 17 00:00:00 2001 From: Aaron Brethorst Date: Mon, 24 Aug 2026 02:32:23 -0700 Subject: [PATCH 21/29] fix: map assignment conflict/FK errors in userAssignVehicle instead of raw 500 --- admin_page_handlers.go | 24 ++++++++++--- admin_page_handlers_test.go | 72 +++++++++++++++++++++++++++++++++++-- 2 files changed, 90 insertions(+), 6 deletions(-) diff --git a/admin_page_handlers.go b/admin_page_handlers.go index 84548e7..078b460 100644 --- a/admin_page_handlers.go +++ b/admin_page_handlers.go @@ -912,6 +912,12 @@ func (ui *adminUI) setUserActive(w http.ResponseWriter, r *http.Request, active // and redirects back to the edit page, where the newly-assigned vehicle now // shows up in the current-assignments list. An empty vehicle_id is a no-op // (no flash) rather than attempting an assignment with an empty vehicle id. +// +// CreateAssignment's sentinel errors are mapped the same way the JSON API +// does in handleCreateAssignment (assignment_handlers.go): ErrAssignmentExists +// (a double-submit or a race with another admin) is treated as success — the +// end state is what the admin wanted, so it just redirects without a flash — +// and ErrVehicleNotFoundFK 404s rather than surfacing a raw 500. func (ui *adminUI) userAssignVehicle(w http.ResponseWriter, r *http.Request) { idStr := r.PathValue("id") id, err := strconv.ParseInt(idStr, 10, 64) @@ -926,11 +932,21 @@ func (ui *adminUI) userAssignVehicle(w http.ResponseWriter, r *http.Request) { vehicleID := r.PostFormValue("vehicle_id") if vehicleID != "" { if _, err := ui.assignments.CreateAssignment(r.Context(), id, vehicleID); err != nil { - slog.Error("user assign vehicle", "user_id", id, "vehicle_id", vehicleID, "error", err) - http.Error(w, "internal server error", http.StatusInternalServerError) - return + if errors.Is(err, ErrVehicleNotFoundFK) { + http.NotFound(w, r) + return + } + if !errors.Is(err, ErrAssignmentExists) { + slog.Error("user assign vehicle", "user_id", id, "vehicle_id", vehicleID, "error", err) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + // ErrAssignmentExists: fall through to the redirect below without + // a flash — the assignment already exists, which is the state + // the admin wanted. + } else { + setFlash(w, "vehicle_assigned") } - setFlash(w, "vehicle_assigned") } http.Redirect(w, r, "/admin/users/"+idStr+"/edit", http.StatusSeeOther) } diff --git a/admin_page_handlers_test.go b/admin_page_handlers_test.go index e4c4e2a..07dde6e 100644 --- a/admin_page_handlers_test.go +++ b/admin_page_handlers_test.go @@ -709,18 +709,32 @@ func (f *fakeUserStore) UpdateUserPassword(_ context.Context, id int64, password // fakeAssignmentStore is an in-memory double implementing // AssignmentCreator/Deleter/ListerByUser, keyed by user ID then vehicle ID. +// missingVehicle lets a test mark specific vehicle IDs as nonexistent, so +// CreateAssignment can exercise the same ErrVehicleNotFoundFK path the real +// store reports on an FK violation. type fakeAssignmentStore struct { - byUser map[int64]map[string]bool + byUser map[int64]map[string]bool + missingVehicle map[string]bool } func newFakeAssignmentStore() *fakeAssignmentStore { - return &fakeAssignmentStore{byUser: map[int64]map[string]bool{}} + return &fakeAssignmentStore{byUser: map[int64]map[string]bool{}, missingVehicle: map[string]bool{}} } +// CreateAssignment mirrors the real store's constraint behavior: a +// duplicate (userID, vehicleID) pair reports ErrAssignmentExists (the real +// store's unique-violation mapping) and a vehicleID marked missing reports +// ErrVehicleNotFoundFK (the real store's FK-violation mapping). func (f *fakeAssignmentStore) CreateAssignment(_ context.Context, userID int64, vehicleID string) (*AssignmentResponse, error) { + if f.missingVehicle[vehicleID] { + return nil, ErrVehicleNotFoundFK + } if f.byUser[userID] == nil { f.byUser[userID] = map[string]bool{} } + if f.byUser[userID][vehicleID] { + return nil, ErrAssignmentExists + } f.byUser[userID][vehicleID] = true return &AssignmentResponse{UserID: userID, VehicleID: vehicleID}, nil } @@ -1056,3 +1070,57 @@ func TestUserAssignUnassignVehicle(t *testing.T) { } assert.True(t, flashed, "expected vehicle_unassigned flash") } + +// TestUserAssignVehicle_AlreadyAssigned verifies that a double-submit (or a +// race with another admin) — CreateAssignment returning ErrAssignmentExists +// — redirects back to the edit page like the happy path, rather than +// surfacing a raw 500. No flash is expected since nothing changed. +func TestUserAssignVehicle_AlreadyAssigned(t *testing.T) { + ui := newTestAdminUI(t) + users := newFakeUserStore(UserResponse{ID: 1, Name: "Ada Admin", Email: "ada@test.com", Role: "admin", Active: true}) + assignments := newFakeAssignmentStore() + _, err := assignments.CreateAssignment(context.Background(), 1, "bus-1") + require.NoError(t, err) + wireFakeUserStore(ui, users, assignments) + wireFakeVehicleStore(ui, newFakeVehicleStore(VehicleResponse{ID: "bus-1", Label: "Bus One", Active: true})) + mux := http.NewServeMux() + registerAdminUI(mux, ui) + + form := url.Values{"vehicle_id": {"bus-1"}} + req := httptest.NewRequest(http.MethodPost, "/admin/users/1/vehicles", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.AddCookie(cookieFor(t, "admin")) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + + require.Equal(t, http.StatusSeeOther, w.Code) + assert.Equal(t, "/admin/users/1/edit", w.Header().Get("Location")) + assert.True(t, assignments.byUser[1]["bus-1"], "assignment should still be present") + for _, c := range w.Result().Cookies() { + assert.NotEqual(t, flashCookieName, c.Name, "no flash expected when the assignment already existed") + } +} + +// TestUserAssignVehicle_UnknownVehicle verifies that CreateAssignment +// returning ErrVehicleNotFoundFK (an FK violation on a vehicle id that +// doesn't exist) 404s rather than surfacing a raw 500. +func TestUserAssignVehicle_UnknownVehicle(t *testing.T) { + ui := newTestAdminUI(t) + users := newFakeUserStore(UserResponse{ID: 1, Name: "Ada Admin", Email: "ada@test.com", Role: "admin", Active: true}) + assignments := newFakeAssignmentStore() + assignments.missingVehicle["ghost-bus"] = true + wireFakeUserStore(ui, users, assignments) + wireFakeVehicleStore(ui, newFakeVehicleStore()) + mux := http.NewServeMux() + registerAdminUI(mux, ui) + + form := url.Values{"vehicle_id": {"ghost-bus"}} + req := httptest.NewRequest(http.MethodPost, "/admin/users/1/vehicles", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.AddCookie(cookieFor(t, "admin")) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + + assert.Equal(t, http.StatusNotFound, w.Code) + assert.False(t, assignments.byUser[1]["ghost-bus"]) +} From ba71c18002f1c7efd7f2ba56dfc00e2e94a6fac3 Mon Sep 17 00:00:00 2001 From: Aaron Brethorst Date: Mon, 24 Aug 2026 02:40:01 -0700 Subject: [PATCH 22/29] feat: trip history page with filters, search, pagination, and trail links --- admin_page_handlers.go | 156 ++++++++++++++++++++++-- admin_page_handlers_test.go | 209 ++++++++++++++++++++++++++++++++- web/templates/views/trips.html | 54 ++++++++- 3 files changed, 406 insertions(+), 13 deletions(-) diff --git a/admin_page_handlers.go b/admin_page_handlers.go index 078b460..ed3cf24 100644 --- a/admin_page_handlers.go +++ b/admin_page_handlers.go @@ -6,6 +6,7 @@ import ( "fmt" "log/slog" "net/http" + "net/url" "sort" "strconv" "time" @@ -69,6 +70,7 @@ type adminUI struct { tracker *Tracker stats adminStatsStore activeTrips ActiveTripLister + trips TripLister vehicles VehicleManager vehicleEditor vehicleEditor vehicleChecker VehicleChecker @@ -95,6 +97,7 @@ func newAdminUI(store appStore, tracker *Tracker, jwtSecret []byte, limiter *Log tracker: tracker, stats: store, activeTrips: store, + trips: store, vehicles: store, vehicleEditor: store, vehicleChecker: store, @@ -975,14 +978,153 @@ func (ui *adminUI) userUnassignVehicle(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/admin/users/"+idStr+"/edit", http.StatusSeeOther) } +// tripsPageSize is the number of trips shown per page on the admin trips +// list. ListTrips is called with Limit: tripsPageSize+1 so an extra row past +// the page boundary reveals whether there's a next page (HasMore), without a +// separate COUNT query. +const tripsPageSize = 50 + +// tripRow is a single row in the trips table: a trip's joined display fields +// plus pre-formatted start/end times and duration, ready for the template. +type tripRow struct { + ID int64 + VehicleLabel string + DriverName string + RouteID string + GtfsTripID string + Start string + End string + Status string + Duration string +} + +// formatTripTimestamp renders a trip's start/end time in the admin UI's +// fixed UTC display format, regardless of the server's local timezone. +func formatTripTimestamp(t time.Time) string { + return t.UTC().Format("2006-01-02 15:04") + " UTC" +} + +// formatTripDuration renders how long a completed trip took, rounded to the +// nearest minute. Active trips (nil end) have no duration yet. +func formatTripDuration(start time.Time, end *time.Time) string { + if end == nil { + return "—" + } + d := end.Sub(start).Round(time.Minute) + h := int(d.Hours()) + m := int(d.Minutes()) % 60 + if h > 0 { + return fmt.Sprintf("%dh %dm", h, m) + } + return fmt.Sprintf("%dm", m) +} + +// tripsPageURL builds a /admin/trips link preserving the current filter +// values with page set to the given page number, for the prev/next +// pagination links. +func tripsPageURL(status, vehicleID, q string, page int) string { + v := url.Values{} + if status != "" { + v.Set("status", status) + } + if vehicleID != "" { + v.Set("vehicle_id", vehicleID) + } + if q != "" { + v.Set("q", q) + } + v.Set("page", strconv.Itoa(page)) + return "/admin/trips?" + v.Encode() +} + +// tripsPage renders the trip history list: real trips from the store, +// filtered by status/vehicle/free-text query, 50 per page. status must be +// ""/active/completed (else 400); an invalid or missing page falls back to +// page 1 rather than erroring, since it's a bookmarkable/shareable URL param +// that's easy to hand-edit into something invalid. func (ui *adminUI) tripsPage(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + query := r.URL.Query() + + status := query.Get("status") + if status != "" && status != "active" && status != "completed" { + http.Error(w, "status must be active or completed", http.StatusBadRequest) + return + } + vehicleID := query.Get("vehicle_id") + q := query.Get("q") + + page := 1 + if raw := query.Get("page"); raw != "" { + if n, err := strconv.Atoi(raw); err == nil && n >= 1 { + page = n + } + } + + filter := TripFilter{ + Status: status, + VehicleID: vehicleID, + Q: q, + Limit: tripsPageSize + 1, + Offset: (page - 1) * tripsPageSize, + } + + trips, err := ui.trips.ListTrips(ctx, filter) + if err != nil { + slog.Error("trips: list trips", "error", err) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + + hasMore := len(trips) > tripsPageSize + if hasMore { + trips = trips[:tripsPageSize] + } + + allVehicles, err := ui.vehicles.ListVehicles(ctx) + if err != nil { + slog.Error("trips: list vehicles", "error", err) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + activeVehicles := make([]VehicleResponse, 0, len(allVehicles)) + for _, v := range allVehicles { + if v.Active { + activeVehicles = append(activeVehicles, v) + } + } + sort.Slice(activeVehicles, func(i, j int) bool { return activeVehicles[i].ID < activeVehicles[j].ID }) + + rows := make([]tripRow, 0, len(trips)) + for _, t := range trips { + row := tripRow{ + ID: t.ID, + VehicleLabel: t.VehicleLabel, + DriverName: t.DriverName, + RouteID: t.RouteID, + GtfsTripID: t.GtfsTripID, + Start: formatTripTimestamp(t.StartTime), + End: "—", + Status: t.Status, + Duration: formatTripDuration(t.StartTime, t.EndTime), + } + if t.EndTime != nil { + row.End = formatTripTimestamp(*t.EndTime) + } + rows = append(rows, row) + } + ui.renderAdmin(w, r, "trips.html", map[string]interface{}{ - "Title": "Trips", - "Page": "trips", - "Trips": []map[string]string{ - {"ID": "T001", "Vehicle": "Bus 001", "Driver": "Tom Hiddlestone", "Route": "Route A", "Start": "07:00", "End": "08:45", "Status": "completed"}, - {"ID": "T002", "Vehicle": "Bus 002", "Driver": "Chris Hensworth", "Route": "Route B", "Start": "07:15", "End": "—", "Status": "active"}, - {"ID": "T003", "Vehicle": "Bus 003", "Driver": "Bruce Wayne", "Route": "Route C", "Start": "06:45", "End": "08:30", "Status": "completed"}, - }, + "Title": "Trips", + "Page": "trips", + "Trips": rows, + "Vehicles": activeVehicles, + "Status": status, + "VehicleID": vehicleID, + "Q": q, + "PageNum": page, + "HasMore": hasMore, + "PrevURL": tripsPageURL(status, vehicleID, q, page-1), + "NextURL": tripsPageURL(status, vehicleID, q, page+1), }) } diff --git a/admin_page_handlers_test.go b/admin_page_handlers_test.go index 07dde6e..db2f9b6 100644 --- a/admin_page_handlers_test.go +++ b/admin_page_handlers_test.go @@ -171,7 +171,7 @@ func TestAdminPagesRenderWithSession(t *testing.T) { {"dashboard", "/admin/dashboard", "Active Trips"}, {"vehicles", "/admin/vehicles", "New vehicle"}, {"users", "/admin/users", "New user"}, - {"trips", "/admin/trips", "Route A"}, + {"trips", "/admin/trips", "No trips found."}, {"map", "/admin/map", "Live Map"}, } for _, tc := range cases { @@ -1124,3 +1124,210 @@ func TestUserAssignVehicle_UnknownVehicle(t *testing.T) { assert.Equal(t, http.StatusNotFound, w.Code) assert.False(t, assignments.byUser[1]["ghost-bus"]) } + +// TestTripsPageRendersRealTrips verifies the trips table renders a seeded +// trip's vehicle label, driver name, UTC-suffixed start/end times, duration, +// and a "View trail" link into the map trail view — and that the old mock +// data is gone. +func TestTripsPageRendersRealTrips(t *testing.T) { + ui := newTestAdminUI(t) + start := time.Date(2026, 1, 2, 7, 0, 0, 0, time.UTC) + end := time.Date(2026, 1, 2, 8, 45, 0, 0, time.UTC) + fake := &fakeTripLister{trips: []TripSummary{ + {ID: 42, VehicleID: "bus-1", VehicleLabel: "Bus One", UserID: 7, DriverName: "Asha Patel", RouteID: "12", GtfsTripID: "trip-99", StartTime: start, EndTime: &end, Status: "completed"}, + }} + ui.trips = fake + mux := http.NewServeMux() + registerAdminUI(mux, ui) + + req := httptest.NewRequest(http.MethodGet, "/admin/trips", nil) + req.AddCookie(cookieFor(t, "admin")) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) + body := w.Body.String() + assert.Contains(t, body, "Bus One") + assert.Contains(t, body, "Asha Patel") + assert.Contains(t, body, "12") + assert.Contains(t, body, "trip-99") + assert.Contains(t, body, "2026-01-02 07:00 UTC") + assert.Contains(t, body, "2026-01-02 08:45 UTC") + assert.Contains(t, body, "1h 45m") + assert.Contains(t, body, `href="/admin/map?trip_id=42"`) + assert.NotContains(t, body, "T001", "mock data must be gone") + assert.NotContains(t, body, "Tom Hiddlestone", "mock data must be gone") +} + +// TestTripsPageActiveTripHasNoEndOrDuration verifies an active trip (nil +// EndTime) renders em-dashes for end time and duration rather than zero +// values. +func TestTripsPageActiveTripHasNoEndOrDuration(t *testing.T) { + ui := newTestAdminUI(t) + start := time.Date(2026, 1, 2, 7, 0, 0, 0, time.UTC) + fake := &fakeTripLister{trips: []TripSummary{ + {ID: 43, VehicleID: "bus-2", VehicleLabel: "Bus Two", UserID: 8, DriverName: "Chris H", RouteID: "13", GtfsTripID: "trip-100", StartTime: start, EndTime: nil, Status: "active"}, + }} + ui.trips = fake + mux := http.NewServeMux() + registerAdminUI(mux, ui) + + req := httptest.NewRequest(http.MethodGet, "/admin/trips", nil) + req.AddCookie(cookieFor(t, "admin")) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) + body := w.Body.String() + assert.Contains(t, body, "2026-01-02 07:00 UTC") + assert.Contains(t, body, "—") +} + +// TestTripsPageFilterPassthrough verifies status/vehicle_id/q/page query +// params are translated into the TripFilter passed to ListTrips, including +// the Limit:51/Offset math for page 2. +func TestTripsPageFilterPassthrough(t *testing.T) { + ui := newTestAdminUI(t) + fake := &fakeTripLister{} + ui.trips = fake + mux := http.NewServeMux() + registerAdminUI(mux, ui) + + req := httptest.NewRequest(http.MethodGet, "/admin/trips?status=active&vehicle_id=bus-1&q=asha&page=2", nil) + req.AddCookie(cookieFor(t, "admin")) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, TripFilter{Status: "active", VehicleID: "bus-1", Q: "asha", Limit: 51, Offset: 50}, fake.captured) +} + +// TestTripsPageBadStatusReturns400 verifies a status value outside +// ""/active/completed is rejected. +func TestTripsPageBadStatusReturns400(t *testing.T) { + ui := newTestAdminUI(t) + ui.trips = &fakeTripLister{} + mux := http.NewServeMux() + registerAdminUI(mux, ui) + + req := httptest.NewRequest(http.MethodGet, "/admin/trips?status=bogus", nil) + req.AddCookie(cookieFor(t, "admin")) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) +} + +// TestTripsPageInvalidPageDefaultsToOne verifies a missing or non-numeric +// page param falls back to page 1 (Offset 0) rather than erroring. +func TestTripsPageInvalidPageDefaultsToOne(t *testing.T) { + for _, page := range []string{"", "abc", "0", "-1"} { + t.Run("page="+page, func(t *testing.T) { + ui := newTestAdminUI(t) + fake := &fakeTripLister{} + ui.trips = fake + mux := http.NewServeMux() + registerAdminUI(mux, ui) + + path := "/admin/trips" + if page != "" { + path += "?page=" + page + } + req := httptest.NewRequest(http.MethodGet, path, nil) + req.AddCookie(cookieFor(t, "admin")) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, 0, fake.captured.Offset) + }) + } +} + +// TestTripsPageHasMorePagination verifies a full page (51 rows returned for +// Limit:51) trims to 50 and shows a Next link, and that page 2 shows a +// Previous link back to page 1. +func TestTripsPageHasMorePagination(t *testing.T) { + ui := newTestAdminUI(t) + trips := make([]TripSummary, 51) + for i := range trips { + trips[i] = TripSummary{ID: int64(i + 1), VehicleID: "bus-1", VehicleLabel: "Bus One", DriverName: "Driver", RouteID: "1", GtfsTripID: "g1", StartTime: time.Now(), Status: "completed"} + } + fake := &fakeTripLister{trips: trips} + ui.trips = fake + mux := http.NewServeMux() + registerAdminUI(mux, ui) + + req := httptest.NewRequest(http.MethodGet, "/admin/trips", nil) + req.AddCookie(cookieFor(t, "admin")) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + require.Equal(t, http.StatusOK, w.Code) + body := w.Body.String() + assert.Contains(t, body, "page=2") + assert.NotContains(t, body, "Previous") + + req = httptest.NewRequest(http.MethodGet, "/admin/trips?page=2", nil) + req.AddCookie(cookieFor(t, "admin")) + w = httptest.NewRecorder() + mux.ServeHTTP(w, req) + require.Equal(t, http.StatusOK, w.Code) + body = w.Body.String() + assert.Contains(t, body, "Previous") + assert.Contains(t, body, "page=1") +} + +// TestTripsPageEmptyState covers the empty-state row when no trips match. +func TestTripsPageEmptyState(t *testing.T) { + ui := newTestAdminUI(t) + ui.trips = &fakeTripLister{} + mux := http.NewServeMux() + registerAdminUI(mux, ui) + + req := httptest.NewRequest(http.MethodGet, "/admin/trips", nil) + req.AddCookie(cookieFor(t, "admin")) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) + assert.Contains(t, w.Body.String(), "No trips found.") +} + +// TestTripsPageStoreErrorReturns500 verifies a ListTrips failure produces a +// 500 rather than a partially-rendered page. +func TestTripsPageStoreErrorReturns500(t *testing.T) { + ui := newTestAdminUI(t) + ui.trips = &fakeTripLister{err: errors.New("boom")} + mux := http.NewServeMux() + registerAdminUI(mux, ui) + + req := httptest.NewRequest(http.MethodGet, "/admin/trips", nil) + req.AddCookie(cookieFor(t, "admin")) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + + assert.Equal(t, http.StatusInternalServerError, w.Code) +} + +// TestTripsPageVehicleSelectActiveOnly verifies the vehicle filter dropdown +// is populated from active vehicles only. +func TestTripsPageVehicleSelectActiveOnly(t *testing.T) { + ui := newTestAdminUI(t) + ui.trips = &fakeTripLister{} + wireFakeVehicleStore(ui, newFakeVehicleStore( + VehicleResponse{ID: "bus-1", Label: "Active Bus", Active: true}, + VehicleResponse{ID: "bus-2", Label: "Retired Bus", Active: false}, + )) + mux := http.NewServeMux() + registerAdminUI(mux, ui) + + req := httptest.NewRequest(http.MethodGet, "/admin/trips", nil) + req.AddCookie(cookieFor(t, "admin")) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) + body := w.Body.String() + assert.Contains(t, body, "Active Bus") + assert.NotContains(t, body, "Retired Bus") +} diff --git a/web/templates/views/trips.html b/web/templates/views/trips.html index 0cad867..e18d7da 100644 --- a/web/templates/views/trips.html +++ b/web/templates/views/trips.html @@ -1,11 +1,27 @@ {{define "content"}}
-
+

Trip History

-

{{len .Trips}} trips today

+

{{len .Trips}} trips shown

+
+ + + + +
Name Email RoleLast SeenStatusVehiclesActions
No users found.
@@ -34,7 +42,28 @@

All Users

Driver {{end}}
{{.LastSeen}} + {{if .Active}} + Active + {{else}} + Deactivated + {{end}} + {{.VehicleCount}} +
+ Edit + {{if .Active}} +
+ +
+ {{else}} +
+ +
+ {{end}} +
+
@@ -15,18 +31,27 @@

Trip History

+ + + + {{if not .Trips}} + + + + {{end}} {{range .Trips}} - - - + + + + + + {{end}}
Vehicle Driver RouteGTFS trip Start End StatusDurationActions
No trips found.
{{.ID}}{{.Vehicle}}{{.Driver}}{{.Route}}{{.VehicleLabel}}{{.DriverName}}{{.RouteID}}{{.GtfsTripID}} {{.Start}} {{.End}} @@ -38,11 +63,30 @@

Trip History

{{.Status}} {{end}}
{{.Duration}} + View trail +
+ {{if or (gt .PageNum 1) .HasMore}} +
+
+ {{if gt .PageNum 1}} + ← Previous + {{end}} +
+
Page {{.PageNum}}
+
+ {{if .HasMore}} + Next → + {{end}} +
+
+ {{end}} {{end}} From 54ea2f2e02454a7c130f73da042433fe7646684a Mon Sep 17 00:00:00 2001 From: Aaron Brethorst Date: Mon, 24 Aug 2026 02:48:12 -0700 Subject: [PATCH 23/29] test: admin route wiring coverage; docs: admin UI setup and operations Add a page-route wiring table covering every protected /admin/* GET/POST route (unauthenticated and driver-session cookie both redirect 303 to /admin/login); verified the existing /api/v1/admin/* wiring tables already cover vehicles/live, trips, and trip-locations with no gaps. Document the admin web UI in README.md (Getting Started) and docs/development.md (dev-workflow voice, make css, pinned Tailwind CLI version): default-on at /admin, ADMIN_UI_ENABLED=false to disable, ADMIN_BOOTSTRAP_EMAIL/PASSWORD or seed_dev.sql for the first admin, and TRUST_PROXY_HEADERS behind a reverse proxy. Also refresh docs/android-smoke-test.md's stale "no bootstrap path exists" section now that seed_dev.sql seeds an admin account. Full-stack smoke test run manually: login page, unauthenticated redirect, form login + session cookie, authenticated dashboard, live vehicles endpoint, and trip history all verified against a real Postgres + server process with simulated location reports. --- README.md | 28 ++++++++++++++++++ docs/android-smoke-test.md | 26 ++++++++--------- docs/development.md | 41 ++++++++++++++++++++++++++ route_wiring_test.go | 59 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 141 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 2fa5be1..078fa29 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,34 @@ Quick-start instructions for running the server locally with Docker Compose, plus API sanity checks and troubleshooting, live in [`docs/development.md`](docs/development.md). +### Admin web UI + +The server also ships a server-rendered admin web UI at `/admin` — sign in, +dashboard, live fleet map with per-trip trails, vehicle CRUD (with CSV export +of location history), user CRUD, vehicle assignments, and trip history. It's +built into the same binary and enabled by default; set +`ADMIN_UI_ENABLED=false` to disable it entirely (the route returns 404). + +**Note for operators upgrading from an earlier version:** the admin UI used to +not exist, so there's nothing to opt into — this is a new default-on surface. +If you don't want it exposed, set `ADMIN_UI_ENABLED=false` before deploying. + +Sign in with an existing admin account. To create the first one: + +- **Production/staging:** set `ADMIN_BOOTSTRAP_EMAIL` and + `ADMIN_BOOTSTRAP_PASSWORD` (8+ characters) before the server's first boot. + It creates that admin account once, only when the `users` table has zero + admins, and is a no-op on every subsequent boot. +- **Local development:** load [`seed_dev.sql`](seed_dev.sql), which seeds + `admin@test.com` / `password` (alongside a seed driver). + +Behind a reverse proxy (nginx, an ALB, etc.), set `TRUST_PROXY_HEADERS=true` +so the server reads the real client IP and scheme from `X-Forwarded-For` / +`X-Forwarded-Proto` — this affects the admin login rate limiter's per-IP +bucketing and whether the session cookie is marked `Secure`. Leave it unset +(false) when the server is reachable directly, since trusting those headers +from an untrusted client would let it spoof its IP. + ### Android driver app The companion driver app lives in [`android/`](android/) (Gradle root — diff --git a/docs/android-smoke-test.md b/docs/android-smoke-test.md index 8c4a052..671687a 100644 --- a/docs/android-smoke-test.md +++ b/docs/android-smoke-test.md @@ -61,24 +61,24 @@ curl -s http://localhost:8080/health # {"status":"ok"} ``` -### 2. Create an admin user (no bootstrap path exists) +### 2. Create an admin user -Neither `seed_dev.sql` nor the migrations create an admin user — only a -driver (see step 3). Every admin endpoint requires an admin-role JWT -(`requireAdmin` in `auth.go`, wired in `main.go`), and account creation -itself is an admin-only endpoint, so the very first admin has to be inserted -directly into the database. Insert one via `psql`, reusing the bcrypt hash -already checked into `seed_dev.sql` (it hashes the password `password`, cost -10, matching the server's `bcrypt.DefaultCost`): +`seed_dev.sql` seeds both a driver and an admin (`admin@test.com` / +`password`). Every admin endpoint requires an admin-role JWT (`requireAdmin` +in `auth.go`, wired in `main.go`), and account creation itself is an +admin-only endpoint, so getting that seed admin in place is the easiest way +to bootstrap: ```bash -docker compose exec -T db psql -U postgres -d vehicle_positions -c " -INSERT INTO users (name, email, password_hash, role) -VALUES ('Admin', 'admin@test.com', '\$2a\$10\$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', 'admin') -ON CONFLICT (email) DO NOTHING; -" +docker compose exec -T db psql -U postgres -d vehicle_positions < seed_dev.sql ``` +(Alternatively, for production/staging deployments rather than local dev, set +`ADMIN_BOOTSTRAP_EMAIL` / `ADMIN_BOOTSTRAP_PASSWORD` before the server's first +boot — it creates that admin once, only when the `users` table has zero +admins. See `docs/development.md` for the full admin-UI setup, including the +server-rendered UI at `/admin` itself.) + Log in to confirm and capture the admin token for the next step: ```bash diff --git a/docs/development.md b/docs/development.md index 6ebb833..d274804 100644 --- a/docs/development.md +++ b/docs/development.md @@ -70,6 +70,47 @@ You can run Postgres in Docker and run the Go server directly: Migrations are applied automatically on server startup. +## Admin Web UI + +The server also serves a session-authenticated admin UI at `/admin` +(dashboard, live map + trails, vehicle/user CRUD, assignments, trip history). +It's on by default; set `ADMIN_UI_ENABLED=false` if you want to run the +server with just the JSON API. + +To sign in locally, seed the dev admin (`admin@test.com` / `password`) the +same way you'd seed the dev driver: + +```bash +docker compose exec -T db psql -U postgres -d vehicle_positions < seed_dev.sql +``` + +Then visit `http://localhost:8080/admin/login`. For a from-scratch admin +instead of the seed one, set `ADMIN_BOOTSTRAP_EMAIL` / +`ADMIN_BOOTSTRAP_PASSWORD` before the server's first boot — it only creates +an admin when none exist yet, so it's safe to leave set across restarts. + +If you're changing anything under `web/templates` or `web/styles/input.css`, +rebuild the compiled Tailwind CSS before checking your changes in the +browser: + +```bash +make css +``` + +This compiles `web/styles/input.css` to `web/static/css/admin.css` (which is +what the server actually embeds and serves — the browser never sees +`input.css`) using a pinned Tailwind CLI binary (currently `v4.2.0`, see +`TAILWIND_VERSION` in the `Makefile`) that `make css` downloads to `.tools/` +on first use. CI checks in `web/static/css/admin.css` against the same +pinned version, so if you bump `TAILWIND_VERSION` in the `Makefile`, also +bump the version CI downloads in `.github/workflows/ci.yml` and re-run `make +css` to regenerate the checked-in output. + +Running behind a reverse proxy locally (rare, but if you're testing that +path)? Set `TRUST_PROXY_HEADERS=true` so client-IP-based rate limiting and +the session cookie's `Secure` flag look at `X-Forwarded-For` / +`X-Forwarded-Proto` instead of the raw connection. + ## Running Tests Run all tests: diff --git a/route_wiring_test.go b/route_wiring_test.go index 5b6359a..61f6f32 100644 --- a/route_wiring_test.go +++ b/route_wiring_test.go @@ -254,6 +254,65 @@ func TestLiveVehiclesRoute_DoesNotHitGetVehicle(t *testing.T) { assert.True(t, hasVehicles, "response must have a \"vehicles\" key, proving handleLiveVehicles served the request, not handleGetVehicle") } +// TestAdminPageRoutes_Wiring verifies every protected /admin/* page and POST +// route registered by registerAdminUI (Tasks 8, 15, 16, 17) enforces +// requireAdminPage: an unauthenticated visitor and a driver-role session +// cookie are both redirected (303) to /admin/login rather than reaching the +// handler. This is the page-route counterpart to +// TestAdminRoutes_DriverTokenRejected/AdminTokenAllowed above, which cover +// the JSON /api/v1/admin/* routes. Add new admin page/POST routes to this +// table so it catches future wiring gaps. (/admin/login, /admin/logout, and +// /admin, /admin/{$} are intentionally excluded — they're unprotected by +// design: the login page/submit must be reachable without a session, and +// logout must work even for an expired one.) +func TestAdminPageRoutes_Wiring(t *testing.T) { + h := newTestHandler(t, true) + + tests := []struct { + method string + path string + }{ + {"GET", "/admin/dashboard"}, + {"GET", "/admin/map"}, + {"GET", "/admin/vehicles"}, + {"GET", "/admin/vehicles/new"}, + {"POST", "/admin/vehicles"}, + {"GET", "/admin/vehicles/bus-1/edit"}, + {"POST", "/admin/vehicles/bus-1"}, + {"POST", "/admin/vehicles/bus-1/deactivate"}, + {"POST", "/admin/vehicles/bus-1/activate"}, + {"GET", "/admin/users"}, + {"GET", "/admin/users/new"}, + {"POST", "/admin/users"}, + {"GET", "/admin/users/1/edit"}, + {"POST", "/admin/users/1"}, + {"POST", "/admin/users/1/deactivate"}, + {"POST", "/admin/users/1/activate"}, + {"POST", "/admin/users/1/vehicles"}, + {"POST", "/admin/users/1/vehicles/bus-1/remove"}, + {"GET", "/admin/trips"}, + } + + for _, tc := range tests { + t.Run("unauthenticated "+tc.method+" "+tc.path, func(t *testing.T) { + req := httptest.NewRequest(tc.method, tc.path, nil) + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + assert.Equal(t, http.StatusSeeOther, w.Code, "unauthenticated request to %s %s must redirect", tc.method, tc.path) + assert.Equal(t, "/admin/login", w.Header().Get("Location"), "%s %s", tc.method, tc.path) + }) + + t.Run("driver session "+tc.method+" "+tc.path, func(t *testing.T) { + req := httptest.NewRequest(tc.method, tc.path, nil) + req.AddCookie(cookieFor(t, "driver")) + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + assert.Equal(t, http.StatusSeeOther, w.Code, "driver session on %s %s must redirect, not reach the handler", tc.method, tc.path) + assert.Equal(t, "/admin/login", w.Header().Get("Location"), "%s %s", tc.method, tc.path) + }) + } +} + // TestDriverVehiclesRoute_Wiring verifies GET /api/v1/vehicles requires // authentication (401 with no token) and accepts any authenticated driver // (200 with a driver-role token) — no admin role required, unlike the From ffc60651aa7258509e89c6aec6a18091b538728c Mon Sep 17 00:00:00 2001 From: Aaron Brethorst Date: Mon, 24 Aug 2026 02:59:26 -0700 Subject: [PATCH 24/29] fix: prevent login limiter map-filling DoS; validate form fields before limiting Allow(ip, email) now checks the IP window first and returns early without touching byEmail when the IP is blocked, so a single IP spraying distinct emails can no longer fill byEmail to maxTrackedLogins and fail-close every new login attempt system-wide. Also reorders the admin form login handler to validate empty email/password before consuming limiter budget, matching the JSON login handler's existing order in auth.go. --- admin_page_handlers.go | 8 +++--- ratelimit_login.go | 14 +++++++--- ratelimit_login_test.go | 59 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 74 insertions(+), 7 deletions(-) diff --git a/admin_page_handlers.go b/admin_page_handlers.go index ed3cf24..a321f0e 100644 --- a/admin_page_handlers.go +++ b/admin_page_handlers.go @@ -170,14 +170,14 @@ func (ui *adminUI) loginSubmit(w http.ResponseWriter, r *http.Request) { } email := r.PostFormValue("email") password := r.PostFormValue("password") - if !ui.loginLimiter.Allow(clientIP(r, ui.cfg.trustProxy), email) { - ui.renderLogin(w, http.StatusTooManyRequests, "Too many attempts, try again shortly.", email) - return - } if email == "" || password == "" { ui.renderLogin(w, http.StatusUnprocessableEntity, "Email and password are required.", email) return } + if !ui.loginLimiter.Allow(clientIP(r, ui.cfg.trustProxy), email) { + ui.renderLogin(w, http.StatusTooManyRequests, "Too many attempts, try again shortly.", email) + return + } user, err := ui.users.GetUserByEmail(r.Context(), email) if err != nil { if errors.Is(err, ErrUserNotFound) { diff --git a/ratelimit_login.go b/ratelimit_login.go index 1ded2fa..8bf5135 100644 --- a/ratelimit_login.go +++ b/ratelimit_login.go @@ -41,13 +41,21 @@ func NewLoginRateLimiter() *LoginRateLimiter { func (l *LoginRateLimiter) Stop() { l.once.Do(func() { close(l.stop) }) } +// Allow checks the IP dimension first and returns false immediately, without +// touching byEmail, when the IP is already blocked. This prevents a single +// IP from spraying distinct emails to fill byEmail up to maxTrackedLogins +// while it is itself IP-blocked, which would otherwise fail every new key +// closed once the map hit capacity (a map-filling DoS from one IP). When the +// IP is not blocked, a single Allow call still consumes budget from both +// dimensions, matching prior behavior. func (l *LoginRateLimiter) Allow(ip, email string) bool { l.mu.Lock() defer l.mu.Unlock() now := time.Now() - okIP := allowInWindow(l.byIP, ip, loginIPLimit, now) - okEmail := allowInWindow(l.byEmail, email, loginEmailLimit, now) - return okIP && okEmail + if !allowInWindow(l.byIP, ip, loginIPLimit, now) { + return false + } + return allowInWindow(l.byEmail, email, loginEmailLimit, now) } func allowInWindow(m map[string]*loginWindowEntry, key string, limit int, now time.Time) bool { diff --git a/ratelimit_login_test.go b/ratelimit_login_test.go index a662942..170456f 100644 --- a/ratelimit_login_test.go +++ b/ratelimit_login_test.go @@ -3,6 +3,7 @@ package main import ( "fmt" "testing" + "time" "github.com/stretchr/testify/assert" ) @@ -25,3 +26,61 @@ func TestLoginRateLimiterPerEmail(t *testing.T) { } assert.False(t, l.Allow("10.0.0.99", "target@test.com"), "6th attempt on same email blocked across IPs") } + +// TestLoginRateLimiterIPBlockDoesNotGrowEmailMap guards against a +// map-filling DoS: once an IP is blocked, further Allow calls from that IP +// with brand-new emails must not insert into byEmail at all. A single IP +// spraying distinct emails must not be able to grow byEmail toward +// maxTrackedLogins while it is itself blocked. +func TestLoginRateLimiterIPBlockDoesNotGrowEmailMap(t *testing.T) { + l := NewLoginRateLimiter() + defer l.Stop() + + ip := "1.2.3.4" + for i := 0; i < loginIPLimit; i++ { + assert.True(t, l.Allow(ip, fmt.Sprintf("u%d@test.com", i)), "attempt %d within IP limit", i) + } + + l.mu.Lock() + emailCountAtIPLimit := len(l.byEmail) + l.mu.Unlock() + assert.Equal(t, loginIPLimit, emailCountAtIPLimit) + + // The IP is now blocked. Further attempts with brand-new emails must be + // denied without inserting into byEmail. + for i := loginIPLimit; i < loginIPLimit+20; i++ { + assert.False(t, l.Allow(ip, fmt.Sprintf("u%d@test.com", i)), "IP-blocked attempt %d", i) + } + + l.mu.Lock() + emailCountAfter := len(l.byEmail) + l.mu.Unlock() + assert.Equal(t, emailCountAtIPLimit, emailCountAfter, "byEmail must not grow while IP is blocked") + + // One of the emails that was never inserted, tried from a fresh IP, + // should still get its full per-email attempt budget. + freshIP := "9.9.9.9" + neverInserted := fmt.Sprintf("u%d@test.com", loginIPLimit) + for i := 0; i < loginEmailLimit; i++ { + assert.True(t, l.Allow(freshIP, neverInserted), "fresh IP + never-inserted email attempt %d", i) + } + assert.False(t, l.Allow(freshIP, neverInserted), "email budget exhausted after loginEmailLimit attempts") +} + +// TestLoginRateLimiterEmailMapCapacityFailsClosed exercises the real +// maxTrackedLogins constant: once byEmail is at capacity, a brand-new email +// (from an IP that is not itself blocked) must be denied rather than +// silently allowed. +func TestLoginRateLimiterEmailMapCapacityFailsClosed(t *testing.T) { + l := NewLoginRateLimiter() + defer l.Stop() + + now := time.Now() + l.mu.Lock() + for i := 0; i < maxTrackedLogins; i++ { + l.byEmail[fmt.Sprintf("filler%d@test.com", i)] = &loginWindowEntry{count: 1, windowStart: now} + } + l.mu.Unlock() + + assert.False(t, l.Allow("9.9.9.9", "newcomer@test.com"), "new email denied when byEmail is at capacity") +} From 6f6f21e9e2e34fb9087c300571950899c724a806 Mon Sep 17 00:00:00 2001 From: Aaron Brethorst Date: Mon, 24 Aug 2026 02:59:26 -0700 Subject: [PATCH 25/29] docs: note deactivation session window; stabilize trips ordering Document that deactivating a user blocks new logins immediately but does not revoke already-issued sessions/tokens, which remain valid until they expire (up to 24 hours). Add a t.id DESC tiebreak to ListTrips' ORDER BY so pagination is deterministic for trips sharing the same start_time. --- README.md | 4 ++++ docs/development.md | 4 ++++ store_trips.go | 2 +- 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 078fa29..8c71a84 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,10 @@ Sign in with an existing admin account. To create the first one: - **Local development:** load [`seed_dev.sql`](seed_dev.sql), which seeds `admin@test.com` / `password` (alongside a seed driver). +Deactivating a user blocks new logins immediately, but it doesn't revoke +sessions already issued — any existing session cookie or JWT for that user +stays valid until it expires (up to 24 hours). + Behind a reverse proxy (nginx, an ALB, etc.), set `TRUST_PROXY_HEADERS=true` so the server reads the real client IP and scheme from `X-Forwarded-For` / `X-Forwarded-Proto` — this affects the admin login rate limiter's per-IP diff --git a/docs/development.md b/docs/development.md index d274804..e7fcf16 100644 --- a/docs/development.md +++ b/docs/development.md @@ -89,6 +89,10 @@ instead of the seed one, set `ADMIN_BOOTSTRAP_EMAIL` / `ADMIN_BOOTSTRAP_PASSWORD` before the server's first boot — it only creates an admin when none exist yet, so it's safe to leave set across restarts. +Deactivating a user blocks new logins immediately, but existing sessions and +tokens for that user remain valid until they expire (up to 24 hours) — this +isn't instant revocation. + If you're changing anything under `web/templates` or `web/styles/input.css`, rebuild the compiled Tailwind CSS before checking your changes in the browser: diff --git a/store_trips.go b/store_trips.go index b450efc..0b13c79 100644 --- a/store_trips.go +++ b/store_trips.go @@ -212,7 +212,7 @@ func (s *Store) ListTrips(ctx context.Context, f TripFilter) ([]TripSummary, err if len(conds) > 0 { query += " WHERE " + strings.Join(conds, " AND ") } - query += " ORDER BY t.start_time DESC LIMIT " + arg(f.Limit) + " OFFSET " + arg(f.Offset) + query += " ORDER BY t.start_time DESC, t.id DESC LIMIT " + arg(f.Limit) + " OFFSET " + arg(f.Offset) rows, err := s.pool.Query(ctx, query, args...) if err != nil { From 18d07d57a7a03c48f786cdb78415635d4ea018dd Mon Sep 17 00:00:00 2001 From: Aaron Brethorst Date: Mon, 24 Aug 2026 03:05:47 -0700 Subject: [PATCH 26/29] fix: run map mode dispatch after let declarations to avoid TDZ ReferenceError --- web/static/js/admin.js | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/web/static/js/admin.js b/web/static/js/admin.js index db03356..b7510b0 100644 --- a/web/static/js/admin.js +++ b/web/static/js/admin.js @@ -9,13 +9,6 @@ }).addTo(map); L.control.zoom({ position: "bottomright" }).addTo(map); - const tripUrl = el.dataset.tripUrl; - if (tripUrl) { - renderTrail(tripUrl); - } else { - startLive(el.dataset.liveUrl); - } - // busIcon returns the shared divIcon markup used for every marker (live // fleet vehicles and trail start/end points). There is only one visual // style now that the map no longer distinguishes idle vehicles. @@ -274,4 +267,13 @@ list.appendChild(card); } + + // Mode dispatch runs last so every let-bound module state (markers, fitted, + // timer) is initialized before startLive's first refresh touches it. + const tripUrl = el.dataset.tripUrl; + if (tripUrl) { + renderTrail(tripUrl); + } else { + startLive(el.dataset.liveUrl); + } })(); From bf08bb0a1df1e84f3f646633bed179e93d228fe3 Mon Sep 17 00:00:00 2001 From: Aaron Brethorst Date: Mon, 24 Aug 2026 03:15:11 -0700 Subject: [PATCH 27/29] simplify: extract shared JWT parse, password/status validation, nullable-float mapping; single active-flag write path --- .../page-2026-08-24T10-03-39-024Z.yml | 13 ++++ .../page-2026-08-24T10-03-55-024Z.yml | 72 +++++++++++++++++ .../page-2026-08-24T10-03-59-900Z.yml | 56 +++++++++++++ .../page-2026-08-24T10-04-38-299Z.yml | 56 +++++++++++++ .../page-2026-08-24T10-05-09-595Z.yml | 63 +++++++++++++++ .../page-2026-08-24T10-05-27-277Z.yml | 78 +++++++++++++++++++ admin_live_handlers.go | 2 +- admin_page_handlers.go | 33 ++++++-- admin_session.go | 18 +---- auth.go | 44 ++++++----- bootstrap.go | 4 +- db/query.sql | 5 -- db/query.sql.go | 14 ---- location_history_store.go | 15 +--- ratelimit_login.go | 22 +++--- store.go | 24 +++--- store_trips.go | 15 +--- store_vehicles.go | 14 +--- user_handlers.go | 8 +- 19 files changed, 432 insertions(+), 124 deletions(-) create mode 100644 .playwright-mcp/page-2026-08-24T10-03-39-024Z.yml create mode 100644 .playwright-mcp/page-2026-08-24T10-03-55-024Z.yml create mode 100644 .playwright-mcp/page-2026-08-24T10-03-59-900Z.yml create mode 100644 .playwright-mcp/page-2026-08-24T10-04-38-299Z.yml create mode 100644 .playwright-mcp/page-2026-08-24T10-05-09-595Z.yml create mode 100644 .playwright-mcp/page-2026-08-24T10-05-27-277Z.yml diff --git a/.playwright-mcp/page-2026-08-24T10-03-39-024Z.yml b/.playwright-mcp/page-2026-08-24T10-03-39-024Z.yml new file mode 100644 index 0000000..d088911 --- /dev/null +++ b/.playwright-mcp/page-2026-08-24T10-03-39-024Z.yml @@ -0,0 +1,13 @@ +- main [ref=e2]: + - generic [ref=e3]: + - paragraph [ref=e4]: Access + - heading "Transit Tracker" [level=1] [ref=e5] + - paragraph [ref=e6]: Fleet operations sign in + - generic [ref=e7]: + - generic [ref=e8]: + - generic [ref=e9]: Email + - textbox "Email" [ref=e10] + - generic [ref=e11]: + - generic [ref=e12]: Password + - textbox "Password" [ref=e13] + - button "Sign in" [ref=e14] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-08-24T10-03-55-024Z.yml b/.playwright-mcp/page-2026-08-24T10-03-55-024Z.yml new file mode 100644 index 0000000..a45a824 --- /dev/null +++ b/.playwright-mcp/page-2026-08-24T10-03-55-024Z.yml @@ -0,0 +1,72 @@ +- generic [ref=f1e2]: + - complementary [ref=f1e3]: + - generic [ref=f1e5]: + - generic [ref=f1e6]: 🚌 + - generic [ref=f1e7]: + - paragraph [ref=f1e8]: Transit Tracker + - paragraph [ref=f1e9]: Fleet Operations + - navigation [ref=f1e10]: + - link "🗺 Live Map" [ref=f1e11] [cursor=pointer]: + - /url: /admin/map + - generic [ref=f1e12]: 🗺 + - text: Live Map + - link "📈 Dashboard" [ref=f1e13] [cursor=pointer]: + - /url: /admin/dashboard + - generic [ref=f1e14]: 📈 + - text: Dashboard + - link "🚌 Vehicles" [ref=f1e15] [cursor=pointer]: + - /url: /admin/vehicles + - generic [ref=f1e16]: 🚌 + - text: Vehicles + - link "👥 Users" [ref=f1e17] [cursor=pointer]: + - /url: /admin/users + - generic [ref=f1e18]: 👥 + - text: Users + - link "🕓 Trips" [ref=f1e19] [cursor=pointer]: + - /url: /admin/trips + - generic [ref=f1e20]: 🕓 + - text: Trips + - paragraph [ref=f1e22]: v0.1.0 + - main [ref=f1e23]: + - generic [ref=f1e24]: + - generic [ref=f1e25]: + - paragraph [ref=f1e26]: Transit control + - heading "Dashboard" [level=1] [ref=f1e27] + - button "Sign out" [ref=f1e30] + - generic [ref=f1e32]: + - generic [ref=f1e33]: + - generic [ref=f1e34]: + - paragraph [ref=f1e35]: Total Fleet + - paragraph [ref=f1e36]: "1" + - paragraph [ref=f1e37]: Registered vehicles + - generic [ref=f1e38]: + - paragraph [ref=f1e39]: Active Now + - paragraph [ref=f1e40]: "1" + - paragraph [ref=f1e41]: On route + - generic [ref=f1e42]: + - paragraph [ref=f1e43]: Drivers + - paragraph [ref=f1e44]: "0" + - paragraph [ref=f1e45]: Active + - generic [ref=f1e46]: + - paragraph [ref=f1e47]: Active Trips + - paragraph [ref=f1e48]: "1" + - paragraph [ref=f1e49]: In progress + - generic [ref=f1e50]: + - generic [ref=f1e51]: "Feed last updated: just now" + - generic [ref=f1e52]: "Staleness threshold: 5 min" + - generic [ref=f1e53]: + - generic [ref=f1e54]: + - heading "Recent Activity" [level=3] [ref=f1e55] + - link "View all →" [ref=f1e56] [cursor=pointer]: + - /url: /admin/vehicles + - table [ref=f1e58]: + - rowgroup [ref=f1e59]: + - row [ref=f1e60]: + - columnheader "Vehicle" [ref=f1e61] + - columnheader "Route" [ref=f1e62] + - columnheader "Last Update" [ref=f1e63] + - rowgroup [ref=f1e64]: + - row [ref=f1e65]: + - cell "Go Bus 1" [ref=f1e66] + - cell "route-5" [ref=f1e67] + - cell "just now" [ref=f1e68] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-08-24T10-03-59-900Z.yml b/.playwright-mcp/page-2026-08-24T10-03-59-900Z.yml new file mode 100644 index 0000000..92f674e --- /dev/null +++ b/.playwright-mcp/page-2026-08-24T10-03-59-900Z.yml @@ -0,0 +1,56 @@ +- generic [ref=f2e2]: + - complementary [ref=f2e3]: + - generic [ref=f2e5]: + - generic [ref=f2e6]: 🚌 + - generic [ref=f2e7]: + - paragraph [ref=f2e8]: Transit Tracker + - paragraph [ref=f2e9]: Fleet Operations + - navigation [ref=f2e10]: + - link "🗺 Live Map" [ref=f2e11] [cursor=pointer]: + - /url: /admin/map + - generic [ref=f2e12]: 🗺 + - text: Live Map + - link "📈 Dashboard" [ref=f2e13] [cursor=pointer]: + - /url: /admin/dashboard + - generic [ref=f2e14]: 📈 + - text: Dashboard + - link "🚌 Vehicles" [ref=f2e15] [cursor=pointer]: + - /url: /admin/vehicles + - generic [ref=f2e16]: 🚌 + - text: Vehicles + - link "👥 Users" [ref=f2e17] [cursor=pointer]: + - /url: /admin/users + - generic [ref=f2e18]: 👥 + - text: Users + - link "🕓 Trips" [ref=f2e19] [cursor=pointer]: + - /url: /admin/trips + - generic [ref=f2e20]: 🕓 + - text: Trips + - paragraph [ref=f2e22]: v0.1.0 + - main [ref=f2e23]: + - generic [ref=f2e24]: + - generic [ref=f2e25]: + - paragraph [ref=f2e26]: Transit control + - heading "Live Map" [level=1] [ref=f2e27] + - button "Sign out" [ref=f2e30] + - generic [ref=f2e33]: + - generic [ref=f2e34]: + - generic [ref=f2e35]: + - generic: + - generic: + - generic [ref=f2e36]: + - button "Zoom in" [ref=f2e37] [cursor=pointer]: + + - button "Zoom out" [ref=f2e38] [cursor=pointer]: − + - generic [ref=f2e39]: + - link "Leaflet" [ref=f2e40] [cursor=pointer]: + - /url: https://leafletjs.com + - text: "| © OpenStreetMap contributors © CARTO" + - generic [ref=f2e45]: + - generic [ref=f2e46]: + - paragraph [ref=f2e47]: "0" + - paragraph [ref=f2e48]: Active + - generic [ref=f2e50]: + - paragraph [ref=f2e51]: "0" + - paragraph [ref=f2e52]: Routes + - generic: Active + - heading "Fleet Status" [level=3] [ref=f2e59] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-08-24T10-04-38-299Z.yml b/.playwright-mcp/page-2026-08-24T10-04-38-299Z.yml new file mode 100644 index 0000000..24ef266 --- /dev/null +++ b/.playwright-mcp/page-2026-08-24T10-04-38-299Z.yml @@ -0,0 +1,56 @@ +- generic [ref=f3e2]: + - complementary [ref=f3e3]: + - generic [ref=f3e5]: + - generic [ref=f3e6]: 🚌 + - generic [ref=f3e7]: + - paragraph [ref=f3e8]: Transit Tracker + - paragraph [ref=f3e9]: Fleet Operations + - navigation [ref=f3e10]: + - link "🗺 Live Map" [ref=f3e11] [cursor=pointer]: + - /url: /admin/map + - generic [ref=f3e12]: 🗺 + - text: Live Map + - link "📈 Dashboard" [ref=f3e13] [cursor=pointer]: + - /url: /admin/dashboard + - generic [ref=f3e14]: 📈 + - text: Dashboard + - link "🚌 Vehicles" [ref=f3e15] [cursor=pointer]: + - /url: /admin/vehicles + - generic [ref=f3e16]: 🚌 + - text: Vehicles + - link "👥 Users" [ref=f3e17] [cursor=pointer]: + - /url: /admin/users + - generic [ref=f3e18]: 👥 + - text: Users + - link "🕓 Trips" [ref=f3e19] [cursor=pointer]: + - /url: /admin/trips + - generic [ref=f3e20]: 🕓 + - text: Trips + - paragraph [ref=f3e22]: v0.1.0 + - main [ref=f3e23]: + - generic [ref=f3e24]: + - generic [ref=f3e25]: + - paragraph [ref=f3e26]: Transit control + - heading "Live Map" [level=1] [ref=f3e27] + - button "Sign out" [ref=f3e30] + - generic [ref=f3e33]: + - generic [ref=f3e34]: + - generic [ref=f3e35]: + - generic: + - generic: + - generic [ref=f3e36]: + - button "Zoom in" [ref=f3e37] [cursor=pointer]: + + - button "Zoom out" [ref=f3e38] [cursor=pointer]: − + - generic [ref=f3e39]: + - link "Leaflet" [ref=f3e40] [cursor=pointer]: + - /url: https://leafletjs.com + - text: "| © OpenStreetMap contributors © CARTO" + - generic [ref=f3e45]: + - generic [ref=f3e46]: + - paragraph [ref=f3e47]: "0" + - paragraph [ref=f3e48]: Active + - generic [ref=f3e50]: + - paragraph [ref=f3e51]: "0" + - paragraph [ref=f3e52]: Routes + - generic: Active + - heading "Fleet Status" [level=3] [ref=f3e59] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-08-24T10-05-09-595Z.yml b/.playwright-mcp/page-2026-08-24T10-05-09-595Z.yml new file mode 100644 index 0000000..7372c51 --- /dev/null +++ b/.playwright-mcp/page-2026-08-24T10-05-09-595Z.yml @@ -0,0 +1,63 @@ +- generic [ref=f4e2]: + - complementary [ref=f4e3]: + - generic [ref=f4e5]: + - generic [ref=f4e6]: 🚌 + - generic [ref=f4e7]: + - paragraph [ref=f4e8]: Transit Tracker + - paragraph [ref=f4e9]: Fleet Operations + - navigation [ref=f4e10]: + - link "🗺 Live Map" [ref=f4e11] [cursor=pointer]: + - /url: /admin/map + - generic [ref=f4e12]: 🗺 + - text: Live Map + - link "📈 Dashboard" [ref=f4e13] [cursor=pointer]: + - /url: /admin/dashboard + - generic [ref=f4e14]: 📈 + - text: Dashboard + - link "🚌 Vehicles" [ref=f4e15] [cursor=pointer]: + - /url: /admin/vehicles + - generic [ref=f4e16]: 🚌 + - text: Vehicles + - link "👥 Users" [ref=f4e17] [cursor=pointer]: + - /url: /admin/users + - generic [ref=f4e18]: 👥 + - text: Users + - link "🕓 Trips" [ref=f4e19] [cursor=pointer]: + - /url: /admin/trips + - generic [ref=f4e20]: 🕓 + - text: Trips + - paragraph [ref=f4e22]: v0.1.0 + - main [ref=f4e23]: + - generic [ref=f4e24]: + - generic [ref=f4e25]: + - paragraph [ref=f4e26]: Transit control + - heading "Live Map" [level=1] [ref=f4e27] + - button "Sign out" [ref=f4e30] + - generic [ref=f4e33]: + - generic [ref=f4e34]: + - generic [ref=f4e35]: + - button "🚌" [ref=f4e36] [cursor=pointer] + - generic: + - generic: + - generic [ref=f4e40]: + - button "Zoom in" [ref=f4e41] [cursor=pointer]: + + - button "Zoom out" [ref=f4e42] [cursor=pointer]: − + - generic [ref=f4e43]: + - link "Leaflet" [ref=f4e44] [cursor=pointer]: + - /url: https://leafletjs.com + - text: "| © OpenStreetMap contributors © CARTO" + - generic [ref=f4e49]: + - generic [ref=f4e50]: + - paragraph [ref=f4e51]: "1" + - paragraph [ref=f4e52]: Active + - generic [ref=f4e54]: + - paragraph [ref=f4e55]: "1" + - paragraph [ref=f4e56]: Routes + - generic: Active + - generic [ref=f4e61]: + - heading "Fleet Status" [level=3] [ref=f4e63] + - generic [ref=f4e65]: + - generic [ref=f4e66]: 🚌 + - generic [ref=f4e67]: + - paragraph [ref=f4e68]: Go Bus 1 + - paragraph [ref=f4e69]: route-5 · Go Driver \ No newline at end of file diff --git a/.playwright-mcp/page-2026-08-24T10-05-27-277Z.yml b/.playwright-mcp/page-2026-08-24T10-05-27-277Z.yml new file mode 100644 index 0000000..862c64f --- /dev/null +++ b/.playwright-mcp/page-2026-08-24T10-05-27-277Z.yml @@ -0,0 +1,78 @@ +- generic [ref=f5e2]: + - complementary [ref=f5e3]: + - generic [ref=f5e5]: + - generic [ref=f5e6]: 🚌 + - generic [ref=f5e7]: + - paragraph [ref=f5e8]: Transit Tracker + - paragraph [ref=f5e9]: Fleet Operations + - navigation [ref=f5e10]: + - link "🗺 Live Map" [ref=f5e11] [cursor=pointer]: + - /url: /admin/map + - generic [ref=f5e12]: 🗺 + - text: Live Map + - link "📈 Dashboard" [ref=f5e13] [cursor=pointer]: + - /url: /admin/dashboard + - generic [ref=f5e14]: 📈 + - text: Dashboard + - link "🚌 Vehicles" [ref=f5e15] [cursor=pointer]: + - /url: /admin/vehicles + - generic [ref=f5e16]: 🚌 + - text: Vehicles + - link "👥 Users" [ref=f5e17] [cursor=pointer]: + - /url: /admin/users + - generic [ref=f5e18]: 👥 + - text: Users + - link "🕓 Trips" [ref=f5e19] [cursor=pointer]: + - /url: /admin/trips + - generic [ref=f5e20]: 🕓 + - text: Trips + - paragraph [ref=f5e22]: v0.1.0 + - main [ref=f5e23]: + - generic [ref=f5e24]: + - generic [ref=f5e25]: + - paragraph [ref=f5e26]: Transit control + - heading "Live Map" [level=1] [ref=f5e27] + - button "Sign out" [ref=f5e30] + - generic [ref=f5e33]: + - generic [ref=f5e34]: + - generic [ref=f5e35]: + - generic: + - generic: + - img: + - generic [ref=f5e36] [cursor=pointer] + - generic: + - button "🚌" [ref=f5e37] [cursor=pointer] + - button "🚌" [ref=f5e41] [cursor=pointer] + - generic: + - generic: + - generic [ref=f5e45]: + - button "Zoom in" [disabled] [ref=f5e46]: + + - button "Zoom out" [ref=f5e47] [cursor=pointer]: − + - generic [ref=f5e48]: + - link "Leaflet" [ref=f5e49] [cursor=pointer]: + - /url: https://leafletjs.com + - text: "| © OpenStreetMap contributors © CARTO" + - generic [ref=f5e54]: + - generic [ref=f5e55]: + - paragraph [ref=f5e56]: "0" + - paragraph [ref=f5e57]: Active + - generic [ref=f5e59]: + - paragraph [ref=f5e60]: "0" + - paragraph [ref=f5e61]: Routes + - generic: Active + - generic [ref=f5e66]: + - heading "Trip Detail" [level=3] [ref=f5e68] + - generic [ref=f5e70]: + - paragraph [ref=f5e71]: Go Bus 1 + - generic [ref=f5e72]: + - strong [ref=f5e73]: Driver + - text: Go Driver + - generic [ref=f5e74]: + - strong [ref=f5e75]: Route + - text: route-5 + - generic [ref=f5e76]: + - strong [ref=f5e77]: Status + - text: active + - generic [ref=f5e78]: + - strong [ref=f5e79]: Started + - text: 2026-08-24T03:02:48.4744-07:00 \ No newline at end of file diff --git a/admin_live_handlers.go b/admin_live_handlers.go index b233be3..147c7b4 100644 --- a/admin_live_handlers.go +++ b/admin_live_handlers.go @@ -146,7 +146,7 @@ func handleListTrips(store TripLister) http.HandlerFunc { q := r.URL.Query() status := q.Get("status") - if status != "" && status != "active" && status != "completed" { + if !validTripStatus(status) { writeJSON(w, http.StatusBadRequest, map[string]string{"error": `status must be "", "active", or "completed"`}) return } diff --git a/admin_page_handlers.go b/admin_page_handlers.go index a321f0e..d6b2a2b 100644 --- a/admin_page_handlers.go +++ b/admin_page_handlers.go @@ -588,10 +588,19 @@ func (ui *adminUI) setVehicleActive(w http.ResponseWriter, r *http.Request, acti http.Redirect(w, r, "/admin/vehicles", http.StatusSeeOther) } -// minPasswordLength is the minimum length required for a new or changed -// user password (create form and edit form's optional password field). +// minPasswordLength is the minimum length required for a user password. It is +// enforced everywhere passwords are set — the users API, the admin UI forms, +// and the bootstrap admin path — via validatePassword. const minPasswordLength = 8 +// validatePassword enforces the shared minimum-password-length policy. +func validatePassword(password string) error { + if len(password) < minPasswordLength { + return fmt.Errorf("password must be at least %d characters", minPasswordLength) + } + return nil +} + // userRow is a single row in the user list table: the user's stored fields // plus how many vehicles are currently assigned to them. type userRow struct { @@ -696,6 +705,12 @@ func validUserRole(role string) bool { return role == "driver" || role == "admin" } +// validTripStatus reports whether s is a valid trips status filter value, +// shared by the trips JSON endpoint and the trips admin page. +func validTripStatus(s string) bool { + return s == "" || s == "active" || s == "completed" +} + // userNewPage renders the blank create-user form. func (ui *adminUI) userNewPage(w http.ResponseWriter, r *http.Request) { ui.renderUserForm(w, r, http.StatusOK, userFormData{Role: "driver"}) @@ -714,8 +729,8 @@ func (ui *adminUI) userCreate(w http.ResponseWriter, r *http.Request) { password := r.PostFormValue("password") role := r.PostFormValue("role") - if len(password) < minPasswordLength { - ui.renderUserForm(w, r, http.StatusUnprocessableEntity, userFormData{Name: name, Email: email, Role: role, Error: "password must be at least 8 characters"}) + if err := validatePassword(password); err != nil { + ui.renderUserForm(w, r, http.StatusUnprocessableEntity, userFormData{Name: name, Email: email, Role: role, Error: err.Error()}) return } if !validUserRole(role) { @@ -847,9 +862,11 @@ func (ui *adminUI) userUpdate(w http.ResponseWriter, r *http.Request) { ui.renderUserEditError(w, r, http.StatusUnprocessableEntity, id, name, email, role, "role must be driver or admin") return } - if password != "" && len(password) < minPasswordLength { - ui.renderUserEditError(w, r, http.StatusUnprocessableEntity, id, name, email, role, "password must be at least 8 characters") - return + if password != "" { + if err := validatePassword(password); err != nil { + ui.renderUserEditError(w, r, http.StatusUnprocessableEntity, id, name, email, role, err.Error()) + return + } } if _, err := ui.userManager.UpdateUser(r.Context(), id, name, email, role); err != nil { @@ -1047,7 +1064,7 @@ func (ui *adminUI) tripsPage(w http.ResponseWriter, r *http.Request) { query := r.URL.Query() status := query.Get("status") - if status != "" && status != "active" && status != "completed" { + if !validTripStatus(status) { http.Error(w, "status must be active or completed", http.StatusBadRequest) return } diff --git a/admin_session.go b/admin_session.go index 9e60615..2cc2b9a 100644 --- a/admin_session.go +++ b/admin_session.go @@ -1,7 +1,6 @@ package main import ( - "fmt" "log/slog" "net/http" "time" @@ -50,24 +49,15 @@ func clearSessionCookie(w http.ResponseWriter) { }) } -// adminClaimsFromCookie validates the session cookie's JWT and requires the -// admin role. It mirrors requireAuth's validation exactly (HS256, issuer). +// adminClaimsFromCookie validates the session cookie's JWT via the shared +// parseSessionToken path and additionally requires the admin role. func adminClaimsFromCookie(r *http.Request, secret []byte) (jwt.MapClaims, bool) { c, err := r.Cookie(sessionCookieName) if err != nil || c.Value == "" { return nil, false } - token, err := jwt.Parse(c.Value, func(t *jwt.Token) (interface{}, error) { - if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { - return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"]) - } - return secret, nil - }, jwt.WithValidMethods([]string{"HS256"}), jwt.WithIssuer("vehicle-positions-api")) - if err != nil || !token.Valid { - return nil, false - } - claims, ok := token.Claims.(jwt.MapClaims) - if !ok { + claims, err := parseSessionToken(c.Value, secret) + if err != nil { return nil, false } if role, _ := claims["role"].(string); role != "admin" { diff --git a/auth.go b/auth.go index 2cb8b49..81eed56 100644 --- a/auth.go +++ b/auth.go @@ -167,6 +167,30 @@ func requireAdmin() func(http.Handler) http.Handler { } } +// parseSessionToken validates an HS256 session JWT (algorithm, issuer) and +// returns its claims. It is the single validation path shared by the API +// middleware and the admin UI's cookie session (adminClaimsFromCookie), so +// changes to token validation cannot silently diverge between the two. +func parseSessionToken(tokenString string, secret []byte) (jwt.MapClaims, error) { + token, err := jwt.Parse(tokenString, func(t *jwt.Token) (interface{}, error) { + if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"]) + } + return secret, nil + }, jwt.WithValidMethods([]string{"HS256"}), jwt.WithIssuer("vehicle-positions-api")) + if err != nil { + return nil, err + } + if !token.Valid { + return nil, errors.New("token marked invalid") + } + claims, ok := token.Claims.(jwt.MapClaims) + if !ok { + return nil, errors.New("invalid token claims") + } + return claims, nil +} + // requireAuth is middleware that validates the Bearer JWT on protected routes. func requireAuth(secret []byte) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { @@ -191,30 +215,12 @@ func requireAuth(secret []byte) func(http.Handler) http.Handler { return } - token, err := jwt.Parse(tokenString, func(t *jwt.Token) (interface{}, error) { - if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { - return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"]) - } - return secret, nil - }, jwt.WithValidMethods([]string{"HS256"}), jwt.WithIssuer("vehicle-positions-api")) - + claims, err := parseSessionToken(tokenString, secret) if err != nil { slog.Warn("token validation failed", "error", err) writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid token"}) return } - - if !token.Valid { - slog.Warn("token validation failed: token marked invalid") - writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid token"}) - return - } - - claims, ok := token.Claims.(jwt.MapClaims) - if !ok { - writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid token claims"}) - return - } ctx := contextWithClaims(r.Context(), claims) next.ServeHTTP(w, r.WithContext(ctx)) }) diff --git a/bootstrap.go b/bootstrap.go index f533ffc..7b27fb6 100644 --- a/bootstrap.go +++ b/bootstrap.go @@ -23,8 +23,8 @@ func bootstrapAdmin(ctx context.Context, store adminBootstrapStore, email, passw slog.Info("admin bootstrap skipped: admin users already exist", "count", n) return nil } - if len(password) < 8 { - return fmt.Errorf("bootstrap admin: password must be at least 8 characters") + if err := validatePassword(password); err != nil { + return fmt.Errorf("bootstrap admin: %w", err) } if _, err := store.CreateUser(ctx, "Administrator", email, password, "admin"); err != nil { return fmt.Errorf("bootstrap admin: create: %w", err) diff --git a/db/query.sql b/db/query.sql index 0841042..18a7ca1 100644 --- a/db/query.sql +++ b/db/query.sql @@ -67,11 +67,6 @@ VALUES ($1, $2, $3) ON CONFLICT (id) DO UPDATE SET label = EXCLUDED.label, agency_tag = EXCLUDED.agency_tag, active = true, updated_at = NOW() RETURNING id, label, agency_tag, active, created_at, updated_at; --- name: DeactivateVehicle :execrows -UPDATE vehicles -SET active = false, updated_at = NOW() -WHERE id = $1; - -- name: CheckUserVehicleAssignment :one SELECT user_id, vehicle_id FROM user_vehicles diff --git a/db/query.sql.go b/db/query.sql.go index 4c159b8..3ca5fe0 100644 --- a/db/query.sql.go +++ b/db/query.sql.go @@ -139,20 +139,6 @@ func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (CreateU return i, err } -const deactivateVehicle = `-- name: DeactivateVehicle :execrows -UPDATE vehicles -SET active = false, updated_at = NOW() -WHERE id = $1 -` - -func (q *Queries) DeactivateVehicle(ctx context.Context, id string) (int64, error) { - result, err := q.db.Exec(ctx, deactivateVehicle, id) - if err != nil { - return 0, err - } - return result.RowsAffected(), nil -} - const deleteUser = `-- name: DeleteUser :execrows DELETE FROM users WHERE id = $1 ` diff --git a/location_history_store.go b/location_history_store.go index 280383f..8eb74fd 100644 --- a/location_history_store.go +++ b/location_history_store.go @@ -52,18 +52,9 @@ func (s *Store) GetLocationHistory(ctx context.Context, vehicleID string, from, TripID: row.TripID, ReceivedAt: row.ReceivedAt.Time, } - if row.Bearing.Valid { - v := row.Bearing.Float64 - p.Bearing = &v - } - if row.Speed.Valid { - v := row.Speed.Float64 - p.Speed = &v - } - if row.Accuracy.Valid { - v := row.Accuracy.Float64 - p.Accuracy = &v - } + p.Bearing = nullableFloat(row.Bearing) + p.Speed = nullableFloat(row.Speed) + p.Accuracy = nullableFloat(row.Accuracy) points = append(points, p) } diff --git a/ratelimit_login.go b/ratelimit_login.go index 8bf5135..9214a20 100644 --- a/ratelimit_login.go +++ b/ratelimit_login.go @@ -77,6 +77,16 @@ func allowInWindow(m map[string]*loginWindowEntry, key string, limit int, now ti return e.count <= limit } +// pruneStaleWindows deletes entries whose window started before cutoff. +// Caller must hold the limiter's mutex. +func pruneStaleWindows(m map[string]*loginWindowEntry, cutoff time.Time) { + for k, e := range m { + if e.windowStart.Before(cutoff) { + delete(m, k) + } + } +} + func (l *LoginRateLimiter) cleanup() { ticker := time.NewTicker(time.Minute) defer ticker.Stop() @@ -85,16 +95,8 @@ func (l *LoginRateLimiter) cleanup() { case <-ticker.C: cutoff := time.Now().Add(-2 * loginWindow) l.mu.Lock() - for k, e := range l.byIP { - if e.windowStart.Before(cutoff) { - delete(l.byIP, k) - } - } - for k, e := range l.byEmail { - if e.windowStart.Before(cutoff) { - delete(l.byEmail, k) - } - } + pruneStaleWindows(l.byIP, cutoff) + pruneStaleWindows(l.byEmail, cutoff) l.mu.Unlock() case <-l.stop: return diff --git a/store.go b/store.go index 8823b82..943521b 100644 --- a/store.go +++ b/store.go @@ -135,24 +135,24 @@ func (s *Store) GetRecentLocations(ctx context.Context, cutoff time.Time) ([]*Lo Timestamp: row.Timestamp, DriverID: row.DriverID, } - if row.Bearing.Valid { - v := row.Bearing.Float64 - loc.Bearing = &v - } - if row.Speed.Valid { - v := row.Speed.Float64 - loc.Speed = &v - } - if row.Accuracy.Valid { - v := row.Accuracy.Float64 - loc.Accuracy = &v - } + loc.Bearing = nullableFloat(row.Bearing) + loc.Speed = nullableFloat(row.Speed) + loc.Accuracy = nullableFloat(row.Accuracy) locations = append(locations, loc) } return locations, nil } +// nullableFloat converts a pgtype.Float8 to a *float64 (nil when NULL). +func nullableFloat(v pgtype.Float8) *float64 { + if !v.Valid { + return nil + } + f := v.Float64 + return &f +} + // Ping checks database connectivity. func (s *Store) Ping(ctx context.Context) error { return s.pool.Ping(ctx) diff --git a/store_trips.go b/store_trips.go index 0b13c79..25604d6 100644 --- a/store_trips.go +++ b/store_trips.go @@ -284,18 +284,9 @@ func (s *Store) ListTripLocations(ctx context.Context, tripID int64) ([]Location TripID: row.TripID, ReceivedAt: row.ReceivedAt.Time, } - if row.Bearing.Valid { - v := row.Bearing.Float64 - p.Bearing = &v - } - if row.Speed.Valid { - v := row.Speed.Float64 - p.Speed = &v - } - if row.Accuracy.Valid { - v := row.Accuracy.Float64 - p.Accuracy = &v - } + p.Bearing = nullableFloat(row.Bearing) + p.Speed = nullableFloat(row.Speed) + p.Accuracy = nullableFloat(row.Accuracy) points = append(points, p) } return points, nil diff --git a/store_vehicles.go b/store_vehicles.go index 46ac6f2..2b6a8ec 100644 --- a/store_vehicles.go +++ b/store_vehicles.go @@ -77,18 +77,10 @@ func (s *Store) UpsertVehicle(ctx context.Context, id, label, agencyTag string) return &v, nil } -// DeactivateVehicle sets a vehicle's active flag to false. +// DeactivateVehicle sets a vehicle's active flag to false. It delegates to +// SetVehicleActive so the active flag has a single write path. func (s *Store) DeactivateVehicle(ctx context.Context, id string) error { - rowsAffected, err := s.queries.DeactivateVehicle(ctx, id) - if err != nil { - return fmt.Errorf("deactivate vehicle: %w", err) - } - // DeactivateVehicle uses :execrows, which returns the count of affected rows - // instead of the row itself. A zero count means no vehicle matched the ID. - if rowsAffected == 0 { - return fmt.Errorf("deactivate vehicle: %w", pgx.ErrNoRows) - } - return nil + return s.SetVehicleActive(ctx, id, false) } // UpdateVehicleInfo updates label/agency tag WITHOUT touching the active flag, diff --git a/user_handlers.go b/user_handlers.go index a4b432f..6aa3aff 100644 --- a/user_handlers.go +++ b/user_handlers.go @@ -96,11 +96,11 @@ func handleCreateUser(store UserCreator) http.HandlerFunc { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "password is required"}) return } - if len(req.Password) < 8 { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "password must be at least 8 characters"}) + if err := validatePassword(req.Password); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return } - if req.Role != "driver" && req.Role != "admin" { + if !validUserRole(req.Role) { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "role must be 'driver' or 'admin'"}) return } @@ -159,7 +159,7 @@ func handleUpdateUser(store UserUpdater) http.HandlerFunc { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "email is required"}) return } - if req.Role != "driver" && req.Role != "admin" { + if !validUserRole(req.Role) { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "role must be 'driver' or 'admin'"}) return } From b347175811d898156b7c11b193d2760b5ea82d9b Mon Sep 17 00:00:00 2001 From: Aaron Brethorst Date: Mon, 24 Aug 2026 03:36:15 -0700 Subject: [PATCH 28/29] =?UTF-8?q?fix:=20apply=20code-review=20findings=20?= =?UTF-8?q?=E2=80=94=20last-admin=20guard,=20form=20validation,=20race-fre?= =?UTF-8?q?e=20vehicle=20create,=20limiter=20reset,=20ILIKE=20escaping,=20?= =?UTF-8?q?flash=20header=20ordering?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- admin_handlers.go | 17 ++-- admin_handlers_test.go | 5 +- admin_page_handlers.go | 149 ++++++++++++++++++++++------- admin_page_handlers_test.go | 66 +++++++++++-- auth.go | 6 ++ db/query.sql | 5 + db/query.sql.go | 20 ++++ main.go | 1 + ratelimit_login.go | 12 +++ route_wiring_test.go | 3 + store_trips.go | 6 +- store_vehicles.go | 24 +++++ web/templates/views/dashboard.html | 4 +- web/templates/views/vehicles.html | 3 +- 14 files changed, 269 insertions(+), 52 deletions(-) diff --git a/admin_handlers.go b/admin_handlers.go index 1acd897..e58d028 100644 --- a/admin_handlers.go +++ b/admin_handlers.go @@ -22,6 +22,8 @@ func adminUIEnabled() bool { } enabled, err := strconv.ParseBool(v) if err != nil { + slog.Warn("ADMIN_UI_ENABLED is not a valid boolean; leaving the admin UI enabled", + "value", v) return true } return enabled @@ -77,12 +79,12 @@ func loadTemplates() (*embeddedTemplates, error) { // programmer error (the route registered it) but is still reported rather // than silently ignored. // -// renderInto never calls WriteHeader itself on the success path: callers that -// need a non-200 status (e.g. a failed login re-rendering the form) must call -// w.WriteHeader before invoking renderInto. On template failure it falls back -// to http.Error, which is a no-op on the status line if one was already -// written but still logs and emits an error body. -func renderInto(w http.ResponseWriter, set map[string]*template.Template, view, rootName string, data map[string]interface{}) { +// renderInto owns writing the status line: it calls WriteHeader(status) only +// after the template has rendered successfully, so callers can keep setting +// headers (e.g. takeFlash's clearing Set-Cookie) right up until the render, +// and a template failure still yields a clean 500 via http.Error rather than +// an error body under an already-committed non-200 status. +func renderInto(w http.ResponseWriter, status int, set map[string]*template.Template, view, rootName string, data map[string]interface{}) { tmpl, ok := set[path.Base(view)] if !ok { slog.Error("template render failed", "view", view, "error", "no such template") @@ -96,6 +98,9 @@ func renderInto(w http.ResponseWriter, set map[string]*template.Template, view, http.Error(w, "internal server error", http.StatusInternalServerError) return } + if status != http.StatusOK { + w.WriteHeader(status) + } if _, err := buf.WriteTo(w); err != nil { // The header is already committed, so we can't convert this to a // 500 — log it so a truncated response is at least visible server-side. diff --git a/admin_handlers_test.go b/admin_handlers_test.go index a93a18e..b7e5aff 100644 --- a/admin_handlers_test.go +++ b/admin_handlers_test.go @@ -2,6 +2,7 @@ package main import ( "html/template" + "net/http" "net/http/httptest" "testing" @@ -29,7 +30,7 @@ func TestRenderUnknownViewWritesCleanError(t *testing.T) { for _, set := range []map[string]*template.Template{tmpls.admin, tmpls.public} { rec := httptest.NewRecorder() - renderInto(rec, set, "ghost.html", "base.html", map[string]interface{}{}) + renderInto(rec, http.StatusOK, set, "ghost.html", "base.html", map[string]interface{}{}) assert.Equal(t, 500, rec.Code) assert.Contains(t, rec.Body.String(), "internal server error") @@ -66,7 +67,7 @@ func TestRenderExecutionErrorIsCleanError(t *testing.T) { set := map[string]*template.Template{"boom.html": tmpl} rec := httptest.NewRecorder() - renderInto(rec, set, "boom.html", "base.html", map[string]interface{}{"Items": []int{}}) + renderInto(rec, http.StatusOK, set, "boom.html", "base.html", map[string]interface{}{"Items": []int{}}) assert.Equal(t, 500, rec.Code) assert.Contains(t, rec.Body.String(), "internal server error") diff --git a/admin_page_handlers.go b/admin_page_handlers.go index d6b2a2b..97f9a8c 100644 --- a/admin_page_handlers.go +++ b/admin_page_handlers.go @@ -73,7 +73,7 @@ type adminUI struct { trips TripLister vehicles VehicleManager vehicleEditor vehicleEditor - vehicleChecker VehicleChecker + vehicleCreator VehicleCreator userManager userManager assignments assignmentManager jwtSecret []byte @@ -100,7 +100,7 @@ func newAdminUI(store appStore, tracker *Tracker, jwtSecret []byte, limiter *Log trips: store, vehicles: store, vehicleEditor: store, - vehicleChecker: store, + vehicleCreator: store, userManager: store, assignments: store, jwtSecret: jwtSecret, @@ -197,6 +197,10 @@ func (ui *adminUI) loginSubmit(w http.ResponseWriter, r *http.Request) { ui.renderLogin(w, http.StatusUnauthorized, "Invalid email or password.", email) return } + // Successful authentication: clear the per-email rate-limit window so + // legitimate repeat logins aren't counted toward the brute-force budget + // (mirrors handleLogin in auth.go). + ui.loginLimiter.ResetEmail(email) if user.Role != "admin" { ui.renderLogin(w, http.StatusForbidden, "Admin access required.", email) return @@ -211,13 +215,10 @@ func (ui *adminUI) loginSubmit(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/admin/dashboard", http.StatusSeeOther) } -// renderLogin sets the response status before rendering (renderInto never -// calls WriteHeader on the success path), so non-200 statuses land correctly. +// renderLogin renders the login form with the given status (renderInto +// writes the status line after a successful render). func (ui *adminUI) renderLogin(w http.ResponseWriter, status int, errMsg, email string) { - if status != http.StatusOK { - w.WriteHeader(status) - } - renderInto(w, ui.tmpl.public, "login.html", "login.html", map[string]interface{}{ + renderInto(w, status, ui.tmpl.public, "login.html", "login.html", map[string]interface{}{ "Title": "Sign In", "Error": errMsg, "Email": email, }) } @@ -229,10 +230,12 @@ func (ui *adminUI) logout(w http.ResponseWriter, r *http.Request) { // renderAdmin renders an admin page through the shared base.html layout, // which pulls in the view's {{define "content"}} block, and threads through -// any pending flash message. -func (ui *adminUI) renderAdmin(w http.ResponseWriter, r *http.Request, view string, data map[string]interface{}) { +// any pending flash message. takeFlash's clearing Set-Cookie must land before +// the status line is committed, which renderInto guarantees by calling +// WriteHeader(status) only after the template has rendered successfully. +func (ui *adminUI) renderAdmin(w http.ResponseWriter, r *http.Request, status int, view string, data map[string]interface{}) { data["Flash"] = takeFlash(w, r) - renderInto(w, ui.tmpl.admin, view, "base.html", data) + renderInto(w, status, ui.tmpl.admin, view, "base.html", data) } // mapPage renders the live fleet map, or (with a ?trip_id= query param) a @@ -248,7 +251,7 @@ func (ui *adminUI) mapPage(w http.ResponseWriter, r *http.Request) { } tripID = raw } - ui.renderAdmin(w, r, "map.html", map[string]interface{}{ + ui.renderAdmin(w, r, http.StatusOK, "map.html", map[string]interface{}{ "Title": "Live Map", "Page": "map", "TripID": tripID, @@ -342,7 +345,7 @@ func (ui *adminUI) dashboardPage(w http.ResponseWriter, r *http.Request) { lastUpdate = humanizeAge(*status.LastUpdate) } - ui.renderAdmin(w, r, "dashboard.html", map[string]interface{}{ + ui.renderAdmin(w, r, http.StatusOK, "dashboard.html", map[string]interface{}{ "Title": "Dashboard", "Page": "dashboard", "TotalVehicles": totalVehicles, @@ -437,7 +440,7 @@ func (ui *adminUI) vehiclesPage(w http.ResponseWriter, r *http.Request) { } sort.Slice(rows, func(i, j int) bool { return rows[i].ID < rows[j].ID }) - ui.renderAdmin(w, r, "vehicles.html", map[string]interface{}{ + ui.renderAdmin(w, r, http.StatusOK, "vehicles.html", map[string]interface{}{ "Title": "Vehicles", "Page": "vehicles", "Vehicles": rows, @@ -457,14 +460,11 @@ type vehicleFormData struct { } func (ui *adminUI) renderVehicleForm(w http.ResponseWriter, r *http.Request, status int, data vehicleFormData) { - if status != http.StatusOK { - w.WriteHeader(status) - } title := "New Vehicle" if data.IsEdit { title = "Edit Vehicle" } - ui.renderAdmin(w, r, "vehicle_form.html", map[string]interface{}{ + ui.renderAdmin(w, r, status, "vehicle_form.html", map[string]interface{}{ "Title": title, "Page": "vehicles", "IsEdit": data.IsEdit, @@ -485,6 +485,12 @@ func (ui *adminUI) vehicleNewPage(w http.ResponseWriter, r *http.Request) { // validation stay in lockstep, and reports the exact same error text on // failure. A 422 re-renders the form with the submitted values so the admin // doesn't have to retype everything. +// +// The insert is a single conditional CreateVehicle (ON CONFLICT DO NOTHING) +// rather than a VehicleExists check followed by UpsertVehicle: the +// check-then-act version had a race window where two concurrent creates with +// the same id both passed the check and the loser silently overwrote (and +// force-reactivated) the winner's vehicle. func (ui *adminUI) vehicleCreate(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { ui.renderVehicleForm(w, r, http.StatusBadRequest, vehicleFormData{Error: "Invalid form submission."}) @@ -499,22 +505,16 @@ func (ui *adminUI) vehicleCreate(w http.ResponseWriter, r *http.Request) { return } - exists, err := ui.vehicleChecker.VehicleExists(r.Context(), id) + created, err := ui.vehicleCreator.CreateVehicle(r.Context(), id, label, agencyTag) if err != nil { - slog.Error("vehicle create: check existence", "vehicle_id", id, "error", err) + slog.Error("vehicle create: create vehicle", "vehicle_id", id, "error", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } - if exists { + if !created { ui.renderVehicleForm(w, r, http.StatusUnprocessableEntity, vehicleFormData{ID: id, Label: label, AgencyTag: agencyTag, Error: "vehicle id already exists"}) return } - - if _, err := ui.vehicles.UpsertVehicle(r.Context(), id, label, agencyTag); err != nil { - slog.Error("vehicle create: upsert vehicle", "vehicle_id", id, "error", err) - http.Error(w, "internal server error", http.StatusInternalServerError) - return - } setFlash(w, "vehicle_created") http.Redirect(w, r, "/admin/vehicles", http.StatusSeeOther) } @@ -645,7 +645,7 @@ func (ui *adminUI) usersPage(w http.ResponseWriter, r *http.Request) { }) } - ui.renderAdmin(w, r, "users.html", map[string]interface{}{ + ui.renderAdmin(w, r, http.StatusOK, "users.html", map[string]interface{}{ "Title": "Users", "Page": "users", "Users": rows, @@ -676,14 +676,11 @@ type userFormData struct { } func (ui *adminUI) renderUserForm(w http.ResponseWriter, r *http.Request, status int, data userFormData) { - if status != http.StatusOK { - w.WriteHeader(status) - } title := "New User" if data.IsEdit { title = "Edit User" } - ui.renderAdmin(w, r, "user_form.html", map[string]interface{}{ + ui.renderAdmin(w, r, status, "user_form.html", map[string]interface{}{ "Title": title, "Page": "users", "IsEdit": data.IsEdit, @@ -729,6 +726,17 @@ func (ui *adminUI) userCreate(w http.ResponseWriter, r *http.Request) { password := r.PostFormValue("password") role := r.PostFormValue("role") + // The form marks name/email required client-side, but form submissions + // aren't trustworthy — enforce it server-side with the same error text as + // the JSON API (handleCreateUser). + if name == "" { + ui.renderUserForm(w, r, http.StatusUnprocessableEntity, userFormData{Name: name, Email: email, Role: role, Error: "name is required"}) + return + } + if email == "" { + ui.renderUserForm(w, r, http.StatusUnprocessableEntity, userFormData{Name: name, Email: email, Role: role, Error: "email is required"}) + return + } if err := validatePassword(password); err != nil { ui.renderUserForm(w, r, http.StatusUnprocessableEntity, userFormData{Name: name, Email: email, Role: role, Error: err.Error()}) return @@ -858,6 +866,16 @@ func (ui *adminUI) userUpdate(w http.ResponseWriter, r *http.Request) { role := r.PostFormValue("role") password := r.PostFormValue("password") + // Server-side required checks mirroring the JSON API (handleUpdateUser); + // the form's client-side `required` attributes aren't trustworthy. + if name == "" { + ui.renderUserEditError(w, r, http.StatusUnprocessableEntity, id, name, email, role, "name is required") + return + } + if email == "" { + ui.renderUserEditError(w, r, http.StatusUnprocessableEntity, id, name, email, role, "email is required") + return + } if !validUserRole(role) { ui.renderUserEditError(w, r, http.StatusUnprocessableEntity, id, name, email, role, "role must be driver or admin") return @@ -869,6 +887,33 @@ func (ui *adminUI) userUpdate(w http.ResponseWriter, r *http.Request) { } } + // Refuse to demote the last active admin: with no active admin left, no + // one can sign in to the admin UI (and ADMIN_BOOTSTRAP doesn't recover, + // since bootstrapAdmin counts existing admins regardless of active flag). + // The check-then-act window here is acceptable for an admin UI guard. + current, err := ui.userManager.GetUser(r.Context(), id) + if err != nil { + if errors.Is(err, ErrUserNotFound) { + http.NotFound(w, r) + return + } + slog.Error("user update: get user", "user_id", id, "error", err) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + if current.Role == "admin" && current.Active && role != "admin" { + admins, err := ui.stats.CountActiveUsersByRole(r.Context(), "admin") + if err != nil { + slog.Error("user update: count active admins", "error", err) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + if admins <= 1 { + ui.renderUserEditError(w, r, http.StatusUnprocessableEntity, id, name, email, role, "cannot demote the last active admin") + return + } + } + if _, err := ui.userManager.UpdateUser(r.Context(), id, name, email, role); err != nil { if errors.Is(err, ErrUserNotFound) { http.NotFound(w, r) @@ -915,6 +960,35 @@ func (ui *adminUI) setUserActive(w http.ResponseWriter, r *http.Request, active http.NotFound(w, r) return } + // Refuse to deactivate the last active admin: with no active admin left, + // no one can sign in to the admin UI, and restarting with ADMIN_BOOTSTRAP + // doesn't recover (bootstrapAdmin counts existing admins regardless of + // active flag), so the lockout would require manual SQL to undo. The + // check-then-act window here is acceptable for an admin UI guard. + if !active { + target, err := ui.userManager.GetUser(r.Context(), id) + if err != nil { + if errors.Is(err, ErrUserNotFound) { + http.NotFound(w, r) + return + } + slog.Error("user set active: get user", "user_id", id, "error", err) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + if target.Role == "admin" && target.Active { + admins, err := ui.stats.CountActiveUsersByRole(r.Context(), "admin") + if err != nil { + slog.Error("user set active: count active admins", "error", err) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + if admins <= 1 { + http.Error(w, "cannot deactivate the last active admin", http.StatusUnprocessableEntity) + return + } + } + } if err := ui.userManager.SetUserActive(r.Context(), id, active); err != nil { if errors.Is(err, ErrUserNotFound) { http.NotFound(w, r) @@ -1001,6 +1075,10 @@ func (ui *adminUI) userUnassignVehicle(w http.ResponseWriter, r *http.Request) { // separate COUNT query. const tripsPageSize = 50 +// maxTripsPage bounds the ?page= query param so the offset arithmetic can +// never overflow into a negative OFFSET; values past it fall back to page 1. +const maxTripsPage = 1_000_000 + // tripRow is a single row in the trips table: a trip's joined display fields // plus pre-formatted start/end times and duration, ready for the template. type tripRow struct { @@ -1073,7 +1151,10 @@ func (ui *adminUI) tripsPage(w http.ResponseWriter, r *http.Request) { page := 1 if raw := query.Get("page"); raw != "" { - if n, err := strconv.Atoi(raw); err == nil && n >= 1 { + // The upper bound keeps (page-1)*tripsPageSize from overflowing int + // into a negative OFFSET (a Postgres error → 500); an absurd page + // number falls back to page 1 like any other invalid value. + if n, err := strconv.Atoi(raw); err == nil && n >= 1 && n <= maxTripsPage { page = n } } @@ -1131,7 +1212,7 @@ func (ui *adminUI) tripsPage(w http.ResponseWriter, r *http.Request) { rows = append(rows, row) } - ui.renderAdmin(w, r, "trips.html", map[string]interface{}{ + ui.renderAdmin(w, r, http.StatusOK, "trips.html", map[string]interface{}{ "Title": "Trips", "Page": "trips", "Trips": rows, diff --git a/admin_page_handlers_test.go b/admin_page_handlers_test.go index db2f9b6..d046707 100644 --- a/admin_page_handlers_test.go +++ b/admin_page_handlers_test.go @@ -334,8 +334,8 @@ func (erroringAdminStats) CountActiveTrips(_ context.Context) (int, error) { } // fakeVehicleStore is an in-memory double implementing VehicleManager, -// vehicleEditor (UpdateVehicleInfo/SetVehicleActive), and VehicleChecker -// (VehicleExists), covering everything the vehicle pages need without a +// vehicleEditor (UpdateVehicleInfo/SetVehicleActive), and VehicleCreator +// (CreateVehicle), covering everything the vehicle pages need without a // database. type fakeVehicleStore struct { vehicles map[string]*VehicleResponse @@ -402,9 +402,14 @@ func (f *fakeVehicleStore) SetVehicleActive(_ context.Context, id string, active return nil } -func (f *fakeVehicleStore) VehicleExists(_ context.Context, id string) (bool, error) { - _, ok := f.vehicles[id] - return ok, nil +// CreateVehicle mirrors the real store's ON CONFLICT DO NOTHING insert: an +// existing id reports created=false without touching the stored vehicle. +func (f *fakeVehicleStore) CreateVehicle(_ context.Context, id, label, agencyTag string) (bool, error) { + if _, ok := f.vehicles[id]; ok { + return false, nil + } + f.vehicles[id] = &VehicleResponse{ID: id, Label: label, AgencyTag: agencyTag, Active: true} + return true, nil } // wireFakeVehicleStore points every vehicle-related adminUI field at the @@ -412,7 +417,7 @@ func (f *fakeVehicleStore) VehicleExists(_ context.Context, id string) (bool, er func wireFakeVehicleStore(ui *adminUI, f *fakeVehicleStore) { ui.vehicles = f ui.vehicleEditor = f - ui.vehicleChecker = f + ui.vehicleCreator = f } // TestVehiclesPageListsRealVehicles verifies the list page renders a seeded @@ -987,6 +992,9 @@ func TestUserDeactivateActivate(t *testing.T) { ui := newTestAdminUI(t) users := newFakeUserStore(UserResponse{ID: 1, Name: "Ada Admin", Email: "ada@test.com", Role: "admin", Active: true}) wireFakeUserStore(ui, users, newFakeAssignmentStore()) + // Two active admins so the last-active-admin guard permits deactivation + // (fakeAdminStats returns the same count for every role). + ui.stats = &fakeAdminStats{drivers: 2} mux := http.NewServeMux() registerAdminUI(mux, ui) @@ -1027,6 +1035,52 @@ func TestUserDeactivateActivate(t *testing.T) { }) } +// TestUserDeactivateLastAdminBlocked verifies the lockout guard: deactivating +// the only active admin is refused (422) and the account stays active, since +// no active admin would be able to sign in afterwards and ADMIN_BOOTSTRAP +// doesn't recreate an admin while a deactivated one still exists. +func TestUserDeactivateLastAdminBlocked(t *testing.T) { + ui := newTestAdminUI(t) + users := newFakeUserStore(UserResponse{ID: 1, Name: "Ada Admin", Email: "ada@test.com", Role: "admin", Active: true}) + wireFakeUserStore(ui, users, newFakeAssignmentStore()) + ui.stats = &fakeAdminStats{drivers: 1} // exactly one active admin + mux := http.NewServeMux() + registerAdminUI(mux, ui) + + req := httptest.NewRequest(http.MethodPost, "/admin/users/1/deactivate", nil) + req.AddCookie(cookieFor(t, "admin")) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + + assert.Equal(t, http.StatusUnprocessableEntity, w.Code) + assert.Contains(t, w.Body.String(), "cannot deactivate the last active admin") + assert.True(t, users.users[1].Active, "the last active admin must stay active") +} + +// TestUserUpdateLastAdminDemotionBlocked verifies the matching guard on the +// edit form: demoting the only active admin to driver 422s with the form +// error rather than leaving the deployment with no active admin. +func TestUserUpdateLastAdminDemotionBlocked(t *testing.T) { + ui := newTestAdminUI(t) + users := newFakeUserStore(UserResponse{ID: 1, Name: "Ada Admin", Email: "ada@test.com", Role: "admin", Active: true}) + wireFakeUserStore(ui, users, newFakeAssignmentStore()) + wireFakeVehicleStore(ui, newFakeVehicleStore()) + ui.stats = &fakeAdminStats{drivers: 1} // exactly one active admin + mux := http.NewServeMux() + registerAdminUI(mux, ui) + + form := url.Values{"name": {"Ada Admin"}, "email": {"ada@test.com"}, "role": {"driver"}} + req := httptest.NewRequest(http.MethodPost, "/admin/users/1", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.AddCookie(cookieFor(t, "admin")) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + + assert.Equal(t, http.StatusUnprocessableEntity, w.Code) + assert.Contains(t, w.Body.String(), "cannot demote the last active admin") + assert.Equal(t, "admin", users.users[1].Role, "the last active admin must keep the admin role") +} + // TestUserAssignUnassignVehicle covers the assign/unassign POST endpoints: // both redirect back to the edit page with the appropriate flash. func TestUserAssignUnassignVehicle(t *testing.T) { diff --git a/auth.go b/auth.go index 81eed56..5acca08 100644 --- a/auth.go +++ b/auth.go @@ -112,6 +112,12 @@ func handleLogin(fetcher UserFetcher, secret []byte, limiter *LoginRateLimiter, return } + // Successful authentication: clear the per-email rate-limit window so + // legitimate repeat logins aren't counted toward the brute-force budget. + if limiter != nil { + limiter.ResetEmail(req.Email) + } + tokenStr, err := generateJWT(user, secret) if err != nil { slog.Error("token generation failed", "error", err) diff --git a/db/query.sql b/db/query.sql index 18a7ca1..2726c10 100644 --- a/db/query.sql +++ b/db/query.sql @@ -61,6 +61,11 @@ SELECT id, label, agency_tag, active, created_at, updated_at FROM vehicles WHERE id = $1; +-- name: CreateVehicle :execrows +INSERT INTO vehicles (id, label, agency_tag) +VALUES ($1, $2, $3) +ON CONFLICT (id) DO NOTHING; + -- name: UpsertAdminVehicle :one INSERT INTO vehicles (id, label, agency_tag) VALUES ($1, $2, $3) diff --git a/db/query.sql.go b/db/query.sql.go index 3ca5fe0..6d4c4cf 100644 --- a/db/query.sql.go +++ b/db/query.sql.go @@ -139,6 +139,26 @@ func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (CreateU return i, err } +const createVehicle = `-- name: CreateVehicle :execrows +INSERT INTO vehicles (id, label, agency_tag) +VALUES ($1, $2, $3) +ON CONFLICT (id) DO NOTHING +` + +type CreateVehicleParams struct { + ID string + Label string + AgencyTag string +} + +func (q *Queries) CreateVehicle(ctx context.Context, arg CreateVehicleParams) (int64, error) { + result, err := q.db.Exec(ctx, createVehicle, arg.ID, arg.Label, arg.AgencyTag) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} + const deleteUser = `-- name: DeleteUser :execrows DELETE FROM users WHERE id = $1 ` diff --git a/main.go b/main.go index 55a1f3f..1146909 100644 --- a/main.go +++ b/main.go @@ -32,6 +32,7 @@ type appStore interface { VehicleManager VehicleInfoUpdater VehicleActivator + VehicleCreator LocationSaver AssignmentCreator AssignmentDeleter diff --git a/ratelimit_login.go b/ratelimit_login.go index 9214a20..7b2ab88 100644 --- a/ratelimit_login.go +++ b/ratelimit_login.go @@ -58,6 +58,18 @@ func (l *LoginRateLimiter) Allow(ip, email string) bool { return allowInWindow(l.byEmail, email, loginEmailLimit, now) } +// ResetEmail clears the per-email window after a successful authentication, +// so an account legitimately signing in several times a minute (a shared +// account, or the driver app plus the admin form) isn't 429'd despite zero +// failed attempts. Only the email dimension is reset — an attacker can't +// trigger it without valid credentials, and the per-IP budget (the +// map-filling-DoS defense) is never relaxed. +func (l *LoginRateLimiter) ResetEmail(email string) { + l.mu.Lock() + defer l.mu.Unlock() + delete(l.byEmail, email) +} + func allowInWindow(m map[string]*loginWindowEntry, key string, limit int, now time.Time) bool { e, ok := m[key] if !ok { diff --git a/route_wiring_test.go b/route_wiring_test.go index 61f6f32..9ab48ae 100644 --- a/route_wiring_test.go +++ b/route_wiring_test.go @@ -78,6 +78,9 @@ func (n *noopStore) GetLocationHistory(_ context.Context, _ string, _, _ int64, func (n *noopStore) VehicleExists(_ context.Context, _ string) (bool, error) { return false, nil } +func (n *noopStore) CreateVehicle(_ context.Context, _, _, _ string) (bool, error) { + return true, nil +} func (n *noopStore) ListActiveVehiclesByUser(_ context.Context, _ int64) ([]VehicleResponse, error) { return make([]VehicleResponse, 0), nil } diff --git a/store_trips.go b/store_trips.go index 25604d6..fa8ef03 100644 --- a/store_trips.go +++ b/store_trips.go @@ -206,7 +206,11 @@ func (s *Store) ListTrips(ctx context.Context, f TripFilter) ([]TripSummary, err conds = append(conds, "t.vehicle_id = "+arg(f.VehicleID)) } if f.Q != "" { - p := arg("%" + f.Q + "%") + // Escape LIKE metacharacters so a search for a literal % or _ + // (common in GTFS ids) matches the literal text instead of acting + // as a wildcard. Backslash is Postgres's default LIKE escape char. + escaped := strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(f.Q) + p := arg("%" + escaped + "%") conds = append(conds, fmt.Sprintf("(u.name ILIKE %s OR t.route_id ILIKE %s OR t.gtfs_trip_id ILIKE %s)", p, p, p)) } if len(conds) > 0 { diff --git a/store_vehicles.go b/store_vehicles.go index 2b6a8ec..d43915c 100644 --- a/store_vehicles.go +++ b/store_vehicles.go @@ -39,6 +39,13 @@ type VehicleActivator interface { SetVehicleActive(ctx context.Context, id string, active bool) error } +// VehicleCreator inserts brand-new vehicles only. Unlike +// VehicleManager.UpsertVehicle it never overwrites or reactivates an +// existing row: created is false (with no error) when the id already exists. +type VehicleCreator interface { + CreateVehicle(ctx context.Context, id, label, agencyTag string) (created bool, err error) +} + // ListVehicles returns all vehicles ordered by creation time. func (s *Store) ListVehicles(ctx context.Context) ([]VehicleResponse, error) { rows, err := s.queries.ListVehicles(ctx) @@ -77,6 +84,23 @@ func (s *Store) UpsertVehicle(ctx context.Context, id, label, agencyTag string) return &v, nil } +// CreateVehicle inserts a new vehicle, reporting created=false (and no +// error) when a vehicle with the same id already exists. The single +// ON CONFLICT DO NOTHING insert makes concurrent duplicate creates safe: +// exactly one wins, and the loser can't overwrite or reactivate the row the +// way a check-then-upsert sequence could. +func (s *Store) CreateVehicle(ctx context.Context, id, label, agencyTag string) (bool, error) { + rows, err := s.queries.CreateVehicle(ctx, db.CreateVehicleParams{ + ID: id, + Label: label, + AgencyTag: agencyTag, + }) + if err != nil { + return false, fmt.Errorf("create vehicle: %w", err) + } + return rows > 0, nil +} + // DeactivateVehicle sets a vehicle's active flag to false. It delegates to // SetVehicleActive so the active flag has a single write path. func (s *Store) DeactivateVehicle(ctx context.Context, id string) error { diff --git a/web/templates/views/dashboard.html b/web/templates/views/dashboard.html index 4d2d497..af0d511 100644 --- a/web/templates/views/dashboard.html +++ b/web/templates/views/dashboard.html @@ -3,9 +3,9 @@
-

Total Fleet

+

Active Fleet

{{.TotalVehicles}}

-

Registered vehicles

+

Active vehicles

Active Now

diff --git a/web/templates/views/vehicles.html b/web/templates/views/vehicles.html index 1017ddb..f97dbc6 100644 --- a/web/templates/views/vehicles.html +++ b/web/templates/views/vehicles.html @@ -56,7 +56,8 @@

All Vehicles

Edit - CSV + {{/* limit=1000 is the endpoint's maximum; without it the export silently caps at the default 100 points. */}} + CSV {{if .Active}}
From 00009b7f510aa31653ac1990b138dc6682047300 Mon Sep 17 00:00:00 2001 From: Aaron Brethorst Date: Tue, 25 Aug 2026 13:12:51 -0700 Subject: [PATCH 29/29] =?UTF-8?q?fix:=20address=20CodeRabbit=20review=20?= =?UTF-8?q?=E2=80=94=20verify=20Tailwind=20binary=20SHA-256,=20tolerate=20?= =?UTF-8?q?bootstrap=20race,=20render=20trip=20header=20for=20empty=20trai?= =?UTF-8?q?ls,=20role-aware=20stats=20fake,=20CI=20read-only=20token?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 7 ++++++- Makefile | 12 +++++++++++- admin_page_handlers_test.go | 15 +++++++++++---- bootstrap.go | 9 +++++++++ bootstrap_test.go | 8 ++++++++ docs/superpowers/plans/2026-08-24-admin-web-ui.md | 2 +- web/static/js/admin.js | 4 +++- 7 files changed, 49 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4432d8e..cb4ea97 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,6 +6,9 @@ on: pull_request: branches: [main] +permissions: + contents: read + jobs: ci: runs-on: ubuntu-latest @@ -25,8 +28,10 @@ jobs: - name: Verify committed Tailwind CSS is current if: runner.os == 'Linux' run: | - # Pinned to the same tailwindcss release as Makefile's TAILWIND_VERSION. + # Pinned to the same tailwindcss release (and SHA-256 from its + # sha256sums.txt) as Makefile's TAILWIND_VERSION — bump together. curl -fsSL https://github.com/tailwindlabs/tailwindcss/releases/download/v4.2.0/tailwindcss-linux-x64 -o /tmp/tailwindcss + echo "8f65e2d21c675f1e8d265219979d17d10634c1f553a2f583265b7edb28726432 /tmp/tailwindcss" | sha256sum -c - chmod +x /tmp/tailwindcss /tmp/tailwindcss -i web/styles/input.css -o /tmp/admin.css --minify diff -q /tmp/admin.css web/static/css/admin.css || { echo "web/static/css/admin.css is stale — run 'make css' and commit"; exit 1; } diff --git a/Makefile b/Makefile index 3a009da..70ca1ca 100644 --- a/Makefile +++ b/Makefile @@ -8,6 +8,13 @@ TAILWIND_VERSION := v4.2.0 TAILWIND_OS := $(shell uname -s | tr '[:upper:]' '[:lower:]' | sed 's/darwin/macos/') TAILWIND_ARCH := $(shell uname -m | sed 's/x86_64/x64/;s/aarch64/arm64/') TAILWIND_BIN := .tools/tailwindcss-$(TAILWIND_VERSION)-$(TAILWIND_OS)-$(TAILWIND_ARCH) +# Expected SHA-256 per platform, from the release's sha256sums.txt: +# https://github.com/tailwindlabs/tailwindcss/releases/download/$(TAILWIND_VERSION)/sha256sums.txt +TAILWIND_SHA256_linux_x64 := 8f65e2d21c675f1e8d265219979d17d10634c1f553a2f583265b7edb28726432 +TAILWIND_SHA256_linux_arm64 := 376fd4da2c29eb81ae0638cd2f84a4304af92532f2f1576555f41bdb44c185da +TAILWIND_SHA256_macos_x64 := 18cd6bb94d0f26ff8a0fa8a966beb9ea36bea2c7c444397f7619a2b880260e65 +TAILWIND_SHA256_macos_arm64 := d9e759fd6612dd442a9caa49d366b24e5097ea9802d35829da3f6db6ee5c2043 +TAILWIND_SHA256 := $(TAILWIND_SHA256_$(TAILWIND_OS)_$(TAILWIND_ARCH)) help: @echo "Available targets:" @@ -65,5 +72,8 @@ css: $(TAILWIND_BIN) $(TAILWIND_BIN): mkdir -p .tools - curl -fsSL https://github.com/tailwindlabs/tailwindcss/releases/download/$(TAILWIND_VERSION)/tailwindcss-$(TAILWIND_OS)-$(TAILWIND_ARCH) -o $(TAILWIND_BIN) + @test -n "$(TAILWIND_SHA256)" || { echo "no pinned Tailwind checksum for $(TAILWIND_OS)-$(TAILWIND_ARCH); add TAILWIND_SHA256_$(TAILWIND_OS)_$(TAILWIND_ARCH) to Makefile"; exit 1; } + curl -fsSL https://github.com/tailwindlabs/tailwindcss/releases/download/$(TAILWIND_VERSION)/tailwindcss-$(TAILWIND_OS)-$(TAILWIND_ARCH) -o $(TAILWIND_BIN).tmp + echo "$(TAILWIND_SHA256) $(TAILWIND_BIN).tmp" | shasum -a 256 -c - || { rm -f $(TAILWIND_BIN).tmp; exit 1; } + mv $(TAILWIND_BIN).tmp $(TAILWIND_BIN) chmod +x $(TAILWIND_BIN) diff --git a/admin_page_handlers_test.go b/admin_page_handlers_test.go index d046707..994b658 100644 --- a/admin_page_handlers_test.go +++ b/admin_page_handlers_test.go @@ -46,11 +46,18 @@ func newTestAdminUI(t *testing.T) *adminUI { type fakeAdminStats struct { vehicles int drivers int + admins int trips int } func (f *fakeAdminStats) CountActiveVehicles(_ context.Context) (int, error) { return f.vehicles, nil } -func (f *fakeAdminStats) CountActiveUsersByRole(_ context.Context, _ string) (int, error) { + +// CountActiveUsersByRole is role-aware so the last-admin guard tests only +// pass when the handler actually queries the "admin" role. +func (f *fakeAdminStats) CountActiveUsersByRole(_ context.Context, role string) (int, error) { + if role == "admin" { + return f.admins, nil + } return f.drivers, nil } func (f *fakeAdminStats) CountActiveTrips(_ context.Context) (int, error) { return f.trips, nil } @@ -994,7 +1001,7 @@ func TestUserDeactivateActivate(t *testing.T) { wireFakeUserStore(ui, users, newFakeAssignmentStore()) // Two active admins so the last-active-admin guard permits deactivation // (fakeAdminStats returns the same count for every role). - ui.stats = &fakeAdminStats{drivers: 2} + ui.stats = &fakeAdminStats{admins: 2} mux := http.NewServeMux() registerAdminUI(mux, ui) @@ -1043,7 +1050,7 @@ func TestUserDeactivateLastAdminBlocked(t *testing.T) { ui := newTestAdminUI(t) users := newFakeUserStore(UserResponse{ID: 1, Name: "Ada Admin", Email: "ada@test.com", Role: "admin", Active: true}) wireFakeUserStore(ui, users, newFakeAssignmentStore()) - ui.stats = &fakeAdminStats{drivers: 1} // exactly one active admin + ui.stats = &fakeAdminStats{admins: 1} // exactly one active admin mux := http.NewServeMux() registerAdminUI(mux, ui) @@ -1065,7 +1072,7 @@ func TestUserUpdateLastAdminDemotionBlocked(t *testing.T) { users := newFakeUserStore(UserResponse{ID: 1, Name: "Ada Admin", Email: "ada@test.com", Role: "admin", Active: true}) wireFakeUserStore(ui, users, newFakeAssignmentStore()) wireFakeVehicleStore(ui, newFakeVehicleStore()) - ui.stats = &fakeAdminStats{drivers: 1} // exactly one active admin + ui.stats = &fakeAdminStats{admins: 1} // exactly one active admin mux := http.NewServeMux() registerAdminUI(mux, ui) diff --git a/bootstrap.go b/bootstrap.go index 7b27fb6..d5dcd69 100644 --- a/bootstrap.go +++ b/bootstrap.go @@ -2,6 +2,7 @@ package main import ( "context" + "errors" "fmt" "log/slog" ) @@ -27,6 +28,14 @@ func bootstrapAdmin(ctx context.Context, store adminBootstrapStore, email, passw return fmt.Errorf("bootstrap admin: %w", err) } if _, err := store.CreateUser(ctx, "Administrator", email, password, "admin"); err != nil { + // Two instances starting concurrently can both observe zero admins; + // the users.email unique index makes the INSERT the arbiter. The + // loser sees a duplicate-email error and must treat it as "already + // bootstrapped" rather than fail startup. + if errors.Is(err, ErrDuplicateEmail) { + slog.Info("admin bootstrap skipped: bootstrap email already exists", "email", email) + return nil + } return fmt.Errorf("bootstrap admin: create: %w", err) } slog.Info("bootstrapped initial admin user", "email", email) diff --git a/bootstrap_test.go b/bootstrap_test.go index 81355e4..39a023e 100644 --- a/bootstrap_test.go +++ b/bootstrap_test.go @@ -64,6 +64,14 @@ func TestBootstrapAdminPropagatesCountError(t *testing.T) { require.Error(t, err) } +// Concurrent startup: both instances read zero admins, one INSERT wins on the +// email unique index, the other must not fail startup. +func TestBootstrapAdminTreatsDuplicateEmailAsAlreadyBootstrapped(t *testing.T) { + store := &fakeBootstrapStore{adminCount: 0, createErr: ErrDuplicateEmail} + err := bootstrapAdmin(context.Background(), store, "admin@example.com", "supersecret123") + require.NoError(t, err) +} + func TestBootstrapAdminPropagatesCreateError(t *testing.T) { store := &fakeBootstrapStore{adminCount: 0, createErr: assert.AnError} err := bootstrapAdmin(context.Background(), store, "admin@example.com", "supersecret123") diff --git a/docs/superpowers/plans/2026-08-24-admin-web-ui.md b/docs/superpowers/plans/2026-08-24-admin-web-ui.md index a96405f..bad5776 100644 --- a/docs/superpowers/plans/2026-08-24-admin-web-ui.md +++ b/docs/superpowers/plans/2026-08-24-admin-web-ui.md @@ -1721,7 +1721,7 @@ In `login.html`, point at `/static/css/admin.css` only. `display-font` keeps `fo ```yaml - name: Verify committed Tailwind CSS is current run: | - curl -fsSL https://github.com/tailwindlabs/tailwindcss/releases/download/v4.1.16/tailwindcss-linux-x64 -o /tmp/tailwindcss + curl -fsSL https://github.com/tailwindlabs/tailwindcss/releases/download/v4.2.0/tailwindcss-linux-x64 -o /tmp/tailwindcss chmod +x /tmp/tailwindcss /tmp/tailwindcss -i web/styles/input.css -o /tmp/admin.css --minify diff -q /tmp/admin.css web/static/css/admin.css || { echo "web/static/css/admin.css is stale — run 'make css' and commit"; exit 1; } diff --git a/web/static/js/admin.js b/web/static/js/admin.js index b7510b0..0a92139 100644 --- a/web/static/js/admin.js +++ b/web/static/js/admin.js @@ -202,6 +202,9 @@ async function renderTrail(url) { try { const data = await fetchJSON(url); + // Header first so a trip with no recorded points still shows its + // summary alongside the empty banner. + renderTripHeader(data.trip); const pts = (data.points || []).map(p => [p.latitude, p.longitude]); if (!pts.length) { document.getElementById("empty-banner")?.classList.remove("hidden"); @@ -211,7 +214,6 @@ L.marker(pts[0], { icon: busIcon() }).addTo(map).bindPopup(trailPopup("Start", data.trip)); L.marker(pts[pts.length - 1], { icon: busIcon() }).addTo(map).bindPopup(trailPopup("End", data.trip)); map.fitBounds(pts, { padding: [40, 40] }); - renderTripHeader(data.trip); } catch (e) { console.error("trail load failed", e); }