diff --git a/apps/api/src/lib/auth.ts b/apps/api/src/lib/auth.ts index 11acfd8..4fa5420 100644 --- a/apps/api/src/lib/auth.ts +++ b/apps/api/src/lib/auth.ts @@ -61,6 +61,19 @@ export function readSessionToken(cookieHeader: string | null | undefined) { return decodeURIComponent(sessionCookie.slice(sessionCookieName.length + 1)); } +/** + * Extract bearer token from Authorization header. + * Used as fallback when cookies are blocked (e.g. Android WebView third-party blocking). + */ +export function readBearerToken(headers?: { get(name: string): string | null }) { + if (!headers) return null; + const auth = headers.get('authorization'); + if (!auth) return null; + const parts = auth.split(' '); + if (parts.length !== 2 || parts[0] !== 'Bearer') return null; + return parts[1]; +} + export async function createSession(userId: string) { const db = createDbClient(); const token = randomBytes(32).toString('base64url'); @@ -83,8 +96,12 @@ export async function deleteSession(token: string | null) { await db.delete(sessions).where(eq(sessions.tokenHash, hashToken(token))); } -export async function getCurrentUser(cookieHeader: string | null | undefined): Promise { - const token = readSessionToken(cookieHeader); +export async function getCurrentUser( + cookieHeader: string | null | undefined, + headers?: { get(name: string): string | null }, +): Promise { + // Try cookie first, then Authorization header (for Android WebView where 3rd-party cookies are blocked) + const token = readSessionToken(cookieHeader) ?? readBearerToken(headers); if (!token) return null; const db = createDbClient(); diff --git a/apps/api/src/routes/auth.ts b/apps/api/src/routes/auth.ts index cb60458..a5e9b0f 100644 --- a/apps/api/src/routes/auth.ts +++ b/apps/api/src/routes/auth.ts @@ -32,7 +32,7 @@ function validatePassword(password: unknown) { export const authRoutes = new Elysia({ prefix: '/api/v1/auth' }) .get('/me', async ({ request }) => { - const user = await getCurrentUser(request.headers.get('cookie')); + const user = await getCurrentUser(request.headers.get('cookie'), request.headers); return { user, features: getAuthFeatures(), @@ -73,6 +73,7 @@ export const authRoutes = new Elysia({ prefix: '/api/v1/auth' }) name: user.name, role: 'user' as const, }, + token, features: getAuthFeatures(), }; } catch (error) { @@ -109,6 +110,7 @@ export const authRoutes = new Elysia({ prefix: '/api/v1/auth' }) name: user.name, role: user.role === 'expert' ? 'expert' as const : 'user' as const, }, + token, features: getAuthFeatures(), }; } catch (error) { diff --git a/apps/web/src/lib/api-client.ts b/apps/web/src/lib/api-client.ts index 4c5d0b9..ab04878 100644 --- a/apps/web/src/lib/api-client.ts +++ b/apps/web/src/lib/api-client.ts @@ -16,6 +16,28 @@ import { recordApiCall } from './telemetry'; const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? 'https://zeavisedu.asepharyana.my.id'; +const AUTH_TOKEN_KEY = 'zeavis_auth_token'; + +function getAuthToken(): string | null { + try { + return localStorage.getItem(AUTH_TOKEN_KEY); + } catch { + return null; + } +} + +export function setAuthToken(token: string | null) { + try { + if (token) { + localStorage.setItem(AUTH_TOKEN_KEY, token); + } else { + localStorage.removeItem(AUTH_TOKEN_KEY); + } + } catch { + // localStorage may throw in private browsing + } +} + export interface ApiError extends Error { status: number; source?: 'uploader' | 'model-service' | 'unknown'; @@ -24,10 +46,21 @@ export interface ApiError extends Error { async function fetchApi(endpoint: string, options?: RequestInit): Promise { const start = performance.now(); const url = `${apiBaseUrl}${endpoint}`; + const token = getAuthToken(); + const headers = new Headers(options?.headers); + + if (token) { + headers.set('Authorization', `Bearer ${token}`); + } + + if (options?.body && !options.method) { + // auto-set Content-Type for JSON bodies + } + const response = await fetch(url, { credentials: 'include', ...options, - headers: options?.headers, + headers, }); const duration = performance.now() - start; @@ -93,23 +126,29 @@ export const apiClient = { }, async register(payload: RegisterRequest): Promise { - return fetchApi('/api/v1/auth/register', { + const result = await fetchApi('/api/v1/auth/register', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); + if (result.token) setAuthToken(result.token); + return result; }, async login(payload: AuthRequest): Promise { - return fetchApi('/api/v1/auth/login', { + const result = await fetchApi('/api/v1/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); + if (result.token) setAuthToken(result.token); + return result; }, async logout(): Promise<{ ok: boolean }> { - return fetchApi('/api/v1/auth/logout', { method: 'POST' }); + const result = await fetchApi<{ ok: boolean }>('/api/v1/auth/logout', { method: 'POST' }); + setAuthToken(null); + return result; }, // Disease catalog methods diff --git a/packages/shared/src/classifications.ts b/packages/shared/src/classifications.ts index d265aaa..29ab9fb 100644 --- a/packages/shared/src/classifications.ts +++ b/packages/shared/src/classifications.ts @@ -66,6 +66,7 @@ export type RegisterRequest = AuthRequest & { export type AuthResponse = { user: AuthUser; features: AuthFeatures; + token?: string; }; export type DiagnosisPrediction = {