initial
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user