/** * Auth storage + API calls + authFetch wrapper. * Mirrors Flutter's SessionStore + AuthApi + AuthRepositoryImpl. */ import { apiRequest, apiUrl, jsonHeaders, toApiError, ApiError } from './api-client'; import { API_ROUTES } from './api-routes'; const STORAGE_KEY = 'meeyao_auth'; export { ApiError }; export interface AuthUser { id: string; email: string; } export interface AuthData { access_token: string; refresh_token: string; expires_at: number; // Unix ms user: AuthUser; } interface SessionResponse { access_token: string; refresh_token: string; expires_in: number; token_type: string; user: { id: string; email: string }; } // --- Language mapping --- /** * Map frontend locale to backend BCP-47 language tag */ export function localeToBackendLanguage(locale: string): string { const mapping: Record = { 'zh': 'zh-CN', 'zh_Hant': 'zh-TW', 'en': 'en-US', }; return mapping[locale] || 'zh-CN'; } /** * Map backend BCP-47 language tag to frontend locale */ export function backendLanguageToLocale(lang: string): string { const mapping: Record = { 'zh-CN': 'zh', 'zh-TW': 'zh_Hant', 'zh-Hant': 'zh_Hant', 'en-US': 'en', 'en': 'en', }; return mapping[lang] || 'zh'; } // --- Storage --- export function getAuth(): AuthData | null { if (typeof window === 'undefined') return null; const raw = localStorage.getItem(STORAGE_KEY); if (!raw) return null; try { return JSON.parse(raw) as AuthData; } catch { clearAuth(); return null; } } export function setAuth(data: AuthData): void { localStorage.setItem(STORAGE_KEY, JSON.stringify(data)); } export function clearAuth(): void { localStorage.removeItem(STORAGE_KEY); } // --- Token status --- export function isTokenExpired(): boolean { const auth = getAuth(); if (!auth) return true; // Refresh 60 seconds before actual expiry return auth.expires_at - 60_000 < Date.now(); } let refreshPromise: Promise | null = null; // --- Helpers --- function toAuthData(response: SessionResponse): AuthData { return { access_token: response.access_token, refresh_token: response.refresh_token, expires_at: Date.now() + response.expires_in * 1000, user: { id: response.user.id, email: response.user.email }, }; } function getLocaleFromPath(): string { if (typeof window === 'undefined') return 'zh'; const match = window.location.pathname.match(/^\/(zh|zh_Hant|en)(?:\/|$)/); return match ? match[1] : 'zh'; } export function loginPath(): string { const locale = getLocaleFromPath(); return `/${locale}/login`; } export function redirectToLogin(): void { if (typeof window === 'undefined') return; window.location.replace(loginPath()); } // --- API calls --- export async function sendOtp(email: string): Promise { await apiRequest(API_ROUTES.auth.sendOtp, { method: 'POST', body: JSON.stringify({ email }), }); } export async function loginWithEmail( email: string, token: string, language?: string, timezone?: string, ): Promise { const body: Record = { email, token }; if (language) body.language = language; if (timezone) body.timezone = timezone; const json = await apiRequest(API_ROUTES.auth.emailSession, { method: 'POST', body: JSON.stringify(body), }); const data = toAuthData(json); setAuth(data); return data; } async function doRefreshAccessToken(): Promise { const auth = getAuth(); if (!auth?.refresh_token) { clearAuth(); throw new Error('No refresh token'); } const res = await fetch(apiUrl(API_ROUTES.auth.refreshSession), { method: 'POST', headers: jsonHeaders(), body: JSON.stringify({ refresh_token: auth.refresh_token }), }); if (!res.ok) { clearAuth(); throw await toApiError(res); } const json: SessionResponse = await res.json(); const data = toAuthData(json); setAuth(data); return data; } export async function refreshAccessToken(): Promise { if (refreshPromise) return refreshPromise; refreshPromise = doRefreshAccessToken(); try { return await refreshPromise; } finally { refreshPromise = null; } } export async function logout(): Promise { const auth = getAuth(); try { if (auth?.refresh_token) { await fetch(apiUrl(API_ROUTES.auth.deleteSession), { method: 'DELETE', headers: jsonHeaders(), body: JSON.stringify({ refresh_token: auth.refresh_token }), }); } } finally { clearAuth(); } } // --- authFetch --- export async function authFetch(path: string, options?: RequestInit): Promise { // 1. Ensure token is fresh if (isTokenExpired()) { try { await refreshAccessToken(); } catch { // refresh failed, redirect to login clearAuth(); redirectToLogin(); throw new Error('Session expired'); } } const auth = getAuth(); if (!auth) { redirectToLogin(); throw new Error('Not authenticated'); } const headers = jsonHeaders(options); headers.set('Authorization', `Bearer ${auth.access_token}`); // 2. Make request const url = apiUrl(path); let res = await fetch(url, { ...options, headers }); // 3. On 401, refresh once and retry if (res.status === 401) { try { await refreshAccessToken(); } catch { clearAuth(); redirectToLogin(); throw new Error('Session expired'); } const refreshed = getAuth(); if (!refreshed) { redirectToLogin(); throw new Error('Not authenticated'); } const retryHeaders = jsonHeaders(options); retryHeaders.set('Authorization', `Bearer ${refreshed.access_token}`); res = await fetch(url, { ...options, headers: retryHeaders }); } if (res.status === 401) { clearAuth(); redirectToLogin(); throw new Error('Not authenticated'); } if (!res.ok) throw await toApiError(res); if (res.status === 204) return undefined as T; return res.json() as Promise; } /** * Like authFetch but returns raw Response for streaming (SSE, etc.) * Does NOT throw on non-OK responses - caller must handle response.status */ export async function authFetchRaw(path: string, options?: RequestInit): Promise { if (isTokenExpired()) { await refreshAccessToken(); } const auth = getAuth(); if (!auth) { redirectToLogin(); throw new Error('Not authenticated'); } const headers = jsonHeaders(options); headers.set('Authorization', `Bearer ${auth.access_token}`); const url = apiUrl(path); let res = await fetch(url, { ...options, headers }); if (res.status === 401) { await refreshAccessToken(); const refreshed = getAuth(); if (!refreshed) { redirectToLogin(); throw new Error('Not authenticated'); } const retryHeaders = jsonHeaders(options); retryHeaders.set('Authorization', `Bearer ${refreshed.access_token}`); res = await fetch(url, { ...options, headers: retryHeaders }); } return res; }