diff --git a/frontend/package-lock.json b/frontend/package-lock.json index d8558e7..1feff06 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -11,7 +11,7 @@ "@hookform/resolvers": "^5.2.2", "@tailwindcss/vite": "^4.2.4", "@tanstack/react-query": "^5.99.2", - "axios": "^1.15.2", + "axios": "^1.16.1", "dotenv": "^17.4.2", "fuse.js": "^7.3.0", "react": "^19.2.5", @@ -1634,6 +1634,18 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, "node_modules/ajv": { "version": "6.15.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", @@ -1845,13 +1857,14 @@ } }, "node_modules/axios": { - "version": "1.15.2", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.2.tgz", - "integrity": "sha512-wLrXxPtcrPTsNlJmKjkPnNPK2Ihe0hn0wGSaTEiHRPxwjvJwT3hKmXF4dpqxmPO9SoNb2FsYXj/xEo0gHN+D5A==", + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.1.tgz", + "integrity": "sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==", "license": "MIT", "dependencies": { - "follow-redirects": "^1.15.11", + "follow-redirects": "^1.16.0", "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", "proxy-from-env": "^2.1.0" } }, @@ -2166,7 +2179,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -3181,6 +3193,19 @@ "hermes-estree": "0.25.1" } }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -4114,7 +4139,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/nanoid": { diff --git a/frontend/package.json b/frontend/package.json index c944088..b566bfc 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,7 +13,7 @@ "@hookform/resolvers": "^5.2.2", "@tailwindcss/vite": "^4.2.4", "@tanstack/react-query": "^5.99.2", - "axios": "^1.15.2", + "axios": "^1.16.1", "dotenv": "^17.4.2", "fuse.js": "^7.3.0", "react": "^19.2.5", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 7059b05..95f73b7 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -69,7 +69,7 @@ function App() { if (!token) return; // error case already reflected in the initial page state localStorage.setItem("token", token); - fetchCurrentUser(token) + fetchCurrentUser() .then((user) => { const hasProfile = Boolean(user.profile?.bio || user.profile?.github); setAuth({ token, user, isNewUser: false }); @@ -82,6 +82,22 @@ function App() { .finally(() => setHydrating(false)); }, []); + // Listen for global 401 errors from apiClient to reset state cleanly + useEffect(() => { + const handleUnauthorized = () => { + localStorage.removeItem("token"); + localStorage.removeItem("org_token"); + setAuth(null); + setActiveInterviewId(null); + setPage("login"); + }; + + window.addEventListener("auth:unauthorized", handleUnauthorized); + return () => { + window.removeEventListener("auth:unauthorized", handleUnauthorized); + }; + }, []); + const handleUserLoginSuccess = (data: TokenResponse) => { const hasProfile = Boolean( data.user.profile?.bio || data.user.profile?.github, @@ -155,7 +171,6 @@ function App() { return ( @@ -166,7 +181,6 @@ function App() { return ( @@ -177,7 +191,6 @@ function App() { return ( ); diff --git a/frontend/src/features/interview/InterviewSessionPage.tsx b/frontend/src/features/interview/InterviewSessionPage.tsx index 5922a50..7ef1c00 100644 --- a/frontend/src/features/interview/InterviewSessionPage.tsx +++ b/frontend/src/features/interview/InterviewSessionPage.tsx @@ -10,13 +10,11 @@ import type { export interface InterviewSessionPageProps { interviewId: number; - token: string; onExit: () => void; } const InterviewSessionPage: React.FC = ({ interviewId, - token, onExit, }) => { const { @@ -28,7 +26,7 @@ const InterviewSessionPage: React.FC = ({ followUpIndex, answer, applyState, - } = useInterviewSession(interviewId, token); + } = useInterviewSession(interviewId); return (
= ({ error, onAnswer: answer, applyState, - token, followUpIndex, })} @@ -91,7 +88,6 @@ interface RenderArgs { error: string | null; onAnswer: (text: string) => Promise; applyState: (next: InterviewStateResponse) => void; - token: string; followUpIndex: number; } @@ -102,7 +98,6 @@ function renderRound({ error, onAnswer, applyState, - token, followUpIndex, }: RenderArgs) { if (question.type === "custom") { @@ -135,7 +130,6 @@ function renderRound({ diff --git a/frontend/src/features/interview/components/DsaPanel.tsx b/frontend/src/features/interview/components/DsaPanel.tsx index ca0941a..322ccd3 100644 --- a/frontend/src/features/interview/components/DsaPanel.tsx +++ b/frontend/src/features/interview/components/DsaPanel.tsx @@ -16,7 +16,6 @@ import type { interface Props { sessionId: number; - token: string; question: Extract; onAdvance: (next: InterviewStateResponse) => void; } @@ -89,12 +88,7 @@ type ConsoleState = } | { kind: "error"; message: string }; -export default function DsaPanel({ - sessionId, - token, - question, - onAdvance, -}: Props) { +export default function DsaPanel({ sessionId, question, onAdvance }: Props) { const [language, setLanguage] = useState("python"); const [source, setSource] = useState( LANGUAGES.find((l) => l.value === "python")?.starter ?? "", @@ -125,11 +119,11 @@ export default function DsaPanel({ const handleRun = async () => { setConsoleState({ kind: "running", label: "Running…" }); try { - const res = await dsaRun( - sessionId, - { source_code: source, language, stdin }, - token, - ); + const res = await dsaRun(sessionId, { + source_code: source, + language, + stdin, + }); setConsoleState({ kind: "run-result", result: res }); } catch (e) { setConsoleState({ @@ -143,11 +137,7 @@ export default function DsaPanel({ const handleTest = async () => { setConsoleState({ kind: "running", label: "Running hidden test cases…" }); try { - const res = await dsaTest( - sessionId, - { source_code: source, language }, - token, - ); + const res = await dsaTest(sessionId, { source_code: source, language }); setConsoleState({ kind: "test-result", result: res }); } catch (e) { setConsoleState({ @@ -162,11 +152,7 @@ export default function DsaPanel({ setShowConfirm(false); setConsoleState({ kind: "running", label: "Grading your submission…" }); try { - const res = await dsaSubmit( - sessionId, - { source_code: source, language }, - token, - ); + const res = await dsaSubmit(sessionId, { source_code: source, language }); setConsoleState({ kind: "submit-result", results: res.case_results, diff --git a/frontend/src/features/interview/hooks/useInterviewSession.ts b/frontend/src/features/interview/hooks/useInterviewSession.ts index 620d1cb..920c5f8 100644 --- a/frontend/src/features/interview/hooks/useInterviewSession.ts +++ b/frontend/src/features/interview/hooks/useInterviewSession.ts @@ -32,7 +32,6 @@ const HEARTBEAT_MS = 5000; export function useInterviewSession( interviewId: number, - token: string, ): UseInterviewSessionReturn { const [phase, setPhase] = useState("loading"); const [state, setState] = useState(null); @@ -59,7 +58,7 @@ export function useInterviewSession( heartbeatRef.current = setInterval(async () => { if (stoppedRef.current) return; try { - const res = await sendHeartbeat(sessionId, token); + const res = await sendHeartbeat(sessionId); if (res.status !== "ongoing") { stoppedRef.current = true; stopHeartbeat(); @@ -71,7 +70,7 @@ export function useInterviewSession( } }, HEARTBEAT_MS); }, - [token, stopHeartbeat], + [stopHeartbeat], ); const applyState = useCallback( @@ -109,7 +108,7 @@ export function useInterviewSession( (async () => { try { - const initial = await startInterview(interviewId, token); + const initial = await startInterview(interviewId); if (cancelled) return; setState(initial); if (initial.completed) { @@ -134,7 +133,7 @@ export function useInterviewSession( stoppedRef.current = true; stopHeartbeat(); }; - }, [interviewId, token, startHeartbeat, stopHeartbeat]); + }, [interviewId, startHeartbeat, stopHeartbeat]); const answer = useCallback( async (text: string) => { @@ -142,7 +141,7 @@ export function useInterviewSession( setIsSubmitting(true); setError(null); try { - const next = await submitAnswer(state.session_id, text, token); + const next = await submitAnswer(state.session_id, text); applyState(next); } catch (e) { if (e instanceof InterviewServiceError) { @@ -161,7 +160,7 @@ export function useInterviewSession( setIsSubmitting(false); } }, - [state, isSubmitting, token, applyState, stopHeartbeat], + [state, isSubmitting, applyState, stopHeartbeat], ); return { diff --git a/frontend/src/features/user/DashboardPage.tsx b/frontend/src/features/user/DashboardPage.tsx index eef129b..e1a5c90 100644 --- a/frontend/src/features/user/DashboardPage.tsx +++ b/frontend/src/features/user/DashboardPage.tsx @@ -8,7 +8,6 @@ import type { export interface DashboardPageProps { user: UserResponse; - token: string; onLogout: () => void; onAttemptInterview?: (interviewId: number) => void; } @@ -64,14 +63,13 @@ function timeRemaining(deadline: string) { const DashboardPage: React.FC = ({ user, - token, onLogout, onAttemptInterview, }) => { const [tab, setTab] = useState("available"); const [query, setQuery] = useState(""); const [selectedId, setSelectedId] = useState(null); - const { available, applied, isLoading, error, refetch } = useDashboard(token); + const { available, applied, isLoading, error, refetch } = useDashboard(); const displayName = user.username; const initials = displayName.slice(0, 2).toUpperCase(); diff --git a/frontend/src/features/user/ProfileSetupPage.tsx b/frontend/src/features/user/ProfileSetupPage.tsx index e2494e3..0eff5e3 100644 --- a/frontend/src/features/user/ProfileSetupPage.tsx +++ b/frontend/src/features/user/ProfileSetupPage.tsx @@ -5,19 +5,17 @@ import type { UserResponse } from "../../services/user.service"; export interface ProfileSetupPageProps { userId: number; - token: string; username: string; onComplete: (user: UserResponse) => void; } -const ProfileSetupPage: React.FC = ({ +export function ProfileSetupPage({ userId, - token, username, onComplete, -}) => { +}: ProfileSetupPageProps) { const { form, isLoading, error, handleChange, handleSubmit, handleSkip } = - useProfileSetup(userId, token, onComplete); + useProfileSetup(userId, onComplete); return (
= ({
); -}; +} const BgBlobs = () => ( <> diff --git a/frontend/src/features/user/hooks/useDashboard.ts b/frontend/src/features/user/hooks/useDashboard.ts index 13a42a9..c85cfc7 100644 --- a/frontend/src/features/user/hooks/useDashboard.ts +++ b/frontend/src/features/user/hooks/useDashboard.ts @@ -71,14 +71,13 @@ const initialState: DashboardState = { tick: 0, }; -export function useDashboard(token: string): UseDashboardReturn { +export function useDashboard(): UseDashboardReturn { const [state, dispatch] = useReducer(reducer, initialState); useEffect(() => { - if (!token) return; dispatch({ type: "FETCH_START" }); - Promise.all([fetchInterviews(token), fetchAppliedInterviews(token)]) + Promise.all([fetchInterviews(), fetchAppliedInterviews()]) .then(([av, ap]) => { dispatch({ type: "FETCH_SUCCESS", available: av, applied: ap }); }) @@ -89,7 +88,7 @@ export function useDashboard(token: string): UseDashboardReturn { : "Failed to load interviews. Please refresh."; dispatch({ type: "FETCH_ERROR", error: message }); }); - }, [token, state.tick]); + }, [state.tick]); const refetch = useCallback(() => dispatch({ type: "REFETCH" }), []); diff --git a/frontend/src/features/user/hooks/useProfileSetup.ts b/frontend/src/features/user/hooks/useProfileSetup.ts index 367fbdb..9d73c2b 100644 --- a/frontend/src/features/user/hooks/useProfileSetup.ts +++ b/frontend/src/features/user/hooks/useProfileSetup.ts @@ -31,7 +31,6 @@ export interface UseProfileSetupReturn { export function useProfileSetup( userId: number, - token: string, onComplete: (user: UserResponse) => void, ): UseProfileSetupReturn { const [form, setForm] = useState({ @@ -56,18 +55,14 @@ export function useProfileSetup( setIsLoading(true); setError(null); try { - const updated = await updateUserProfile( - userId, - { - profile: { - bio: form.bio || null, - github: form.github || null, - linkedin: form.linkedin || null, - leetcode: form.leetcode || null, - }, + const updated = await updateUserProfile(userId, { + profile: { + bio: form.bio || null, + github: form.github || null, + linkedin: form.linkedin || null, + leetcode: form.leetcode || null, }, - token, - ); + }); onComplete(updated); } catch (err) { if (err instanceof UserServiceError) { @@ -84,7 +79,7 @@ export function useProfileSetup( const handleSkip = () => { // We don't have the latest user object here — pass a signal for the caller // We'll call the backend with empty profile to keep things clean - void updateUserProfile(userId, { profile: {} }, token) + void updateUserProfile(userId, { profile: {} }) .then(onComplete) .catch(() => { /* silent skip */ diff --git a/frontend/src/services/apiClient.ts b/frontend/src/services/apiClient.ts new file mode 100644 index 0000000..fff266f --- /dev/null +++ b/frontend/src/services/apiClient.ts @@ -0,0 +1,45 @@ +import axios from "axios"; + +const BASE_URL = import.meta.env.VITE_API_URL ?? "http://localhost:8000"; + +export const apiClient = axios.create({ + baseURL: BASE_URL, +}); + +// Request Interceptor: Attach token from localStorage +apiClient.interceptors.request.use( + (config) => { + // Check for user token first, then org token + const token = + localStorage.getItem("token") || localStorage.getItem("org_token"); + if (token && config.headers) { + config.headers.Authorization = `Bearer ${token}`; + } + return config; + }, + (error) => { + return Promise.reject(error); + }, +); + +// Response Interceptor: Handle global 401 errors +apiClient.interceptors.response.use( + (response) => response, + (error) => { + if (error.response && error.response.status === 401) { + const url = error.config?.url || ""; + // Do not trigger global redirect for login or signup endpoints + if (url.includes("/login") || url.includes("/signup")) { + return Promise.reject(error); + } + + // Clear tokens + localStorage.removeItem("token"); + localStorage.removeItem("org_token"); + + // Dispatch custom event so App.tsx can cleanly update the page state + window.dispatchEvent(new CustomEvent("auth:unauthorized")); + } + return Promise.reject(error); + }, +); diff --git a/frontend/src/services/auth.service.ts b/frontend/src/services/auth.service.ts index 28873ed..5145b82 100644 --- a/frontend/src/services/auth.service.ts +++ b/frontend/src/services/auth.service.ts @@ -4,6 +4,9 @@ * Mirrors the backend schemas in app/schemas/user.py */ +import { AxiosError } from "axios"; +import { apiClient } from "./apiClient"; + const BASE_URL = import.meta.env.VITE_API_URL ?? "http://localhost:8000"; // ── Request / Response types (mirrors backend schemas) ────────────────────── @@ -47,21 +50,20 @@ export class AuthServiceError extends Error { export async function loginUser( credentials: LoginRequest, ): Promise { - const response = await fetch(`${BASE_URL}/users/login`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(credentials), - }); - - if (!response.ok) { - const data = await response.json().catch(() => ({})); + try { + const response = await apiClient.post( + "/users/login", + credentials, + ); + return response.data; + } catch (error) { + const axiosError = error as AxiosError<{ detail: string }>; throw new AuthServiceError( - response.status, - data?.detail ?? "Login failed. Please check your credentials.", + axiosError.response?.status ?? 500, + axiosError.response?.data?.detail ?? + "Login failed. Please check your credentials.", ); } - - return response.json() as Promise; } // ── Google OIDC ─────────────────────────────────────────────────────────────── @@ -79,18 +81,15 @@ export function startGoogleOAuth(): void { } /** GET /users/me — resolve the authenticated user from a bearer token. */ -export async function fetchCurrentUser(token: string): Promise { - const response = await fetch(`${BASE_URL}/users/me`, { - headers: { Authorization: `Bearer ${token}` }, - }); - - if (!response.ok) { - const data = await response.json().catch(() => ({})); +export async function fetchCurrentUser(): Promise { + try { + const response = await apiClient.get("/users/me"); + return response.data; + } catch (error) { + const axiosError = error as AxiosError<{ detail: string }>; throw new AuthServiceError( - response.status, - data?.detail ?? "Could not load your account.", + axiosError.response?.status ?? 500, + axiosError.response?.data?.detail ?? "Could not load your account.", ); } - - return response.json() as Promise; } diff --git a/frontend/src/services/interview.service.ts b/frontend/src/services/interview.service.ts index c0f2818..3025b43 100644 --- a/frontend/src/services/interview.service.ts +++ b/frontend/src/services/interview.service.ts @@ -1,4 +1,5 @@ -const BASE_URL = import.meta.env.VITE_API_URL ?? "http://localhost:8000"; +import { AxiosError } from "axios"; +import { apiClient } from "./apiClient"; export class InterviewServiceError extends Error { public readonly statusCode: number; @@ -87,94 +88,112 @@ export interface DsaSubmitResponse { next_state: InterviewStateResponse; } -function authHeaders(token: string) { - return { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }; -} - -async function handle(res: Response): Promise { - if (!res.ok) { - const data = await res.json().catch(() => ({})); - throw new InterviewServiceError( - res.status, - data?.detail ?? "Request failed.", - ); - } - return res.json() as Promise; -} - export async function startInterview( interviewId: number, - token: string, ): Promise { - const res = await fetch(`${BASE_URL}/interviews/${interviewId}/start`, { - method: "POST", - headers: authHeaders(token), - }); - return handle(res); + try { + const res = await apiClient.post( + `/interviews/${interviewId}/start`, + ); + return res.data; + } catch (error) { + const axiosError = error as AxiosError<{ detail: string }>; + throw new InterviewServiceError( + axiosError.response?.status ?? 500, + axiosError.response?.data?.detail ?? "Request failed.", + ); + } } export async function sendHeartbeat( sessionId: number, - token: string, ): Promise { - const res = await fetch(`${BASE_URL}/sessions/${sessionId}/heartbeat`, { - method: "POST", - headers: authHeaders(token), - }); - return handle(res); + try { + const res = await apiClient.post( + `/sessions/${sessionId}/heartbeat`, + ); + return res.data; + } catch (error) { + const axiosError = error as AxiosError<{ detail: string }>; + throw new InterviewServiceError( + axiosError.response?.status ?? 500, + axiosError.response?.data?.detail ?? "Request failed.", + ); + } } export async function submitAnswer( sessionId: number, answer: string, - token: string, ): Promise { - const res = await fetch(`${BASE_URL}/sessions/${sessionId}/answer`, { - method: "POST", - headers: authHeaders(token), - body: JSON.stringify({ answer }), - }); - return handle(res); + try { + const res = await apiClient.post( + `/sessions/${sessionId}/answer`, + { answer }, + ); + return res.data; + } catch (error) { + const axiosError = error as AxiosError<{ detail: string }>; + throw new InterviewServiceError( + axiosError.response?.status ?? 500, + axiosError.response?.data?.detail ?? "Request failed.", + ); + } } export async function dsaRun( sessionId: number, payload: DsaRunRequest, - token: string, ): Promise { - const res = await fetch(`${BASE_URL}/sessions/${sessionId}/dsa/run`, { - method: "POST", - headers: authHeaders(token), - body: JSON.stringify(payload), - }); - return handle(res); + try { + const res = await apiClient.post( + `/sessions/${sessionId}/dsa/run`, + payload, + ); + return res.data; + } catch (error) { + const axiosError = error as AxiosError<{ detail: string }>; + throw new InterviewServiceError( + axiosError.response?.status ?? 500, + axiosError.response?.data?.detail ?? "Request failed.", + ); + } } export async function dsaTest( sessionId: number, payload: DsaTestRequest, - token: string, ): Promise { - const res = await fetch(`${BASE_URL}/sessions/${sessionId}/dsa/test`, { - method: "POST", - headers: authHeaders(token), - body: JSON.stringify(payload), - }); - return handle(res); + try { + const res = await apiClient.post( + `/sessions/${sessionId}/dsa/test`, + payload, + ); + return res.data; + } catch (error) { + const axiosError = error as AxiosError<{ detail: string }>; + throw new InterviewServiceError( + axiosError.response?.status ?? 500, + axiosError.response?.data?.detail ?? "Request failed.", + ); + } } export async function dsaSubmit( sessionId: number, payload: DsaSubmitRequest, - token: string, ): Promise { - const res = await fetch(`${BASE_URL}/sessions/${sessionId}/dsa/submit`, { - method: "POST", - headers: authHeaders(token), - body: JSON.stringify(payload), - }); - return handle(res); + try { + const res = await apiClient.post( + `/sessions/${sessionId}/dsa/submit`, + payload, + ); + return res.data; + } catch (error) { + const axiosError = error as AxiosError<{ detail: string }>; + throw new InterviewServiceError( + axiosError.response?.status ?? 500, + axiosError.response?.data?.detail ?? "Request failed.", + ); + } } diff --git a/frontend/src/services/organization.service.ts b/frontend/src/services/organization.service.ts index fb444cd..a202235 100644 --- a/frontend/src/services/organization.service.ts +++ b/frontend/src/services/organization.service.ts @@ -4,7 +4,8 @@ * Mirrors app/schemas/organization.py and app/routers/organization.py */ -const BASE_URL = import.meta.env.VITE_API_URL ?? "http://localhost:8000"; +import { AxiosError } from "axios"; +import { apiClient } from "./apiClient"; // ── Types (mirrors backend schemas) ───────────────────────────────────────── @@ -41,31 +42,25 @@ export class OrgServiceError extends Error { } } -// ── Helpers ────────────────────────────────────────────────────────────────── - -async function handleResponse(response: Response): Promise { - if (!response.ok) { - const data = await response.json().catch(() => ({})); - throw new OrgServiceError( - response.status, - data?.detail ?? "Request failed. Please try again.", - ); - } - return response.json() as Promise; -} - // ── Endpoints ──────────────────────────────────────────────────────────────── /** POST /organizations/signup */ export async function signupOrganization( payload: OrgSignupRequest, ): Promise { - const response = await fetch(`${BASE_URL}/organizations/signup`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), - }); - return handleResponse(response); + try { + const response = await apiClient.post( + "/organizations/signup", + payload, + ); + return response.data; + } catch (error) { + const axiosError = error as AxiosError<{ detail: string }>; + throw new OrgServiceError( + axiosError.response?.status ?? 500, + axiosError.response?.data?.detail ?? "Request failed. Please try again.", + ); + } } /** @@ -76,11 +71,18 @@ export async function loginOrganization(credentials: { username: string; password: string; }): Promise<{ token: string }> { - const response = await fetch(`${BASE_URL}/users/login`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(credentials), - }); - const data = await handleResponse<{ token: string; user: unknown }>(response); - return { token: data.token }; + try { + const response = await apiClient.post<{ token: string; user: unknown }>( + "/users/login", + credentials, + ); + return { token: response.data.token }; + } catch (error) { + const axiosError = error as AxiosError<{ detail: string }>; + throw new OrgServiceError( + axiosError.response?.status ?? 500, + axiosError.response?.data?.detail ?? + "Login failed. Please check your credentials.", + ); + } } diff --git a/frontend/src/services/user.service.ts b/frontend/src/services/user.service.ts index 794ff3b..18429c0 100644 --- a/frontend/src/services/user.service.ts +++ b/frontend/src/services/user.service.ts @@ -4,7 +4,8 @@ * Mirrors backend schemas: app/schemas/user.py + app/schemas/interview.py */ -const BASE_URL = import.meta.env.VITE_API_URL ?? "http://localhost:8000"; +import { AxiosError } from "axios"; +import { apiClient } from "./apiClient"; // ── Helpers ─────────────────────────────────────────────────────────────────── export class UserServiceError extends Error { @@ -16,21 +17,6 @@ export class UserServiceError extends Error { } } -function authHeaders(token: string) { - return { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }; -} - -async function handleResponse(res: Response): Promise { - if (!res.ok) { - const data = await res.json().catch(() => ({})); - throw new UserServiceError(res.status, data?.detail ?? "Request failed."); - } - return res.json() as Promise; -} - // ── Types (mirror backend schemas) ─────────────────────────────────────────── export interface UserProfileUpdate { @@ -83,32 +69,43 @@ export interface AppliedInterview extends InterviewBasic { export async function updateUserProfile( userId: number, data: UserUpdate, - token: string, ): Promise { - const res = await fetch(`${BASE_URL}/users/${userId}`, { - method: "PUT", - headers: authHeaders(token), - body: JSON.stringify(data), - }); - return handleResponse(res); + try { + const res = await apiClient.put(`/users/${userId}`, data); + return res.data; + } catch (error) { + const axiosError = error as AxiosError<{ detail: string }>; + throw new UserServiceError( + axiosError.response?.status ?? 500, + axiosError.response?.data?.detail ?? "Request failed.", + ); + } } /** GET /interviews/ — available interviews for this user */ -export async function fetchInterviews( - token: string, -): Promise { - const res = await fetch(`${BASE_URL}/interviews/`, { - headers: { Authorization: `Bearer ${token}` }, - }); - return handleResponse(res); +export async function fetchInterviews(): Promise { + try { + const res = await apiClient.get("/interviews/"); + return res.data; + } catch (error) { + const axiosError = error as AxiosError<{ detail: string }>; + throw new UserServiceError( + axiosError.response?.status ?? 500, + axiosError.response?.data?.detail ?? "Request failed.", + ); + } } /** GET /interviews/applied — interviews the user has applied to */ -export async function fetchAppliedInterviews( - token: string, -): Promise { - const res = await fetch(`${BASE_URL}/interviews/applied`, { - headers: { Authorization: `Bearer ${token}` }, - }); - return handleResponse(res); +export async function fetchAppliedInterviews(): Promise { + try { + const res = await apiClient.get("/interviews/applied"); + return res.data; + } catch (error) { + const axiosError = error as AxiosError<{ detail: string }>; + throw new UserServiceError( + axiosError.response?.status ?? 500, + axiosError.response?.data?.detail ?? "Request failed.", + ); + } }