Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7693a02c98 | ||
|
|
528a1622d0 | ||
|
|
51a4cb9ed2 |
@@ -28,14 +28,26 @@ function hashToken(token: string) {
|
|||||||
return createHash('sha256').update(`${env.sessionSecret}:${token}`).digest('hex');
|
return createHash('sha256').update(`${env.sessionSecret}:${token}`).digest('hex');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createSessionCookie(token: string) {
|
function isSecureRequest(headers?: { get(name: string): string | null }) {
|
||||||
|
if (env.secureCookies) return true;
|
||||||
|
// Detect HTTPS behind proxy (X-Forwarded-Proto)
|
||||||
|
const proto = headers?.get('x-forwarded-proto');
|
||||||
|
if (proto === 'https') return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSameSite(headers?: { get(name: string): string | null }) {
|
||||||
|
return isSecureRequest(headers) ? 'SameSite=None; Secure' : 'SameSite=Lax';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSessionCookie(token: string, headers?: { get(name: string): string | null }) {
|
||||||
const maxAge = 60 * 60 * 24 * 30;
|
const maxAge = 60 * 60 * 24 * 30;
|
||||||
const sameSite = env.secureCookies ? 'SameSite=None; Secure' : 'SameSite=Lax';
|
const sameSite = buildSameSite(headers);
|
||||||
return `${sessionCookieName}=${token}; HttpOnly; Path=/; ${sameSite}; Max-Age=${maxAge}`;
|
return `${sessionCookieName}=${token}; HttpOnly; Path=/; ${sameSite}; Max-Age=${maxAge}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function clearSessionCookie() {
|
export function clearSessionCookie(headers?: { get(name: string): string | null }) {
|
||||||
const sameSite = env.secureCookies ? 'SameSite=None; Secure' : 'SameSite=Lax';
|
const sameSite = buildSameSite(headers);
|
||||||
return `${sessionCookieName}=; HttpOnly; Path=/; ${sameSite}; Max-Age=0`;
|
return `${sessionCookieName}=; HttpOnly; Path=/; ${sameSite}; Max-Age=0`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,6 +61,19 @@ export function readSessionToken(cookieHeader: string | null | undefined) {
|
|||||||
return decodeURIComponent(sessionCookie.slice(sessionCookieName.length + 1));
|
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) {
|
export async function createSession(userId: string) {
|
||||||
const db = createDbClient();
|
const db = createDbClient();
|
||||||
const token = randomBytes(32).toString('base64url');
|
const token = randomBytes(32).toString('base64url');
|
||||||
@@ -71,8 +96,12 @@ export async function deleteSession(token: string | null) {
|
|||||||
await db.delete(sessions).where(eq(sessions.tokenHash, hashToken(token)));
|
await db.delete(sessions).where(eq(sessions.tokenHash, hashToken(token)));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getCurrentUser(cookieHeader: string | null | undefined): Promise<CurrentUser | null> {
|
export async function getCurrentUser(
|
||||||
const token = readSessionToken(cookieHeader);
|
cookieHeader: string | null | undefined,
|
||||||
|
headers?: { get(name: string): string | null },
|
||||||
|
): Promise<CurrentUser | null> {
|
||||||
|
// 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;
|
if (!token) return null;
|
||||||
|
|
||||||
const db = createDbClient();
|
const db = createDbClient();
|
||||||
|
|||||||
@@ -32,13 +32,13 @@ function validatePassword(password: unknown) {
|
|||||||
|
|
||||||
export const authRoutes = new Elysia({ prefix: '/api/v1/auth' })
|
export const authRoutes = new Elysia({ prefix: '/api/v1/auth' })
|
||||||
.get('/me', async ({ request }) => {
|
.get('/me', async ({ request }) => {
|
||||||
const user = await getCurrentUser(request.headers.get('cookie'));
|
const user = await getCurrentUser(request.headers.get('cookie'), request.headers);
|
||||||
return {
|
return {
|
||||||
user,
|
user,
|
||||||
features: getAuthFeatures(),
|
features: getAuthFeatures(),
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.post('/register', async ({ body, set }) => {
|
.post('/register', async ({ body, set, request }) => {
|
||||||
const req = body as Partial<RegisterRequest> | undefined;
|
const req = body as Partial<RegisterRequest> | undefined;
|
||||||
const email = normalizeEmail(req?.email);
|
const email = normalizeEmail(req?.email);
|
||||||
const name = normalizeName(req?.name);
|
const name = normalizeName(req?.name);
|
||||||
@@ -62,7 +62,7 @@ export const authRoutes = new Elysia({ prefix: '/api/v1/auth' })
|
|||||||
|
|
||||||
const user = inserted[0];
|
const user = inserted[0];
|
||||||
const token = await createSession(user.id);
|
const token = await createSession(user.id);
|
||||||
set.headers['Set-Cookie'] = createSessionCookie(token);
|
set.headers['Set-Cookie'] = createSessionCookie(token, request.headers);
|
||||||
|
|
||||||
authCounter.labels('register', 'true').inc();
|
authCounter.labels('register', 'true').inc();
|
||||||
|
|
||||||
@@ -73,13 +73,14 @@ export const authRoutes = new Elysia({ prefix: '/api/v1/auth' })
|
|||||||
name: user.name,
|
name: user.name,
|
||||||
role: 'user' as const,
|
role: 'user' as const,
|
||||||
},
|
},
|
||||||
|
token,
|
||||||
features: getAuthFeatures(),
|
features: getAuthFeatures(),
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return serviceUnavailable('Database unavailable');
|
return serviceUnavailable('Database unavailable');
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.post('/login', async ({ body, set }) => {
|
.post('/login', async ({ body, set, request }) => {
|
||||||
const req = body as Partial<AuthRequest> | undefined;
|
const req = body as Partial<AuthRequest> | undefined;
|
||||||
const email = normalizeEmail(req?.email);
|
const email = normalizeEmail(req?.email);
|
||||||
|
|
||||||
@@ -98,7 +99,7 @@ export const authRoutes = new Elysia({ prefix: '/api/v1/auth' })
|
|||||||
}
|
}
|
||||||
|
|
||||||
const token = await createSession(user.id);
|
const token = await createSession(user.id);
|
||||||
set.headers['Set-Cookie'] = createSessionCookie(token);
|
set.headers['Set-Cookie'] = createSessionCookie(token, request.headers);
|
||||||
|
|
||||||
authCounter.labels('login', 'true').inc();
|
authCounter.labels('login', 'true').inc();
|
||||||
|
|
||||||
@@ -109,6 +110,7 @@ export const authRoutes = new Elysia({ prefix: '/api/v1/auth' })
|
|||||||
name: user.name,
|
name: user.name,
|
||||||
role: user.role === 'expert' ? 'expert' as const : 'user' as const,
|
role: user.role === 'expert' ? 'expert' as const : 'user' as const,
|
||||||
},
|
},
|
||||||
|
token,
|
||||||
features: getAuthFeatures(),
|
features: getAuthFeatures(),
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -116,8 +118,9 @@ export const authRoutes = new Elysia({ prefix: '/api/v1/auth' })
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.post('/logout', async ({ request, set }) => {
|
.post('/logout', async ({ request, set }) => {
|
||||||
await deleteSession(readSessionToken(request.headers.get('cookie')));
|
const cookieHeader = request.headers.get('cookie');
|
||||||
set.headers['Set-Cookie'] = clearSessionCookie();
|
await deleteSession(readSessionToken(cookieHeader));
|
||||||
|
set.headers['Set-Cookie'] = clearSessionCookie(request.headers);
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
})
|
})
|
||||||
.get('/google', ({ set }) => {
|
.get('/google', ({ set }) => {
|
||||||
|
|||||||
@@ -10,27 +10,32 @@ type AuthGuardProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function AuthGuard({ children, requireExpert = false }: AuthGuardProps) {
|
export function AuthGuard({ children, requireExpert = false }: AuthGuardProps) {
|
||||||
|
const user = useAuthStore((state) => state.user);
|
||||||
const setUser = useAuthStore((state) => state.setUser);
|
const setUser = useAuthStore((state) => state.setUser);
|
||||||
const query = useQuery({
|
const query = useQuery({
|
||||||
queryKey: ['auth', 'me'],
|
queryKey: ['auth', 'me'],
|
||||||
queryFn: () => apiClient.getMe(),
|
queryFn: () => apiClient.getMe(),
|
||||||
|
staleTime: 30_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (query.data) {
|
if (query.data?.user) {
|
||||||
setUser(query.data.user);
|
setUser(query.data.user);
|
||||||
}
|
}
|
||||||
}, [query.data, setUser]);
|
}, [query.data, setUser]);
|
||||||
|
|
||||||
if (query.isLoading) {
|
// Tunjukkan loading hanya jika belum ada user di store
|
||||||
|
if (query.isLoading && !user) {
|
||||||
return <main className="min-h-screen p-8 text-center text-muted-foreground">Memeriksa sesi...</main>;
|
return <main className="min-h-screen p-8 text-center text-muted-foreground">Memeriksa sesi...</main>;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!query.data?.user) {
|
// Cek store dulu, baru query — mencegah redirect saat refetch background
|
||||||
|
const currentUser = query.data?.user ?? user;
|
||||||
|
if (!currentUser) {
|
||||||
return <Navigate to="/login" replace />;
|
return <Navigate to="/login" replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (requireExpert && query.data.user.role !== 'expert') {
|
if (requireExpert && currentUser.role !== 'expert') {
|
||||||
return <Navigate to="/dashboard" replace />;
|
return <Navigate to="/dashboard" replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,10 +9,11 @@ export function AuthInitializer() {
|
|||||||
queryKey: ['auth', 'me'],
|
queryKey: ['auth', 'me'],
|
||||||
queryFn: () => apiClient.getMe(),
|
queryFn: () => apiClient.getMe(),
|
||||||
retry: false,
|
retry: false,
|
||||||
|
staleTime: 30_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (query.data) {
|
if (query.data?.user) {
|
||||||
setUser(query.data.user);
|
setUser(query.data.user);
|
||||||
}
|
}
|
||||||
}, [query.data, setUser]);
|
}, [query.data, setUser]);
|
||||||
|
|||||||
@@ -16,6 +16,28 @@ import { recordApiCall } from './telemetry';
|
|||||||
|
|
||||||
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? 'https://zeavisedu.asepharyana.my.id';
|
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 {
|
export interface ApiError extends Error {
|
||||||
status: number;
|
status: number;
|
||||||
source?: 'uploader' | 'model-service' | 'unknown';
|
source?: 'uploader' | 'model-service' | 'unknown';
|
||||||
@@ -24,10 +46,21 @@ export interface ApiError extends Error {
|
|||||||
async function fetchApi<T>(endpoint: string, options?: RequestInit): Promise<T> {
|
async function fetchApi<T>(endpoint: string, options?: RequestInit): Promise<T> {
|
||||||
const start = performance.now();
|
const start = performance.now();
|
||||||
const url = `${apiBaseUrl}${endpoint}`;
|
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, {
|
const response = await fetch(url, {
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
...options,
|
...options,
|
||||||
headers: options?.headers,
|
headers,
|
||||||
});
|
});
|
||||||
|
|
||||||
const duration = performance.now() - start;
|
const duration = performance.now() - start;
|
||||||
@@ -93,23 +126,29 @@ export const apiClient = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async register(payload: RegisterRequest): Promise<AuthResponse> {
|
async register(payload: RegisterRequest): Promise<AuthResponse> {
|
||||||
return fetchApi('/api/v1/auth/register', {
|
const result = await fetchApi<AuthResponse>('/api/v1/auth/register', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
});
|
});
|
||||||
|
if (result.token) setAuthToken(result.token);
|
||||||
|
return result;
|
||||||
},
|
},
|
||||||
|
|
||||||
async login(payload: AuthRequest): Promise<AuthResponse> {
|
async login(payload: AuthRequest): Promise<AuthResponse> {
|
||||||
return fetchApi('/api/v1/auth/login', {
|
const result = await fetchApi<AuthResponse>('/api/v1/auth/login', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
});
|
});
|
||||||
|
if (result.token) setAuthToken(result.token);
|
||||||
|
return result;
|
||||||
},
|
},
|
||||||
|
|
||||||
async logout(): Promise<{ ok: boolean }> {
|
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
|
// Disease catalog methods
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ export type RegisterRequest = AuthRequest & {
|
|||||||
export type AuthResponse = {
|
export type AuthResponse = {
|
||||||
user: AuthUser;
|
user: AuthUser;
|
||||||
features: AuthFeatures;
|
features: AuthFeatures;
|
||||||
|
token?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type DiagnosisPrediction = {
|
export type DiagnosisPrediction = {
|
||||||
|
|||||||
Reference in New Issue
Block a user