auth refactored
This commit is contained in:
parent
b546e870a1
commit
8ba8d133a5
12
README.md
12
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
|
||||
|
||||
|
||||
@ -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 (
|
||||
<AuthProvider>
|
||||
{/* Above the routes on purpose: a `closed` block must cover login too. */}
|
||||
<NoticeLayer />
|
||||
<BrowserRouter basename={routerBasename}>
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
|
||||
@ -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<PasswordPolicy> {
|
||||
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),
|
||||
});
|
||||
|
||||
@ -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<T>(method: string, path: string, body?: unknown, customHeaders?: Record<string, string>): Promise<T> {
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json', ...customHeaders };
|
||||
if (this.token) headers['Authorization'] = `Bearer ${this.token}`;
|
||||
|
||||
return this.send<T>(method, path, {
|
||||
headers,
|
||||
@ -79,17 +81,27 @@ export class ZinoClient {
|
||||
|
||||
// Multipart variant — no Content-Type header (the browser sets the boundary).
|
||||
private async requestForm<T>(path: string, form: FormData): Promise<T> {
|
||||
const headers: Record<string, string> = {};
|
||||
if (this.token) headers['Authorization'] = `Bearer ${this.token}`;
|
||||
return this.send<T>('POST', path, { headers, body: form });
|
||||
return this.send<T>('POST', path, { headers: {}, body: form });
|
||||
}
|
||||
|
||||
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) {
|
||||
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<void> {
|
||||
this.setUser(null);
|
||||
return sessionClient.logout();
|
||||
}
|
||||
|
||||
async getMe(): Promise<User> {
|
||||
const res = await this.request<User>('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<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`, {
|
||||
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<string, unknown>,
|
||||
): Promise<ActivityResult> {
|
||||
assertWritable();
|
||||
return this.request<ActivityResult>('POST', `/app/${APP_ID}/activity`, {
|
||||
workflow_uuid: this.workflowUuid,
|
||||
instance_id: typeof instanceId === 'string' ? Number(instanceId) || instanceId : instanceId,
|
||||
|
||||
@ -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<void> {
|
||||
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). */
|
||||
|
||||
@ -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
|
||||
|
||||
209
src/api/notices.ts
Normal file
209
src/api/notices.ts
Normal 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
559
src/api/session.ts
Normal 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();
|
||||
@ -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 */
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
77
src/components/common/NoticeBanner.tsx
Normal file
77
src/components/common/NoticeBanner.tsx
Normal 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} />;
|
||||
}
|
||||
25
src/hooks/usePlatformNotices.ts
Normal file
25
src/hooks/usePlatformNotices.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
@ -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() {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<NoticeStrips />
|
||||
|
||||
<main className="flex-1 overflow-y-auto p-6">
|
||||
<div className="max-w-[1240px] w-full mx-auto flex flex-col gap-5">
|
||||
<Outlet />
|
||||
|
||||
Loading…
Reference in New Issue
Block a user