fix: graceful login error handling and remove hard redirects
Some checks failed
CI Pipeline / Rust Format Check (push) Failing after 35s
CI Pipeline / Clippy Lints (push) Successful in 45s
CI Pipeline / Rust Unit Tests (push) Successful in 1m2s
CI Pipeline / Security Audit (push) Successful in 4s
CI Pipeline / Frontend Lint & Type Check (push) Successful in 13s
CI Pipeline / Build .deb & Release (push) Has been skipped
Some checks failed
CI Pipeline / Rust Format Check (push) Failing after 35s
CI Pipeline / Clippy Lints (push) Successful in 45s
CI Pipeline / Rust Unit Tests (push) Successful in 1m2s
CI Pipeline / Security Audit (push) Successful in 4s
CI Pipeline / Frontend Lint & Type Check (push) Successful in 13s
CI Pipeline / Build .deb & Release (push) Has been skipped
- LoginPage.tsx: proper error handling for network errors, rate limiting
(429), MFA required, account disabled, and server errors
- LoginPage.tsx: dismissible error alerts with onClose
- LoginPage.tsx: added 🐉 branding to login title
- client.ts: removed window.location.href hard redirects on auth failure
(now uses React state-based logout instead of full page reload)
- client.ts: auth errors now propagate naturally through React Router
This commit is contained in:
@ -60,10 +60,8 @@ apiClient.interceptors.response.use(
|
|||||||
isRefreshing = true
|
isRefreshing = true
|
||||||
|
|
||||||
const { refreshToken, setTokens, logout } = useAuthStore.getState()
|
const { refreshToken, setTokens, logout } = useAuthStore.getState()
|
||||||
|
|
||||||
if (!refreshToken) {
|
if (!refreshToken) {
|
||||||
logout()
|
logout()
|
||||||
window.location.href = '/login'
|
|
||||||
return Promise.reject(error)
|
return Promise.reject(error)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -78,7 +76,6 @@ apiClient.interceptors.response.use(
|
|||||||
} catch (refreshError) {
|
} catch (refreshError) {
|
||||||
processQueue(refreshError, null)
|
processQueue(refreshError, null)
|
||||||
logout()
|
logout()
|
||||||
window.location.href = '/login'
|
|
||||||
return Promise.reject(refreshError)
|
return Promise.reject(refreshError)
|
||||||
} finally {
|
} finally {
|
||||||
isRefreshing = false
|
isRefreshing = false
|
||||||
|
|||||||
@ -9,6 +9,53 @@ import { authApi } from '../api/client'
|
|||||||
import { useAuthStore } from '../store/authStore'
|
import { useAuthStore } from '../store/authStore'
|
||||||
import type { User } from '../types'
|
import type { User } from '../types'
|
||||||
|
|
||||||
|
/** Extract a human-readable error message from an Axios error. */
|
||||||
|
function getErrorMessage(err: unknown): string {
|
||||||
|
// Network error — no response at all (server unreachable, CORS, DNS failure)
|
||||||
|
if (err instanceof Error && err.message === 'Network Error') {
|
||||||
|
return 'Unable to connect to the server. Please check your network connection and try again.'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Axios-style error with a response body
|
||||||
|
const axiosErr = err as { response?: { status?: number; data?: { error?: { code?: string; message?: string } } } }
|
||||||
|
const status = axiosErr.response?.status
|
||||||
|
const code = axiosErr.response?.data?.error?.code
|
||||||
|
const msg = axiosErr.response?.data?.error?.message
|
||||||
|
|
||||||
|
// Rate limited
|
||||||
|
if (status === 429) {
|
||||||
|
return 'Too many login attempts. Please wait a moment and try again.'
|
||||||
|
}
|
||||||
|
|
||||||
|
// MFA required
|
||||||
|
if (code === 'mfa_required') {
|
||||||
|
return 'MFA_REQUIRED' // sentinel — caller checks this
|
||||||
|
}
|
||||||
|
|
||||||
|
// Account disabled
|
||||||
|
if (code === 'account_disabled') {
|
||||||
|
return 'This account has been disabled. Contact your administrator.'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Server-provided message
|
||||||
|
if (msg) {
|
||||||
|
return msg
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generic status-based messages
|
||||||
|
if (status === 401) {
|
||||||
|
return 'Invalid username or password.'
|
||||||
|
}
|
||||||
|
if (status === 403) {
|
||||||
|
return 'Access denied.'
|
||||||
|
}
|
||||||
|
if (status && status >= 500) {
|
||||||
|
return 'A server error occurred. Please try again later.'
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Login failed. Please try again.'
|
||||||
|
}
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { setTokens, setUser } = useAuthStore()
|
const { setTokens, setUser } = useAuthStore()
|
||||||
@ -33,13 +80,12 @@ export default function LoginPage() {
|
|||||||
setUser(user as User)
|
setUser(user as User)
|
||||||
navigate('/dashboard', { replace: true })
|
navigate('/dashboard', { replace: true })
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const e = err as { response?: { data?: { error?: { code?: string; message?: string } } } }
|
const message = getErrorMessage(err)
|
||||||
const code = e.response?.data?.error?.code
|
if (message === 'MFA_REQUIRED') {
|
||||||
if (code === 'mfa_required') {
|
|
||||||
setNeedsMfa(true)
|
setNeedsMfa(true)
|
||||||
setError('Please enter your MFA code.')
|
setError('Please enter your MFA code.')
|
||||||
} else {
|
} else {
|
||||||
setError(e.response?.data?.error?.message || 'Login failed')
|
setError(message)
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
@ -50,16 +96,24 @@ export default function LoginPage() {
|
|||||||
<Container maxWidth="xs" sx={{ mt: 12 }}>
|
<Container maxWidth="xs" sx={{ mt: 12 }}>
|
||||||
<Paper elevation={4} sx={{ p: 4 }}>
|
<Paper elevation={4} sx={{ p: 4 }}>
|
||||||
<Typography variant="h5" fontWeight={700} mb={3} align="center">
|
<Typography variant="h5" fontWeight={700} mb={3} align="center">
|
||||||
Linux Patch Manager
|
🐉 Linux Patch Manager
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
{error && <Alert severity={needsMfa && error.startsWith('Please') ? 'info' : 'error'} sx={{ mb: 2 }}>{error}</Alert>}
|
{error && (
|
||||||
|
<Alert
|
||||||
|
severity={needsMfa ? 'info' : 'error'}
|
||||||
|
sx={{ mb: 2 }}
|
||||||
|
onClose={() => setError(null)}
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
<Box component="form" onSubmit={handleSubmit} noValidate>
|
<Box component="form" onSubmit={handleSubmit} noValidate>
|
||||||
<TextField
|
<TextField
|
||||||
fullWidth margin="normal" label="Username" autoComplete="username"
|
fullWidth margin="normal" label="Username" autoComplete="username"
|
||||||
value={username} onChange={(e) => setUsername(e.target.value)}
|
value={username} onChange={(e) => setUsername(e.target.value)}
|
||||||
disabled={loading} required
|
disabled={loading} required autoFocus
|
||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
fullWidth margin="normal" label="Password" type={showPassword ? 'text' : 'password'}
|
fullWidth margin="normal" label="Password" type={showPassword ? 'text' : 'password'}
|
||||||
|
|||||||
Reference in New Issue
Block a user