feat(auth): R4 auth reliability — fix PKCE race condition + RequireAuth loading skeleton
This commit is contained in:
parent
0150ca3c32
commit
ade0be97f6
@ -45,8 +45,6 @@ import {
|
|||||||
} from './pages/timetable';
|
} from './pages/timetable';
|
||||||
|
|
||||||
const FullContextRoutes: React.FC = () => {
|
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();
|
const { isInitialized: isUserInitialized } = useUser();
|
||||||
|
|
||||||
if (!isUserInitialized) {
|
if (!isUserInitialized) {
|
||||||
@ -68,6 +66,31 @@ const FullContextRoutes: React.FC = () => {
|
|||||||
return <Outlet />;
|
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 AppRoutes: React.FC = () => {
|
||||||
const { user, user_role, loading: isAuthLoading } = useAuth();
|
const { user, user_role, loading: isAuthLoading } = useAuth();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
@ -136,7 +159,7 @@ const AppRoutes: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Authentication only routes - only render if all contexts are initialized */}
|
{/* Authentication only routes - only render if all contexts are initialized */}
|
||||||
{user && (
|
<RequireAuth>
|
||||||
<Route element={<FullContextRoutes />}>
|
<Route element={<FullContextRoutes />}>
|
||||||
{/* Timetable Module Routes */}
|
{/* Timetable Module Routes */}
|
||||||
<Route path="/timetable" element={<TimetablePage />} />
|
<Route path="/timetable" element={<TimetablePage />} />
|
||||||
@ -167,7 +190,7 @@ const AppRoutes: React.FC = () => {
|
|||||||
<Route path="/calendar" element={<CalendarPage />} />
|
<Route path="/calendar" element={<CalendarPage />} />
|
||||||
<Route path="/settings" element={<SettingsPage />} />
|
<Route path="/settings" element={<SettingsPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
)}
|
</RequireAuth>
|
||||||
|
|
||||||
{/* Fallback route - authenticated users go to dashboard, unauthenticated see public 404 */}
|
{/* Fallback route - authenticated users go to dashboard, unauthenticated see public 404 */}
|
||||||
<Route path="*" element={user ? <Navigate to="/dashboard" replace /> : <NotFoundPublic />} />
|
<Route path="*" element={user ? <Navigate to="/dashboard" replace /> : <NotFoundPublic />} />
|
||||||
|
|||||||
@ -18,6 +18,7 @@ export interface AuthContextType {
|
|||||||
signOut: () => Promise<void>;
|
signOut: () => Promise<void>;
|
||||||
clearError: () => void;
|
clearError: () => void;
|
||||||
bootstrapData: BootstrapResponse | null;
|
bootstrapData: BootstrapResponse | null;
|
||||||
|
isAuthResolving: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AuthContext = createContext<AuthContextType>({
|
export const AuthContext = createContext<AuthContextType>({
|
||||||
@ -29,7 +30,8 @@ export const AuthContext = createContext<AuthContextType>({
|
|||||||
signIn: async () => {},
|
signIn: async () => {},
|
||||||
signOut: async () => {},
|
signOut: async () => {},
|
||||||
clearError: () => {},
|
clearError: () => {},
|
||||||
bootstrapData: null
|
bootstrapData: null,
|
||||||
|
isAuthResolving: false
|
||||||
});
|
});
|
||||||
|
|
||||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||||
@ -40,6 +42,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<Error | null>(null);
|
const [error, setError] = useState<Error | null>(null);
|
||||||
const [bootstrapData, setBootstrapData] = useState<BootstrapResponse | null>(null);
|
const [bootstrapData, setBootstrapData] = useState<BootstrapResponse | null>(null);
|
||||||
|
const [isAuthResolving, setIsAuthResolving] = useState(false);
|
||||||
|
|
||||||
|
|
||||||
const persistSession = useCallback((session: Session | null) => {
|
const persistSession = useCallback((session: Session | null) => {
|
||||||
@ -92,6 +95,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
|
|
||||||
if (event === 'SIGNED_IN') {
|
if (event === 'SIGNED_IN') {
|
||||||
persistSession(session ?? null);
|
persistSession(session ?? null);
|
||||||
|
setIsAuthResolving(!!session?.user);
|
||||||
if (session?.user) {
|
if (session?.user) {
|
||||||
try {
|
try {
|
||||||
const { user: resolvedUser, role } = await buildUserFromSupabase(session.user);
|
const { user: resolvedUser, role } = await buildUserFromSupabase(session.user);
|
||||||
@ -112,11 +116,13 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
setAccessToken(null);
|
setAccessToken(null);
|
||||||
}
|
}
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
setIsAuthResolving(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event === 'INITIAL_SESSION' || event === 'TOKEN_REFRESHED') {
|
if (event === 'INITIAL_SESSION' || event === 'TOKEN_REFRESHED') {
|
||||||
persistSession(session ?? null);
|
persistSession(session ?? null);
|
||||||
|
setIsAuthResolving(!!session?.user);
|
||||||
if (session?.user) {
|
if (session?.user) {
|
||||||
try {
|
try {
|
||||||
const { user: resolvedUser, role } = await buildUserFromSupabase(session.user);
|
const { user: resolvedUser, role } = await buildUserFromSupabase(session.user);
|
||||||
@ -137,11 +143,13 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
setAccessToken(null);
|
setAccessToken(null);
|
||||||
}
|
}
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
setIsAuthResolving(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event === 'SIGNED_OUT') {
|
if (event === 'SIGNED_OUT') {
|
||||||
persistSession(null);
|
persistSession(null);
|
||||||
|
setIsAuthResolving(false);
|
||||||
setUser(null);
|
setUser(null);
|
||||||
setUserRole(null);
|
setUserRole(null);
|
||||||
setAccessToken(null);
|
setAccessToken(null);
|
||||||
@ -214,7 +222,8 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
signIn,
|
signIn,
|
||||||
signOut,
|
signOut,
|
||||||
clearError,
|
clearError,
|
||||||
bootstrapData
|
bootstrapData,
|
||||||
|
isAuthResolving
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@ -6,34 +6,50 @@ import { EmailLoginForm } from './EmailLoginForm';
|
|||||||
import { EmailCredentials } from '../../services/auth/authService';
|
import { EmailCredentials } from '../../services/auth/authService';
|
||||||
import { logger } from '../../debugConfig';
|
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 LoginPage: React.FC = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { user, signIn } = useAuth();
|
const { user, signIn, isAuthResolving } = useAuth();
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
logger.debug('login-page', '🔍 Login page loaded', {
|
logger.debug('login-page', '🔍 Login page loaded', {
|
||||||
hasUser: !!user
|
hasUser: !!user,
|
||||||
|
isAuthResolving
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (user) {
|
if (!isAuthResolving && user) {
|
||||||
navigate('/dashboard');
|
navigate('/dashboard');
|
||||||
}
|
}
|
||||||
}, [user, navigate]);
|
}, [user, isAuthResolving, navigate]);
|
||||||
|
|
||||||
const handleLogin = async (credentials: EmailCredentials) => {
|
const handleLogin = async (credentials: EmailCredentials) => {
|
||||||
try {
|
try {
|
||||||
setError(null);
|
setError(null);
|
||||||
await signIn(credentials.email, credentials.password);
|
await signIn(credentials.email, credentials.password);
|
||||||
navigate('/dashboard');
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('login-page', '❌ Login failed', error);
|
logger.error('login-page', '❌ Login failed', error);
|
||||||
setError(error instanceof Error ? error.message : 'Login failed');
|
const message = getLoginErrorMessage(error);
|
||||||
|
setError(message);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (user) {
|
if (user && !isAuthResolving) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user