feat(web): add authenticated app shell

This commit is contained in:
zl-q
2026-05-09 16:00:29 +08:00
parent c12320cb79
commit 5aa46d3311
73 changed files with 2571 additions and 250 deletions
+211
View File
@@ -0,0 +1,211 @@
/**
* 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 };
}
// --- 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();
}
// --- 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;
}
export async function refreshAccessToken(): 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 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>;
}