fix: parse login

This commit is contained in:
2026-08-11 23:10:00 +07:00
parent d82255bc5b
commit b44092153d
9 changed files with 81 additions and 26 deletions
+1 -1
View File
@@ -1,3 +1,3 @@
# Copy to `.env` for local development (never commit `.env`). # 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 VITE_APP_NAME=APLP
+1 -1
View File
@@ -34,7 +34,7 @@ describe('api client', () => {
} }
it('attaches Bearer token from storage on request', async () => { 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 handlers = client.interceptors.request.handlers;
const config = { headers: new axios.AxiosHeaders() }; const config = { headers: new axios.AxiosHeaders() };
+9 -4
View File
@@ -2,17 +2,22 @@
* In-memory token storage. * In-memory token storage.
* *
* Access token is kept in memory only (never persisted to localStorage / * Access token is kept in memory only (never persisted to localStorage /
* sessionStorage) to reduce XSS exposure. The refresh token is handled by the * sessionStorage) to reduce XSS exposure. The backend issues the refresh token
* backend (HttpOnly cookie), so the client never touches it directly. * 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 accessToken: string | null = null;
let refreshToken: string | null = null;
export const tokenStorage = { export const tokenStorage = {
get: (): string | null => accessToken, get: (): string | null => accessToken,
set: (token: string): void => { getRefreshToken: (): string | null => refreshToken,
accessToken = token; set: (access: string, refresh: string | null): void => {
accessToken = access;
refreshToken = refresh;
}, },
clear: (): void => { clear: (): void => {
accessToken = null; accessToken = null;
refreshToken = null;
}, },
}; };
+36 -12
View File
@@ -1,36 +1,60 @@
import client from '@/api/client'; 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'; import type { AuthUser, LoginCredentials, LoginResponse, RefreshResponse } from '../types';
/** /**
* Endpoint constants live here (not scattered in components). * 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 = { const ENDPOINTS = {
login: '/auth/login', login: '/auth/login',
logout: '/auth/logout', logout: '/auth/logout',
refresh: '/auth/refresh', refresh: '/auth/refresh',
me: '/auth/me', me: '/learners/me',
} as const; } 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 = { export const authApi = {
async login(credentials: LoginCredentials): Promise<LoginResponse> { async login(credentials: LoginCredentials): Promise<LoginResponse> {
const { data } = await client.post<ApiResponse<LoginResponse>>(ENDPOINTS.login, credentials); const { data } = await client.post<AuthResponseDto>(ENDPOINTS.login, credentials);
return data.data; return toAuthResult(data);
}, },
async logout(): Promise<void> { async logout(refreshToken: string): Promise<void> {
await client.post(ENDPOINTS.logout); await client.post(ENDPOINTS.logout, { refreshToken });
}, },
async refresh(): Promise<RefreshResponse> { async refresh(refreshToken: string): Promise<RefreshResponse> {
const { data } = await client.post<ApiResponse<RefreshResponse>>(ENDPOINTS.refresh); const { data } = await client.post<AuthResponseDto>(ENDPOINTS.refresh, { refreshToken });
return data.data; return toAuthResult(data);
}, },
async getMe(): Promise<AuthUser> { async getMe(): Promise<AuthUser> {
const { data } = await client.get<ApiResponse<AuthUser>>(ENDPOINTS.me); const { data } = await client.get<LearnerProfileDto>(ENDPOINTS.me);
return data.data; return { id: String(data.id), email: '', name: data.displayName };
}, },
async listUsers(): Promise<ApiListResponse<AuthUser>> { async listUsers(): Promise<ApiListResponse<AuthUser>> {
+1 -1
View File
@@ -16,7 +16,7 @@ export function useAuthUser() {
}); });
return { return {
user: query.data ?? user, user: query.data ? { ...query.data, email: user?.email ?? query.data.email } : user,
isLoading: query.isLoading, isLoading: query.isLoading,
isError: query.isError, isError: query.isError,
}; };
@@ -42,6 +42,7 @@ describe('LoginPage', () => {
vi.clearAllMocks(); vi.clearAllMocks();
mockedAuthApi.login.mockResolvedValue({ mockedAuthApi.login.mockResolvedValue({
accessToken: 'access-123', accessToken: 'access-123',
refreshToken: 'refresh-123',
user: { id: 'u1', email: 'learner@aplp.io', name: 'Learner' }, user: { id: 'u1', email: 'learner@aplp.io', name: 'Learner' },
}); });
}); });
+15
View File
@@ -23,10 +23,12 @@ describe('authStore', () => {
vi.clearAllMocks(); vi.clearAllMocks();
mockedAuthApi.login.mockResolvedValue({ mockedAuthApi.login.mockResolvedValue({
accessToken: 'access-123', accessToken: 'access-123',
refreshToken: 'refresh-123',
user: { id: 'u1', email: 'learner@aplp.io', name: 'Learner' }, user: { id: 'u1', email: 'learner@aplp.io', name: 'Learner' },
}); });
mockedAuthApi.refresh.mockResolvedValue({ mockedAuthApi.refresh.mockResolvedValue({
accessToken: 'access-refreshed', accessToken: 'access-refreshed',
refreshToken: 'refresh-refreshed',
user: { id: 'u1', email: 'learner@aplp.io', name: 'Learner' }, user: { id: 'u1', email: 'learner@aplp.io', name: 'Learner' },
}); });
}); });
@@ -64,15 +66,28 @@ describe('authStore', () => {
}); });
it('init restores session from refresh token', async () => { it('init restores session from refresh token', async () => {
tokenStorage.set('old-access', 'refresh-123');
await useAuthStore.getState().init(); await useAuthStore.getState().init();
expect(mockedAuthApi.refresh).toHaveBeenCalledOnce(); expect(mockedAuthApi.refresh).toHaveBeenCalledOnce();
expect(mockedAuthApi.refresh).toHaveBeenCalledWith('refresh-123');
expect(tokenStorage.get()).toBe('access-refreshed'); expect(tokenStorage.get()).toBe('access-refreshed');
expect(useAuthStore.getState().user?.id).toBe('u1'); expect(useAuthStore.getState().user?.id).toBe('u1');
expect(useAuthStore.getState().isInitializing).toBe(false); 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 () => { it('init handles expired refresh token gracefully', async () => {
tokenStorage.set('old-access', 'refresh-expired');
mockedAuthApi.refresh.mockRejectedValue(new Error('401')); mockedAuthApi.refresh.mockRejectedValue(new Error('401'));
await useAuthStore.getState().init(); await useAuthStore.getState().init();
+15 -7
View File
@@ -21,14 +21,14 @@ export const useAuthStore = create<AuthState>()((set) => ({
isInitializing: true, isInitializing: true,
login: async (email, password) => { login: async (email, password) => {
const { accessToken, user } = await authApi.login({ email, password }); const { accessToken, refreshToken, user } = await authApi.login({ email, password });
tokenStorage.set(accessToken); tokenStorage.set(accessToken, refreshToken);
set({ user, isInitializing: false }); set({ user, isInitializing: false });
}, },
logout: async () => { logout: async () => {
try { try {
await authApi.logout(); await authApi.logout(tokenStorage.getRefreshToken() ?? '');
} catch { } catch {
// Best-effort: always clear the local session, even if the API call fails. // Best-effort: always clear the local session, even if the API call fails.
} finally { } finally {
@@ -38,9 +38,15 @@ export const useAuthStore = create<AuthState>()((set) => ({
}, },
init: async () => { init: async () => {
const refreshToken = tokenStorage.getRefreshToken();
if (!refreshToken) {
tokenStorage.clear();
set({ user: null, isInitializing: false });
return;
}
try { try {
const { accessToken, user } = await authApi.refresh(); const { accessToken, refreshToken: nextRefreshToken, user } = await authApi.refresh(refreshToken);
tokenStorage.set(accessToken); tokenStorage.set(accessToken, nextRefreshToken);
set({ user, isInitializing: false }); set({ user, isInitializing: false });
} catch { } catch {
tokenStorage.clear(); tokenStorage.clear();
@@ -55,9 +61,11 @@ export const useAuthStore = create<AuthState>()((set) => ({
*/ */
registerAuthHandlers( registerAuthHandlers(
async () => { async () => {
const refreshToken = tokenStorage.getRefreshToken();
if (!refreshToken) return null;
try { try {
const { accessToken } = await authApi.refresh(); const { accessToken, refreshToken: nextRefreshToken } = await authApi.refresh(refreshToken);
tokenStorage.set(accessToken); tokenStorage.set(accessToken, nextRefreshToken);
return accessToken; return accessToken;
} catch { } catch {
return null; return null;
+2
View File
@@ -11,10 +11,12 @@ export interface LoginCredentials {
export interface LoginResponse { export interface LoginResponse {
accessToken: string; accessToken: string;
refreshToken: string;
user: AuthUser; user: AuthUser;
} }
export interface RefreshResponse { export interface RefreshResponse {
accessToken: string; accessToken: string;
refreshToken: string;
user: AuthUser; user: AuthUser;
} }