diff --git a/src/api/client.ts b/src/api/client.ts index f4cce1a..cc6cea7 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -6,6 +6,7 @@ * changes. Nothing else in the app calls `fetch`. */ +import { authHeader } from '@/auth/token'; import type { FiestaEnvelope } from './types'; /** @@ -147,7 +148,10 @@ async function request(path: string, options: RequestOptions = {}): Promise( const { method = 'GET', params, body } = options; const url = `${API_BASE}${path}${toQueryString(params)}`; - const init: RequestInit = { method, headers: { Accept: 'application/json' } }; + const init: RequestInit = { method, headers: { Accept: 'application/json', ...authHeader() } }; if (body !== undefined) { init.headers = { ...init.headers, 'Content-Type': 'application/json' }; init.body = JSON.stringify(body); diff --git a/src/api/types.ts b/src/api/types.ts index 9f9357d..001a46d 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -33,6 +33,17 @@ export interface FiestaEnvelope { data?: T; /** Present on the tenant login endpoints. */ tenantform?: boolean; + /** + * The console session, issued by the login endpoints only. + * + * Sits beside `details` rather than inside it because it is not a fact about + * the user — it is what proves the request is theirs. Every later call sends + * it as `Authorization: Bearer`, and `middleware.WebAuth` reads the tenant out + * of it instead of believing the one on the query string. + */ + token?: string; + /** Unix seconds. The tab closing normally ends the session well before this. */ + tokenexpiresat?: number; } /* ──────────────────────────────────────────────────────────────────────────── diff --git a/src/auth/roles.ts b/src/auth/roles.ts index aa3192a..f527e6b 100644 --- a/src/auth/roles.ts +++ b/src/auth/roles.ts @@ -34,6 +34,19 @@ export interface SessionUser { tenantid: number; locationid: number; issuperadmin: boolean; + /** + * The signed session, from the login response. + * + * Optional, and that is the rollout rather than an oversight: a console built + * against a Fiesta that does not issue tokens yet stores nothing here and + * keeps working exactly as before. It becomes required when + * `WEB_AUTH_REQUIRED` is switched on server-side. + * + * Everything else on this record describes the user. This one is the only + * field the server will not take the console's word for — which is the whole + * point of it. + */ + token?: string; } /** diff --git a/src/auth/session.ts b/src/auth/session.ts index acb9420..1411523 100644 --- a/src/auth/session.ts +++ b/src/auth/session.ts @@ -1,17 +1,26 @@ /** * Sign-in and session persistence. * - * There is no token to hold. `TenantWebLogin` returns the user record and - * nothing else, so the session IS that record. It is kept in sessionStorage - * rather than localStorage: a shared back-office machine should not stay signed - * in after the browser closes, and there is no server-side session to revoke. + * The session is the user record plus, now, a signed token. Until Fiesta grew + * `middleware.WebAuth` there was no token to hold: login returned the record and + * nothing else, the console asserted its own `tenantid` on every request, and + * the server believed it. The record is still what the app renders from; the + * token is the only part the server will not take our word for. + * + * Kept in sessionStorage rather than localStorage: a shared back-office machine + * should not stay signed in after the browser closes. That also means the tab + * closing is what normally ends a session — the token's own expiry is a + * backstop for a tab left open, not the mechanism. + * + * The storage key lives in `./token`, which the HTTP client also reads. It has + * to sit under both: this file calls the API to sign in, and the client needs + * the token to make that call authorised, so neither can import the other. */ import { api, WEB } from '@/api/client'; import type { FiestaUser } from '@/api/types'; import { toSessionUser, type SessionUser } from './roles'; - -const STORAGE_KEY = 'nearle.session.v1'; +import { SESSION_STORAGE_KEY } from './token'; /** Thrown when the account exists but has never had a password set. */ export class PasswordSetupRequiredError extends Error { @@ -72,7 +81,11 @@ export async function login(email: string, password: string): Promise { + const token = readSessionToken(); + return token ? { Authorization: `Bearer ${token}` } : {}; +} diff --git a/src/features/store-admin/pages/dispatch.css b/src/features/store-admin/pages/dispatch.css index 5276446..259db71 100644 --- a/src/features/store-admin/pages/dispatch.css +++ b/src/features/store-admin/pages/dispatch.css @@ -4,8 +4,8 @@ /* ── Dispatch's palette is now the console's ──────────────────────────────── - These fourteen names are kept, and every one of them is an ALIAS. About - fifty rules in this file reference them, so pointing the names at the shared + These seven names are kept, and every one of them is an ALIAS. Fifty-two + rules in this file reference them, so pointing the names at the shared tokens restyles the whole page at once — and, more to the point, makes it impossible for Dispatch to drift away from the rest of the console again. @@ -17,25 +17,21 @@ inconsistency rather than a decision, and far enough that a Dispatch card beside a Console card were visibly two different whites. - The two shadow steps are now the card's, which is to say nothing at all. - `--dispatch-shadow-lg` is referenced by no rule in this file and is gone - rather than aliased. */ + Eight further names were dropped rather than aliased, because no rule read + them: `--dispatch-bg`, `--dispatch-card-bg`, `--dispatch-border-active`, + `--dispatch-accent-light`, `--dispatch-warning`, `--dispatch-error` and the + two shadow steps. Aliasing them kept a palette in step with a page that had + stopped asking for it, which reads as deliberate support for a variant that + does not exist. `--dispatch-accent-light` was the only real value among + them — #9255ab, the ported product's, matching nothing in this console. */ :root { - --dispatch-bg: var(--color-surface); --dispatch-bg-sub: var(--color-surface-subtle); - --dispatch-card-bg: var(--card-bg); --dispatch-border: var(--color-line); - --dispatch-border-active: var(--color-brand); --dispatch-text: var(--color-ink-1); --dispatch-text-muted: var(--color-ink-3); --dispatch-accent: var(--color-brand); - --dispatch-accent-light: #9255ab; --dispatch-accent-soft: color-mix(in oklab, var(--color-brand) 8%, transparent); --dispatch-success: var(--color-success, #1f9d55); - --dispatch-warning: var(--color-warning, #b7860b); - --dispatch-error: var(--color-error, #d64545); - --dispatch-shadow-sm: var(--card-shadow); - --dispatch-shadow-md: var(--card-shadow); } .dispatch-container { @@ -390,7 +386,6 @@ /* ── Main Area Cards & Map Header ─────────────────────────────────────────── */ .dispatch-card-elevated, -.dispatch-focus-card, .dispatch-map-card { background: var(--card-bg) !important; border: var(--card-border) !important; @@ -399,88 +394,6 @@ overflow: hidden; } -/* Focused Rider Live Summary Bar */ -.group-detail-avatar { - width: 42px; - height: 42px; - border-radius: 12px; - display: flex; - align-items: center; - justify-content: center; - color: #ffffff; - font-weight: 800; - font-size: 14px; -} - -.rider-phone-chip { - display: inline-flex; - align-items: center; - gap: 4px; - font-size: 11.5px; - font-weight: 600; - color: var(--dispatch-accent); - background: var(--dispatch-accent-soft); - padding: 2px 8px; - border-radius: 6px; -} - -.focus-metrics-cluster { - display: flex; - align-items: center; - gap: 8px; -} - -.focus-pill { - font-size: 12px; - font-weight: 600; - padding: 6px 12px; - border-radius: 10px; - background: var(--dispatch-bg-sub); - border: 1px solid var(--dispatch-border); - color: var(--dispatch-text); -} - -.focus-pill.value { - background: var(--dispatch-accent-soft); - border-color: rgba(102, 37, 130, 0.25); - color: var(--dispatch-accent); - font-weight: 700; -} - -.rider-live-summary-bar { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); - gap: 8px; - padding: 10px 14px; - background: var(--dispatch-bg-sub); - border: 1px solid var(--dispatch-border); - border-radius: var(--card-radius); -} - -.rl-item { - display: flex; - flex-direction: column; - gap: 2px; -} - -.rl-label { - font-size: 10.5px; - font-weight: 700; - color: var(--dispatch-text-muted); - text-transform: uppercase; - letter-spacing: 0.05em; -} - -.rl-val { - font-size: 13px; - font-weight: 700; - color: var(--dispatch-text); -} - -.status-badge-live { - color: var(--dispatch-success) !important; -} - /* ── Stop Sequence Timeline Carousel (under Map) ──────────────────────────── */ .round-timeline-section, .stops-quick-carousel-wrap {