feat(frontend): Implement complete authentication system
Implemented comprehensive JWT-based authentication with token refresh mechanism, user state management, and protected routes. Changes: - Upgraded API client from fetch to Axios with automatic token refresh interceptors - Created API configuration with centralized endpoint definitions - Implemented Zustand auth store for user state management with persistence - Created React Query hooks for login, register, logout, and current user - Built login and registration pages with form validation (Zod + React Hook Form) - Implemented AuthGuard component for route protection - Enhanced Header with user dropdown menu and logout functionality - Updated Sidebar with user information display at bottom - Added Team navigation item to sidebar - Configured environment variables for API base URL Technical Details: - JWT token storage in localStorage with secure key names - Automatic token refresh on 401 responses - Request queueing during token refresh to prevent race conditions - TypeScript strict typing throughout - ESLint compliant code (fixed type safety issues) - Proper error handling with user-friendly messages 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,124 +1,135 @@
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:5000/api/v1';
|
||||
import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios';
|
||||
import { API_BASE_URL } from './config';
|
||||
|
||||
// Log API URL for debugging
|
||||
if (typeof window !== 'undefined') {
|
||||
console.log('[API Client] API_URL:', API_URL);
|
||||
console.log('[API Client] NEXT_PUBLIC_API_URL:', process.env.NEXT_PUBLIC_API_URL);
|
||||
}
|
||||
// Create axios instance
|
||||
export const apiClient = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
public status: number,
|
||||
message: string,
|
||||
public data?: any
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'ApiError';
|
||||
}
|
||||
}
|
||||
// Token management
|
||||
const TOKEN_KEY = 'colaflow_access_token';
|
||||
const REFRESH_TOKEN_KEY = 'colaflow_refresh_token';
|
||||
|
||||
async function handleResponse<T>(response: Response): Promise<T> {
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
const error = new ApiError(
|
||||
response.status,
|
||||
errorData.message || response.statusText,
|
||||
errorData
|
||||
);
|
||||
console.error('[API Client] Request failed:', {
|
||||
url: response.url,
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
errorData,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
export const tokenManager = {
|
||||
getAccessToken: () => {
|
||||
if (typeof window === 'undefined') return null;
|
||||
return localStorage.getItem(TOKEN_KEY);
|
||||
},
|
||||
|
||||
if (response.status === 204) {
|
||||
return {} as T;
|
||||
}
|
||||
setAccessToken: (token: string) => {
|
||||
if (typeof window === 'undefined') return;
|
||||
localStorage.setItem(TOKEN_KEY, token);
|
||||
},
|
||||
|
||||
return response.json();
|
||||
}
|
||||
getRefreshToken: () => {
|
||||
if (typeof window === 'undefined') return null;
|
||||
return localStorage.getItem(REFRESH_TOKEN_KEY);
|
||||
},
|
||||
|
||||
export async function apiRequest<T>(
|
||||
endpoint: string,
|
||||
options: RequestInit = {}
|
||||
): Promise<T> {
|
||||
const url = `${API_URL}${endpoint}`;
|
||||
setRefreshToken: (token: string) => {
|
||||
if (typeof window === 'undefined') return;
|
||||
localStorage.setItem(REFRESH_TOKEN_KEY, token);
|
||||
},
|
||||
|
||||
console.log('[API Client] Request:', {
|
||||
method: options.method || 'GET',
|
||||
url,
|
||||
endpoint,
|
||||
clearTokens: () => {
|
||||
if (typeof window === 'undefined') return;
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
localStorage.removeItem(REFRESH_TOKEN_KEY);
|
||||
},
|
||||
};
|
||||
|
||||
// Request interceptor: automatically add Access Token
|
||||
apiClient.interceptors.request.use(
|
||||
(config: InternalAxiosRequestConfig) => {
|
||||
const token = tokenManager.getAccessToken();
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
},
|
||||
(error) => Promise.reject(error)
|
||||
);
|
||||
|
||||
// Response interceptor: automatically refresh Token
|
||||
let isRefreshing = false;
|
||||
let failedQueue: Array<{
|
||||
resolve: (value?: unknown) => void;
|
||||
reject: (reason?: unknown) => void;
|
||||
}> = [];
|
||||
|
||||
const processQueue = (error: unknown, token: string | null = null) => {
|
||||
failedQueue.forEach((prom) => {
|
||||
if (error) {
|
||||
prom.reject(error);
|
||||
} else {
|
||||
prom.resolve(token);
|
||||
}
|
||||
});
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
// Add auth token if available
|
||||
if (typeof window !== 'undefined') {
|
||||
const token = localStorage.getItem('accessToken');
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Merge with options headers
|
||||
if (options.headers) {
|
||||
Object.assign(headers, options.headers);
|
||||
}
|
||||
|
||||
const config: RequestInit = {
|
||||
...options,
|
||||
headers,
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(url, config);
|
||||
const result = await handleResponse<T>(response);
|
||||
console.log('[API Client] Response:', {
|
||||
url,
|
||||
status: response.status,
|
||||
data: result,
|
||||
});
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('[API Client] Network error:', {
|
||||
url,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
errorObject: error,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: <T>(endpoint: string, options?: RequestInit) =>
|
||||
apiRequest<T>(endpoint, { ...options, method: 'GET' }),
|
||||
|
||||
post: <T>(endpoint: string, data?: any, options?: RequestInit) =>
|
||||
apiRequest<T>(endpoint, {
|
||||
...options,
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
|
||||
put: <T>(endpoint: string, data?: any, options?: RequestInit) =>
|
||||
apiRequest<T>(endpoint, {
|
||||
...options,
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
|
||||
patch: <T>(endpoint: string, data?: any, options?: RequestInit) =>
|
||||
apiRequest<T>(endpoint, {
|
||||
...options,
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
|
||||
delete: <T>(endpoint: string, options?: RequestInit) =>
|
||||
apiRequest<T>(endpoint, { ...options, method: 'DELETE' }),
|
||||
failedQueue = [];
|
||||
};
|
||||
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error: AxiosError) => {
|
||||
const originalRequest = error.config as InternalAxiosRequestConfig & {
|
||||
_retry?: boolean;
|
||||
};
|
||||
|
||||
// If 401 and not a refresh token request, try to refresh token
|
||||
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||
if (isRefreshing) {
|
||||
// If already refreshing, queue this request
|
||||
return new Promise((resolve, reject) => {
|
||||
failedQueue.push({ resolve, reject });
|
||||
}).then((token) => {
|
||||
originalRequest.headers.Authorization = `Bearer ${token}`;
|
||||
return apiClient(originalRequest);
|
||||
});
|
||||
}
|
||||
|
||||
originalRequest._retry = true;
|
||||
isRefreshing = true;
|
||||
|
||||
const refreshToken = tokenManager.getRefreshToken();
|
||||
|
||||
if (!refreshToken) {
|
||||
tokenManager.clearTokens();
|
||||
if (typeof window !== 'undefined') {
|
||||
window.location.href = '/login';
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
try {
|
||||
const { data } = await axios.post(`${API_BASE_URL}/api/auth/refresh`, {
|
||||
refreshToken,
|
||||
});
|
||||
|
||||
tokenManager.setAccessToken(data.accessToken);
|
||||
tokenManager.setRefreshToken(data.refreshToken);
|
||||
|
||||
apiClient.defaults.headers.common.Authorization = `Bearer ${data.accessToken}`;
|
||||
originalRequest.headers.Authorization = `Bearer ${data.accessToken}`;
|
||||
|
||||
processQueue(null, data.accessToken);
|
||||
|
||||
return apiClient(originalRequest);
|
||||
} catch (refreshError) {
|
||||
processQueue(refreshError, null);
|
||||
tokenManager.clearTokens();
|
||||
if (typeof window !== 'undefined') {
|
||||
window.location.href = '/login';
|
||||
}
|
||||
return Promise.reject(refreshError);
|
||||
} finally {
|
||||
isRefreshing = false;
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
23
lib/api/config.ts
Normal file
23
lib/api/config.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
export const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:5000';
|
||||
|
||||
export const API_ENDPOINTS = {
|
||||
// Auth
|
||||
LOGIN: '/api/auth/login',
|
||||
REGISTER_TENANT: '/api/auth/register-tenant',
|
||||
REFRESH_TOKEN: '/api/auth/refresh',
|
||||
LOGOUT: '/api/auth/logout',
|
||||
ME: '/api/auth/me',
|
||||
|
||||
// Users
|
||||
USERS: '/api/users',
|
||||
USER_PROFILE: (userId: string) => `/api/users/${userId}`,
|
||||
|
||||
// Tenants
|
||||
TENANT_USERS: (tenantId: string) => `/api/tenants/${tenantId}/users`,
|
||||
ASSIGN_ROLE: (tenantId: string, userId: string) =>
|
||||
`/api/tenants/${tenantId}/users/${userId}/role`,
|
||||
|
||||
// Projects (to be implemented)
|
||||
PROJECTS: '/api/projects',
|
||||
PROJECT: (id: string) => `/api/projects/${id}`,
|
||||
};
|
||||
Reference in New Issue
Block a user