cookies added for auth

This commit is contained in:
suryac 2026-11-06 12:13:42 +05:30
parent eab38564c2
commit f652d302b4
8 changed files with 692 additions and 42 deletions

4
package-lock.json generated
View File

@ -1,12 +1,12 @@
{ {
"name": "krishna-sales-pwa", "name": "krishna-sales-pwa",
"version": "0.0.0", "version": "1.0.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "krishna-sales-pwa", "name": "krishna-sales-pwa",
"version": "0.0.0", "version": "1.0.2",
"dependencies": { "dependencies": {
"@react-google-maps/api": "^2.20.8", "@react-google-maps/api": "^2.20.8",
"jspdf": "^4.2.1", "jspdf": "^4.2.1",

View File

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

View File

@ -11,6 +11,7 @@ import type {
User, User,
} from './types'; } from './types';
import { APP_ID, PIPELINE } from './config'; import { APP_ID, PIPELINE } from './config';
import { sessionClient, AUTH_CREDENTIALS } from './session';
const TOKEN_KEY = 'krishna_sales_token'; const TOKEN_KEY = 'krishna_sales_token';
@ -23,41 +24,31 @@ const TOKEN_KEY = 'krishna_sales_token';
export class ZinoClient { export class ZinoClient {
readonly baseUrl: string; readonly baseUrl: string;
readonly workflowUuid: string; readonly workflowUuid: string;
private token: string | null = null;
private onAuthError?: () => void; private onAuthError?: () => void;
constructor(baseUrl: string, workflowUuid: string, onAuthError?: () => void) { constructor(baseUrl: string, workflowUuid: string, onAuthError?: () => void) {
this.baseUrl = baseUrl.replace(/\/+$/, ''); this.baseUrl = baseUrl.replace(/\/+$/, '');
this.workflowUuid = workflowUuid; this.workflowUuid = workflowUuid;
this.onAuthError = onAuthError; this.onAuthError = onAuthError;
if (typeof window !== 'undefined') this.token = localStorage.getItem(TOKEN_KEY);
} }
setAuthErrorHandler(fn: () => void): void { setAuthErrorHandler(fn: () => void): void {
this.onAuthError = fn; this.onAuthError = fn;
} }
setToken(token: string | null, user?: User | null): void { /** Cache the signed-in user's profile (the token itself lives in the session client). */
this.token = token; setUser(user?: User | null): void {
if (typeof window === 'undefined') return; if (typeof window === 'undefined') return;
if (token) { if (user) localStorage.setItem(TOKEN_KEY + '_user', JSON.stringify(user));
localStorage.setItem(TOKEN_KEY, token); else localStorage.removeItem(TOKEN_KEY + '_user');
if (user) {
localStorage.setItem(TOKEN_KEY + '_user', JSON.stringify(user));
}
} else {
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(TOKEN_KEY + '_user');
}
} }
getToken(): string | null { getToken(): string | null {
return this.token; return sessionClient.getAccessToken();
} }
async request<T>(method: string, path: string, body?: unknown, customHeaders?: Record<string, string>): Promise<T> { async request<T>(method: string, path: string, body?: unknown, customHeaders?: Record<string, string>): Promise<T> {
const headers: Record<string, string> = { 'Content-Type': 'application/json', ...customHeaders }; const headers: Record<string, string> = { 'Content-Type': 'application/json', ...customHeaders };
if (this.token) headers['Authorization'] = `Bearer ${this.token}`;
return this.send<T>(method, path, { return this.send<T>(method, path, {
headers, headers,
@ -67,17 +58,27 @@ export class ZinoClient {
// Multipart variant — no Content-Type header (the browser sets the boundary). // Multipart variant — no Content-Type header (the browser sets the boundary).
private async requestForm<T>(path: string, form: FormData): Promise<T> { private async requestForm<T>(path: string, form: FormData): Promise<T> {
const headers: Record<string, string> = {}; return this.send<T>('POST', path, { headers: {}, body: form });
if (this.token) headers['Authorization'] = `Bearer ${this.token}`;
return this.send<T>('POST', path, { headers, body: form });
} }
private async send<T>(method: string, path: string, init: RequestInit): Promise<T> { private async send<T>(method: string, path: string, init: RequestInit): Promise<T> {
const res = await fetch(`${this.baseUrl}${path}`, { method, ...init }); // The bearer is stamped per attempt from the session, so a refresh between
// the first send and its replay swaps the token (an expired one 401s into it).
const isAuthPath = path.startsWith('/usr/');
const { res, ended } = await sessionClient.sendWithRefresh((token) => {
const headers = { ...(init.headers as Record<string, string>) };
if (token) headers['Authorization'] = `Bearer ${token}`;
return fetch(`${this.baseUrl}${path}`, {
method,
...init,
headers,
...(isAuthPath ? { credentials: AUTH_CREDENTIALS } : {}),
});
}, path);
if (res.status === 401) { if (res.status === 401) {
this.setToken(null); // Only a really ended session logs out; an unreachable server must not.
this.onAuthError?.(); if (ended) this.onAuthError?.();
throw { status: 401, message: 'Unauthorized' } as ApiError; throw { status: 401, message: 'Unauthorized' } as ApiError;
} }
if (!res.ok) { if (!res.ok) {
@ -115,12 +116,14 @@ export class ZinoClient {
password, password,
...(orgId ? { org_id: Number(orgId) } : {}), ...(orgId ? { org_id: Number(orgId) } : {}),
}); });
this.setToken(res.token, res.user); sessionClient.adopt(res);
this.setUser(res.user);
return res; return res;
} }
logout(): void { logout(): Promise<void> {
this.setToken(null); this.setUser(null);
return sessionClient.logout();
} }
async getMe(): Promise<User> { async getMe(): Promise<User> {
@ -142,8 +145,9 @@ export class ZinoClient {
/** Decode the persisted JWT into a User (no network). */ /** Decode the persisted JWT into a User (no network). */
currentUser(): User | null { 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 // First, try to retrieve the user object stored during login
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
try { try {
@ -157,7 +161,7 @@ export class ZinoClient {
} }
try { try {
const p = JSON.parse(atob(this.token.split('.')[1])); const p = JSON.parse(atob(token.split('.')[1]));
return { return {
id: String(p.user_id ?? p.sub ?? ''), id: String(p.user_id ?? p.sub ?? ''),
org_id: String(p.org_id ?? ''), org_id: String(p.org_id ?? ''),

View File

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

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

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

View File

@ -1,6 +1,7 @@
import { useState, type ReactNode, useEffect } from 'react'; import { useState, type ReactNode, useEffect } from 'react';
import { loginAll, logoutAll, currentToken, orderBookingClient, completePasswordChangeAll } from '../api/clients'; import { loginAll, logoutAll, currentToken, orderBookingClient, completePasswordChangeAll } from '../api/clients';
import { AuthCtx, type AuthValue } from './context'; import { AuthCtx, type AuthValue, type PasswordStatus } from './context';
import { sessionClient } from '../api/session';
export function AuthProvider({ children }: { children: ReactNode }) { export function AuthProvider({ children }: { children: ReactNode }) {
const [authed, setAuthed] = useState(() => !!currentToken()); const [authed, setAuthed] = useState(() => !!currentToken());
@ -15,6 +16,37 @@ export function AuthProvider({ children }: { children: ReactNode }) {
return r ? JSON.parse(r) : []; return r ? JSON.parse(r) : [];
}); });
const [passwordStatus, setPasswordStatus] = useState<PasswordStatus | null>(() => {
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(() => { useEffect(() => {
if (authed && (!isAdmin || roles.length === 0)) { if (authed && (!isAdmin || roles.length === 0)) {
orderBookingClient.getMe().then(me => { orderBookingClient.getMe().then(me => {
@ -36,6 +68,11 @@ export function AuthProvider({ children }: { children: ReactNode }) {
localStorage.setItem('krishna_sales_mobile_is_admin', userIsAdmin ? 'true' : 'false'); localStorage.setItem('krishna_sales_mobile_is_admin', userIsAdmin ? 'true' : 'false');
setRoles(userRoles); setRoles(userRoles);
localStorage.setItem('krishna_sales_mobile_roles', JSON.stringify(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 = { const value: AuthValue = {
@ -43,6 +80,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
isAdmin, isAdmin,
roles, roles,
userEmail, userEmail,
passwordStatus,
login: async (email, password, orgId) => { login: async (email, password, orgId) => {
const res = await loginAll(email, password, orgId); const res = await loginAll(email, password, orgId);
applyUserSession(res); applyUserSession(res);
@ -52,11 +90,12 @@ export function AuthProvider({ children }: { children: ReactNode }) {
applyUserSession(res); applyUserSession(res);
}, },
logout: () => { logout: () => {
logoutAll(); void logoutAll(); // local state ends at once; the server revoke follows
setAuthed(false); setAuthed(false);
setUserEmail(null); setUserEmail(null);
setIsAdmin(false); setIsAdmin(false);
setRoles([]); setRoles([]);
setPasswordStatus(null);
localStorage.removeItem('krishna_sales_mobile_user_email'); localStorage.removeItem('krishna_sales_mobile_user_email');
localStorage.removeItem('krishna_sales_mobile_is_admin'); localStorage.removeItem('krishna_sales_mobile_is_admin');
localStorage.removeItem('krishna_sales_mobile_roles'); localStorage.removeItem('krishna_sales_mobile_roles');

View File

@ -6,8 +6,16 @@ export interface PasswordChangeRequiredInfo {
rules?: string[]; 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 { export interface AuthValue {
authed: boolean; authed: boolean;
passwordStatus: PasswordStatus | null;
isAdmin: boolean; isAdmin: boolean;
roles: string[]; roles: string[];
userEmail: string | null; userEmail: string | null;

View File

@ -1,6 +1,6 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { NavLink, Navigate, Outlet, useNavigate } from 'react-router-dom'; 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 { cn } from '../lib/cn';
import { useAuth } from '../auth/context'; import { useAuth } from '../auth/context';
import { onAuthErrorAll, orderBookingClient } from '../api/clients'; import { onAuthErrorAll, orderBookingClient } from '../api/clients';
@ -23,7 +23,8 @@ const canAccessScreen = (key: string, isAdmin: boolean, roles: string[]) => {
/** Auth-guarded shell: slim navy top bar + fixed bottom tab nav + routed <Outlet>. */ /** Auth-guarded shell: slim navy top bar + fixed bottom tab nav + routed <Outlet>. */
export function ConsoleLayout() { 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 navigate = useNavigate();
const user = orderBookingClient.currentUser(); const user = orderBookingClient.currentUser();
@ -59,6 +60,38 @@ export function ConsoleLayout() {
</div> </div>
</header> </header>
{passwordStatus?.warn && !passwordDueDismissed && (
<div
role="alert"
className="print:hidden shrink-0 flex items-center gap-2 px-4 py-2 bg-amber-50 border-b border-amber-200 text-xs text-amber-900"
>
<AlertTriangle size={16} className="shrink-0 text-amber-700" />
<span className="flex-1">
<strong>Password change due.</strong>{' '}
{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.'}
</span>
<button
type="button"
onClick={() => setIsChangePasswordOpen(true)}
className="font-semibold text-amber-900 underline cursor-pointer"
>
Change now
</button>
<button
type="button"
aria-label="Dismiss"
onClick={() => setPasswordDueDismissed(true)}
className="text-amber-700 hover:text-amber-900 cursor-pointer"
>
<X size={14} />
</button>
</div>
)}
<main className="flex-1 overflow-y-auto px-4 pt-4 pb-24"> <main className="flex-1 overflow-y-auto px-4 pt-4 pb-24">
<div className="w-full max-w-[1200px] mx-auto flex flex-col gap-5"> <div className="w-full max-w-[1200px] mx-auto flex flex-col gap-5">
<Outlet /> <Outlet />