feat(auth): R4 auth reliability — fix PKCE race condition + RequireAuth loading skeleton

This commit is contained in:
CC Worker 2026-06-01 02:16:05 +00:00
parent 0150ca3c32
commit ade0be97f6
3 changed files with 61 additions and 13 deletions

View File

@ -45,8 +45,6 @@ import {
} from './pages/timetable';
const FullContextRoutes: React.FC = () => {
// Only block on Supabase profile being ready — Neo4j contexts initialize in the background.
// Individual pages handle their own Neo4j loading states.
const { isInitialized: isUserInitialized } = useUser();
if (!isUserInitialized) {
@ -68,6 +66,31 @@ const FullContextRoutes: React.FC = () => {
return <Outlet />;
};
const RequireAuth: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { user, isAuthResolving } = useAuth();
if (isAuthResolving) {
return (
<div
style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: '100vh'
}}
>
<CircularProgress />
</div>
);
}
if (!user) {
return <Navigate to="/login" replace />;
}
return <>{children}</>;
};
const AppRoutes: React.FC = () => {
const { user, user_role, loading: isAuthLoading } = useAuth();
const location = useLocation();
@ -136,7 +159,7 @@ const AppRoutes: React.FC = () => {
/>
{/* Authentication only routes - only render if all contexts are initialized */}
{user && (
<RequireAuth>
<Route element={<FullContextRoutes />}>
{/* Timetable Module Routes */}
<Route path="/timetable" element={<TimetablePage />} />
@ -167,7 +190,7 @@ const AppRoutes: React.FC = () => {
<Route path="/calendar" element={<CalendarPage />} />
<Route path="/settings" element={<SettingsPage />} />
</Route>
)}
</RequireAuth>
{/* Fallback route - authenticated users go to dashboard, unauthenticated see public 404 */}
<Route path="*" element={user ? <Navigate to="/dashboard" replace /> : <NotFoundPublic />} />

View File

@ -18,6 +18,7 @@ export interface AuthContextType {
signOut: () => Promise<void>;
clearError: () => void;
bootstrapData: BootstrapResponse | null;
isAuthResolving: boolean;
}
export const AuthContext = createContext<AuthContextType>({
@ -29,7 +30,8 @@ export const AuthContext = createContext<AuthContextType>({
signIn: async () => {},
signOut: async () => {},
clearError: () => {},
bootstrapData: null
bootstrapData: null,
isAuthResolving: false
});
export function AuthProvider({ children }: { children: React.ReactNode }) {
@ -40,6 +42,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
const [bootstrapData, setBootstrapData] = useState<BootstrapResponse | null>(null);
const [isAuthResolving, setIsAuthResolving] = useState(false);
const persistSession = useCallback((session: Session | null) => {
@ -92,6 +95,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
if (event === 'SIGNED_IN') {
persistSession(session ?? null);
setIsAuthResolving(!!session?.user);
if (session?.user) {
try {
const { user: resolvedUser, role } = await buildUserFromSupabase(session.user);
@ -112,11 +116,13 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
setAccessToken(null);
}
setLoading(false);
setIsAuthResolving(false);
return;
}
if (event === 'INITIAL_SESSION' || event === 'TOKEN_REFRESHED') {
persistSession(session ?? null);
setIsAuthResolving(!!session?.user);
if (session?.user) {
try {
const { user: resolvedUser, role } = await buildUserFromSupabase(session.user);
@ -137,11 +143,13 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
setAccessToken(null);
}
setLoading(false);
setIsAuthResolving(false);
return;
}
if (event === 'SIGNED_OUT') {
persistSession(null);
setIsAuthResolving(false);
setUser(null);
setUserRole(null);
setAccessToken(null);
@ -214,7 +222,8 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
signIn,
signOut,
clearError,
bootstrapData
bootstrapData,
isAuthResolving
}}
>
{children}

View File

@ -6,34 +6,50 @@ import { EmailLoginForm } from './EmailLoginForm';
import { EmailCredentials } from '../../services/auth/authService';
import { logger } from '../../debugConfig';
const getLoginErrorMessage = (error: unknown): string => {
const status = (error as { status?: number } | null)?.status;
const code = (error as { code?: string } | null)?.code;
if (code === 'email_not_confirmed' || status === 401 && code === 'invalid_grant') {
return 'Please confirm your email before logging in';
}
if (status === 401 || code === 'invalid_grant') {
return 'Invalid email or password';
}
return error instanceof Error ? error.message : 'Login failed';
};
const LoginPage: React.FC = () => {
const navigate = useNavigate();
const { user, signIn } = useAuth();
const { user, signIn, isAuthResolving } = useAuth();
const [error, setError] = useState<string | null>(null);
logger.debug('login-page', '🔍 Login page loaded', {
hasUser: !!user
hasUser: !!user,
isAuthResolving
});
useEffect(() => {
if (user) {
if (!isAuthResolving && user) {
navigate('/dashboard');
}
}, [user, navigate]);
}, [user, isAuthResolving, navigate]);
const handleLogin = async (credentials: EmailCredentials) => {
try {
setError(null);
await signIn(credentials.email, credentials.password);
navigate('/dashboard');
} catch (error) {
logger.error('login-page', '❌ Login failed', error);
setError(error instanceof Error ? error.message : 'Login failed');
const message = getLoginErrorMessage(error);
setError(message);
throw error;
}
};
if (user) {
if (user && !isAuthResolving) {
return null;
}