From f652d302b49a9980700ef55a6c758d378bdb020a Mon Sep 17 00:00:00 2001 From: suryac Date: Fri, 6 Nov 2026 12:13:42 +0530 Subject: [PATCH] cookies added for auth --- package-lock.json | 4 +- src/api/auth.ts | 6 + src/api/client.ts | 58 ++-- src/api/clients.ts | 19 +- src/api/session.ts | 559 ++++++++++++++++++++++++++++++++++ src/auth/AuthProvider.tsx | 43 ++- src/auth/context.ts | 8 + src/screens/ConsoleLayout.tsx | 37 ++- 8 files changed, 692 insertions(+), 42 deletions(-) create mode 100644 src/api/session.ts diff --git a/package-lock.json b/package-lock.json index bf19de6..750832c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "krishna-sales-pwa", - "version": "0.0.0", + "version": "1.0.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "krishna-sales-pwa", - "version": "0.0.0", + "version": "1.0.2", "dependencies": { "@react-google-maps/api": "^2.20.8", "jspdf": "^4.2.1", 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 c4ba19d..e2b23d2 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -11,6 +11,7 @@ import type { User, } from './types'; import { APP_ID, PIPELINE } from './config'; +import { sessionClient, AUTH_CREDENTIALS } from './session'; const TOKEN_KEY = 'krishna_sales_token'; @@ -23,41 +24,31 @@ const TOKEN_KEY = 'krishna_sales_token'; export class ZinoClient { readonly baseUrl: string; readonly workflowUuid: string; - private token: string | null = null; private onAuthError?: () => void; constructor(baseUrl: string, workflowUuid: string, onAuthError?: () => void) { this.baseUrl = baseUrl.replace(/\/+$/, ''); this.workflowUuid = workflowUuid; this.onAuthError = onAuthError; - if (typeof window !== 'undefined') this.token = localStorage.getItem(TOKEN_KEY); } setAuthErrorHandler(fn: () => void): void { 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 { if (typeof window === 'undefined') return; - if (token) { - localStorage.setItem(TOKEN_KEY, token); - if (user) { - localStorage.setItem(TOKEN_KEY + '_user', JSON.stringify(user)); - } - } else { - localStorage.removeItem(TOKEN_KEY); - localStorage.removeItem(TOKEN_KEY + '_user'); - } + if (user) localStorage.setItem(TOKEN_KEY + '_user', JSON.stringify(user)); + else localStorage.removeItem(TOKEN_KEY + '_user'); } 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, @@ -67,17 +58,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) { @@ -115,12 +116,14 @@ 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 { @@ -142,8 +145,9 @@ export class ZinoClient { /** Decode the persisted JWT into a User (no network). */ currentUser(): User | null { - if (!this.token) return null; - + const token = this.getToken(); + if (!token) return null; + // First, try to retrieve the user object stored during login if (typeof window !== 'undefined') { try { @@ -157,7 +161,7 @@ export class ZinoClient { } 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 ?? ''), diff --git a/src/api/clients.ts b/src/api/clients.ts index 779965e..c0e2891 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)); 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)); + sessionClient.adopt(res); + orderBookingClient.setUser(res.user); try { const me = await orderBookingClient.getMe(); res.user = { ...res.user, ...me }; @@ -60,14 +61,14 @@ 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 { + 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/session.ts b/src/api/session.ts new file mode 100644 index 0000000..3ba4edf --- /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_mobile_user_email', + 'krishna_sales_mobile_is_admin', + 'krishna_sales_mobile_roles', + 'krishna_sales_mobile_password_status', +]; + +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 7401e23..c651f2d 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 { AuthCtx, type AuthValue, type PasswordStatus } from './context'; +import { sessionClient } from '../api/session'; export function AuthProvider({ children }: { children: ReactNode }) { const [authed, setAuthed] = useState(() => !!currentToken()); @@ -15,6 +16,37 @@ export function AuthProvider({ children }: { children: ReactNode }) { return r ? JSON.parse(r) : []; }); + const [passwordStatus, setPasswordStatus] = useState(() => { + try { + const raw = localStorage.getItem('krishna_sales_mobile_password_status'); + return raw ? (JSON.parse(raw) as PasswordStatus) : null; + } catch { + return null; + } + }); + + // 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([]); + setPasswordStatus(null); + } else if (type === 'user-switched') { + // This tab's state belongs to the previous user. + window.location.reload(); + } + }); + return () => { + off(); + sessionClient.stop(); + }; + }, []); + useEffect(() => { if (authed && (!isAdmin || roles.length === 0)) { orderBookingClient.getMe().then(me => { @@ -36,6 +68,11 @@ export function AuthProvider({ children }: { children: ReactNode }) { localStorage.setItem('krishna_sales_mobile_is_admin', userIsAdmin ? 'true' : 'false'); setRoles(userRoles); localStorage.setItem('krishna_sales_mobile_roles', JSON.stringify(userRoles)); + // Only a due/expiring password is worth remembering; a fresh login replaces it. + const status: PasswordStatus | null = res.user?.password_status?.warn ? res.user.password_status : null; + setPasswordStatus(status); + if (status) localStorage.setItem('krishna_sales_mobile_password_status', JSON.stringify(status)); + else localStorage.removeItem('krishna_sales_mobile_password_status'); }; const value: AuthValue = { @@ -43,6 +80,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { isAdmin, roles, userEmail, + passwordStatus, login: async (email, password, orgId) => { const res = await loginAll(email, password, orgId); applyUserSession(res); @@ -52,11 +90,12 @@ 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([]); + setPasswordStatus(null); localStorage.removeItem('krishna_sales_mobile_user_email'); localStorage.removeItem('krishna_sales_mobile_is_admin'); localStorage.removeItem('krishna_sales_mobile_roles'); diff --git a/src/auth/context.ts b/src/auth/context.ts index 6f4f5d3..1ef3818 100644 --- a/src/auth/context.ts +++ b/src/auth/context.ts @@ -6,8 +6,16 @@ export interface PasswordChangeRequiredInfo { rules?: string[]; } +/** `user.password_status` from /usr/login: set when a password change is coming due. */ +export interface PasswordStatus { + warn?: boolean; + days_left?: number; + change_required_from?: string; +} + export interface AuthValue { authed: boolean; + passwordStatus: PasswordStatus | null; isAdmin: boolean; roles: string[]; userEmail: string | null; diff --git a/src/screens/ConsoleLayout.tsx b/src/screens/ConsoleLayout.tsx index 33681b5..08f38fc 100644 --- a/src/screens/ConsoleLayout.tsx +++ b/src/screens/ConsoleLayout.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react'; import { NavLink, Navigate, Outlet, useNavigate } from 'react-router-dom'; -import { LogOut, Menu, X, Database, Users, KeyRound } from 'lucide-react'; +import { LogOut, Menu, X, Database, Users, KeyRound, AlertTriangle } from 'lucide-react'; import { cn } from '../lib/cn'; import { useAuth } from '../auth/context'; import { onAuthErrorAll, orderBookingClient } from '../api/clients'; @@ -23,7 +23,8 @@ const canAccessScreen = (key: string, isAdmin: boolean, roles: string[]) => { /** Auth-guarded shell: slim navy top bar + fixed bottom tab nav + routed . */ export function ConsoleLayout() { - const { authed, logout, isAdmin, roles: userRoles, userEmail } = useAuth(); + const { authed, logout, isAdmin, roles: userRoles, userEmail, passwordStatus } = useAuth(); + const [passwordDueDismissed, setPasswordDueDismissed] = useState(false); const navigate = useNavigate(); const user = orderBookingClient.currentUser(); @@ -59,6 +60,38 @@ export function ConsoleLayout() { + {passwordStatus?.warn && !passwordDueDismissed && ( +
+ + + Password change due.{' '} + {typeof passwordStatus.days_left === 'number' + ? passwordStatus.days_left <= 0 + ? 'Your password expires today.' + : `Your password expires in ${passwordStatus.days_left} day${passwordStatus.days_left === 1 ? '' : 's'}.` + : 'Please update it soon.'} + + + +
+ )} +