This commit is contained in:
2026-08-11 21:09:32 +07:00
parent e63c8f61aa
commit 94c0c47fd0
44 changed files with 5215 additions and 71 deletions
+40
View File
@@ -0,0 +1,40 @@
import client from '@/api/client';
import type { ApiListResponse, ApiResponse } from '@/types/api';
import type { AuthUser, LoginCredentials, LoginResponse, RefreshResponse } from '../types';
/**
* Endpoint constants live here (not scattered in components).
* Contract per backend `aplp.backend.spring` (candidate, confirm at Phase 0).
*/
const ENDPOINTS = {
login: '/auth/login',
logout: '/auth/logout',
refresh: '/auth/refresh',
me: '/auth/me',
} as const;
export const authApi = {
async login(credentials: LoginCredentials): Promise<LoginResponse> {
const { data } = await client.post<ApiResponse<LoginResponse>>(ENDPOINTS.login, credentials);
return data.data;
},
async logout(): Promise<void> {
await client.post(ENDPOINTS.logout);
},
async refresh(): Promise<RefreshResponse> {
const { data } = await client.post<ApiResponse<RefreshResponse>>(ENDPOINTS.refresh);
return data.data;
},
async getMe(): Promise<AuthUser> {
const { data } = await client.get<ApiResponse<AuthUser>>(ENDPOINTS.me);
return data.data;
},
async listUsers(): Promise<ApiListResponse<AuthUser>> {
const { data } = await client.get<ApiListResponse<AuthUser>>('/users');
return data;
},
};
@@ -0,0 +1,48 @@
import { render, screen } from '@testing-library/react';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import { describe, expect, it } from 'vitest';
import { ProtectedRoute } from '@/features/auth/components/ProtectedRoute';
import { useAuthStore } from '@/features/auth/store/authStore';
function renderProtected() {
return render(
<MemoryRouter initialEntries={['/secret']}>
<Routes>
<Route element={<ProtectedRoute />}>
<Route path="/secret" element={<div>secret content</div>} />
</Route>
<Route path="/login" element={<div>login page</div>} />
</Routes>
</MemoryRouter>,
);
}
describe('ProtectedRoute', () => {
it('shows spinner while session is initializing', () => {
useAuthStore.setState({ user: null, isInitializing: true });
renderProtected();
expect(screen.getByRole('status')).toBeInTheDocument();
expect(screen.queryByText('secret content')).not.toBeInTheDocument();
});
it('redirects unauthenticated users to /login', () => {
useAuthStore.setState({ user: null, isInitializing: false });
renderProtected();
expect(screen.getByText('login page')).toBeInTheDocument();
expect(screen.queryByText('secret content')).not.toBeInTheDocument();
});
it('renders protected content for authenticated users', () => {
useAuthStore.setState({
user: { id: 'u1', email: 'learner@aplp.io', name: 'Learner' },
isInitializing: false,
});
renderProtected();
expect(screen.getByText('secret content')).toBeInTheDocument();
expect(screen.queryByText('login page')).not.toBeInTheDocument();
});
});
@@ -0,0 +1,26 @@
import { Navigate, Outlet, useLocation } from 'react-router-dom';
import { FullPageSpinner } from '@/shared/components/FullPageSpinner';
import { useAuthStore } from '../store/authStore';
/**
* Route guard: only renders the protected subtree when the user is
* authenticated. While the session is bootstrapping, shows a full-page spinner.
* Unauthenticated users are redirected to `/login` (remembering where they
* came from).
*/
export function ProtectedRoute() {
const user = useAuthStore((s) => s.user);
const isInitializing = useAuthStore((s) => s.isInitializing);
const location = useLocation();
if (isInitializing) {
return <FullPageSpinner />;
}
if (!user) {
return <Navigate to="/login" replace state={{ from: location.pathname }} />;
}
return <Outlet />;
}
+23
View File
@@ -0,0 +1,23 @@
import { useQuery } from '@tanstack/react-query';
import { useAuthStore } from '../store/authStore';
import { authApi } from '../api/authApi';
/** Current authenticated user (server state via React Query). */
export function useAuthUser() {
const user = useAuthStore((s) => s.user);
const isAuthenticated = user !== null;
const query = useQuery({
queryKey: ['auth', 'me'],
queryFn: authApi.getMe,
enabled: isAuthenticated,
staleTime: 5 * 60 * 1000,
});
return {
user: query.data ?? user,
isLoading: query.isLoading,
isError: query.isError,
};
}
+12
View File
@@ -0,0 +1,12 @@
import { useMutation } from '@tanstack/react-query';
import { useAuthStore } from '../store/authStore';
import type { LoginCredentials } from '../types';
export function useLogin() {
const login = useAuthStore((s) => s.login);
return useMutation({
mutationFn: (credentials: LoginCredentials) => login(credentials.email, credentials.password),
});
}
+9
View File
@@ -0,0 +1,9 @@
import { useMutation } from '@tanstack/react-query';
import { useAuthStore } from '../store/authStore';
export function useLogout() {
const logout = useAuthStore((s) => s.logout);
return useMutation({ mutationFn: () => logout() });
}
@@ -0,0 +1,85 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { authApi } from '@/features/auth/api/authApi';
import { LoginPage } from '@/features/auth/pages/LoginPage';
import { useAuthStore } from '@/features/auth/store/authStore';
vi.mock('@/features/auth/api/authApi', () => ({
authApi: {
login: vi.fn(),
logout: vi.fn(),
refresh: vi.fn(),
getMe: vi.fn(),
listUsers: vi.fn(),
},
}));
const mockedAuthApi = vi.mocked(authApi);
function renderLogin() {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return render(
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={['/login']}>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/" element={<div>home page</div>} />
</Routes>
</MemoryRouter>
</QueryClientProvider>,
);
}
describe('LoginPage', () => {
beforeEach(() => {
useAuthStore.setState({ user: null, isInitializing: false });
vi.clearAllMocks();
mockedAuthApi.login.mockResolvedValue({
accessToken: 'access-123',
user: { id: 'u1', email: 'learner@aplp.io', name: 'Learner' },
});
});
it('renders the login form', () => {
renderLogin();
expect(screen.getByTestId('login-form')).toBeInTheDocument();
expect(screen.getByLabelText('Email')).toBeInTheDocument();
expect(screen.getByLabelText('Password')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /sign in/i })).toBeInTheDocument();
});
it('submits credentials and navigates to home on success', async () => {
const user = userEvent.setup();
renderLogin();
await user.type(screen.getByLabelText('Email'), 'learner@aplp.io');
await user.type(screen.getByLabelText('Password'), 'secret');
await user.click(screen.getByRole('button', { name: /sign in/i }));
expect(mockedAuthApi.login).toHaveBeenCalledWith({
email: 'learner@aplp.io',
password: 'secret',
});
await waitFor(() => expect(screen.getByText('home page')).toBeInTheDocument());
});
it('shows an error message on failed login', async () => {
mockedAuthApi.login.mockRejectedValue(new Error('Invalid credentials'));
const user = userEvent.setup();
renderLogin();
await user.type(screen.getByLabelText('Email'), 'learner@aplp.io');
await user.type(screen.getByLabelText('Password'), 'wrong');
await user.click(screen.getByRole('button', { name: /sign in/i }));
await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('Invalid credentials'));
});
});
+80
View File
@@ -0,0 +1,80 @@
import { useState, type FormEvent } from 'react';
import { Navigate, useLocation } from 'react-router-dom';
import env from '@/shared/config/env';
import { useLogin } from '../hooks/useLogin';
import { useAuthStore } from '../store/authStore';
export function LoginPage() {
const user = useAuthStore((s) => s.user);
const { state } = useLocation();
const login = useLogin();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const from = (state as { from?: string } | null)?.from ?? '/';
if (user) {
return <Navigate to={from} replace />;
}
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
login.mutate({ email, password });
}
return (
<div className="flex min-h-screen items-center justify-center bg-gray-50 px-4">
<div className="w-full max-w-sm rounded-lg border border-gray-200 bg-white p-8 shadow-sm">
<h1 className="mb-1 text-2xl font-semibold text-gray-900">{env.VITE_APP_NAME}</h1>
<p className="mb-6 text-sm text-gray-500">Sign in to your learning account</p>
<form onSubmit={handleSubmit} className="space-y-4" data-testid="login-form">
<div>
<label htmlFor="email" className="mb-1 block text-sm font-medium text-gray-700">
Email
</label>
<input
id="email"
type="email"
autoComplete="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
/>
</div>
<div>
<label htmlFor="password" className="mb-1 block text-sm font-medium text-gray-700">
Password
</label>
<input
id="password"
type="password"
autoComplete="current-password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
/>
</div>
{login.isError && (
<p role="alert" className="text-sm text-red-600">
{login.error instanceof Error ? login.error.message : 'Login failed'}
</p>
)}
<button
type="submit"
disabled={login.isPending}
className="w-full rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 disabled:opacity-50"
>
{login.isPending ? 'Signing in…' : 'Sign in'}
</button>
</form>
</div>
</div>
);
}
+84
View File
@@ -0,0 +1,84 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { tokenStorage } from '@/api/tokenStorage';
import { authApi } from '@/features/auth/api/authApi';
import { useAuthStore } from '@/features/auth/store/authStore';
vi.mock('@/features/auth/api/authApi', () => ({
authApi: {
login: vi.fn(),
logout: vi.fn(),
refresh: vi.fn(),
getMe: vi.fn(),
listUsers: vi.fn(),
},
}));
const mockedAuthApi = vi.mocked(authApi);
describe('authStore', () => {
beforeEach(() => {
tokenStorage.clear();
useAuthStore.setState({ user: null, isInitializing: true });
vi.clearAllMocks();
mockedAuthApi.login.mockResolvedValue({
accessToken: 'access-123',
user: { id: 'u1', email: 'learner@aplp.io', name: 'Learner' },
});
mockedAuthApi.refresh.mockResolvedValue({
accessToken: 'access-refreshed',
user: { id: 'u1', email: 'learner@aplp.io', name: 'Learner' },
});
});
it('login stores token and user', async () => {
await useAuthStore.getState().login('learner@aplp.io', 'secret');
expect(mockedAuthApi.login).toHaveBeenCalledWith({
email: 'learner@aplp.io',
password: 'secret',
});
expect(tokenStorage.get()).toBe('access-123');
expect(useAuthStore.getState().user?.email).toBe('learner@aplp.io');
expect(useAuthStore.getState().isInitializing).toBe(false);
});
it('login failure leaves user signed out', async () => {
mockedAuthApi.login.mockRejectedValue(new Error('invalid credentials'));
await expect(useAuthStore.getState().login('learner@aplp.io', 'wrong')).rejects.toThrow(
'invalid credentials',
);
expect(tokenStorage.get()).toBeNull();
expect(useAuthStore.getState().user).toBeNull();
});
it('logout clears token and user even when API call fails', async () => {
await useAuthStore.getState().login('learner@aplp.io', 'secret');
mockedAuthApi.logout.mockRejectedValue(new Error('network'));
await useAuthStore.getState().logout();
expect(tokenStorage.get()).toBeNull();
expect(useAuthStore.getState().user).toBeNull();
});
it('init restores session from refresh token', async () => {
await useAuthStore.getState().init();
expect(mockedAuthApi.refresh).toHaveBeenCalledOnce();
expect(tokenStorage.get()).toBe('access-refreshed');
expect(useAuthStore.getState().user?.id).toBe('u1');
expect(useAuthStore.getState().isInitializing).toBe(false);
});
it('init handles expired refresh token gracefully', async () => {
mockedAuthApi.refresh.mockRejectedValue(new Error('401'));
await useAuthStore.getState().init();
expect(tokenStorage.get()).toBeNull();
expect(useAuthStore.getState().user).toBeNull();
expect(useAuthStore.getState().isInitializing).toBe(false);
});
});
+70
View File
@@ -0,0 +1,70 @@
import { create } from 'zustand';
import { registerAuthHandlers } from '@/api/client';
import { tokenStorage } from '@/api/tokenStorage';
import { authApi } from '../api/authApi';
import type { AuthUser } from '../types';
interface AuthState {
/** Authenticated user, or null when signed out. */
user: AuthUser | null;
/** Whether a session bootstrap is in progress. */
isInitializing: boolean;
login: (email: string, password: string) => Promise<void>;
logout: () => Promise<void>;
/** Bootstrap session: try refresh on app start. */
init: () => Promise<void>;
}
export const useAuthStore = create<AuthState>()((set) => ({
user: null,
isInitializing: true,
login: async (email, password) => {
const { accessToken, user } = await authApi.login({ email, password });
tokenStorage.set(accessToken);
set({ user, isInitializing: false });
},
logout: async () => {
try {
await authApi.logout();
} catch {
// Best-effort: always clear the local session, even if the API call fails.
} finally {
tokenStorage.clear();
set({ user: null, isInitializing: false });
}
},
init: async () => {
try {
const { accessToken, user } = await authApi.refresh();
tokenStorage.set(accessToken);
set({ user, isInitializing: false });
} catch {
tokenStorage.clear();
set({ user: null, isInitializing: false });
}
},
}));
/**
* Wire the API client to this store: refresh access token and clear session on
* hard 401 (unrecoverable). Keeps the auth concern centralized, not scattered.
*/
registerAuthHandlers(
async () => {
try {
const { accessToken } = await authApi.refresh();
tokenStorage.set(accessToken);
return accessToken;
} catch {
return null;
}
},
() => {
tokenStorage.clear();
useAuthStore.setState({ user: null, isInitializing: false });
},
);
+20
View File
@@ -0,0 +1,20 @@
export interface AuthUser {
id: string;
email: string;
name: string;
}
export interface LoginCredentials {
email: string;
password: string;
}
export interface LoginResponse {
accessToken: string;
user: AuthUser;
}
export interface RefreshResponse {
accessToken: string;
user: AuthUser;
}