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
+94
View File
@@ -0,0 +1,94 @@
import axios from 'axios';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { registerAuthHandlers } from '@/api/client';
import { tokenStorage } from '@/api/tokenStorage';
// Import after env default is fixed by importing the real module tree.
import client from '@/api/client';
describe('api client', () => {
const refresh = vi.fn();
const unauthorized = vi.fn();
beforeEach(() => {
registerAuthHandlers(refresh, unauthorized);
tokenStorage.clear();
refresh.mockReset();
refresh.mockResolvedValue('new-access');
unauthorized.mockReset();
});
afterEach(() => {
vi.restoreAllMocks();
});
function stubErrorResponse(status: number, url: string) {
const error = {
config: { url, headers: new axios.AxiosHeaders(), method: 'get' },
response: { status, data: { message: 'boom', code: 'E_TEST' } },
isAxiosError: true,
message: 'Request failed',
};
return error;
}
it('attaches Bearer token from storage on request', async () => {
tokenStorage.set('tok-1');
const handlers = client.interceptors.request.handlers;
const config = { headers: new axios.AxiosHeaders() };
const result = handlers?.[0]?.fulfilled?.(config) as typeof config;
expect(result.headers.get('Authorization')).toBe('Bearer tok-1');
});
it('refreshes token and retries the original request on 401', async () => {
const adapter = vi
.fn()
.mockImplementationOnce(() => Promise.reject(stubErrorResponse(401, '/courses')))
.mockImplementationOnce(() =>
Promise.resolve({
data: { data: [{ id: 'c1' }] },
status: 200,
statusText: 'OK',
headers: {},
config: {},
}),
);
client.defaults.adapter = adapter;
const result = await client.get('/courses');
expect(refresh).toHaveBeenCalledOnce();
expect(unauthorized).not.toHaveBeenCalled();
expect(adapter).toHaveBeenCalledTimes(2);
expect(result.data.data).toEqual([{ id: 'c1' }]);
});
it('does not retry 401 on auth endpoints (avoids refresh loop)', async () => {
const adapter = vi
.fn()
.mockImplementationOnce(() => Promise.reject(stubErrorResponse(401, '/auth/login')));
client.defaults.adapter = adapter;
await expect(client.post('/auth/login', {})).rejects.toMatchObject({ status: 401 });
expect(refresh).not.toHaveBeenCalled();
expect(adapter).toHaveBeenCalledTimes(1);
});
it('calls unauthorized handler when refresh fails', async () => {
refresh.mockResolvedValue(null);
const adapter = vi
.fn()
.mockImplementationOnce(() => Promise.reject(stubErrorResponse(401, '/courses')))
.mockImplementationOnce(() => Promise.reject(stubErrorResponse(401, '/courses')));
client.defaults.adapter = adapter;
await expect(client.get('/courses')).rejects.toMatchObject({ status: 401 });
expect(refresh).toHaveBeenCalledOnce();
expect(unauthorized).toHaveBeenCalledOnce();
});
});
+98
View File
@@ -0,0 +1,98 @@
import axios, { AxiosError, type AxiosInstance, type InternalAxiosRequestConfig } from 'axios';
import env from '@/shared/config/env';
import type { ApiError, ApiErrorPayload } from '@/types/api';
import { tokenStorage } from '@/api/tokenStorage';
export interface ApiClientConfig {
baseURL: string;
timeout: number;
}
interface RetryableRequestConfig extends InternalAxiosRequestConfig {
_retried?: boolean;
}
/**
* Refresh callback, registered by the auth feature. Returns the new access
* token, or null when the refresh fails (e.g. the refresh token is expired).
*/
export type RefreshHandler = () => Promise<string | null>;
export type UnauthorizedHandler = () => void;
let refreshHandler: RefreshHandler | null = null;
let unauthorizedHandler: UnauthorizedHandler | null = null;
/** Register the auth handlers used by the client (called by the auth feature). */
export function registerAuthHandlers(
refresh: RefreshHandler,
unauthorized: UnauthorizedHandler,
): void {
refreshHandler = refresh;
unauthorizedHandler = unauthorized;
}
let refreshPromise: Promise<string | null> | null = null;
export function createApiClient(config: ApiClientConfig): AxiosInstance {
const client = axios.create({
baseURL: config.baseURL,
timeout: config.timeout,
headers: { 'Content-Type': 'application/json' },
withCredentials: true, // send HttpOnly refresh-token cookie
});
client.interceptors.request.use((requestConfig) => {
const token = tokenStorage.get();
if (token) {
requestConfig.headers.set('Authorization', `Bearer ${token}`);
}
return requestConfig;
});
client.interceptors.response.use(
(response) => response,
async (error: AxiosError<ApiErrorPayload>) => {
const original = error.config as RetryableRequestConfig | undefined;
const isAuthEndpoint = original?.url?.startsWith('/auth');
if (error.response?.status === 401 && original && !original._retried && !isAuthEndpoint) {
original._retried = true;
try {
if (!refreshPromise && refreshHandler) {
refreshPromise = refreshHandler();
}
const newToken = await refreshPromise;
if (newToken) {
original.headers.set('Authorization', `Bearer ${newToken}`);
return client(original);
}
unauthorizedHandler?.();
} catch {
unauthorizedHandler?.();
} finally {
refreshPromise = null;
}
}
return Promise.reject(normalizeError(error));
},
);
return client;
}
function normalizeError(error: AxiosError<ApiErrorPayload>): ApiError {
const payload = error.response?.data;
return {
status: error.response?.status ?? 0,
code: payload?.code,
message: payload?.message ?? error.message ?? 'Unexpected error',
fieldErrors: payload?.fieldErrors,
raw: error,
};
}
const client = createApiClient({ baseURL: env.VITE_API_BASE_URL, timeout: 15_000 });
export default client;
+18
View File
@@ -0,0 +1,18 @@
/**
* In-memory token storage.
*
* Access token is kept in memory only (never persisted to localStorage /
* sessionStorage) to reduce XSS exposure. The refresh token is handled by the
* backend (HttpOnly cookie), so the client never touches it directly.
*/
let accessToken: string | null = null;
export const tokenStorage = {
get: (): string | null => accessToken,
set: (token: string): void => {
accessToken = token;
},
clear: (): void => {
accessToken = null;
},
};
+29
View File
@@ -0,0 +1,29 @@
import { useEffect } from 'react';
import { Navigate, Route, Routes } from 'react-router-dom';
import { AppLayout } from '@/app/layouts/AppLayout';
import { HomePage } from '@/app/pages/HomePage';
import { NotFoundPage } from '@/app/pages/NotFoundPage';
import { ProtectedRoute } from '@/features/auth/components/ProtectedRoute';
import { LoginPage } from '@/features/auth/pages/LoginPage';
import { useAuthStore } from '@/features/auth/store/authStore';
export default function App() {
useEffect(() => {
void useAuthStore.getState().init();
}, []);
return (
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route element={<ProtectedRoute />}>
<Route element={<AppLayout />}>
<Route index element={<HomePage />} />
<Route path="/learn" element={<div>Learn (placeholder)</div>} />
</Route>
</Route>
<Route path="/404" element={<NotFoundPage />} />
<Route path="*" element={<Navigate to="/404" replace />} />
</Routes>
);
}
+46
View File
@@ -0,0 +1,46 @@
import { Link, Outlet } from 'react-router-dom';
import env from '@/shared/config/env';
import { useLogout } from '@/features/auth/hooks/useLogout';
import { useAuthStore } from '@/features/auth/store/authStore';
export function AppLayout() {
const user = useAuthStore((s) => s.user);
const logout = useLogout();
return (
<div className="min-h-screen bg-gray-50">
<header className="border-b border-gray-200 bg-white">
<div className="mx-auto flex h-14 max-w-5xl items-center justify-between px-4">
<div className="flex items-center gap-6">
<Link to="/" className="text-lg font-semibold text-gray-900">
{env.VITE_APP_NAME}
</Link>
<nav className="flex items-center gap-4 text-sm text-gray-600">
<Link to="/" className="hover:text-gray-900">
Home
</Link>
<Link to="/learn" className="hover:text-gray-900">
Learn
</Link>
</nav>
</div>
<div className="flex items-center gap-4">
{user && <span className="text-sm text-gray-500">{user.email}</span>}
<button
type="button"
onClick={() => logout.mutate()}
disabled={logout.isPending}
className="rounded-md border border-gray-300 px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-100 disabled:opacity-50"
>
{logout.isPending ? 'Signing out…' : 'Sign out'}
</button>
</div>
</div>
</header>
<main className="mx-auto max-w-5xl px-4 py-8">
<Outlet />
</main>
</div>
);
}
+26
View File
@@ -0,0 +1,26 @@
import env from '@/shared/config/env';
import { useAuthUser } from '@/features/auth/hooks/useAuthUser';
export function HomePage() {
const { user, isLoading, isError } = useAuthUser();
return (
<div>
<h1 className="text-2xl font-semibold text-gray-900">
Welcome{user ? `, ${user.name}` : ''} 👋
</h1>
<p className="mt-2 text-gray-600">{env.VITE_APP_NAME} your adaptive learning workspace.</p>
<div className="mt-6 rounded-lg border border-gray-200 bg-white p-6 text-sm text-gray-600">
{isLoading && <p>Loading profile</p>}
{isError && <p>Could not load profile details.</p>}
{user && !isLoading && (
<ul className="space-y-1">
<li>ID: {user.id}</li>
<li>Email: {user.email}</li>
<li>Name: {user.name}</li>
</ul>
)}
</div>
</div>
);
}
+13
View File
@@ -0,0 +1,13 @@
import { Link } from 'react-router-dom';
export function NotFoundPage() {
return (
<div className="flex min-h-screen flex-col items-center justify-center bg-gray-50 px-4 text-center">
<h1 className="text-4xl font-semibold text-gray-900">404</h1>
<p className="mt-2 text-gray-600">This page does not exist.</p>
<Link to="/" className="mt-4 text-sm font-medium text-indigo-600 hover:text-indigo-700">
Back home
</Link>
</div>
);
}
+29
View File
@@ -0,0 +1,29 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { useState, type ReactNode } from 'react';
import { ErrorBoundary } from '@/shared/components/ErrorBoundary';
interface QueryProviderProps {
children: ReactNode;
}
export function QueryProvider({ children }: QueryProviderProps) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
retry: 1,
refetchOnWindowFocus: false,
staleTime: 60_000,
},
},
}),
);
return (
<QueryClientProvider client={queryClient}>
<ErrorBoundary>{children}</ErrorBoundary>
</QueryClientProvider>
);
}
+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;
}
+1
View File
@@ -0,0 +1 @@
@import 'tailwindcss';
+17
View File
@@ -0,0 +1,17 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import App from '@/app/App';
import { QueryProvider } from '@/app/providers/QueryProvider';
import './index.css';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<QueryProvider>
<BrowserRouter>
<App />
</BrowserRouter>
</QueryProvider>
</StrictMode>,
);
+8
View File
@@ -0,0 +1,8 @@
export function EmptyState({ title, description }: { title: string; description?: string }) {
return (
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed border-gray-300 bg-white px-6 py-12 text-center">
<p className="text-sm font-medium text-gray-700">{title}</p>
{description && <p className="mt-1 text-sm text-gray-500">{description}</p>}
</div>
);
}
+36
View File
@@ -0,0 +1,36 @@
import { Component, type ErrorInfo, type ReactNode } from 'react';
interface ErrorBoundaryProps {
children: ReactNode;
fallback?: ReactNode;
}
interface ErrorBoundaryState {
hasError: boolean;
}
/** Catches render-time errors in the tree and shows a fallback UI. */
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
state: ErrorBoundaryState = { hasError: false };
static getDerivedStateFromError(): ErrorBoundaryState {
return { hasError: true };
}
componentDidCatch(error: Error, info: ErrorInfo): void {
console.error('Unhandled error in component tree', error, info.componentStack);
}
render(): ReactNode {
if (this.state.hasError) {
return (
this.props.fallback ?? (
<div className="flex min-h-screen items-center justify-center px-4 text-center">
<p className="text-sm text-gray-600">Something went wrong. Please reload the page.</p>
</div>
)
);
}
return this.props.children;
}
}
+15
View File
@@ -0,0 +1,15 @@
import type { ApiError } from '@/types/api';
interface ErrorStateProps {
title?: string;
error?: ApiError | Error | null;
}
export function ErrorState({ title = 'Something went wrong', error }: ErrorStateProps) {
return (
<div className="flex flex-col items-center justify-center rounded-lg border border-red-200 bg-red-50 px-6 py-8 text-center">
<p className="text-sm font-medium text-red-700">{title}</p>
{error && <p className="mt-1 text-sm text-red-600">{error.message}</p>}
</div>
);
}
@@ -0,0 +1,9 @@
import { Spinner } from '@/shared/components/Spinner';
export function FullPageSpinner({ label = 'Loading' }: { label?: string }) {
return (
<div className="flex min-h-screen items-center justify-center">
<Spinner label={label} />
</div>
);
}
+18
View File
@@ -0,0 +1,18 @@
export function Spinner({ label = 'Loading' }: { label?: string }) {
return (
<span role="status" className="inline-flex items-center gap-2 text-sm text-gray-500">
<svg className="h-4 w-4 animate-spin text-indigo-600" viewBox="0 0 24 24" fill="none">
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
</svg>
{label}
</span>
);
}
+13
View File
@@ -0,0 +1,13 @@
interface Env {
/** Base URL of the APLP backend REST API. */
VITE_API_BASE_URL: string;
/** Display name of the application. */
VITE_APP_NAME: string;
}
const env: Env = {
VITE_API_BASE_URL: import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:8080/api',
VITE_APP_NAME: import.meta.env.VITE_APP_NAME ?? 'APLP',
};
export default env;
+1
View File
@@ -0,0 +1 @@
import '@testing-library/jest-dom/vitest';
+30
View File
@@ -0,0 +1,30 @@
export interface ApiErrorPayload {
/** Backend error code (stable identifier for the error). */
code?: string;
/** Human-readable message. */
message?: string;
/** Field-level validation errors, keyed by field name. */
fieldErrors?: Record<string, string[]>;
}
export interface ApiResponse<T> {
data: T;
}
export interface ApiListResponse<T> {
data: T[];
meta?: {
page?: number;
size?: number;
total?: number;
};
}
/** Normalized error thrown by the API layer. */
export interface ApiError {
status: number;
code?: string;
message: string;
fieldErrors?: Record<string, string[]>;
raw: unknown;
}