frontend phase 1: auth — phone-OTP login, role routing & session refresh
Replace the username/password stub with Balinyaar's real credential (phone-OTP) and add the role router every authenticated screen sits behind. - services/auth rewritten for OTP over the b2 contract: types/keys/constants, clientApi+mockApi behind a config'd seam, hooks (useRequestOtp/useVerifyOtp/ useMe/useRefresh/useLogout/useSelectRole/useSessionRoleSync). Stub removed. - A1/A2 customer login + B1/B2 nurse switch as one OTP flow at /login (PhoneStep/OtpStep: auto-verify, resend countdown, wrong/expired/lockout states). - Role router: pure resolveRoleDestination + RoleRouter -> family / nurse / admin / select-role, with a splash while /me loads (no wrong-shell flash). - SelectRole first-use screen at /select-role. - Widened AuthState (roles via SessionUser), hydrated from /me by useSessionRoleSync. - Fetch-layer silent token refresh (single-flight + one retry) + shared persistAuthTokens/clearAuthTokens; useRefresh as the on-demand path. - auth i18n namespace in both locales; tests for routing branches, countdown, OtpStep state machine, RoleRouter branches; fixed jest @/ -> src alias. - Docs: client/CLAUDE.md, frontend STATUS/report, for-backend REQ-002..004. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -17,6 +17,11 @@ import { deleteClientCookie, getClientCookie } from '@/lib/cookies/client';
|
||||
import { dispatchToast } from '@/lib/toast/dispatchToast';
|
||||
import { ROUTES } from '@/constants';
|
||||
import { ApiError } from './errors';
|
||||
import { attemptTokenRefresh } from './refresh';
|
||||
|
||||
// Routes that must never trigger the silent-refresh-on-401 retry: the refresh call itself
|
||||
// (a 401 there is terminal reuse/expiry) and the unauthenticated OTP endpoints.
|
||||
const NO_REFRESH_PATHS = ['/auth/refresh', '/auth/request_otp', '/auth/verify_otp'];
|
||||
|
||||
async function parseBody(response: Response): Promise<{ message?: string; code?: string }> {
|
||||
try {
|
||||
@@ -26,7 +31,7 @@ async function parseBody(response: Response): Promise<{ message?: string; code?:
|
||||
}
|
||||
}
|
||||
|
||||
export async function clientFetch<T>(path: string, options?: RequestInit): Promise<T> {
|
||||
export async function clientFetch<T>(path: string, options?: RequestInit, isRetry = false): Promise<T> {
|
||||
const token = getClientCookie(COOKIE_NAMES.ACCESS_TOKEN);
|
||||
const locale = window.location.pathname.split('/')[1] || 'fa';
|
||||
|
||||
@@ -59,6 +64,13 @@ export async function clientFetch<T>(path: string, options?: RequestInit): Promi
|
||||
const { code } = body;
|
||||
|
||||
if (response.status === 401) {
|
||||
// Try one silent refresh + retry before giving up — an expired access token is rotated
|
||||
// transparently so the user keeps their session. Skip for the refresh/OTP endpoints and
|
||||
// never loop (single retry).
|
||||
const refreshEligible = !isRetry && !NO_REFRESH_PATHS.some((p) => path.includes(p));
|
||||
if (refreshEligible && (await attemptTokenRefresh())) {
|
||||
return clientFetch<T>(path, options, true);
|
||||
}
|
||||
deleteClientCookie(COOKIE_NAMES.ACCESS_TOKEN);
|
||||
deleteClientCookie(COOKIE_NAMES.REFRESH_TOKEN);
|
||||
dispatchToast('Session expired. Please log in again.', 'error');
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Silent access-token refresh for the client fetch layer. On a 401, `clientFetch` calls this
|
||||
* once to rotate the token pair (the server does rotation + reuse-detection) and, on success,
|
||||
* retries the original request. A module-level single-flight promise coalesces concurrent 401s
|
||||
* into one refresh so a burst of parallel requests rotates the session exactly once.
|
||||
*
|
||||
* This is deliberately a bare `fetch` (not `clientFetch`, not the `services/auth` client) to
|
||||
* avoid an import cycle — it only shares the token-cookie writers in `@/lib/auth/session`.
|
||||
*/
|
||||
import { API_URL } from '@/config';
|
||||
import { COOKIE_NAMES } from '@/lib/cookies';
|
||||
import { getClientCookie } from '@/lib/cookies/client';
|
||||
import { clearAuthTokens, persistAuthTokens } from '@/lib/auth/session';
|
||||
|
||||
const REFRESH_PATH = '/api/v1/auth/refresh';
|
||||
|
||||
let inFlight: Promise<boolean> | null = null;
|
||||
|
||||
async function doRefresh(): Promise<boolean> {
|
||||
const refreshToken = getClientCookie(COOKIE_NAMES.REFRESH_TOKEN);
|
||||
if (!refreshToken) return false;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_URL}${REFRESH_PATH}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ refreshToken }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
// 401 here means an unknown/expired/reused (stolen) token — the session is gone.
|
||||
clearAuthTokens();
|
||||
return false;
|
||||
}
|
||||
const envelope = await response.json();
|
||||
const tokens = envelope?.data;
|
||||
if (!tokens?.accessToken || !tokens?.refreshToken) {
|
||||
clearAuthTokens();
|
||||
return false;
|
||||
}
|
||||
persistAuthTokens(tokens);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function attemptTokenRefresh(): Promise<boolean> {
|
||||
if (!inFlight) {
|
||||
inFlight = doRefresh().finally(() => {
|
||||
inFlight = null;
|
||||
});
|
||||
}
|
||||
return inFlight;
|
||||
}
|
||||
Reference in New Issue
Block a user