diff --git a/.env.example b/.env.example index 0bc1444..e05c62e 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,3 @@ # Copy to `.env` for local development (never commit `.env`). -VITE_API_BASE_URL=http://localhost:8080/api +VITE_API_BASE_URL=http://localhost:8080/api/v1 VITE_APP_NAME=APLP diff --git a/src/api/client.test.ts b/src/api/client.test.ts index 3a63f22..4aaa8a9 100644 --- a/src/api/client.test.ts +++ b/src/api/client.test.ts @@ -34,7 +34,7 @@ describe('api client', () => { } it('attaches Bearer token from storage on request', async () => { - tokenStorage.set('tok-1'); + tokenStorage.set('tok-1', null); const handlers = client.interceptors.request.handlers; const config = { headers: new axios.AxiosHeaders() }; diff --git a/src/api/tokenStorage.ts b/src/api/tokenStorage.ts index 4449481..7bdd4a3 100644 --- a/src/api/tokenStorage.ts +++ b/src/api/tokenStorage.ts @@ -2,17 +2,22 @@ * 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. + * sessionStorage) to reduce XSS exposure. The backend issues the refresh token + * in the login/refresh response and expects it back in the request body, so we + * keep it in memory alongside the access token for the session's lifetime. */ let accessToken: string | null = null; +let refreshToken: string | null = null; export const tokenStorage = { get: (): string | null => accessToken, - set: (token: string): void => { - accessToken = token; + getRefreshToken: (): string | null => refreshToken, + set: (access: string, refresh: string | null): void => { + accessToken = access; + refreshToken = refresh; }, clear: (): void => { accessToken = null; + refreshToken = null; }, }; diff --git a/src/features/auth/api/authApi.ts b/src/features/auth/api/authApi.ts index 63862dd..1c7e508 100644 --- a/src/features/auth/api/authApi.ts +++ b/src/features/auth/api/authApi.ts @@ -1,36 +1,60 @@ import client from '@/api/client'; -import type { ApiListResponse, ApiResponse } from '@/types/api'; +import type { ApiListResponse } 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). + * Contract per backend `aplp.backend.spring`. */ const ENDPOINTS = { login: '/auth/login', logout: '/auth/logout', refresh: '/auth/refresh', - me: '/auth/me', + me: '/learners/me', } as const; +/** AuthResponse as returned by the Spring backend (no `data` wrapper). */ +interface AuthResponseDto { + accessToken: string; + refreshToken: string; + tokenType: string; + expiresIn: number; + user: { id: number; email: string; displayName: string }; +} + +/** LearnerProfileResponse as returned by `GET /learners/me`. */ +interface LearnerProfileDto { + id: number; + userId: number; + displayName: string; +} + +function toAuthUser(user: AuthResponseDto['user']): AuthUser { + return { id: String(user.id), email: user.email, name: user.displayName }; +} + +function toAuthResult(data: AuthResponseDto): LoginResponse { + return { accessToken: data.accessToken, refreshToken: data.refreshToken, user: toAuthUser(data.user) }; +} + export const authApi = { async login(credentials: LoginCredentials): Promise { - const { data } = await client.post>(ENDPOINTS.login, credentials); - return data.data; + const { data } = await client.post(ENDPOINTS.login, credentials); + return toAuthResult(data); }, - async logout(): Promise { - await client.post(ENDPOINTS.logout); + async logout(refreshToken: string): Promise { + await client.post(ENDPOINTS.logout, { refreshToken }); }, - async refresh(): Promise { - const { data } = await client.post>(ENDPOINTS.refresh); - return data.data; + async refresh(refreshToken: string): Promise { + const { data } = await client.post(ENDPOINTS.refresh, { refreshToken }); + return toAuthResult(data); }, async getMe(): Promise { - const { data } = await client.get>(ENDPOINTS.me); - return data.data; + const { data } = await client.get(ENDPOINTS.me); + return { id: String(data.id), email: '', name: data.displayName }; }, async listUsers(): Promise> { diff --git a/src/features/auth/hooks/useAuthUser.ts b/src/features/auth/hooks/useAuthUser.ts index e0a6fe7..9c8ffff 100644 --- a/src/features/auth/hooks/useAuthUser.ts +++ b/src/features/auth/hooks/useAuthUser.ts @@ -16,7 +16,7 @@ export function useAuthUser() { }); return { - user: query.data ?? user, + user: query.data ? { ...query.data, email: user?.email ?? query.data.email } : user, isLoading: query.isLoading, isError: query.isError, }; diff --git a/src/features/auth/pages/LoginPage.test.tsx b/src/features/auth/pages/LoginPage.test.tsx index 7ec1786..a90c797 100644 --- a/src/features/auth/pages/LoginPage.test.tsx +++ b/src/features/auth/pages/LoginPage.test.tsx @@ -42,6 +42,7 @@ describe('LoginPage', () => { vi.clearAllMocks(); mockedAuthApi.login.mockResolvedValue({ accessToken: 'access-123', + refreshToken: 'refresh-123', user: { id: 'u1', email: 'learner@aplp.io', name: 'Learner' }, }); }); diff --git a/src/features/auth/store/authStore.test.ts b/src/features/auth/store/authStore.test.ts index d5b76a8..3a9c08d 100644 --- a/src/features/auth/store/authStore.test.ts +++ b/src/features/auth/store/authStore.test.ts @@ -23,10 +23,12 @@ describe('authStore', () => { vi.clearAllMocks(); mockedAuthApi.login.mockResolvedValue({ accessToken: 'access-123', + refreshToken: 'refresh-123', user: { id: 'u1', email: 'learner@aplp.io', name: 'Learner' }, }); mockedAuthApi.refresh.mockResolvedValue({ accessToken: 'access-refreshed', + refreshToken: 'refresh-refreshed', user: { id: 'u1', email: 'learner@aplp.io', name: 'Learner' }, }); }); @@ -64,15 +66,28 @@ describe('authStore', () => { }); it('init restores session from refresh token', async () => { + tokenStorage.set('old-access', 'refresh-123'); + await useAuthStore.getState().init(); expect(mockedAuthApi.refresh).toHaveBeenCalledOnce(); + expect(mockedAuthApi.refresh).toHaveBeenCalledWith('refresh-123'); expect(tokenStorage.get()).toBe('access-refreshed'); expect(useAuthStore.getState().user?.id).toBe('u1'); expect(useAuthStore.getState().isInitializing).toBe(false); }); + it('init skips refresh when no refresh token is stored', async () => { + await useAuthStore.getState().init(); + + expect(mockedAuthApi.refresh).not.toHaveBeenCalled(); + expect(tokenStorage.get()).toBeNull(); + expect(useAuthStore.getState().user).toBeNull(); + expect(useAuthStore.getState().isInitializing).toBe(false); + }); + it('init handles expired refresh token gracefully', async () => { + tokenStorage.set('old-access', 'refresh-expired'); mockedAuthApi.refresh.mockRejectedValue(new Error('401')); await useAuthStore.getState().init(); diff --git a/src/features/auth/store/authStore.ts b/src/features/auth/store/authStore.ts index 776d0d3..c12974a 100644 --- a/src/features/auth/store/authStore.ts +++ b/src/features/auth/store/authStore.ts @@ -21,14 +21,14 @@ export const useAuthStore = create()((set) => ({ isInitializing: true, login: async (email, password) => { - const { accessToken, user } = await authApi.login({ email, password }); - tokenStorage.set(accessToken); + const { accessToken, refreshToken, user } = await authApi.login({ email, password }); + tokenStorage.set(accessToken, refreshToken); set({ user, isInitializing: false }); }, logout: async () => { try { - await authApi.logout(); + await authApi.logout(tokenStorage.getRefreshToken() ?? ''); } catch { // Best-effort: always clear the local session, even if the API call fails. } finally { @@ -38,9 +38,15 @@ export const useAuthStore = create()((set) => ({ }, init: async () => { + const refreshToken = tokenStorage.getRefreshToken(); + if (!refreshToken) { + tokenStorage.clear(); + set({ user: null, isInitializing: false }); + return; + } try { - const { accessToken, user } = await authApi.refresh(); - tokenStorage.set(accessToken); + const { accessToken, refreshToken: nextRefreshToken, user } = await authApi.refresh(refreshToken); + tokenStorage.set(accessToken, nextRefreshToken); set({ user, isInitializing: false }); } catch { tokenStorage.clear(); @@ -55,9 +61,11 @@ export const useAuthStore = create()((set) => ({ */ registerAuthHandlers( async () => { + const refreshToken = tokenStorage.getRefreshToken(); + if (!refreshToken) return null; try { - const { accessToken } = await authApi.refresh(); - tokenStorage.set(accessToken); + const { accessToken, refreshToken: nextRefreshToken } = await authApi.refresh(refreshToken); + tokenStorage.set(accessToken, nextRefreshToken); return accessToken; } catch { return null; diff --git a/src/features/auth/types.ts b/src/features/auth/types.ts index cd46933..c16f520 100644 --- a/src/features/auth/types.ts +++ b/src/features/auth/types.ts @@ -11,10 +11,12 @@ export interface LoginCredentials { export interface LoginResponse { accessToken: string; + refreshToken: string; user: AuthUser; } export interface RefreshResponse { accessToken: string; + refreshToken: string; user: AuthUser; }