From 29ba73906345895ebf8472b288c28acea44a7cdd Mon Sep 17 00:00:00 2001 From: Viktor Barzin Date: Mon, 2 Feb 2026 20:08:03 +0000 Subject: [PATCH] Improve login UI with error handling and callback page --- crawler/frontend/src/App.tsx | 16 +- crawler/frontend/src/auth/authService.ts | 33 +++- crawler/frontend/src/auth/errors.ts | 60 +++++++ .../frontend/src/components/AuthCallback.tsx | 111 +++++++++++++ .../frontend/src/components/LoginModal.tsx | 148 ++++++++++++++---- 5 files changed, 324 insertions(+), 44 deletions(-) create mode 100644 crawler/frontend/src/auth/errors.ts create mode 100644 crawler/frontend/src/components/AuthCallback.tsx diff --git a/crawler/frontend/src/App.tsx b/crawler/frontend/src/App.tsx index 08be85c..5e90bdf 100644 --- a/crawler/frontend/src/App.tsx +++ b/crawler/frontend/src/App.tsx @@ -1,9 +1,10 @@ import type { User } from 'oidc-client-ts'; import { useEffect, useState, useRef, useCallback } from 'react'; import './App.css'; -import { getUser, handleCallback } from './auth/authService'; +import { getUser } from './auth/authService'; import AlertError from './components/AlertError'; import LoginModal from './components/LoginModal'; +import AuthCallback from './components/AuthCallback'; import { Map } from './components/Map'; import { FilterPanel, type ParameterValues, DEFAULT_FILTER_VALUES } from './components/FilterPanel'; import { Header } from './components/Header'; @@ -34,15 +35,12 @@ function App() { // Ref to track if initial load has been triggered const initialLoadTriggeredRef = useRef(false); - useEffect(() => { - // Check if this is a callback from Authentik (after login) - if (window.location.pathname === '/callback') { - handleCallback().then(() => { - window.location.href = '/'; // Redirect to home after login - }); - return; - } + // Check if this is the callback route - render dedicated component + if (window.location.pathname === '/callback') { + return ; + } + useEffect(() => { // Load user data getUser().then(setUser); }, []); diff --git a/crawler/frontend/src/auth/authService.ts b/crawler/frontend/src/auth/authService.ts index 726dbd3..c63b253 100644 --- a/crawler/frontend/src/auth/authService.ts +++ b/crawler/frontend/src/auth/authService.ts @@ -1,11 +1,36 @@ import { User, UserManager } from 'oidc-client-ts'; import { oidcConfig } from './config'; +import { parseOidcError, type AuthError } from './errors'; const userManager = new UserManager(oidcConfig); -export const login = () => userManager.signinRedirect(); -export const logout = () => userManager.signoutRedirect(); -export const handleCallback = () => userManager.signinRedirectCallback(); +export const login = async (): Promise => { + try { + await userManager.signinRedirect(); + } catch (error) { + console.error('Login redirect failed:', error); + throw parseOidcError(error); + } +}; + +export const logout = async (): Promise => { + try { + await userManager.signoutRedirect(); + } catch (error) { + console.error('Logout redirect failed:', error); + throw parseOidcError(error); + } +}; + +export const handleCallback = async (): Promise => { + try { + const user = await userManager.signinRedirectCallback(); + return user; + } catch (error) { + console.error('Callback handling failed:', error); + throw parseOidcError(error); + } +}; export const getUser = async (): Promise => { try { @@ -16,3 +41,5 @@ export const getUser = async (): Promise => { return null; } }; + +export type { AuthError }; diff --git a/crawler/frontend/src/auth/errors.ts b/crawler/frontend/src/auth/errors.ts new file mode 100644 index 0000000..ce82fc5 --- /dev/null +++ b/crawler/frontend/src/auth/errors.ts @@ -0,0 +1,60 @@ +export enum AuthErrorType { + REDIRECT_FAILED = 'REDIRECT_FAILED', + CALLBACK_FAILED = 'CALLBACK_FAILED', + NETWORK_ERROR = 'NETWORK_ERROR', + USER_CANCELLED = 'USER_CANCELLED', +} + +export interface AuthError { + type: AuthErrorType; + message: string; + retryable: boolean; +} + +export function parseOidcError(error: unknown): AuthError { + const errorMessage = error instanceof Error ? error.message : String(error); + const errorString = errorMessage.toLowerCase(); + + // Check for popup/redirect blocked errors + if (errorString.includes('popup') || errorString.includes('blocked') || errorString.includes('window')) { + return { + type: AuthErrorType.REDIRECT_FAILED, + message: 'Unable to redirect. Please check if popups are blocked.', + retryable: true, + }; + } + + // Check for user cancellation + if (errorString.includes('cancel') || errorString.includes('closed') || errorString.includes('denied')) { + return { + type: AuthErrorType.USER_CANCELLED, + message: 'Sign in was cancelled.', + retryable: true, + }; + } + + // Check for network errors + if (errorString.includes('network') || errorString.includes('fetch') || errorString.includes('timeout') || errorString.includes('failed to fetch')) { + return { + type: AuthErrorType.NETWORK_ERROR, + message: 'Unable to reach authentication server. Please check your connection.', + retryable: true, + }; + } + + // Check for callback/state errors + if (errorString.includes('state') || errorString.includes('invalid') || errorString.includes('mismatch') || errorString.includes('no matching state')) { + return { + type: AuthErrorType.CALLBACK_FAILED, + message: 'Login verification failed. Please try again.', + retryable: true, + }; + } + + // Default error + return { + type: AuthErrorType.CALLBACK_FAILED, + message: errorMessage || 'An unexpected error occurred during sign in.', + retryable: true, + }; +} diff --git a/crawler/frontend/src/components/AuthCallback.tsx b/crawler/frontend/src/components/AuthCallback.tsx new file mode 100644 index 0000000..165a4e3 --- /dev/null +++ b/crawler/frontend/src/components/AuthCallback.tsx @@ -0,0 +1,111 @@ +import React, { useEffect, useState } from 'react'; +import { handleCallback, login, type AuthError } from '@/auth/authService'; +import { Loader2, CheckCircle, AlertCircle, Home } from 'lucide-react'; +import { Button } from './ui/button'; + +type CallbackState = 'processing' | 'success' | 'error'; + +const AuthCallback: React.FC = () => { + const [state, setState] = useState('processing'); + const [error, setError] = useState(null); + + useEffect(() => { + const processCallback = async () => { + try { + await handleCallback(); + setState('success'); + // Auto-redirect after success + setTimeout(() => { + window.location.href = '/'; + }, 1500); + } catch (err) { + setError(err as AuthError); + setState('error'); + } + }; + + processCallback(); + }, []); + + const handleRetry = async () => { + setState('processing'); + setError(null); + try { + await login(); + } catch (err) { + setError(err as AuthError); + setState('error'); + } + }; + + const handleGoHome = () => { + window.location.href = '/'; + }; + + return ( +
+
+
+ {state === 'processing' && ( +
+
+
+ +
+
+
+

Completing Sign In

+

+ Please wait while we verify your credentials... +

+
+
+ )} + + {state === 'success' && ( +
+
+
+ +
+
+
+

Welcome Back!

+

+ Redirecting you to the dashboard... +

+
+
+ )} + + {state === 'error' && ( +
+
+
+ +
+
+
+

Sign In Failed

+

+ {error?.message || 'An unexpected error occurred.'} +

+
+
+ + +
+
+ )} +
+
+
+ ); +}; + +export default AuthCallback; diff --git a/crawler/frontend/src/components/LoginModal.tsx b/crawler/frontend/src/components/LoginModal.tsx index 556f9fd..1eaf4c5 100644 --- a/crawler/frontend/src/components/LoginModal.tsx +++ b/crawler/frontend/src/components/LoginModal.tsx @@ -1,43 +1,127 @@ -import { login } from '@/auth/authService'; +import { login, type AuthError } from '@/auth/authService'; import { Button } from "@/components/ui/button"; import { DialogDescription } from '@radix-ui/react-dialog'; import React, { useState } from 'react'; import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from './ui/dialog'; +import { Home, LogIn, AlertCircle, Loader2 } from 'lucide-react'; -interface ModalProps { - isOpen: boolean; +interface LoginModalProps { + isOpen: boolean; } -const Modal: React.FC = ({ - isOpen, -}) => { - if (!isOpen) return null; - const [isLoading, setIsLoading] = useState(false) +const LoginModal: React.FC = ({ isOpen }) => { + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); - return ( - -
- - - Login to Wrongmove - (We are currently in closed beta; ask Viktor to send you an invitation) + if (!isOpen) return null; - - - {isLoading && ( -
Signing in. Please wait...
- ) - } - -
-
-
-
- ) + const handleLogin = async () => { + setIsLoading(true); + setError(null); + try { + await login(); + } catch (err) { + setError(err as AuthError); + setIsLoading(false); + } + }; + + const handleRetry = () => { + setError(null); + handleLogin(); + }; + + const handleCancel = () => { + setError(null); + setIsLoading(false); + }; + + return ( + + + +
+
+ +
+
+ Wrongmove + + Your smart property search companion + +
+
+
+ +
+ {/* Beta Notice */} +
+

+ We are currently in closed beta. Please contact Viktor to request an invitation. +

+
+ + {/* Error State */} + {error && ( +
+ +
+

{error.message}

+
+ + +
+
+
+ )} + + {/* Loading State */} + {isLoading && !error && ( +
+ + Redirecting to login... +
+ )} +
+ + + {!error && ( + + )} + +
+
+ ); }; -export default Modal; +export default LoginModal;