auth refactored

This commit is contained in:
suryac 2026-11-06 13:30:59 +05:30
parent b546e870a1
commit 8ba8d133a5
12 changed files with 1000 additions and 51 deletions

View File

@ -3,7 +3,17 @@
This is a Progressive Web App (Pwa) built for the Krishna Sales team. This is a Progressive Web App (Pwa) built for the Krishna Sales team.
## Version ## Version
**1.0.2** **1.0.4**
## Changelog
### 1.0.4
- **Refresh-cookie sessions** : the server sets an HttpOnly `__Host-sm_rt` refresh cookie; the short-lived access token is kept in localStorage (`sm_session`) and renewed by `src/api/session.ts` (single-flight across tabs, proactive refresh, backoff when offline). A 401 triggers one refresh + replay; only a really ended session logs the user out.
- Multi-tab sync over `BroadcastChannel('sm-auth')`: logout in one tab signs out the others; a different user signing in reloads the stale tab. An undelivered logout is retried (`sm_logout_pending`).
- All `/usr/*` auth calls send credentials (`AUTH_CREDENTIALS`) so the cookie travels same-origin.
- **Platform maintenance notices** (`src/api/notices.ts`): polls `GET /usr/notices` every 30s. `announced` shows a dismissible strip, `draining` keeps the strip and refuses writes (`PLATFORM_MAINTENANCE` 503), `closed` shows a full-screen block that lifts on its own.
- Other 503s show an "unavailable" strip without blocking writes.
- Logout now clears per-user data but keeps session-protocol keys instead of `localStorage.clear()`.
## Sitemap ## Sitemap

View File

