feat: implement 5 QA-driven security and quality proposals

Implemented proposals from comprehensive QA review:

1. extend-csrf-protection
   - Add POST to CSRF protected methods in frontend
   - Global CSRF middleware for all state-changing operations
   - Update tests with CSRF token fixtures

2. tighten-cors-websocket-security
   - Replace wildcard CORS with explicit method/header lists
   - Disable query parameter auth in production (code 4002)
   - Add per-user WebSocket connection limit (max 5, code 4005)

3. shorten-jwt-expiry
   - Reduce JWT expiry from 7 days to 60 minutes
   - Add refresh token support with 7-day expiry
   - Implement token rotation on refresh
   - Frontend auto-refresh when token near expiry (<5 min)

4. fix-frontend-quality
   - Add React.lazy() code splitting for all pages
   - Fix useCallback dependency arrays (Dashboard, Comments)
   - Add localStorage data validation in AuthContext
   - Complete i18n for AttachmentUpload component

5. enhance-backend-validation
   - Add SecurityAuditMiddleware for access denied logging
   - Add ErrorSanitizerMiddleware for production error messages
   - Protect /health/detailed with admin authentication
   - Add input length validation (comment 5000, desc 10000)

All 521 backend tests passing. Frontend builds successfully.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
beabigegg
2026-01-12 23:19:05 +08:00
parent df50d5e7f8
commit 35c90fe76b
48 changed files with 2132 additions and 403 deletions

View File

@@ -19,7 +19,8 @@
"emailRequired": "Email is required",
"passwordRequired": "Password is required",
"invalidEmail": "Please enter a valid email address",
"loginFailed": "Login failed. Please try again later."
"loginFailed": "Login failed. Please try again later.",
"sessionExpired": "Your session has expired. Please sign in again."
},
"welcome": {
"title": "Project Control Center",

View File

@@ -129,5 +129,11 @@
"message": "Unable to display this widget.",
"errorSuffix": "error"
}
},
"attachments": {
"dropzone": "Drop files here or click to upload",
"maxFileSize": "Maximum file size: {{size}}",
"uploading": "Uploading {{filename}} ({{current}}/{{total}})...",
"uploadFailed": "Upload failed"
}
}

View File

@@ -19,7 +19,8 @@
"emailRequired": "請輸入電子郵件",
"passwordRequired": "請輸入密碼",
"invalidEmail": "請輸入有效的電子郵件地址",
"loginFailed": "登入失敗,請稍後再試"
"loginFailed": "登入失敗,請稍後再試",
"sessionExpired": "您的登入時段已過期,請重新登入。"
},
"welcome": {
"title": "專案控制中心",

View File

@@ -129,5 +129,11 @@
"message": "無法顯示此元件。",
"errorSuffix": "發生錯誤"
}
},
"attachments": {
"dropzone": "拖曳檔案至此或點擊上傳",
"maxFileSize": "檔案大小上限:{{size}}",
"uploading": "正在上傳 {{filename}} ({{current}}/{{total}})...",
"uploadFailed": "上傳失敗"
}
}

View File

