feat(web): add authenticated app shell
This commit is contained in:
@@ -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>;
|
||||
}
|
||||
Reference in New Issue
Block a user