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