feat(web): add authenticated app shell
This commit is contained in:
@@ -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>;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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',
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -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