Files
eryao/web/src/lib/auth.ts
T

289 lines
6.9 KiB
TypeScript

/**
* 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<string, string> = {
'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<string, string> = {
'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<AuthData> | 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<void> {
await apiRequest<void>(API_ROUTES.auth.sendOtp, {
method: 'POST',
body: JSON.stringify({ email }),
});
}
export async function loginWithEmail(
email: string,
token: string,
language?: string,
timezone?: string,
): Promise<AuthData> {
const body: Record<string, string> = { email, token };
if (language) body.language = language;
if (timezone) body.timezone = timezone;
const json = await apiRequest<SessionResponse>(API_ROUTES.auth.emailSession, {
method: 'POST',
body: JSON.stringify(body),
});
const data = toAuthData(json);
setAuth(data);
return data;
}
async function doRefreshAccessToken(): Promise<AuthData> {
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<AuthData> {
if (refreshPromise) return refreshPromise;
refreshPromise = doRefreshAccessToken();
try {
return await refreshPromise;
} finally {
refreshPromise = null;
}
}
export async function logout(): Promise<void> {
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<T>(path: string, options?: RequestInit): Promise<T> {
// 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<T>;
}
/**
* 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<Response> {
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;
}