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`).
VITE_API_BASE_URL=http://localhost:8080/api
VITE_API_BASE_URL=http://localhost:8080/api/v1
VITE_APP_NAME=APLP
+1 -1
View File
@@ -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() };
+9 -4
View File
@@ -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;
},
};
+36 -12
View File
@@ -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<LoginResponse> {
const { data } = await client.post<ApiResponse<LoginResponse>>(ENDPOINTS.login, credentials);
return data.data;
const { data } = await client.post<AuthResponseDto>(ENDPOINTS.login, credentials);
return toAuthResult(data);
},
async logout(): Promise<void> {
await client.post(ENDPOINTS.logout);
async logout(refreshToken: string): Promise<void> {
await client.post(ENDPOINTS.logout, { refreshToken });
},
async refresh(): Promise<RefreshResponse> {
const { data } = await client.post<ApiResponse<RefreshResponse>>(ENDPOINTS.refresh);
return data.data;
async refresh(refreshToken: string): Promise<RefreshResponse> {
const { data } = await client.post<AuthResponseDto>(ENDPOINTS.refresh, { refreshToken });
return toAuthResult(data);
},
async getMe(): Promise<AuthUser> {
const { data } = await client.get<ApiResponse<AuthUser>>(ENDPOINTS.me);
return data.data;
const { data } = await client.get<LearnerProfileDto>(ENDPOINTS.me);
return { id: String(data.id), email: '', name: data.displayName };
},
async listUsers(): Promise<ApiListResponse<AuthUser>> {
+1 -1
View File
@@ -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,
};
@@ -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' },
});
});
+15
View File
@@ -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();
+15 -7
View File
@@ -21,14 +21,14 @@ export const useAuthStore = create<AuthState>()((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<AuthState>()((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<AuthState>()((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;
+2
View File
@@ -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;
}