@@ -1,21 +1,35 @@
import { lazy, Suspense } from 'react'
import { Routes, Route, Navigate } from 'react-router-dom'
import { useAuth } from './contexts/AuthContext'
import { Skeleton } from './components/Skeleton'
import { ErrorBoundary } from './components/ErrorBoundary'
import { SectionErrorBoundary } from './components/ErrorBoundaryWithI18n'
import Login from './pages/Login'
import Dashboard from './pages/Dashboard'
import Spaces from './pages/Spaces'
import Projects from './pages/Projects'
import Tasks from './pages/Tasks'
import ProjectSettings from './pages/ProjectSettings'
import MySettings from './pages/MySettings'
import AuditPage from './pages/AuditPage'
import WorkloadPage from './pages/WorkloadPage'
import ProjectHealthPage from './pages/ProjectHealthPage'
import ProtectedRoute from './components/ProtectedRoute'
import Layout from './components/Layout'
// Lazy load pages for code splitting
const Login = lazy(() => import('./pages/Login'))
const Dashboard = lazy(() => import('./pages/Dashboard'))
const Spaces = lazy(() => import('./pages/Spaces'))
const Projects = lazy(() => import('./pages/Projects'))
const Tasks = lazy(() => import('./pages/Tasks'))
const ProjectSettings = lazy(() => import('./pages/ProjectSettings'))
const MySettings = lazy(() => import('./pages/MySettings'))
const AuditPage = lazy(() => import('./pages/AuditPage'))
const WorkloadPage = lazy(() => import('./pages/WorkloadPage'))
const ProjectHealthPage = lazy(() => import('./pages/ProjectHealthPage'))
// Loading fallback component for Suspense
function PageLoadingFallback() {
return (
<div className="container" style={{ padding: '24px', maxWidth: '1200px', margin: '0 auto' }}>
<Skeleton variant="text" width={200} height={32} style={{ marginBottom: '16px' }} />
<Skeleton variant="rect" width="100%" height={200} style={{ marginBottom: '16px' }} />
<Skeleton variant="rect" width="100%" height={150} />
</div>
)
}
function App() {
const { isAuthenticated, loading } = useAuth()
@@ -30,120 +44,122 @@ function App() {
return (
<ErrorBoundary variant="page">
<Routes>
<Route
path="/login"
element={isAuthenticated ? <Navigate to="/" /> : <Login />}
/>
<Route
path="/"
element={
<ProtectedRoute>
<Layout>
<SectionErrorBoundary sectionName="Dashboard">
<Dashboard />
</SectionErrorBoundary>
</Layout>
</ProtectedRoute>
}
/>
<Route
path="/spaces"
element={
<ProtectedRoute>
<Layout>
<SectionErrorBoundary sectionName="Spaces">
<Spaces />
</SectionErrorBoundary>
</Layout>
</ProtectedRoute>
}
/>
<Route
path="/spaces/:spaceId"
element={
<ProtectedRoute>
<Layout>
<SectionErrorBoundary sectionName="Projects">
<Projects />
</SectionErrorBoundary>
</Layout>
</ProtectedRoute>
}
/>
<Route
path="/projects/:projectId"
element={
<ProtectedRoute>
<Layout>
<SectionErrorBoundary sectionName="Tasks">
<Tasks />
</SectionErrorBoundary>
</Layout>
</ProtectedRoute>
}
/>
<Route
path="/projects/:projectId/settings"
element={
<ProtectedRoute>
<Layout>
<SectionErrorBoundary sectionName="Project Settings">
<ProjectSettings />
</SectionErrorBoundary>
</Layout>
</ProtectedRoute>
}
/>
<Route
path="/audit"
element={
<ProtectedRoute>
<Layout>
<SectionErrorBoundary sectionName="Audit">
<AuditPage />
</SectionErrorBoundary>
</Layout>
</ProtectedRoute>
}
/>
<Route
path="/workload"
element={
<ProtectedRoute>
<Layout>
<SectionErrorBoundary sectionName="Workload">
<WorkloadPage />
</SectionErrorBoundary>
</Layout>
</ProtectedRoute>
}
/>
<Route
path="/project-health"
element={
<ProtectedRoute>
<Layout>
<SectionErrorBoundary sectionName="Project Health">
<ProjectHealthPage />
</SectionErrorBoundary>
</Layout>
</ProtectedRoute>
}
/>
<Route
path="/my-settings"
element={
<ProtectedRoute>
<Layout>
<SectionErrorBoundary sectionName="Settings">
<MySettings />
</SectionErrorBoundary>
</Layout>
</ProtectedRoute>
}
/>
</Routes>
<Suspense fallback={<PageLoadingFallback />}>
<Routes>
<Route
path="/login"
element={isAuthenticated ? <Navigate to="/" /> : <Login />}
/>
<Route
path="/"
element={
<ProtectedRoute>
<Layout>
<SectionErrorBoundary sectionName="Dashboard">
<Dashboard />
</SectionErrorBoundary>
</Layout>
</ProtectedRoute>
}
/>
<Route
path="/spaces"
element={
<ProtectedRoute>
<Layout>
<SectionErrorBoundary sectionName="Spaces">
<Spaces />
</SectionErrorBoundary>
</Layout>
</ProtectedRoute>
}
/>
<Route
path="/spaces/:spaceId"
element={
<ProtectedRoute>
<Layout>
<SectionErrorBoundary sectionName="Projects">
<Projects />
</SectionErrorBoundary>
</Layout>
</ProtectedRoute>
}
/>
<Route
path="/projects/:projectId"
element={
<ProtectedRoute>
<Layout>
<SectionErrorBoundary sectionName="Tasks">
<Tasks />
</SectionErrorBoundary>
</Layout>
</ProtectedRoute>
}
/>
<Route
path="/projects/:projectId/settings"
element={
<ProtectedRoute>
<Layout>
<SectionErrorBoundary sectionName="Project Settings">
<ProjectSettings />
</SectionErrorBoundary>
</Layout>
</ProtectedRoute>
}
/>
<Route
path="/audit"
element={
<ProtectedRoute>
<Layout>
<SectionErrorBoundary sectionName="Audit">
<AuditPage />
</SectionErrorBoundary>
</Layout>
</ProtectedRoute>
}
/>
<Route
path="/workload"
element={
<ProtectedRoute>
<Layout>
<SectionErrorBoundary sectionName="Workload">
<WorkloadPage />
</SectionErrorBoundary>
</Layout>
</ProtectedRoute>
}
/>
<Route
path="/project-health"
element={
<ProtectedRoute>
<Layout>
<SectionErrorBoundary sectionName="Project Health">
<ProjectHealthPage />
</SectionErrorBoundary>
</Layout>
</ProtectedRoute>
}
/>
<Route
path="/my-settings"
element={
<ProtectedRoute>
<Layout>
<SectionErrorBoundary sectionName="Settings">
<MySettings />
</SectionErrorBoundary>
</Layout>
</ProtectedRoute>
}
/>
</Routes>
</Suspense>
</ErrorBoundary>
)
}

View File

@@ -1,4 +1,5 @@
import { useState, useRef, useEffect, DragEvent, ChangeEvent } from 'react'
import { useTranslation } from 'react-i18next'
import { attachmentService } from '../services/attachments'
// Spinner animation keyframes - injected once via useEffect
@@ -10,6 +11,7 @@ interface AttachmentUploadProps {
}
export function AttachmentUpload({ taskId, onUploadComplete }: AttachmentUploadProps) {
const { t } = useTranslation('common')
const [isDragging, setIsDragging] = useState(false)
const [uploading, setUploading] = useState(false)
const [uploadProgress, setUploadProgress] = useState<string | null>(null)
@@ -79,14 +81,20 @@ export function AttachmentUpload({ taskId, onUploadComplete }: AttachmentUploadP
try {
for (let i = 0; i < files.length; i++) {
const file = files[i]
setUploadProgress(`Uploading ${file.name} (${i + 1}/${files.length})...`)
setUploadProgress(
t('attachments.uploading', {
filename: file.name,
current: i + 1,
total: files.length,
})
)
await attachmentService.uploadAttachment(taskId, file)
}
setUploadProgress(null)
onUploadComplete?.()
} catch (err: unknown) {
console.error('Upload failed:', err)
const errorMessage = err instanceof Error ? err.message : 'Upload failed'
const errorMessage = err instanceof Error ? err.message : t('attachments.uploadFailed')
setError(errorMessage)
} finally {
setUploading(false)
@@ -127,10 +135,10 @@ export function AttachmentUpload({ taskId, onUploadComplete }: AttachmentUploadP
<div style={styles.content}>
<span style={styles.icon}>📎</span>
<span style={styles.text}>
Drop files here or click to upload
{t('attachments.dropzone')}
</span>
<span style={styles.hint}>
Maximum file size: 50MB
{t('attachments.maxFileSize', { size: '50MB' })}
</span>
</div>
)}

View File

@@ -35,7 +35,7 @@ export function Comments({ taskId }: CommentsProps) {
} finally {
setLoading(false)
}
}, [taskId])
}, [taskId, t])
useEffect(() => {
fetchComments()

View File

@@ -1,5 +1,58 @@
import { createContext, useContext, useState, useEffect, ReactNode } from 'react'
import { authApi, User, LoginRequest } from '../services/api'
import {
authApi,
User,
LoginRequest,
storeTokens,
clearStoredTokens,
getStoredToken,
isTokenExpired,
} from '../services/api'
/**
* Validates that a parsed object has the required User properties.
* Returns the validated User object or null if validation fails.
*/
function validateUserData(data: unknown): User | null {
// Check if data is an object
if (!data || typeof data !== 'object') {
return null
}
const obj = data as Record<string, unknown>
// Validate required string fields
if (typeof obj.id !== 'string' || obj.id.length === 0) {
return null
}
if (typeof obj.email !== 'string' || obj.email.length === 0) {
return null
}
if (typeof obj.name !== 'string' || obj.name.length === 0) {
return null
}
// Validate optional/nullable fields
if (obj.role !== null && typeof obj.role !== 'string') {
return null
}
if (obj.department_id !== null && typeof obj.department_id !== 'string') {
return null
}
if (typeof obj.is_system_admin !== 'boolean') {
return null
}
// Return validated user object
return {
id: obj.id,
email: obj.email,
name: obj.name,
role: obj.role as string | null,
department_id: obj.department_id as string | null,
is_system_admin: obj.is_system_admin,
}
}
interface AuthContextType {
user: User | null
@@ -17,15 +70,35 @@ export function AuthProvider({ children }: { children: ReactNode }) {
useEffect(() => {
// Check for existing token on mount
const token = localStorage.getItem('token')
const token = getStoredToken()
const storedUser = localStorage.getItem('user')
if (token && storedUser) {
try {
setUser(JSON.parse(storedUser))
} catch {
localStorage.removeItem('token')
localStorage.removeItem('user')
// Check if token is expired
if (isTokenExpired(token)) {
// Token is expired, clear storage and don't restore user
// The refresh will happen automatically on next API call if refresh token exists
clearStoredTokens()
} else {
// Parse and validate stored user data
const parsedUser = JSON.parse(storedUser)
const validatedUser = validateUserData(parsedUser)
if (validatedUser) {
setUser(validatedUser)
} else {
// Invalid user data structure, clear storage and redirect to login
console.warn('Invalid user data in localStorage, clearing session')
clearStoredTokens()
// Don't redirect here as we're in initial loading state
// The app will naturally show login page when user is null
}
}
} catch (err) {
// JSON parse error or other unexpected error
console.error('Error parsing stored user data:', err)
clearStoredTokens()
}
}
setLoading(false)
@@ -33,7 +106,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const login = async (data: LoginRequest) => {
const response = await authApi.login(data)
localStorage.setItem('token', response.access_token)
// Store access token and refresh token (if provided by backend)
storeTokens(response.access_token, response.refresh_token)
localStorage.setItem('user', JSON.stringify(response.user))
setUser(response.user)
}
@@ -44,8 +118,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
} catch {
// Ignore errors on logout
} finally {
localStorage.removeItem('token')
localStorage.removeItem('user')
// Clear all tokens (access, refresh, and user data)
clearStoredTokens()
setUser(null)
}
}

View File

@@ -29,7 +29,7 @@ export default function Dashboard() {
} finally {
setLoading(false)
}
}, [])
}, [t])
useEffect(() => {
fetchDashboard()

View File

@@ -1,5 +1,5 @@
import { useState, FormEvent } from 'react'
import { useNavigate } from 'react-router-dom'
import { useState, useEffect, FormEvent } from 'react'
import { useNavigate, useSearchParams } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { useAuth } from '../contexts/AuthContext'
import { LanguageSwitcher } from '../components/LanguageSwitcher'
@@ -9,9 +9,21 @@ export default function Login() {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [info, setInfo] = useState('')
const [loading, setLoading] = useState(false)
const { login } = useAuth()
const navigate = useNavigate()
const [searchParams, setSearchParams] = useSearchParams()
// Check for session expired redirect
useEffect(() => {
const reason = searchParams.get('reason')
if (reason === 'session_expired') {
setInfo(t('errors.sessionExpired'))
// Clean up the URL by removing the query parameter
setSearchParams({}, { replace: true })
}
}, [searchParams, setSearchParams, t])
const handleSubmit = async (e: FormEvent) => {
e.preventDefault()
@@ -45,6 +57,7 @@ export default function Login() {
<p style={styles.subtitle}>{t('login.subtitle')}</p>
<form onSubmit={handleSubmit} style={styles.form}>
{info && <div style={styles.info}>{info}</div>}
{error && <div style={styles.error}>{error}</div>}
<div style={styles.field}>
@@ -163,4 +176,11 @@ const styles: { [key: string]: React.CSSProperties } = {
borderRadius: '4px',
fontSize: '14px',
},
info: {
backgroundColor: '#e6f4ff',
color: '#0066cc',
padding: '10px',
borderRadius: '4px',
fontSize: '14px',
},
}

View File

@@ -1,4 +1,4 @@
import axios, { InternalAxiosRequestConfig } from 'axios'
import axios, { InternalAxiosRequestConfig, AxiosError } from 'axios'
// API base URL - using legacy routes until v1 migration is complete
// TODO: Switch to /api/v1 when all routes are migrated
@@ -9,10 +9,141 @@ const API_BASE_URL = '/api'
let csrfToken: string | null = null
let csrfTokenExpiry: number | null = null
const CSRF_TOKEN_HEADER = 'X-CSRF-Token'
const CSRF_PROTECTED_METHODS = ['DELETE', 'PUT', 'PATCH']
const CSRF_PROTECTED_METHODS = ['POST', 'DELETE', 'PUT', 'PATCH']
// Token expires in 1 hour, refresh 5 minutes before expiry
const CSRF_TOKEN_LIFETIME_MS = 55 * 60 * 1000
// JWT Token refresh configuration
// Access tokens expire in 60 minutes, refresh 5 minutes before expiry
const TOKEN_REFRESH_THRESHOLD_MS = 5 * 60 * 1000
// Token refresh state management
let isRefreshing = false
let refreshSubscribers: Array<(token: string) => void> = []
/**
* JWT Token Utilities
*/
/**
* Decode a JWT token payload without verification.
* Note: This is for reading claims only, not for security validation.
* Security validation happens on the backend.
*/
export function decodeJwtPayload(token: string): JwtPayload | null {
try {
const parts = token.split('.')
if (parts.length !== 3) {
return null
}
// Decode base64url to base64
const base64 = parts[1].replace(/-/g, '+').replace(/_/g, '/')
// Add padding if needed
const padded = base64 + '='.repeat((4 - (base64.length % 4)) % 4)
const decoded = atob(padded)
return JSON.parse(decoded)
} catch {
return null
}
}
/**
* Get the expiration time (in milliseconds since epoch) from a JWT token.
*/
export function getTokenExpiryTime(token: string): number | null {
const payload = decodeJwtPayload(token)
if (!payload || typeof payload.exp !== 'number') {
return null
}
// JWT exp is in seconds, convert to milliseconds
return payload.exp * 1000
}
/**
* Check if a token is about to expire (within threshold).
* Returns true if token will expire within the threshold or has already expired.
*/
export function isTokenExpiringSoon(
token: string,
thresholdMs: number = TOKEN_REFRESH_THRESHOLD_MS
): boolean {
const expiryTime = getTokenExpiryTime(token)
if (expiryTime === null) {
// If we can't determine expiry, assume it needs refresh
return true
}
return Date.now() >= expiryTime - thresholdMs
}
/**
* Check if a token has already expired.
*/
export function isTokenExpired(token: string): boolean {
const expiryTime = getTokenExpiryTime(token)
if (expiryTime === null) {
return true
}
return Date.now() >= expiryTime
}
interface JwtPayload {
sub: string
email: string
role?: string | null
department_id?: string | null
is_system_admin?: boolean
exp: number
iat: number
}
/**
* Token Storage Utilities
* Note: Using localStorage for token storage. While httpOnly cookies are more
* secure against XSS attacks, localStorage is acceptable for this implementation
* as long as proper XSS protections are in place (Content Security Policy, etc.).
* The refresh token mechanism limits exposure time if a token is compromised.
*/
const TOKEN_KEY = 'token'
const REFRESH_TOKEN_KEY = 'refresh_token'
const USER_KEY = 'user'
export function getStoredToken(): string | null {
return localStorage.getItem(TOKEN_KEY)
}
export function getStoredRefreshToken(): string | null {
return localStorage.getItem(REFRESH_TOKEN_KEY)
}
export function storeTokens(accessToken: string, refreshToken?: string): void {
localStorage.setItem(TOKEN_KEY, accessToken)
if (refreshToken) {
localStorage.setItem(REFRESH_TOKEN_KEY, refreshToken)
}
}
export function clearStoredTokens(): void {
localStorage.removeItem(TOKEN_KEY)
localStorage.removeItem(REFRESH_TOKEN_KEY)
localStorage.removeItem(USER_KEY)
}
/**
* Subscribe to token refresh completion.
* Used to queue requests while a refresh is in progress.
*/
function subscribeToTokenRefresh(callback: (token: string) => void): void {
refreshSubscribers.push(callback)
}
/**
* Notify all subscribers that token has been refreshed.
*/
function onTokenRefreshed(newToken: string): void {
refreshSubscribers.forEach((callback) => callback(newToken))
refreshSubscribers = []
}
const api = axios.create({
baseURL: API_BASE_URL,
headers: {
@@ -77,33 +208,149 @@ export async function prefetchCsrfToken(): Promise<void> {
await fetchCsrfToken()
}
// Add token to requests and CSRF token for protected methods
api.interceptors.request.use(async (config: InternalAxiosRequestConfig) => {
const token = localStorage.getItem('token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
/**
* Refresh the access token using the refresh token.
* This is called automatically when the access token is about to expire.
*
* @returns The new access token, or null if refresh failed
*/
async function refreshAccessToken(): Promise<string | null> {
const refreshToken = getStoredRefreshToken()
if (!refreshToken) {
return null
}
// Add CSRF token for protected methods
const method = config.method?.toUpperCase()
if (method && CSRF_PROTECTED_METHODS.includes(method)) {
const csrf = await getValidCsrfToken()
if (csrf) {
config.headers[CSRF_TOKEN_HEADER] = csrf
try {
// Use axios directly to avoid interceptor loops
const response = await axios.post<{
access_token: string
refresh_token?: string
token_type: string
}>(
`${API_BASE_URL}/auth/refresh`,
{ refresh_token: refreshToken },
{
headers: {
'Content-Type': 'application/json',
},
}
)
const { access_token, refresh_token: newRefreshToken } = response.data
// Store the new tokens
storeTokens(access_token, newRefreshToken || refreshToken)
return access_token
} catch (error) {
// If refresh fails (401 or other error), the token is invalid
// Clear all tokens and let the response interceptor handle redirect
return null
}
}
/**
* Ensure we have a valid access token, refreshing if necessary.
* This implements a queue mechanism to prevent multiple simultaneous refresh requests.
*
* @returns A promise that resolves with a valid token or null if unavailable
*/
async function ensureValidToken(): Promise<string | null> {
const token = getStoredToken()
if (!token) {
return null
}
// If token is not expiring soon, use it as-is
if (!isTokenExpiringSoon(token)) {
return token
}
// If we're already refreshing, wait for it to complete
if (isRefreshing) {
return new Promise((resolve) => {
subscribeToTokenRefresh((newToken) => {
resolve(newToken)
})
})
}
// Start the refresh process
isRefreshing = true
try {
const newToken = await refreshAccessToken()
if (newToken) {
onTokenRefreshed(newToken)
return newToken
} else {
// Refresh failed - clear tokens and redirect to login
clearStoredTokens()
clearCsrfToken()
// Notify subscribers with empty token (they'll fail but won't retry)
refreshSubscribers = []
window.location.href = '/login?reason=session_expired'
return null
}
} finally {
isRefreshing = false
}
}
// Add token to requests and CSRF token for protected methods
// This interceptor ensures tokens are refreshed before they expire
api.interceptors.request.use(async (config: InternalAxiosRequestConfig) => {
// Skip token handling for auth endpoints that don't require authentication
const isAuthEndpoint =
config.url?.includes('/auth/login') || config.url?.includes('/auth/refresh')
if (!isAuthEndpoint) {
// Ensure we have a valid token (will refresh if expiring soon)
const token = await ensureValidToken()
if (token) {
config.headers.Authorization = `Bearer ${token}`
// Add CSRF token for protected methods
const method = config.method?.toUpperCase()
if (method && CSRF_PROTECTED_METHODS.includes(method)) {
const csrf = await getValidCsrfToken()
if (csrf) {
config.headers[CSRF_TOKEN_HEADER] = csrf
}
}
}
}
return config
})
// Handle 401 responses
// Handle 401 responses - clear tokens and redirect to login
// Note: Token refresh is handled proactively in the request interceptor
// A 401 here means either:
// 1. The token was revoked on the server
// 2. The refresh token has expired
// 3. Some other authentication issue
api.interceptors.response.use(
(response) => response,
(error) => {
(error: AxiosError) => {
if (error.response?.status === 401) {
localStorage.removeItem('token')
localStorage.removeItem('user')
clearCsrfToken()
window.location.href = '/login'
// Check if this is from a refresh endpoint to avoid redirect loops
const isRefreshRequest = error.config?.url?.includes('/auth/refresh')
if (!isRefreshRequest) {
// Clear all auth state
clearStoredTokens()
clearCsrfToken()
// Redirect to login with appropriate message
const currentPath = window.location.pathname
if (currentPath !== '/login') {
window.location.href = '/login?reason=session_expired'
}
}
}
return Promise.reject(error)
}
@@ -125,10 +372,17 @@ export interface User {
export interface LoginResponse {
access_token: string
refresh_token?: string // Optional for backward compatibility during migration
token_type: string
user: User
}
export interface RefreshTokenResponse {
access_token: string
refresh_token?: string // New refresh token if rotation is enabled
token_type: string
}
export const authApi = {
login: async (data: LoginRequest): Promise<LoginResponse> => {
const response = await api.post<LoginResponse>('/auth/login', data)