diff --git a/frontend/src/api/axiosInstance.ts b/frontend/src/api/axiosInstance.ts
index 3587f73..bd70d7b 100644
--- a/frontend/src/api/axiosInstance.ts
+++ b/frontend/src/api/axiosInstance.ts
@@ -1,5 +1,12 @@
import axios, { AxiosError } from 'axios';
+function getCsrfToken(): string | null {
+ const match = document.cookie
+ .split('; ')
+ .find((row) => row.startsWith('XSRF-TOKEN='));
+ return match ? decodeURIComponent(match.split('=')[1]) : null;
+}
+
const axiosInstance = axios.create({
baseURL: import.meta.env.VITE_API_URL,
headers: {
@@ -9,6 +16,14 @@ const axiosInstance = axios.create({
withCredentials: true,
});
+axiosInstance.interceptors.request.use((config) => {
+ const token = getCsrfToken();
+ if (token) {
+ config.headers['X-XSRF-TOKEN'] = token;
+ }
+ return config;
+});
+
axiosInstance.interceptors.response.use(
(response) => response,
(error: AxiosError) => {
diff --git a/frontend/src/components/LogoutButton.tsx b/frontend/src/components/LogoutButton.tsx
new file mode 100644
index 0000000..aadd343
--- /dev/null
+++ b/frontend/src/components/LogoutButton.tsx
@@ -0,0 +1,77 @@
+import { useState } from 'react';
+import { useNavigate } from 'react-router-dom';
+import { AxiosError } from 'axios';
+import { authService } from '../services/authService';
+import { useAuth } from '../hooks/useAuth';
+
+export function LogoutButton() {
+ const { clearAuth } = useAuth();
+ const navigate = useNavigate();
+ const [isLoading, setIsLoading] = useState(false);
+ const [error, setError] = useState('');
+
+ async function handleLogout() {
+ setIsLoading(true);
+ setError('');
+
+ try {
+ await authService.logout();
+ clearAuth();
+ navigate('/login', { replace: true });
+ } catch (err) {
+ const axiosError = err as AxiosError;
+
+ if (axiosError.response?.status === 401) {
+ // Session already expired — clear state and redirect anyway
+ clearAuth();
+ navigate('/login', { replace: true });
+ } else {
+ setError('Logout failed. Please try again.');
+ setIsLoading(false);
+ }
+ }
+ }
+
+ return (
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+ );
+}
+
+const styles: Record = {
+ button: {
+ padding: '8px 16px',
+ backgroundColor: '#ef4444',
+ color: '#ffffff',
+ border: 'none',
+ borderRadius: '6px',
+ fontSize: '14px',
+ fontWeight: 600,
+ cursor: 'pointer',
+ },
+ buttonDisabled: {
+ backgroundColor: '#fca5a5',
+ cursor: 'not-allowed',
+ },
+ error: {
+ fontSize: '12px',
+ color: '#ef4444',
+ display: 'block',
+ marginBottom: '8px',
+ },
+};
\ No newline at end of file
diff --git a/frontend/src/contexts/AuthContext.tsx b/frontend/src/contexts/AuthContext.tsx
index 8c7b23e..87f1793 100644
--- a/frontend/src/contexts/AuthContext.tsx
+++ b/frontend/src/contexts/AuthContext.tsx
@@ -2,19 +2,37 @@ import {
createContext,
useState,
useCallback,
+ useEffect,
type ReactNode,
} from 'react';
import type { AuthUser, AuthState, LoginResponseData } from '../types/auth';
+import { authService } from '../services/authService';
interface AuthContextValue extends AuthState {
setAuth: (data: LoginResponseData) => void;
clearAuth: () => void;
+ isLoading: boolean;
}
export const AuthContext = createContext(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState(null);
+ const [isLoading, setIsLoading] = useState(true);
+
+ useEffect(() => {
+ authService
+ .getMe()
+ .then((response) => {
+ setUser(response.data);
+ })
+ .catch(() => {
+ setUser(null);
+ })
+ .finally(() => {
+ setIsLoading(false);
+ });
+ }, []);
const setAuth = useCallback((data: LoginResponseData) => {
setUser(data.user);
@@ -31,6 +49,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
isAuthenticated: !!user,
setAuth,
clearAuth,
+ isLoading,
}}
>
{children}
diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx
new file mode 100644
index 0000000..e69de29
diff --git a/frontend/src/routes/ProtectedRoute.tsx b/frontend/src/routes/ProtectedRoute.tsx
index a9b21d8..af8e2b3 100644
--- a/frontend/src/routes/ProtectedRoute.tsx
+++ b/frontend/src/routes/ProtectedRoute.tsx
@@ -2,7 +2,11 @@ import { Navigate, Outlet } from 'react-router-dom';
import { useAuth } from '../hooks/useAuth';
export function ProtectedRoute() {
- const { isAuthenticated } = useAuth();
+ const { isAuthenticated, isLoading } = useAuth();
+
+ if (isLoading) {
+ return null;
+ }
if (!isAuthenticated) {
return ;
diff --git a/frontend/src/routes/PublicRoute.tsx b/frontend/src/routes/PublicRoute.tsx
index 3faff51..c14c02f 100644
--- a/frontend/src/routes/PublicRoute.tsx
+++ b/frontend/src/routes/PublicRoute.tsx
@@ -2,7 +2,11 @@ import { Navigate, Outlet } from 'react-router-dom';
import { useAuth } from '../hooks/useAuth';
export function PublicRoute() {
- const { isAuthenticated } = useAuth();
+ const { isAuthenticated, isLoading } = useAuth();
+
+ if (isLoading) {
+ return null;
+ }
if (isAuthenticated) {
return ;
diff --git a/frontend/src/services/authService.ts b/frontend/src/services/authService.ts
index b746785..79b888b 100644
--- a/frontend/src/services/authService.ts
+++ b/frontend/src/services/authService.ts
@@ -39,4 +39,10 @@ export const authService = {
async logout(): Promise {
await axiosInstance.post('/auth/logout');
},
+
+ async getMe() : Promise> {
+ const response =
+ await axiosInstance.get>('/auth/me');
+ return response.data;
+ },
};
\ No newline at end of file
diff --git a/frontend/src/tests/pages/LoginPage.test.tsx b/frontend/src/tests/pages/LoginPage.test.tsx
index b88f88e..af5f32f 100644
--- a/frontend/src/tests/pages/LoginPage.test.tsx
+++ b/frontend/src/tests/pages/LoginPage.test.tsx
@@ -20,9 +20,11 @@ const mockClearAuth = vi.fn<() => void>();
const mockAuthContextValue = {
user: null,
isAuthenticated: false,
+ isLoading: false,
setAuth: mockSetAuth,
clearAuth: mockClearAuth,
} satisfies AuthState & {
+ isLoading: boolean;
setAuth: (data: LoginResponseData) => void;
clearAuth: () => void;
};
diff --git a/frontend/src/tests/pages/LogoutButton.test.tsx b/frontend/src/tests/pages/LogoutButton.test.tsx
new file mode 100644
index 0000000..9511eb4
--- /dev/null
+++ b/frontend/src/tests/pages/LogoutButton.test.tsx
@@ -0,0 +1,121 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { render, screen, fireEvent, waitFor } from '@testing-library/react';
+import { MemoryRouter } from 'react-router-dom';
+import { AuthContext } from '../../contexts/AuthContext';
+import { LogoutButton } from '../../components/LogoutButton';
+import { authService } from '../../services/authService';
+import type { AuthState, LoginResponseData } from '../../types/auth';
+
+vi.mock('../../services/authService');
+
+const mockNavigate = vi.fn();
+vi.mock('react-router-dom', async () => {
+ const actual = await vi.importActual('react-router-dom');
+ return { ...actual, useNavigate: () => mockNavigate };
+});
+
+const mockClearAuth = vi.fn<() => void>();
+
+const mockAuthContextValue = {
+ user: null,
+ isAuthenticated: false,
+ isLoading: false,
+ setAuth: vi.fn<(data: LoginResponseData) => void>(),
+ clearAuth: mockClearAuth,
+} satisfies AuthState & {
+ isLoading: boolean;
+ setAuth: (data: LoginResponseData) => void;
+ clearAuth: () => void;
+};
+
+function renderLogoutButton() {
+ return render(
+
+
+
+
+
+ );
+}
+
+describe('LogoutButton', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('renders logout button', () => {
+ renderLogoutButton();
+ expect(
+ screen.getByRole('button', { name: 'Logout' })
+ ).toBeInTheDocument();
+ });
+
+ it('calls authService.logout, clears auth, and redirects to /login on success', async () => {
+ vi.mocked(authService.logout).mockResolvedValueOnce(undefined);
+
+ renderLogoutButton();
+ fireEvent.click(screen.getByRole('button', { name: 'Logout' }));
+
+ await waitFor(() => {
+ expect(authService.logout).toHaveBeenCalledOnce();
+ expect(mockClearAuth).toHaveBeenCalledOnce();
+ expect(mockNavigate).toHaveBeenCalledWith('/login', { replace: true });
+ });
+ });
+
+ it('clears auth and redirects on 401 — session already expired', async () => {
+ const { AxiosError } = await import('axios');
+ const error = new AxiosError('Unauthorized');
+ error.response = {
+ status: 401,
+ data: { message: 'Unauthenticated.' },
+ } as never;
+
+ vi.mocked(authService.logout).mockRejectedValueOnce(error);
+
+ renderLogoutButton();
+ fireEvent.click(screen.getByRole('button', { name: 'Logout' }));
+
+ await waitFor(() => {
+ expect(mockClearAuth).toHaveBeenCalledOnce();
+ expect(mockNavigate).toHaveBeenCalledWith('/login', { replace: true });
+ });
+ });
+
+ it('shows generic error and does not clear auth on non-401 error', async () => {
+ const { AxiosError } = await import('axios');
+ const error = new AxiosError('Server Error');
+ error.response = {
+ status: 500,
+ data: { message: 'Server Error.' },
+ } as never;
+
+ vi.mocked(authService.logout).mockRejectedValueOnce(error);
+
+ renderLogoutButton();
+ fireEvent.click(screen.getByRole('button', { name: 'Logout' }));
+
+ await waitFor(() => {
+ expect(
+ screen.getByText('Logout failed. Please try again.')
+ ).toBeInTheDocument();
+ expect(mockClearAuth).not.toHaveBeenCalled();
+ expect(mockNavigate).not.toHaveBeenCalled();
+ });
+ });
+
+ it('disables button while loading', async () => {
+ vi.mocked(authService.logout).mockImplementation(
+ () => new Promise(() => {})
+ );
+
+ renderLogoutButton();
+ fireEvent.click(screen.getByRole('button', { name: 'Logout' }));
+
+ await waitFor(() => {
+ expect(
+ screen.getByRole('button', { name: 'Logging out...' })
+ ).toBeDisabled();
+ });
+ });
+});
\ No newline at end of file
diff --git a/frontend/src/tests/pages/RegisterPage.test.tsx b/frontend/src/tests/pages/RegisterPage.test.tsx
index 59b614b..dce6f49 100644
--- a/frontend/src/tests/pages/RegisterPage.test.tsx
+++ b/frontend/src/tests/pages/RegisterPage.test.tsx
@@ -20,9 +20,11 @@ const mockClearAuth = vi.fn<() => void>();
const mockAuthContextValue = {
user: null,
isAuthenticated: false,
+ isLoading: false,
setAuth: mockSetAuth,
clearAuth: mockClearAuth,
} satisfies AuthState & {
+ isLoading: boolean;
setAuth: (data: LoginResponseData) => void;
clearAuth: () => void;
};