phase 0 and 1
This commit is contained in:
@@ -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<T>(path: string, options: RequestOptions = {}): Promise<T
|
||||
|
||||
const init: RequestInit = {
|
||||
method,
|
||||
headers: { Accept: 'application/json' },
|
||||
// `authHeader()` is read per request, never captured: sign-in and sign-out
|
||||
// both happen while the app is running, and a header bound once would keep
|
||||
// authorising calls for whoever signed in first.
|
||||
headers: { Accept: 'application/json', ...authHeader() },
|
||||
signal: signal ?? null,
|
||||
};
|
||||
|
||||
@@ -204,7 +208,7 @@ async function requestEnvelope<T>(
|
||||
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);
|
||||
|
||||
@@ -33,6 +33,17 @@ export interface FiestaEnvelope<T> {
|
||||
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;
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<SessionUse
|
||||
throw new Error(loginMessage(envelope.code, envelope.message));
|
||||
}
|
||||
|
||||
const session = toSessionUser(envelope.details);
|
||||
// The token rides on the envelope, not on `details` — it is not a fact about
|
||||
// the user, it is what proves a later request is theirs. Absent against a
|
||||
// Fiesta that does not issue one yet, which is why it is spread in rather
|
||||
// than assigned: `exactOptionalPropertyTypes` refuses an explicit undefined.
|
||||
const session = { ...toSessionUser(envelope.details), ...(envelope.token ? { token: envelope.token } : {}) };
|
||||
persist(session);
|
||||
return session;
|
||||
}
|
||||
@@ -184,11 +197,11 @@ function loginMessage(code: number | undefined, message: string | undefined): st
|
||||
}
|
||||
|
||||
export function persist(session: SessionUser): void {
|
||||
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(session));
|
||||
sessionStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify(session));
|
||||
}
|
||||
|
||||
export function restore(): SessionUser | null {
|
||||
const raw = sessionStorage.getItem(STORAGE_KEY);
|
||||
const raw = sessionStorage.getItem(SESSION_STORAGE_KEY);
|
||||
if (!raw) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as SessionUser;
|
||||
@@ -202,5 +215,5 @@ export function restore(): SessionUser | null {
|
||||
}
|
||||
|
||||
export function clear(): void {
|
||||
sessionStorage.removeItem(STORAGE_KEY);
|
||||
sessionStorage.removeItem(SESSION_STORAGE_KEY);
|
||||
}
|
||||
|
||||
60
src/auth/token.ts
Normal file
60
src/auth/token.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Where the session token is kept, and how the HTTP client reaches it.
|
||||
*
|
||||
* A module of its own, holding nothing but the storage key and a reader,
|
||||
* because the two files that need it cannot import each other: `session.ts`
|
||||
* calls the API to sign in, and `client.ts` needs the token to make that same
|
||||
* API call authorised. Anything shared between them has to sit underneath both.
|
||||
*
|
||||
* It imports nothing, on purpose — that is what keeps it free of the cycle.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The single owner of this key.
|
||||
*
|
||||
* `session.ts` writes the blob and `client.ts` reads one field out of it, and a
|
||||
* second copy of the string is how those two quietly stop agreeing after a
|
||||
* rename.
|
||||
*/
|
||||
export const SESSION_STORAGE_KEY = 'nearle.session.v1';
|
||||
|
||||
/**
|
||||
* The signed session on this tab, if there is one.
|
||||
*
|
||||
* Reads storage on every call rather than caching. Sign-in and sign-out both
|
||||
* happen while the app is running, and a cached token would keep authorising
|
||||
* requests for a user who has just left — or send nothing for one who has just
|
||||
* arrived, until a reload.
|
||||
*
|
||||
* Returns undefined for every failure, including a throw. `sessionStorage`
|
||||
* raises rather than returning null in a browser with site data blocked, and a
|
||||
* console that cannot read a token should make an unauthenticated request and
|
||||
* be refused by the server, not fail to render.
|
||||
*/
|
||||
export function readSessionToken(): string | undefined {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(SESSION_STORAGE_KEY);
|
||||
if (!raw) return undefined;
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
if (typeof parsed !== 'object' || parsed === null) return undefined;
|
||||
const token = (parsed as { token?: unknown }).token;
|
||||
return typeof token === 'string' && token !== '' ? token : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The `Authorization` header for a request, or nothing at all.
|
||||
*
|
||||
* Nothing, rather than an empty or `Bearer null` header, when there is no
|
||||
* session. A header that is present but meaningless is worse than an absent
|
||||
* one: `middleware.WebAuth` refuses a token that does not verify whatever the
|
||||
* enforcement flag says, so sending rubbish would turn every anonymous call
|
||||
* into a 401 — including the ones that are still meant to work while the
|
||||
* rollout is in progress.
|
||||
*/
|
||||
export function authHeader(): Record<string, string> {
|
||||
const token = readSessionToken();
|
||||
return token ? { Authorization: `Bearer ${token}` } : {};
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user