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

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