@ -7,6 +7,7 @@ import { ConsoleLayout } from './screens/ConsoleLayout'
import { DatasetItemsPage } from './screens/admin/DatasetItemsPage' import { DatasetItemsPage } from './screens/admin/DatasetItemsPage'
import { ProtectedRoute, getDefaultRoute } from './auth/ProtectedRoute' import { ProtectedRoute, getDefaultRoute } from './auth/ProtectedRoute'
import { useAuth } from './auth/context' import { useAuth } from './auth/context'
import { NoticeLayer } from './components/common/NoticeBanner'
function RootRedirect() { function RootRedirect() {
const { roles, isAdmin } = useAuth(); const { roles, isAdmin } = useAuth();
@ -24,6 +25,8 @@ const routerBasename =
function App() { function App() {
return ( return (
<AuthProvider> <AuthProvider>
{/* Above the routes on purpose: a `closed` block must cover login too. */}
<NoticeLayer />
<BrowserRouter basename={routerBasename}> <BrowserRouter basename={routerBasename}>
<Routes> <Routes>
<Route path="/login" element={<LoginPage />} /> <Route path="/login" element={<LoginPage />} />

View File

@ -1,5 +1,6 @@
import { BASE_URL } from './config'; import { BASE_URL } from './config';
import { currentToken } from './clients'; import { currentToken } from './clients';
import { AUTH_CREDENTIALS } from './session';
import { passwordPolicyMessage, type PasswordPolicy } from '../utils/passwordPolicy'; import { passwordPolicyMessage, type PasswordPolicy } from '../utils/passwordPolicy';
export class AuthApiError extends Error { export class AuthApiError extends Error {
@ -35,6 +36,7 @@ export async function fetchPasswordPolicy(): Promise<PasswordPolicy> {
export async function forgotPassword(email: string): Promise<{ message: string }> { export async function forgotPassword(email: string): Promise<{ message: string }> {
const res = await fetch(`${BASE_URL}/usr/forgot-password`, { const res = await fetch(`${BASE_URL}/usr/forgot-password`, {
method: 'POST', method: 'POST',
credentials: AUTH_CREDENTIALS,
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: email.trim() }), body: JSON.stringify({ email: email.trim() }),
}); });
@ -51,6 +53,7 @@ export async function forgotPassword(email: string): Promise<{ message: string }
export async function verifyResetCode(email: string, code: string): Promise<{ reset_token: string }> { export async function verifyResetCode(email: string, code: string): Promise<{ reset_token: string }> {
const res = await fetch(`${BASE_URL}/usr/verify-reset-code`, { const res = await fetch(`${BASE_URL}/usr/verify-reset-code`, {
method: 'POST', method: 'POST',
credentials: AUTH_CREDENTIALS,
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: email.trim(), code: code.trim() }), body: JSON.stringify({ email: email.trim(), code: code.trim() }),
}); });
@ -67,6 +70,7 @@ export async function verifyResetCode(email: string, code: string): Promise<{ re
export async function resetPassword(resetToken: string, newPassword: string): Promise<{ message: string }> { export async function resetPassword(resetToken: string, newPassword: string): Promise<{ message: string }> {
const res = await fetch(`${BASE_URL}/usr/reset-password`, { const res = await fetch(`${BASE_URL}/usr/reset-password`, {
method: 'POST', method: 'POST',
credentials: AUTH_CREDENTIALS,
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ reset_token: resetToken, new_password: newPassword }), body: JSON.stringify({ reset_token: resetToken, new_password: newPassword }),
}); });
@ -95,6 +99,7 @@ export async function changePassword(
const res = await fetch(`${BASE_URL}/usr/me/password`, { const res = await fetch(`${BASE_URL}/usr/me/password`, {
method: 'POST', method: 'POST',
credentials: AUTH_CREDENTIALS,
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
Authorization: `Bearer ${authToken}`, Authorization: `Bearer ${authToken}`,
@ -133,6 +138,7 @@ export async function changeExpiredPassword(
const res = await fetch(`${BASE_URL}/usr/change-expired-password`, { const res = await fetch(`${BASE_URL}/usr/change-expired-password`, {
method: 'POST', method: 'POST',
credentials: AUTH_CREDENTIALS,
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body), body: JSON.stringify(body),
}); });

View File

@ -11,20 +11,28 @@ import type {
User, User,
} from './types'; } from './types';
import { APP_ID } from './config'; import { APP_ID } from './config';
import { sessionClient, AUTH_CREDENTIALS } from './session';
import { getNoticesState, isWritesPaused, raiseLocalNotice, repollNotices, writeBlockReason } from './notices';
const TOKEN_KEY = 'krishna_sales_token';
const USER_KEY = 'krishna_sales_user'; const USER_KEY = 'krishna_sales_user';
/** Refuses a user-initiated write while a notice is draining or closed the app. */
function assertWritable(): void {
if (isWritesPaused(getNoticesState().mode)) {
throw { status: 503, code: 'PLATFORM_MAINTENANCE', message: writeBlockReason() } as ApiError;
}
}
/** /**
* HTTP client for the Zino gateway (sandbox). * HTTP client for the Zino gateway (sandbox).
* *
* Routes are app-scoped (`/app/385/...`); login + ai-employee monitor are not. * Routes are app-scoped (`/app/434/...`); login + ai-employee monitor are not.
* JWT persists in localStorage so the session survives refresh. * The access token lives in the shared session client (src/api/session.ts), which
* renews it from the HttpOnly refresh cookie; only the user profile is cached here.
*/ */
export class ZinoClient { export class ZinoClient {
readonly baseUrl: string; readonly baseUrl: string;
readonly workflowUuid: string; readonly workflowUuid: string;
private token: string | null = null;
private user: User | null = null; private user: User | null = null;
private onAuthError?: () => void; private onAuthError?: () => void;
@ -33,7 +41,6 @@ export class ZinoClient {
this.workflowUuid = workflowUuid; this.workflowUuid = workflowUuid;
this.onAuthError = onAuthError; this.onAuthError = onAuthError;
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
this.token = localStorage.getItem(TOKEN_KEY);
try { try {
const u = localStorage.getItem(USER_KEY); const u = localStorage.getItem(USER_KEY);
if (u) this.user = JSON.parse(u); if (u) this.user = JSON.parse(u);
@ -47,29 +54,24 @@ export class ZinoClient {
this.onAuthError = fn; this.onAuthError = fn;
} }
setToken(token: string | null, user?: User | null): void { /** Cache the signed-in user's profile (the token itself lives in the session client). */
this.token = token; setUser(user?: User | null): void {
this.user = user ?? null;
if (typeof window === 'undefined') return; if (typeof window === 'undefined') return;
if (token) { try {
localStorage.setItem(TOKEN_KEY, token); if (user) localStorage.setItem(USER_KEY, JSON.stringify(user));
if (user) { else localStorage.removeItem(USER_KEY);
this.user = user; } catch {
localStorage.setItem(USER_KEY, JSON.stringify(user)); /* not persisted */
}
} else {
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(USER_KEY);
this.user = null;
} }
} }
getToken(): string | null { getToken(): string | null {
return this.token; return sessionClient.getAccessToken();
} }
async request<T>(method: string, path: string, body?: unknown, customHeaders?: Record<string, string>): Promise<T> { async request<T>(method: string, path: string, body?: unknown, customHeaders?: Record<string, string>): Promise<T> {
const headers: Record<string, string> = { 'Content-Type': 'application/json', ...customHeaders }; const headers: Record<string, string> = { 'Content-Type': 'application/json', ...customHeaders };
if (this.token) headers['Authorization'] = `Bearer ${this.token}`;
return this.send<T>(method, path, { return this.send<T>(method, path, {
headers, headers,
@ -79,17 +81,27 @@ export class ZinoClient {
// Multipart variant — no Content-Type header (the browser sets the boundary). // Multipart variant — no Content-Type header (the browser sets the boundary).
private async requestForm<T>(path: string, form: FormData): Promise<T> { private async requestForm<T>(path: string, form: FormData): Promise<T> {
const headers: Record<string, string> = {}; return this.send<T>('POST', path, { headers: {}, body: form });
if (this.token) headers['Authorization'] = `Bearer ${this.token}`;
return this.send<T>('POST', path, { headers, body: form });
} }
private async send<T>(method: string, path: string, init: RequestInit): Promise<T> { private async send<T>(method: string, path: string, init: RequestInit): Promise<T> {
const res = await fetch(`${this.baseUrl}${path}`, { method, ...init }); // The bearer is stamped per attempt from the session, so a refresh between
// the first send and its replay swaps the token (an expired one 401s into it).
const isAuthPath = path.startsWith('/usr/');
const { res, ended } = await sessionClient.sendWithRefresh((token) => {
const headers = { ...(init.headers as Record<string, string>) };
if (token) headers['Authorization'] = `Bearer ${token}`;
return fetch(`${this.baseUrl}${path}`, {
method,
...init,
headers,
...(isAuthPath ? { credentials: AUTH_CREDENTIALS } : {}),
});
}, path);
if (res.status === 401) { if (res.status === 401) {
this.setToken(null); // Only a really ended session logs out; an unreachable server must not.
this.onAuthError?.(); if (ended) this.onAuthError?.();
throw { status: 401, message: 'Unauthorized' } as ApiError; throw { status: 401, message: 'Unauthorized' } as ApiError;
} }
if (!res.ok) { if (!res.ok) {
@ -105,6 +117,19 @@ export class ZinoClient {
} catch { } catch {
/* non-JSON body */ /* non-JSON body */
} }
// A write refused for maintenance: the answer that let the user click is
// stale, so read the notices once more (the strip / block follow from it).
// The caller keeps its form; nothing here resends.
if (res.status === 503 && (code === 'PLATFORM_MAINTENANCE' || res.headers.get('X-Error-Code') === 'PLATFORM_MAINTENANCE')) {
code = 'PLATFORM_MAINTENANCE';
await repollNotices();
// No notice explains it (or the read failed): show the server's reason locally.
if (!isWritesPaused(getNoticesState().mode)) raiseLocalNotice('draining', message);
message = writeBlockReason();
} else if (res.status === 503) {
// Any other 503 (gateway / backend down): say so, without blocking writes.
raiseLocalNotice('unavailable', 'Some features may not work right now. Please try again in a moment.');
}
throw { throw {
status: res.status, status: res.status,
message, message,
@ -127,31 +152,29 @@ export class ZinoClient {
password, password,
...(orgId ? { org_id: Number(orgId) } : {}), ...(orgId ? { org_id: Number(orgId) } : {}),
}); });
this.setToken(res.token, res.user); sessionClient.adopt(res);
this.setUser(res.user);
return res; return res;
} }
logout(): void { logout(): Promise<void> {
this.setToken(null); this.setUser(null);
return sessionClient.logout();
} }
async getMe(): Promise<User> { async getMe(): Promise<User> {
const res = await this.request<User>('GET', `/usr/app/${APP_ID}/me`); const res = await this.request<User>('GET', `/usr/app/${APP_ID}/me`);
if (this.user) { if (this.user) this.setUser({ ...this.user, ...res });
this.user = { ...this.user, ...res };
if (typeof window !== 'undefined') {
localStorage.setItem(USER_KEY, JSON.stringify(this.user));
}
}
return res; return res;
} }
/** Decode the persisted JWT into a User (no network) or use the saved user. */ /** Decode the persisted JWT into a User (no network) or use the saved user. */
currentUser(): User | null { currentUser(): User | null {
if (!this.token) return null; const token = this.getToken();
if (!token) return null;
if (this.user) return this.user; if (this.user) return this.user;
try { try {
const p = JSON.parse(atob(this.token.split('.')[1])); const p = JSON.parse(atob(token.split('.')[1]));
return { return {
id: String(p.user_id ?? p.sub ?? ''), id: String(p.user_id ?? p.sub ?? ''),
org_id: String(p.org_id ?? ''), org_id: String(p.org_id ?? ''),
@ -234,7 +257,8 @@ export class ZinoClient {
// --- Workflow execution (core) --- // --- Workflow execution (core) ---
startInstance(activityUid: string, data: Record<string, unknown>, opts?: { version_uuid?: string; version_id?: number }): Promise<ActivityResult> { async startInstance(activityUid: string, data: Record<string, unknown>, opts?: { version_uuid?: string; version_id?: number }): Promise<ActivityResult> {
assertWritable();
return this.request<ActivityResult>('POST', `/app/${APP_ID}/start`, { return this.request<ActivityResult>('POST', `/app/${APP_ID}/start`, {
workflow_uuid: this.workflowUuid, workflow_uuid: this.workflowUuid,
...(opts?.version_uuid && { version_uuid: opts.version_uuid }), ...(opts?.version_uuid && { version_uuid: opts.version_uuid }),
@ -244,11 +268,12 @@ export class ZinoClient {
}); });
} }
performActivity( async performActivity(
instanceId: number | string, instanceId: number | string,
activityUid: string, activityUid: string,
data: Record<string, unknown>, data: Record<string, unknown>,
): Promise<ActivityResult> { ): Promise<ActivityResult> {
assertWritable();
return this.request<ActivityResult>('POST', `/app/${APP_ID}/activity`, { return this.request<ActivityResult>('POST', `/app/${APP_ID}/activity`, {
workflow_uuid: this.workflowUuid, workflow_uuid: this.workflowUuid,
instance_id: typeof instanceId === 'string' ? Number(instanceId) || instanceId : instanceId, instance_id: typeof instanceId === 'string' ? Number(instanceId) || instanceId : instanceId,

View File

@ -23,16 +23,16 @@ export const storeClient = clientFor('store');
export const dailyReportsClient = clientFor('dailyReports'); export const dailyReportsClient = clientFor('dailyReports');
export const userClient = clientFor('user'); export const userClient = clientFor('user');
// The user JWT (from /usr/login) is valid across every workflow, but each // The session (access token + refresh cookie) is shared: every client reads the
// client holds its own copy — so auth helpers fan out to all of them. // token from the one session client, so a refresh reaches all of them at once.
const ALL = [orderBookingClient, storeClient, dailyReportsClient, userClient]; const ALL = [orderBookingClient, storeClient, dailyReportsClient, userClient];
import { changeExpiredPassword } from './auth'; import { changeExpiredPassword } from './auth';
import { sessionClient } from './session';
/** Log in once and share the JWT with every workflow client. */ /** Log in once; the session is shared by every workflow client. */
export async function loginAll(email: string, password: string, orgId?: string) { export async function loginAll(email: string, password: string, orgId?: string) {
const res = await orderBookingClient.login(email, password, orgId); const res = await orderBookingClient.login(email, password, orgId);
ALL.forEach((c) => c.setToken(res.token, res.user));
try { try {
const me = await orderBookingClient.getMe(); const me = await orderBookingClient.getMe();
res.user = { ...res.user, ...me }; res.user = { ...res.user, ...me };
@ -50,7 +50,8 @@ export async function completePasswordChangeAll(
orgId?: string orgId?: string
) { ) {
const res = await changeExpiredPassword(email, currentPassword, newPassword, orgId); const res = await changeExpiredPassword(email, currentPassword, newPassword, orgId);
ALL.forEach((c) => c.setToken(res.token, res.user)); sessionClient.adopt(res);
ALL.forEach((c) => c.setUser(res.user));
try { try {
const me = await orderBookingClient.getMe(); const me = await orderBookingClient.getMe();
res.user = { ...res.user, ...me }; res.user = { ...res.user, ...me };
@ -60,14 +61,15 @@ export async function completePasswordChangeAll(
return res; return res;
} }
/** Clear the session on every client. */ /** End the session: cleared locally at once, then revoked on the server. */
export function logoutAll(): void { export function logoutAll(): Promise<void> {
ALL.forEach((c) => c.setToken(null)); ALL.forEach((c) => c.setUser(null));
return orderBookingClient.logout();
} }
/** Current shared token (null when logged out). */ /** Current shared token (null when logged out). */
export function currentToken(): string | null { export function currentToken(): string | null {
return orderBookingClient.getToken(); return sessionClient.getAccessToken();
} }
/** Register a 401 handler on every client (e.g. to bounce back to login). */ /** Register a 401 handler on every client (e.g. to bounce back to login). */

View File

@ -5,7 +5,7 @@
// docs under src/docs/api. // docs under src/docs/api.
export const APP_ID = '434'; export const APP_ID = '434';
export const APP_VERSION = '1.0.3'; export const APP_VERSION = '1.0.4';
export const FORM_DRAFT_EXPIRY_MS = 24 * 60 * 60 * 1000; export const FORM_DRAFT_EXPIRY_MS = 24 * 60 * 60 * 1000;
// Backend URL comes from the runtime config.js the frontgen server writes at // Backend URL comes from the runtime config.js the frontgen server writes at

209
src/api/notices.ts Normal file
View File

@ -0,0 +1,209 @@
// Platform notices (maintenance announcements) — read side + a tiny store.
// Mirrors renderer-desktop/services/notices.js: GET /usr/notices?app_id=… is the
// one source; the server computes the effective `mode`.
//
// announced → dismissible strip
// draining → strip stays, writes are refused (client guard + server 503)
// closed → full-screen block, never dismissible, lifts by itself on poll
//
// A bare fetch, never the ZinoClient: it needs no token and must not be able to
// start a refresh or a logout. It NEVER rejects — a failed read is "no notices",
// because a notice that could break the app would be worse than the outage.
import { APP_ID, BASE_URL } from './config';
export const MODE_ANNOUNCED = 'announced';
export const MODE_DRAINING = 'draining';
export const MODE_CLOSED = 'closed';
export interface Notice {
id: string | number;
mode: string;
title: string;
body: string;
endsAt: string;
updatedAt: string;
}
export interface NoticesState {
notices: Notice[];
mode: string;
dismissed: string[];
}
interface RawNotice {
notice_id: string | number;
mode?: string;
title?: string;
body?: string;
ends_at?: string;
updated_at?: string;
}
export const isClosed = (mode: string) => mode === MODE_CLOSED;
/** `closed` implies writes are paused. An unknown mode is treated as the mildest case. */
export const isWritesPaused = (mode: string) => mode === MODE_DRAINING || mode === MODE_CLOSED;
export const dismissKey = (n: Notice) => `${n.id}:${n.updatedAt}`;
/** A `closed` notice is never dismissible: it explains a screen the user cannot pass. */
export function visibleNotices(notices: Notice[], dismissed: string[]): Notice[] {
const seen = new Set(dismissed);
return notices.filter((n) => isClosed(n.mode) || !seen.has(dismissKey(n)));
}
/** "Expected back by 23:30" — an absolute clock time, plus the date when not today. */
export function fmtEndsAt(endsAt: string, now = Date.now()): string {
if (!endsAt) return '';
const d = new Date(endsAt);
if (Number.isNaN(d.getTime())) return '';
const time = d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
const sameDay = new Date(now).toDateString() === d.toDateString();
return sameDay ? time : `${d.toLocaleDateString(undefined, { day: 'numeric', month: 'short' })}, ${time}`;
}
// ── dismissal storage (may be empty or throw: private window, blocked data) ──
const DISMISS_KEY = 'krishna_sales_notice_dismissed';
const DISMISS_CAP = 20;
function readDismissed(): string[] {
try {
const parsed = JSON.parse(localStorage.getItem(DISMISS_KEY) || '[]');
return Array.isArray(parsed) ? parsed.filter((k): k is string => typeof k === 'string') : [];
} catch {
return [];
}
}
// ── read ──
const NOTICES_TIMEOUT_MS = 2000;
const POLL_MS = 30_000;
async function fetchNotices(noStore = false): Promise<{ notices: Notice[]; mode: string } | null> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), NOTICES_TIMEOUT_MS);
try {
const res = await fetch(`${BASE_URL}/usr/notices?app_id=${encodeURIComponent(APP_ID)}`, {
signal: controller.signal,
...(noStore ? { cache: 'no-store' as RequestCache } : {}),
});
if (!res.ok) return null;
const data = await res.json();
if (!data || !Array.isArray(data.notices)) return null;
return {
notices: (data.notices as RawNotice[]).map((n) => ({
id: n.notice_id,
mode: n.mode || '',
title: n.title || '',
body: n.body || '',
endsAt: n.ends_at || '',
updatedAt: n.updated_at || '',
})),
mode: data.mode || '',
};
} catch {
return null;
} finally {
clearTimeout(timer);
}
}
// ── store (useSyncExternalStore) ──
let state: NoticesState = { notices: [], mode: '', dismissed: readDismissed() };
const listeners = new Set<() => void>();
let issued = 0;
let applied = 0;
function setState(next: Partial<NoticesState>): void {
state = { ...state, ...next };
listeners.forEach((l) => l());
}
export const subscribeNotices = (cb: () => void) => {
listeners.add(cb);
return () => {
listeners.delete(cb);
};
};
export const getNoticesState = () => state;
/** Reads once and applies the answer; an older read never overwrites a newer one. */
async function readNotices(noStore = false): Promise<void> {
const seq = ++issued;
const result = await fetchNotices(noStore);
// A failed read keeps what is shown: briefly losing the network must not lift a
// `closed` block, and the next poll corrects it either way.
if (!result || seq < applied) return;
applied = seq;
setState(result);
}
let reread: Promise<void> | null = null;
/** After a write was refused for maintenance: one uncached re-read (overlapping refusals share it). */
export function repollNotices(): Promise<void> {
if (!reread) reread = readNotices(true).finally(() => (reread = null));
return reread;
}
/**
* The backend answered 503 and no notice explains it (or the notice read failed):
* show it anyway, locally, until the next poll gives the real answer.
* draining — the server said writes are refused (PLATFORM_MAINTENANCE): pause them here too
* unavailable — any other 503: say so, but do not block writes (it may be a blip)
*/
export function raiseLocalNotice(mode: 'draining' | 'unavailable', body: string): void {
if (state.notices.some((n) => isWritesPaused(n.mode)) && mode === 'draining') return;
const local: Notice = {
id: 'local-503',
mode,
title: mode === 'draining' ? 'Maintenance in progress.' : 'Service temporarily unavailable.',
body,
endsAt: '',
updatedAt: String(Date.now()),
};
setState({
notices: [...state.notices.filter((n) => n.id !== 'local-503'), local],
mode: mode === 'draining' || !state.mode ? mode : state.mode,
});
}
export function dismissNotice(n: Notice): void {
const key = dismissKey(n);
const next = [key, ...state.dismissed.filter((k) => k !== key)].slice(0, DISMISS_CAP);
try {
localStorage.setItem(DISMISS_KEY, JSON.stringify(next));
} catch {
/* stays dismissed for this page view either way */
}
setState({ dismissed: next });
}
/** Polls every 30s, paused while the tab is hidden, re-read on return. Returns stop. */
export function startNoticePolling(): () => void {
let timer: ReturnType<typeof setInterval> | null = null;
const stop = () => {
if (timer != null) clearInterval(timer);
timer = null;
};
const start = () => {
stop();
void readNotices();
timer = setInterval(() => void readNotices(), POLL_MS);
};
const onVisibility = () => (document.visibilityState === 'visible' ? start() : stop());
if (document.visibilityState === 'visible') start();
document.addEventListener('visibilitychange', onVisibility);
return () => {
stop();
document.removeEventListener('visibilitychange', onVisibility);
};
}
/** The reason a refused write shows: the notice's own words, else a fixed sentence. */
export function writeBlockReason(s: NoticesState = state): string {
const n = s.notices.find((x) => isWritesPaused(x.mode));
const text = n && [n.title, n.body].filter(Boolean).join(' — ');
return text || 'The app is under maintenance right now. Please try again shortly.';
}

559
src/api/session.ts Normal file
View File

@ -0,0 +1,559 @@
// Browser half of the server-owned user session.
//
// The server (user-service) sets the refresh token as an HttpOnly cookie
// (`__Host-sm_rt`: Secure, SameSite=Strict, no Domain — same-origin only) and
// answers {access_token, expires_at, sid, user_id}. The 15-min access token is
// kept in localStorage; this module renews it from the cookie.
//
// - Refresh: POST /usr/session/refresh, single-flight in the tab and across
// tabs (Web Lock; inside it a newer stored session is reused, no POST).
// - A 401/403 from the refresh ends the session. A network failure never does.
// - Proactive refresh in the last REFRESH_LEAD_MS of a token while the tab is
// visible; the idle signal (real input only) is sent as last_activity_at.
// - Tabs sync over BroadcastChannel('sm-auth') (storage events as fallback).
// - A logout the server never heard about is finished later (sm_logout_pending).
import { BASE_URL } from './config';
const SESSION_KEY = 'sm_session';
const ACTIVITY_KEY = 'sm_activity';
const LOGOUT_PENDING_KEY = 'sm_logout_pending';
const CHANNEL_NAME = 'sm-auth';
const REFRESH_LOCK = 'sm-auth-refresh';
// Pre-session keys this app used to write; removed so nobody stays signed in
// with a token that has no refresh cookie behind it.
const LEGACY_TOKEN_KEY = 'krishna_sales_token';
const CACHED_PROFILE_KEYS = [
LEGACY_TOKEN_KEY,
LEGACY_TOKEN_KEY + '_user',
'krishna_sales_user',
'krishna_sales_user_email',
'krishna_sales_is_admin',
'krishna_sales_roles',
];
const REFRESH_LEAD_MS = 180_000;
const REFRESH_TIMEOUT_MS = 10_000;
const LOGOUT_TIMEOUT_MS = 5_000;
const ACTIVITY_THROTTLE_MS = 15_000;
const BACKOFF_START_MS = 2_000;
const BACKOFF_MAX_MS = 60_000;
const INPUT_EVENTS = ['pointerdown', 'keydown', 'touchstart', 'wheel'] as const;
export interface Session {
access_token: string;
expires_at: number; // unix seconds
sid: string | null;
user_id: string | null;
}
export type SessionEvent = 'refreshed' | 'logged-out' | 'user-switched';
// 'refused' = a proactive refresh was refused while the token is still valid
// (e.g. cross-origin dev, no usable cookie): keep the session until it expires.
type RefreshResult = { status: 'ok'; token: string } | { status: 'ended' } | { status: 'offline' } | { status: 'refused' };
type RefreshReason = 'proactive' | 'unauthorized';
const REFUSED: RefreshResult = { status: 'refused' };
const OFFLINE: RefreshResult = { status: 'offline' };
const ENDED: RefreshResult = { status: 'ended' };
// Only same-origin can hold the cookie; a cross-origin API (local dev against a
// remote host) gets no refresh cookie, so credentials stay at the default there
// (an `include` request would also need a non-wildcard CORS answer).
const SAME_ORIGIN = typeof window !== 'undefined' && BASE_URL === window.location.origin.replace(/\/$/, '');
export const AUTH_CREDENTIALS: RequestCredentials = SAME_ORIGIN ? 'include' : 'same-origin';
/** The session endpoints themselves: a 401 from one must never start a refresh. */
export function isSessionRoute(path: string): boolean {
const p = path.split(/[?#]/)[0];
return /\/usr\/session(\/|$)/.test(p) || /\/usr\/(logout|login|change-expired-password)\/?$/.test(p);
}
function jwtPayload(token: string): Record<string, unknown> | null {
try {
const part = token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/');
return JSON.parse(decodeURIComponent(escape(atob(part))));
} catch {
return null;
}
}
const userIdOf = (id: unknown): string | null => (id == null || id === '' ? null : String(id));
/** A login / exchange / refresh body → the stored shape, or null without a token. */
function toSession(input: unknown): Session | null {
if (!input || typeof input !== 'object') return null;
const answer = input as Record<string, unknown> & { user?: { id?: unknown } };
const token = answer.access_token || answer.token;
if (typeof token !== 'string' || !token) return null;
const claims = jwtPayload(token);
let expiresAt = Number(answer.expires_at ?? claims?.exp);
if (!Number.isFinite(expiresAt) || expiresAt <= 0) expiresAt = Math.floor(Date.now() / 1000) + 900;
const sid = answer.sid ?? claims?.sid;
return {
access_token: token,
expires_at: expiresAt,
sid: typeof sid === 'string' ? sid : null,
user_id: userIdOf(answer.user_id ?? answer.user?.id ?? claims?.user_id),
};
}
function read(key: string): string | null {
try {
return localStorage.getItem(key);
} catch {
return null;
}
}
function write(key: string, value: string): void {
try {
localStorage.setItem(key, value);
} catch {
/* not persisted */
}
}
function remove(key: string): void {
try {
localStorage.removeItem(key);
} catch {
/* nothing to do */
}
}
function withTimeout<T>(promise: Promise<T>, ms: number, onTimeout?: () => void): Promise<T> {
return new Promise((resolve, reject) => {
const id = setTimeout(() => {
onTimeout?.();
reject(new Error('timeout'));
}, ms);
promise.then(
(v) => {
clearTimeout(id);
resolve(v);
},
(e) => {
clearTimeout(id);
reject(e);
},
);
});
}
class SessionClient {
private listeners = new Set<(type: SessionEvent, session: Session | null) => void>();
private started = false;
private inflight: Promise<RefreshResult> | null = null;
private logoutInflight: Promise<void> | null = null;
private channel: BroadcastChannel | null = null;
private proactiveTimer: ReturnType<typeof setTimeout> | null = null;
private retryTimer: ReturnType<typeof setTimeout> | null = null;
private backoffMs = BACKOFF_START_MS;
private lastActivityWrite = -Infinity;
// expires_at of a still-valid token whose proactive refresh was refused: not
// asked again (the next real 401 decides). Any newer session lifts it.
private stoppedExp: number | null = null;
// Bumped by a logout: a refresh answer landing after it is not adopted.
private generation = 0;
private currentUserId: string | null = this.readSession()?.user_id ?? null;
// ── storage ──
private readSession(): Session | null {
const raw = read(SESSION_KEY);
if (!raw) return null;
try {
return toSession(JSON.parse(raw));
} catch {
return null;
}
}
private readActivity(): number {
const v = Number(read(ACTIVITY_KEY));
return Number.isFinite(v) && v > 0 ? v : 0;
}
private logoutPending = () => read(LOGOUT_PENDING_KEY) != null;
private isLive = (s: Session | null) => !!s && Date.now() < s.expires_at * 1000;
private isDue = (s: Session | null) => !!s && Date.now() >= s.expires_at * 1000 - REFRESH_LEAD_MS;
private isVisible = () => typeof document === 'undefined' || document.visibilityState === 'visible';
// ── public reads ──
/** The stored session even when expired — an expired token 401s into a refresh. */
getSession(): Session | null {
return this.readSession();
}
getAccessToken(): string | null {
return this.readSession()?.access_token ?? null;
}
onChange(cb: (type: SessionEvent, session: Session | null) => void): () => void {
this.listeners.add(cb);
return () => {
this.listeners.delete(cb);
};
}
private emit(type: SessionEvent, session: Session | null): void {
for (const cb of [...this.listeners]) {
try {
cb(type, session);
} catch {
/* one consumer's failure must not starve the others */
}
}
}
private getChannel(): BroadcastChannel | null {
if (!this.channel && typeof BroadcastChannel !== 'undefined') {
try {
this.channel = new BroadcastChannel(CHANNEL_NAME);
} catch {
this.channel = null;
}
}
return this.channel;
}
private post(type: string): void {
try {
this.getChannel()?.postMessage({ type });
} catch {
/* other tabs catch up on their next refresh */
}
}
// ── sign-in ──
/** Login / change-expired-password answer: this tab now belongs to that user. */
adopt(answer: unknown): void {
const session = toSession(answer);
if (!session) return;
write(SESSION_KEY, JSON.stringify(session));
remove(LOGOUT_PENDING_KEY);
remove(LEGACY_TOKEN_KEY);
this.currentUserId = session.user_id;
this.stoppedExp = null;
this.backoffMs = BACKOFF_START_MS;
this.clearRetry();
this.post('refreshed');
this.scheduleProactive();
}
// ── refresh ──
private withLock<T>(fn: () => Promise<T>): Promise<T> {
const locks = typeof navigator !== 'undefined' ? navigator.locks : undefined;
if (!locks || typeof locks.request !== 'function') return fn();
let ran = false;
return Promise.resolve()
.then(() =>
locks.request(REFRESH_LOCK, () => {
ran = true;
return fn();
}),
)
.catch((err: unknown) => {
if (ran) throw err;
return fn(); // the lock API itself failed: run unlocked
});
}
private async runRefresh(start: Session | null, activity: number, gen: number, reason: RefreshReason): Promise<RefreshResult> {
if (this.logoutPending()) return ENDED;
const before = this.readSession();
if (before && before.expires_at > (start?.expires_at ?? 0) && this.isLive(before)) {
// Another tab refreshed while this one waited for the lock.
return { status: 'ok', token: before.access_token };
}
const controller = typeof AbortController !== 'undefined' ? new AbortController() : null;
let res: Response;
let body: unknown;
try {
const answer = await withTimeout(
(async () => {
const r = await fetch(`${BASE_URL}/usr/session/refresh`, {
method: 'POST',
credentials: AUTH_CREDENTIALS,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ last_activity_at: activity }),
signal: controller?.signal,
});
const b = r.ok ? await r.json().catch(() => null) : null;
return { r, b };
})(),
REFRESH_TIMEOUT_MS,
() => controller?.abort(),
);
res = answer.r;
body = answer.b;
} catch {
return OFFLINE;
}
if (gen !== this.generation) return ENDED; // logged out meanwhile
const stored = this.readSession();
if (stored?.access_token !== before?.access_token) {
// Changed while the POST was out (another tab signed in/out): that wins.
return this.isLive(stored) ? { status: 'ok', token: stored!.access_token } : ENDED;
}
// 401 = session ended. 403 = the cookie is unusable here (cross-origin
// CSRF refusal), which for this tab is the same thing.
if (res.status === 401 || res.status === 403) {
if (reason === 'proactive' && stored && this.isLive(stored)) {
this.stoppedExp = stored.expires_at;
return REFUSED;
}
return this.endSession(stored);
}
if (!res.ok) return OFFLINE;
const session = toSession(body);
if (!session) return OFFLINE;
write(SESSION_KEY, JSON.stringify(session));
this.post('refreshed');
if (session.user_id != null) this.currentUserId = session.user_id;
this.emit('refreshed', session);
return { status: 'ok', token: session.access_token };
}
private endSession(session: Session | null): RefreshResult {
if (session) remove(SESSION_KEY);
for (const k of CACHED_PROFILE_KEYS) remove(k);
this.currentUserId = null;
if (session) {
this.post('logged-out');
this.emit('logged-out', null);
}
return ENDED;
}
/** Never rejects. */
refresh(reason: RefreshReason = 'unauthorized'): Promise<RefreshResult> {
if (this.inflight) return this.inflight;
const start = this.readSession();
const activity = this.readActivity();
const gen = this.generation;
this.inflight = this.withLock(() => this.runRefresh(start, activity, gen, reason))
.catch(() => OFFLINE)
.then((result) => {
this.settle(result);
return result;
})
.finally(() => {
this.inflight = null;
});
return this.inflight;
}
private settle(result: RefreshResult): void {
if (result.status === 'ok') {
this.backoffMs = BACKOFF_START_MS;
this.clearRetry();
this.scheduleProactive();
} else if (result.status === 'ended' || result.status === 'refused') {
this.backoffMs = BACKOFF_START_MS;
this.clearRetry();
this.clearProactive();
} else {
this.scheduleRetry();
}
}
/**
* Runs `send(token)`; on a 401 refreshes once and replays with the new token.
* `ended` is true only when the session is really over (refresh refused or
* the replay 401'd too) — an unreachable server is not.
*/
async sendWithRefresh(
send: (token: string | null) => Promise<Response>,
path: string,
): Promise<{ res: Response; ended: boolean }> {
const first = this.getAccessToken();
const res = await send(first);
// No token sent (or a sign-in call): a 401 is not an expired session.
if (res.status !== 401 || !first || isSessionRoute(path)) return { res, ended: false };
const result = await this.refresh();
if (result.status === 'ok') {
const replay = await send(result.token);
return { res: replay, ended: replay.status === 401 };
}
return { res, ended: result.status === 'ended' };
}
// ── timers ──
private clearProactive(): void {
if (this.proactiveTimer != null) clearTimeout(this.proactiveTimer);
this.proactiveTimer = null;
}
private clearRetry(): void {
if (this.retryTimer != null) clearTimeout(this.retryTimer);
this.retryTimer = null;
}
private scheduleProactive(): void {
this.clearProactive();
if (!this.started || this.retryTimer != null || !this.isVisible()) return;
const session = this.readSession();
if (!session || session.expires_at === this.stoppedExp) return;
const delay = Math.max(0, session.expires_at * 1000 - REFRESH_LEAD_MS - Date.now());
this.proactiveTimer = setTimeout(() => {
this.proactiveTimer = null;
if (!this.isVisible()) return;
const s = this.readSession();
if (!this.isDue(s)) return this.scheduleProactive(); // another tab moved it on
if (s?.expires_at === this.stoppedExp) return;
void this.refresh('proactive');
}, delay);
}
private scheduleRetry(): void {
if (!this.started || this.retryTimer != null) return;
this.clearProactive();
const delay = this.backoffMs;
this.backoffMs = Math.min(this.backoffMs * 2, BACKOFF_MAX_MS);
this.retryTimer = setTimeout(() => {
this.retryTimer = null;
const s = this.readSession();
if (s && !this.isDue(s)) return this.scheduleProactive();
void this.refresh(this.isLive(s) ? 'proactive' : 'unauthorized');
}, delay);
}
// ── logout ──
private sendLogout(token: string | null): Promise<void> {
if (this.logoutInflight) return this.logoutInflight;
const request = fetch(`${BASE_URL}/usr/logout`, {
method: 'POST',
credentials: AUTH_CREDENTIALS,
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
this.logoutInflight = withTimeout(request, LOGOUT_TIMEOUT_MS)
.then(
() => remove(LOGOUT_PENDING_KEY), // any HTTP answer: the server heard it
() => undefined, // unanswered: the marker stays and is retried
)
.finally(() => {
this.logoutInflight = null;
});
return this.logoutInflight;
}
/** Ends the session locally at once, then tells the server (revokes the cookie). */
async logout(): Promise<void> {
const token = this.getAccessToken();
this.generation += 1;
write(LOGOUT_PENDING_KEY, String(Date.now()));
this.clearProactive();
this.clearRetry();
this.backoffMs = BACKOFF_START_MS;
remove(SESSION_KEY);
remove(ACTIVITY_KEY);
for (const k of CACHED_PROFILE_KEYS) remove(k);
this.currentUserId = null;
this.post('logged-out');
this.emit('logged-out', null);
await this.sendLogout(token);
}
// ── browser events ──
private recordActivity = (): void => {
const t = Date.now();
if (t - this.lastActivityWrite < ACTIVITY_THROTTLE_MS) return;
this.lastActivityWrite = t;
write(ACTIVITY_KEY, String(Math.max(this.readActivity(), t)));
};
private onVisibility = (): void => {
if (!this.isVisible()) return this.clearProactive();
const s = this.readSession();
// Asked BEFORE the wake is recorded: a tab woken past the idle deadline
// must report the activity it really had, so the server can end it.
if (this.started && this.retryTimer == null && this.isDue(s) && s?.expires_at !== this.stoppedExp) {
void this.refresh(this.isLive(s) ? 'proactive' : 'unauthorized');
} else this.scheduleProactive();
this.recordActivity();
};
private onOnline = (): void => {
if (this.logoutPending()) void this.sendLogout(null);
if (this.retryTimer == null) return;
this.clearRetry();
void this.refresh();
};
private handleRemote = (type: string | undefined): void => {
if (type === 'logged-out') {
this.generation += 1;
this.clearProactive();
this.clearRetry();
this.currentUserId = null;
this.emit('logged-out', null);
return;
}
if (type !== 'refreshed') return;
const session = this.readSession();
if (session?.user_id && this.currentUserId && session.user_id !== this.currentUserId) {
this.clearProactive();
this.clearRetry();
this.emit('user-switched', session);
return;
}
if (session?.user_id) this.currentUserId = session.user_id;
this.clearRetry();
this.scheduleProactive();
this.emit('refreshed', session);
};
private onStorage = (e: StorageEvent): void => {
if (e.key !== SESSION_KEY && e.key !== null) return;
this.handleRemote(this.readSession() ? 'refreshed' : 'logged-out');
};
// ── lifecycle ──
start(): void {
if (this.started || typeof window === 'undefined') return;
this.started = true;
remove(LEGACY_TOKEN_KEY); // no refresh cookie stands behind it
const ch = this.getChannel();
if (ch) ch.onmessage = (e) => this.handleRemote(e?.data?.type);
else window.addEventListener('storage', this.onStorage);
for (const t of INPUT_EVENTS) window.addEventListener(t, this.recordActivity, { capture: true, passive: true });
window.addEventListener('online', this.onOnline);
document.addEventListener('visibilitychange', this.onVisibility);
if (this.logoutPending()) {
// A logout the server never heard about: the cookie may still be live.
void this.sendLogout(null);
return;
}
const s = this.readSession();
if (this.isDue(s)) void this.refresh(this.isLive(s) ? 'proactive' : 'unauthorized');
else this.scheduleProactive();
}
stop(): void {
if (!this.started) return;
this.started = false;
this.clearProactive();
this.clearRetry();
window.removeEventListener('storage', this.onStorage);
for (const t of INPUT_EVENTS) window.removeEventListener(t, this.recordActivity, { capture: true });
window.removeEventListener('online', this.onOnline);
document.removeEventListener('visibilitychange', this.onVisibility);
if (this.channel) {
this.channel.onmessage = null;
this.channel.close();
this.channel = null;
}
}
}
export const sessionClient = new SessionClient();

View File

@ -1,6 +1,7 @@
import { useState, type ReactNode, useEffect } from 'react'; import { useState, type ReactNode, useEffect } from 'react';
import { loginAll, logoutAll, currentToken, orderBookingClient, completePasswordChangeAll } from '../api/clients'; import { loginAll, logoutAll, currentToken, orderBookingClient, completePasswordChangeAll } from '../api/clients';
import { AuthCtx, type AuthValue } from './context'; import { AuthCtx, type AuthValue } from './context';
import { sessionClient } from '../api/session';
export function AuthProvider({ children }: { children: ReactNode }) { export function AuthProvider({ children }: { children: ReactNode }) {
const [authed, setAuthed] = useState(() => !!currentToken()); const [authed, setAuthed] = useState(() => !!currentToken());
@ -14,6 +15,27 @@ export function AuthProvider({ children }: { children: ReactNode }) {
return orderBookingClient.currentUser()?.roles ?? []; return orderBookingClient.currentUser()?.roles ?? [];
}); });
// Keep React in step with the session: refresh/idle expiry, a sign-out or a
// different user signing in from another tab.
useEffect(() => {
sessionClient.start();
const off = sessionClient.onChange((type) => {
if (type === 'logged-out') {
setAuthed(false);
setUserEmail(null);
setIsAdmin(false);
setRoles([]);
} else if (type === 'user-switched') {
// This tab's state belongs to the previous user.
window.location.reload();
}
});
return () => {
off();
sessionClient.stop();
};
}, []);
// If we are authenticated but don't have isAdmin from cache, we might want to fetch it // If we are authenticated but don't have isAdmin from cache, we might want to fetch it
useEffect(() => { useEffect(() => {
if (authed && (!isAdmin || roles.length === 0)) { if (authed && (!isAdmin || roles.length === 0)) {
@ -52,12 +74,20 @@ export function AuthProvider({ children }: { children: ReactNode }) {
applyUserSession(res); applyUserSession(res);
}, },
logout: () => { logout: () => {
logoutAll(); void logoutAll(); // local state ends at once; the server revoke follows
setAuthed(false); setAuthed(false);
setUserEmail(null); setUserEmail(null);
setIsAdmin(false); setIsAdmin(false);
setRoles([]); setRoles([]);
localStorage.clear(); // Drop this user's cached data (incl. form drafts) but keep the session
// protocol keys (sm_*): the pending-logout marker must survive until the server hears it.
try {
Object.keys(localStorage)
.filter((k) => !k.startsWith('sm_') && k !== 'krishna_sales_notice_dismissed')
.forEach((k) => localStorage.removeItem(k));
} catch {
/* storage unavailable */
}
}, },
}; };

View File

@ -0,0 +1,77 @@
import { AlertTriangle, Info, Wrench, X } from 'lucide-react';
import { fmtEndsAt, isClosed, MODE_DRAINING, type Notice } from '../../api/notices';
import { usePlatformNotices } from '../../hooks/usePlatformNotices';
/** Strips for announced / draining notices (closed is NoticeBlock's job). */
export function NoticeStrips() {
const { notices, dismiss } = usePlatformNotices();
const strips = notices.filter((n) => !isClosed(n.mode));
if (strips.length === 0) return null;
return (
<div className="print:hidden shrink-0 flex flex-col">
{strips.map((n) => {
// draining and the local "unavailable" 503 notice are the amber, undismissible kind.
const draining = n.mode === MODE_DRAINING || n.mode === 'unavailable';
const Icon = draining ? AlertTriangle : Info;
const back = fmtEndsAt(n.endsAt);
return (
<div
key={`${n.id}:${n.updatedAt}`}
role={draining ? 'alert' : 'status'}
className={
draining
? 'flex items-start gap-2 px-4 py-2 bg-amber-50 border-b border-amber-200 text-xs text-amber-900'
: 'flex items-start gap-2 px-4 py-2 bg-sky-50 border-b border-sky-200 text-xs text-sky-900'
}
>
<Icon size={16} className="shrink-0 mt-0.5" />
<span className="flex-1">
{n.title && <strong>{n.title} </strong>}
{n.body}
{back && <span className="font-semibold"> Expected back by {back}.</span>}
</span>
{/* `draining` stays: it is the only explanation for a refused submit. */}
{!draining && (
<button
type="button"
aria-label="Dismiss"
onClick={() => dismiss(n)}
className="shrink-0 opacity-70 hover:opacity-100 cursor-pointer"
>
<X size={14} />
</button>
)}
</div>
);
})}
</div>
);
}
/** Full-screen, opaque block while the app is `closed`. Lifts itself on the next poll. */
export function NoticeBlock({ notice }: { notice?: Notice }) {
if (!notice) return null;
const back = fmtEndsAt(notice.endsAt);
return (
<div
role="alertdialog"
aria-modal="true"
className="fixed inset-0 z-[1000] bg-white flex items-center justify-center p-6 text-center"
>
<div className="max-w-sm flex flex-col items-center gap-3">
<Wrench size={40} className="text-emerald-700" />
<h1 className="m-0 text-xl font-extrabold text-emerald-900">{notice.title || 'We’ll be right back'}</h1>
{notice.body && <p className="m-0 text-sm text-slate-600 leading-relaxed">{notice.body}</p>}
{back && <p className="m-0 text-sm font-semibold text-slate-800">Expected back by {back}</p>}
<p className="m-0 text-xs text-slate-400">This page updates on its own — no need to refresh.</p>
</div>
</div>
);
}
/** Mount once at the app root: starts polling and shows the block when closed. */
export function NoticeLayer() {
const { closedNotice } = usePlatformNotices({ poll: true });
return <NoticeBlock notice={closedNotice} />;
}

View File

@ -0,0 +1,25 @@
import { useEffect, useSyncExternalStore } from 'react';
import {
dismissNotice,
getNoticesState,
isClosed,
startNoticePolling,
subscribeNotices,
visibleNotices,
type Notice,
} from '../api/notices';
/** The notices in force for this app. Reading is free; polling starts with `poll`. */
export function usePlatformNotices({ poll = false }: { poll?: boolean } = {}) {
const state = useSyncExternalStore(subscribeNotices, getNoticesState);
useEffect(() => (poll ? startNoticePolling() : undefined), [poll]);
const notices = visibleNotices(state.notices, state.dismissed);
return {
notices,
mode: state.mode,
closedNotice: state.notices.find((n) => isClosed(n.mode)),
dismiss: (n: Notice) => dismissNotice(n),
};
}

View File

@ -5,6 +5,7 @@ import { cn } from '../lib/cn';
import { useAuth } from '../auth/context'; import { useAuth } from '../auth/context';
import { onAuthErrorAll, orderBookingClient } from '../api/clients'; import { onAuthErrorAll, orderBookingClient } from '../api/clients';
import { APP_VERSION } from '../api/config'; import { APP_VERSION } from '../api/config';
import { NoticeStrips } from '../components/common/NoticeBanner';
import { ChangePasswordModal } from '../components/auth/ChangePasswordModal'; import { ChangePasswordModal } from '../components/auth/ChangePasswordModal';
import { REPORT_MAP } from './ReportPage'; import { REPORT_MAP } from './ReportPage';
@ -185,6 +186,8 @@ export function ConsoleLayout() {
</div> </div>
</header> </header>
<NoticeStrips />
<main className="flex-1 overflow-y-auto p-6"> <main className="flex-1 overflow-y-auto p-6">
<div className="max-w-[1240px] w-full mx-auto flex flex-col gap-5"> <div className="max-w-[1240px] w-full mx-auto flex flex-col gap-5">
<Outlet /> <Outlet />