From d5190167561b88b7d1e588188dc662b27db4ab20 Mon Sep 17 00:00:00 2001 From: MyPC Date: Tue, 23 Jun 2026 16:11:36 +0700 Subject: [PATCH 1/2] Migrasi autentikasi dari Bearer Token ke Sanctum Cookie-based --- frontend/src/api/axiosInstance.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) 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) => { From aacc1240f2ee66e9b5c8bf196c0e73dc8c1932dc Mon Sep 17 00:00:00 2001 From: MyPC Date: Tue, 23 Jun 2026 19:58:15 +0700 Subject: [PATCH 2/2] QNH-313 FE - Logout Action --- frontend/src/components/LogoutButton.tsx | 77 +++++++++++ frontend/src/contexts/AuthContext.tsx | 19 +++ frontend/src/pages/DashboardPage.tsx | 0 frontend/src/routes/ProtectedRoute.tsx | 6 +- frontend/src/routes/PublicRoute.tsx | 6 +- frontend/src/services/authService.ts | 6 + frontend/src/tests/pages/LoginPage.test.tsx | 2 + .../src/tests/pages/LogoutButton.test.tsx | 121 ++++++++++++++++++ .../src/tests/pages/RegisterPage.test.tsx | 2 + 9 files changed, 237 insertions(+), 2 deletions(-) create mode 100644 frontend/src/components/LogoutButton.tsx create mode 100644 frontend/src/pages/DashboardPage.tsx create mode 100644 frontend/src/tests/pages/LogoutButton.test.tsx 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; };