Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions frontend/src/api/axiosInstance.ts
Original file line number Diff line number Diff line change
@@ -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: {
Expand All @@ -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) => {
Expand Down
77 changes: 77 additions & 0 deletions frontend/src/components/LogoutButton.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div>
{error && (
<span style={styles.error} role="alert">
{error}
</span>
)}
<button
onClick={handleLogout}
disabled={isLoading}
style={{
...styles.button,
...(isLoading ? styles.buttonDisabled : {}),
}}
>
{isLoading ? 'Logging out...' : 'Logout'}
</button>
</div>
);
}

const styles: Record<string, React.CSSProperties> = {
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',
},
};
19 changes: 19 additions & 0 deletions frontend/src/contexts/AuthContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<AuthContextValue | null>(null);

export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<AuthUser | null>(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);
Expand All @@ -31,6 +49,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
isAuthenticated: !!user,
setAuth,
clearAuth,
isLoading,
}}
>
{children}
Expand Down
Empty file.
6 changes: 5 additions & 1 deletion frontend/src/routes/ProtectedRoute.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <Navigate to="/login" replace />;
Expand Down
6 changes: 5 additions & 1 deletion frontend/src/routes/PublicRoute.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <Navigate to="/dashboard" replace />;
Expand Down
6 changes: 6 additions & 0 deletions frontend/src/services/authService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,4 +39,10 @@ export const authService = {
async logout(): Promise<void> {
await axiosInstance.post('/auth/logout');
},

async getMe() : Promise<ApiSuccessResponse<AuthUser>> {
const response =
await axiosInstance.get<ApiSuccessResponse<AuthUser>>('/auth/me');
return response.data;
},
};
2 changes: 2 additions & 0 deletions frontend/src/tests/pages/LoginPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
Expand Down
121 changes: 121 additions & 0 deletions frontend/src/tests/pages/LogoutButton.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<MemoryRouter>
<AuthContext.Provider value={mockAuthContextValue}>
<LogoutButton />
</AuthContext.Provider>
</MemoryRouter>
);
}

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();
});
});
});
2 changes: 2 additions & 0 deletions frontend/src/tests/pages/RegisterPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
Expand Down
Loading