Private
Public Access
1
0

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

- 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:
2026-04-29 01:27:58 +00:00
parent 8ef118a515
commit eec976d093
2 changed files with 61 additions and 10 deletions

View File

@ -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

View File

@ -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'}