fix: parse login
This commit is contained in:
+1
-1
@@ -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
|
||||
|
||||
@@ -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() };
|
||||
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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>> {
|
||||
|
||||
@@ -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' },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user