cookies added for auth
This commit is contained in:
parent
eab38564c2
commit
f652d302b4
4
package-lock.json
generated
4
package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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,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<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,
|
||||
@ -67,17 +58,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) {
|
||||
@ -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<void> {
|
||||
this.setUser(null);
|
||||
return sessionClient.logout();
|
||||
}
|
||||
|
||||
async getMe(): Promise<User> {
|
||||
@ -142,7 +145,8 @@ 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') {
|
||||
@ -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 ?? ''),
|
||||
|
||||
@ -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<void> {
|
||||
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). */
|
||||
|
||||
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_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();
|
||||
@ -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<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(() => {
|
||||
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');
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 <Outlet>. */
|
||||
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() {
|
||||
</div>
|
||||
</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">
|
||||
<div className="w-full max-w-[1200px] mx-auto flex flex-col gap-5">
|
||||
<Outlet />
|
||||
|
||||
Loading…
Reference in New Issue
Block a user