diff --git a/README.md b/README.md index 22c21f6..2c30650 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,17 @@ This is a Progressive Web App (Pwa) built for the Krishna Sales team. ## 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 diff --git a/src/App.tsx b/src/App.tsx index 4654e99..305db7e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -7,6 +7,7 @@ import { ConsoleLayout } from './screens/ConsoleLayout' import { DatasetItemsPage } from './screens/admin/DatasetItemsPage' import { ProtectedRoute, getDefaultRoute } from './auth/ProtectedRoute' import { useAuth } from './auth/context' +import { NoticeLayer } from './components/common/NoticeBanner' function RootRedirect() { const { roles, isAdmin } = useAuth(); @@ -24,6 +25,8 @@ const routerBasename = function App() { return ( + {/* Above the routes on purpose: a `closed` block must cover login too. */} + } /> diff --git a/src/api/auth.ts b/src/api/auth.ts index 4411d7c..9236b89 100644 --- a/src/api/auth.ts +++ b/src/api/auth.ts @@ -1,5 +1,6 @@ import { BASE_URL } from './config'; import { currentToken } from './clients'; +import { AUTH_CREDENTIALS } from './session'; import { passwordPolicyMessage, type PasswordPolicy } from '../utils/passwordPolicy'; export class AuthApiError extends Error { @@ -35,6 +36,7 @@ export async function fetchPasswordPolicy(): Promise { export async function forgotPassword(email: string): Promise<{ message: string }> { const res = await fetch(`${BASE_URL}/usr/forgot-password`, { method: 'POST', + credentials: AUTH_CREDENTIALS, headers: { 'Content-Type': 'application/json' }, 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 }> { const res = await fetch(`${BASE_URL}/usr/verify-reset-code`, { method: 'POST', + credentials: AUTH_CREDENTIALS, headers: { 'Content-Type': 'application/json' }, 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 }> { const res = await fetch(`${BASE_URL}/usr/reset-password`, { method: 'POST', + credentials: AUTH_CREDENTIALS, headers: { 'Content-Type': 'application/json' }, 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`, { method: 'POST', + credentials: AUTH_CREDENTIALS, headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${authToken}`, @@ -133,6 +138,7 @@ export async function changeExpiredPassword( const res = await fetch(`${BASE_URL}/usr/change-expired-password`, { method: 'POST', + credentials: AUTH_CREDENTIALS, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); diff --git a/src/api/client.ts b/src/api/client.ts index 931520c..057be59 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -11,20 +11,28 @@ import type { User, } from './types'; 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'; +/** 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). * - * Routes are app-scoped (`/app/385/...`); login + ai-employee monitor are not. - * JWT persists in localStorage so the session survives refresh. + * Routes are app-scoped (`/app/434/...`); login + ai-employee monitor are not. + * 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 { readonly baseUrl: string; readonly workflowUuid: string; - private token: string | null = null; private user: User | null = null; private onAuthError?: () => void; @@ -33,7 +41,6 @@ export class ZinoClient { this.workflowUuid = workflowUuid; this.onAuthError = onAuthError; if (typeof window !== 'undefined') { - this.token = localStorage.getItem(TOKEN_KEY); try { const u = localStorage.getItem(USER_KEY); if (u) this.user = JSON.parse(u); @@ -47,29 +54,24 @@ export class ZinoClient { this.onAuthError = fn; } - setToken(token: string | null, user?: User | null): void { - this.token = token; + /** Cache the signed-in user's profile (the token itself lives in the session client). */ + setUser(user?: User | null): void { + this.user = user ?? null; if (typeof window === 'undefined') return; - if (token) { - localStorage.setItem(TOKEN_KEY, token); - if (user) { - this.user = user; - localStorage.setItem(USER_KEY, JSON.stringify(user)); - } - } else { - localStorage.removeItem(TOKEN_KEY); - localStorage.removeItem(USER_KEY); - this.user = null; + try { + if (user) localStorage.setItem(USER_KEY, JSON.stringify(user)); + else localStorage.removeItem(USER_KEY); + } catch { + /* not persisted */ } } getToken(): string | null { - return this.token; + return sessionClient.getAccessToken(); } async request(method: string, path: string, body?: unknown, customHeaders?: Record): Promise { const headers: Record = { 'Content-Type': 'application/json', ...customHeaders }; - if (this.token) headers['Authorization'] = `Bearer ${this.token}`; return this.send(method, path, { headers, @@ -79,17 +81,27 @@ export class ZinoClient { // Multipart variant — no Content-Type header (the browser sets the boundary). private async requestForm(path: string, form: FormData): Promise { - const headers: Record = {}; - if (this.token) headers['Authorization'] = `Bearer ${this.token}`; - return this.send('POST', path, { headers, body: form }); + return this.send('POST', path, { headers: {}, body: form }); } private async send(method: string, path: string, init: RequestInit): Promise { - 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) }; + if (token) headers['Authorization'] = `Bearer ${token}`; + return fetch(`${this.baseUrl}${path}`, { + method, + ...init, + headers, + ...(isAuthPath ? { credentials: AUTH_CREDENTIALS } : {}), + }); + }, path); if (res.status === 401) { - this.setToken(null); - this.onAuthError?.(); + // Only a really ended session logs out; an unreachable server must not. + if (ended) this.onAuthError?.(); throw { status: 401, message: 'Unauthorized' } as ApiError; } if (!res.ok) { @@ -105,6 +117,19 @@ export class ZinoClient { } catch { /* 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 { status: res.status, message, @@ -127,31 +152,29 @@ export class ZinoClient { password, ...(orgId ? { org_id: Number(orgId) } : {}), }); - this.setToken(res.token, res.user); + sessionClient.adopt(res); + this.setUser(res.user); return res; } - logout(): void { - this.setToken(null); + logout(): Promise { + this.setUser(null); + return sessionClient.logout(); } async getMe(): Promise { const res = await this.request('GET', `/usr/app/${APP_ID}/me`); - if (this.user) { - this.user = { ...this.user, ...res }; - if (typeof window !== 'undefined') { - localStorage.setItem(USER_KEY, JSON.stringify(this.user)); - } - } + if (this.user) this.setUser({ ...this.user, ...res }); return res; } /** Decode the persisted JWT into a User (no network) or use the saved user. */ currentUser(): User | null { - if (!this.token) return null; + const token = this.getToken(); + if (!token) return null; if (this.user) return this.user; try { - const p = JSON.parse(atob(this.token.split('.')[1])); + const p = JSON.parse(atob(token.split('.')[1])); return { id: String(p.user_id ?? p.sub ?? ''), org_id: String(p.org_id ?? ''), @@ -234,7 +257,8 @@ export class ZinoClient { // --- Workflow execution (core) --- - startInstance(activityUid: string, data: Record, opts?: { version_uuid?: string; version_id?: number }): Promise { + async startInstance(activityUid: string, data: Record, opts?: { version_uuid?: string; version_id?: number }): Promise { + assertWritable(); return this.request('POST', `/app/${APP_ID}/start`, { workflow_uuid: this.workflowUuid, ...(opts?.version_uuid && { version_uuid: opts.version_uuid }), @@ -244,11 +268,12 @@ export class ZinoClient { }); } - performActivity( + async performActivity( instanceId: number | string, activityUid: string, data: Record, ): Promise { + assertWritable(); return this.request('POST', `/app/${APP_ID}/activity`, { workflow_uuid: this.workflowUuid, instance_id: typeof instanceId === 'string' ? Number(instanceId) || instanceId : instanceId, diff --git a/src/api/clients.ts b/src/api/clients.ts index 43a89d7..c1e9819 100644 --- a/src/api/clients.ts +++ b/src/api/clients.ts @@ -23,16 +23,16 @@ export const storeClient = clientFor('store'); export const dailyReportsClient = clientFor('dailyReports'); export const userClient = clientFor('user'); -// The user JWT (from /usr/login) is valid across every workflow, but each -// client holds its own copy — so auth helpers fan out to all of them. +// The session (access token + refresh cookie) is shared: every client reads the +// token from the one session client, so a refresh reaches all of them at once. const ALL = [orderBookingClient, storeClient, dailyReportsClient, userClient]; 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) { const res = await orderBookingClient.login(email, password, orgId); - ALL.forEach((c) => c.setToken(res.token, res.user)); try { const me = await orderBookingClient.getMe(); res.user = { ...res.user, ...me }; @@ -50,7 +50,8 @@ export async function completePasswordChangeAll( orgId?: string ) { 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 { const me = await orderBookingClient.getMe(); res.user = { ...res.user, ...me }; @@ -60,14 +61,15 @@ export async function completePasswordChangeAll( return res; } -/** Clear the session on every client. */ -export function logoutAll(): void { - ALL.forEach((c) => c.setToken(null)); +/** End the session: cleared locally at once, then revoked on the server. */ +export function logoutAll(): Promise { + ALL.forEach((c) => c.setUser(null)); + return orderBookingClient.logout(); } /** Current shared token (null when logged out). */ 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). */ diff --git a/src/api/config.ts b/src/api/config.ts index 8b17f82..f0ad5fa 100644 --- a/src/api/config.ts +++ b/src/api/config.ts @@ -5,7 +5,7 @@ // docs under src/docs/api. 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; // Backend URL comes from the runtime config.js the frontgen server writes at diff --git a/src/api/notices.ts b/src/api/notices.ts new file mode 100644 index 0000000..9402036 --- /dev/null +++ b/src/api/notices.ts @@ -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): 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 { + 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 | null = null; +/** After a write was refused for maintenance: one uncached re-read (overlapping refusals share it). */ +export function repollNotices(): Promise { + 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 | 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.'; +} diff --git a/src/api/session.ts b/src/api/session.ts new file mode 100644 index 0000000..cf58486 --- /dev/null +++ b/src/api/session.ts @@ -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 | 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 & { 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(promise: Promise, ms: number, onTimeout?: () => void): Promise { + 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 | null = null; + private logoutInflight: Promise | null = null; + private channel: BroadcastChannel | null = null; + private proactiveTimer: ReturnType | null = null; + private retryTimer: ReturnType | 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(fn: () => Promise): Promise { + 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 { + 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 { + 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, + 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 { + 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 { + 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(); diff --git a/src/auth/AuthProvider.tsx b/src/auth/AuthProvider.tsx index 059b422..86f9337 100644 --- a/src/auth/AuthProvider.tsx +++ b/src/auth/AuthProvider.tsx @@ -1,6 +1,7 @@ import { useState, type ReactNode, useEffect } from 'react'; import { loginAll, logoutAll, currentToken, orderBookingClient, completePasswordChangeAll } from '../api/clients'; import { AuthCtx, type AuthValue } from './context'; +import { sessionClient } from '../api/session'; export function AuthProvider({ children }: { children: ReactNode }) { const [authed, setAuthed] = useState(() => !!currentToken()); @@ -14,6 +15,27 @@ export function AuthProvider({ children }: { children: ReactNode }) { 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 useEffect(() => { if (authed && (!isAdmin || roles.length === 0)) { @@ -52,12 +74,20 @@ export function AuthProvider({ children }: { children: ReactNode }) { applyUserSession(res); }, logout: () => { - logoutAll(); + void logoutAll(); // local state ends at once; the server revoke follows setAuthed(false); setUserEmail(null); setIsAdmin(false); 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 */ + } }, }; diff --git a/src/components/common/NoticeBanner.tsx b/src/components/common/NoticeBanner.tsx new file mode 100644 index 0000000..c7007d2 --- /dev/null +++ b/src/components/common/NoticeBanner.tsx @@ -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 ( +
+ {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 ( +
+ + + {n.title && {n.title} } + {n.body} + {back && Expected back by {back}.} + + {/* `draining` stays: it is the only explanation for a refused submit. */} + {!draining && ( + + )} +
+ ); + })} +
+ ); +} + +/** 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 ( +
+
+ +

{notice.title || 'We’ll be right back'}

+ {notice.body &&

{notice.body}

} + {back &&

Expected back by {back}

} +

This page updates on its own — no need to refresh.

+
+
+ ); +} + +/** Mount once at the app root: starts polling and shows the block when closed. */ +export function NoticeLayer() { + const { closedNotice } = usePlatformNotices({ poll: true }); + return ; +} diff --git a/src/hooks/usePlatformNotices.ts b/src/hooks/usePlatformNotices.ts new file mode 100644 index 0000000..294a356 --- /dev/null +++ b/src/hooks/usePlatformNotices.ts @@ -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), + }; +} diff --git a/src/screens/ConsoleLayout.tsx b/src/screens/ConsoleLayout.tsx index b0e7854..cfa3a86 100644 --- a/src/screens/ConsoleLayout.tsx +++ b/src/screens/ConsoleLayout.tsx @@ -5,6 +5,7 @@ import { cn } from '../lib/cn'; import { useAuth } from '../auth/context'; import { onAuthErrorAll, orderBookingClient } from '../api/clients'; import { APP_VERSION } from '../api/config'; +import { NoticeStrips } from '../components/common/NoticeBanner'; import { ChangePasswordModal } from '../components/auth/ChangePasswordModal'; import { REPORT_MAP } from './ReportPage'; @@ -185,6 +186,8 @@ export function ConsoleLayout() { + +