289 lines
6.9 KiB
TypeScript
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;
|
|
}
|