Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
Root cause: loadFromStorage() set isAuthenticated=true from localStorage without validating the HttpOnly cookie. On page refresh with expired cookie, children rendered and made failing API calls before AuthGuard could redirect. Fix: - authStore: isAuthenticated starts false, never trusted from localStorage - AuthGuard: always calls GET /auth/me on mount (unless login flow set it) - Three-state guard (checking/authenticated/unauthenticated) eliminates race
72 lines
2.3 KiB
TypeScript
72 lines
2.3 KiB
TypeScript
// ============================================================
|
|
// ZCLAW Admin V2 — Auth Guard with session restore
|
|
// ============================================================
|
|
//
|
|
// Auth strategy:
|
|
// 1. On first mount, always validate the HttpOnly cookie via GET /auth/me
|
|
// 2. If cookie valid -> restore session and render children
|
|
// 3. If cookie invalid -> clean up and redirect to /login
|
|
// 4. If already authenticated (from login flow) -> render immediately
|
|
//
|
|
// This eliminates the race condition where localStorage had account data
|
|
// but the HttpOnly cookie was expired, causing children to render and
|
|
// make failing API calls.
|
|
|
|
import { useEffect, useRef, useState } from 'react'
|
|
import { Navigate, useLocation } from 'react-router-dom'
|
|
import { Spin } from 'antd'
|
|
import { useAuthStore } from '@/stores/authStore'
|
|
import { authService } from '@/services/auth'
|
|
|
|
type GuardState = 'checking' | 'authenticated' | 'unauthenticated'
|
|
|
|
export function AuthGuard({ children }: { children: React.ReactNode }) {
|
|
const isAuthenticated = useAuthStore((s) => s.isAuthenticated)
|
|
const login = useAuthStore((s) => s.login)
|
|
const logout = useAuthStore((s) => s.logout)
|
|
const location = useLocation()
|
|
|
|
// Track validation attempt to avoid double-calling (React StrictMode)
|
|
const validated = useRef(false)
|
|
const [guardState, setGuardState] = useState<GuardState>(
|
|
isAuthenticated ? 'authenticated' : 'checking'
|
|
)
|
|
|
|
useEffect(() => {
|
|
// Already authenticated from login flow — skip validation
|
|
if (isAuthenticated) {
|
|
setGuardState('authenticated')
|
|
return
|
|
}
|
|
|
|
// Prevent double-validation in React StrictMode
|
|
if (validated.current) return
|
|
validated.current = true
|
|
|
|
// Validate HttpOnly cookie via /auth/me
|
|
authService.me()
|
|
.then((meAccount) => {
|
|
login(meAccount)
|
|
setGuardState('authenticated')
|
|
})
|
|
.catch(() => {
|
|
logout()
|
|
setGuardState('unauthenticated')
|
|
})
|
|
}, []) // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
if (guardState === 'checking') {
|
|
return (
|
|
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
|
|
<Spin size="large" />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (guardState === 'unauthenticated') {
|
|
return <Navigate to="/login" state={{ from: location }} replace />
|
|
}
|
|
|
|
return <>{children}</>
|
|
}
|