phase 0 and 1

This commit is contained in:
2026-09-23 11:36:10 +05:30
parent cca3c50a89
commit 34c68034de
6 changed files with 122 additions and 108 deletions

View File

@@ -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);

View File

@@ -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;
}
/* ────────────────────────────────────────────────────────────────────────────

View File

@@ -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;
}
/**

View File

@@ -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
View 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}` } : {};
}

View File

@@ -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 {