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
+51
View File
@@ -0,0 +1,51 @@
const apiBase = (): string => import.meta.env.PUBLIC_API_URL || '';
export function apiUrl(path: string): string {
return path.startsWith('http') ? path : `${apiBase()}${path}`;
}
export class ApiError extends Error {
status: number;
code?: string;
detail?: string;
constructor(status: number, title: string, code?: string, detail?: string) {
super(title);
this.name = 'ApiError';
this.status = status;
this.code = code;
this.detail = detail;
}
}
export async function toApiError(res: Response): Promise<ApiError> {
try {
const body = await res.json();
return new ApiError(
res.status,
body.title || body.detail || `Request failed (${res.status})`,
body.code,
body.detail,
);
} catch {
return new ApiError(res.status, `Request failed (${res.status})`);
}
}
export function jsonHeaders(options?: RequestInit): Headers {
const headers = new Headers(options?.headers);
if (!headers.has('Content-Type') && !(options?.body instanceof FormData)) {
headers.set('Content-Type', 'application/json');
}
return headers;
}
export async function apiRequest<T>(path: string, options?: RequestInit): Promise<T> {
const res = await fetch(apiUrl(path), {
...options,
headers: jsonHeaders(options),
});
if (!res.ok) throw await toApiError(res);
if (res.status === 204) return undefined as T;
return res.json() as Promise<T>;
}
+20
View File
@@ -0,0 +1,20 @@
export const API_ROUTES = {
auth: {
sendOtp: '/api/v1/auth/otp/send',
emailSession: '/api/v1/auth/email-session',
refreshSession: '/api/v1/auth/sessions/refresh',
deleteSession: '/api/v1/auth/sessions',
},
users: {
profile: '/api/v1/users/me/profile',
},
points: {
balance: '/api/v1/points/balance',
},
notifications: {
unreadCount: '/api/v1/notifications/unread-count',
},
agent: {
history: '/api/v1/agent/history',
},
} as const;
+120
View File
@@ -0,0 +1,120 @@
/**
* Typed API client for backend endpoints.
* Wraps authFetch for authenticated requests.
*/
import { authFetch } from './auth';
import { API_ROUTES } from './api-routes';
// --- User Profile ---
export interface UserProfile {
user_id: string;
display_name: string;
bio: string;
avatar_path: string | null;
avatar_url: string | null;
settings: {
preferences: {
language: string;
timezone: string;
};
};
updated_at: string;
}
export function getUserProfile(): Promise<UserProfile> {
return authFetch<UserProfile>(API_ROUTES.users.profile);
}
// --- Points ---
export interface PointsBalance {
balance: number;
frozenBalance: number;
availableBalance: number;
runCost: number;
canRun: boolean;
}
export function getPointsBalance(): Promise<PointsBalance> {
return authFetch<PointsBalance>(API_ROUTES.points.balance);
}
// --- Notifications ---
export interface UnreadCount {
count: number;
}
export function getUnreadNotificationCount(): Promise<UnreadCount> {
return authFetch<UnreadCount>(API_ROUTES.notifications.unreadCount);
}
// --- Agent History ---
export interface HistoryAgentOutput {
status?: string | null;
sign_level?: string | null;
conclusion?: string[];
focus_points?: string[];
advice?: string[];
keywords?: string[];
answer?: string | null;
divination_derived?: {
guaName?: string;
gua_name?: string;
binaryCode?: string;
changedBinaryCode?: string;
} | null;
}
export interface HistoryMessage {
id: string;
threadId: string;
seq: number;
role: 'user' | 'assistant';
content: string;
timestamp: string;
agent_output?: HistoryAgentOutput | null;
}
export interface HistoryItem {
id: string;
threadId: string;
question: string;
category: string;
hexagram_name: string;
rating: string;
created_at: string;
can_follow_up: boolean;
}
export interface HistorySnapshot {
scope: string;
threadId: string | null;
day: string | null;
hasMore: boolean;
messages: HistoryMessage[];
}
export async function getAgentHistory(): Promise<HistorySnapshot> {
return authFetch<HistorySnapshot>(API_ROUTES.agent.history);
}
export function mapHistoryMessagesToItems(messages: HistoryMessage[]): HistoryItem[] {
return messages.map((message) => {
const output = message.agent_output;
const derived = output?.divination_derived;
return {
id: message.id,
threadId: message.threadId,
question: output?.answer || message.content,
category: output?.keywords?.[0] || '',
hexagram_name: derived?.guaName || derived?.gua_name || '',
rating: output?.sign_level || '',
created_at: message.timestamp,
can_follow_up: output?.status === 'success',
};
});
}
+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>;
}