initial
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
@@ -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'));
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
},
|
||||
);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
@import 'tailwindcss';
|
||||
@@ -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>,
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -0,0 +1 @@
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user