Improve login UI with error handling and callback page
This commit is contained in:
parent
ceb943f198
commit
29ba739063
5 changed files with 324 additions and 44 deletions
|
|
@ -1,9 +1,10 @@
|
||||||
import type { User } from 'oidc-client-ts';
|
import type { User } from 'oidc-client-ts';
|
||||||
import { useEffect, useState, useRef, useCallback } from 'react';
|
import { useEffect, useState, useRef, useCallback } from 'react';
|
||||||
import './App.css';
|
import './App.css';
|
||||||
import { getUser, handleCallback } from './auth/authService';
|
import { getUser } from './auth/authService';
|
||||||
import AlertError from './components/AlertError';
|
import AlertError from './components/AlertError';
|
||||||
import LoginModal from './components/LoginModal';
|
import LoginModal from './components/LoginModal';
|
||||||
|
import AuthCallback from './components/AuthCallback';
|
||||||
import { Map } from './components/Map';
|
import { Map } from './components/Map';
|
||||||
import { FilterPanel, type ParameterValues, DEFAULT_FILTER_VALUES } from './components/FilterPanel';
|
import { FilterPanel, type ParameterValues, DEFAULT_FILTER_VALUES } from './components/FilterPanel';
|
||||||
import { Header } from './components/Header';
|
import { Header } from './components/Header';
|
||||||
|
|
@ -34,15 +35,12 @@ function App() {
|
||||||
// Ref to track if initial load has been triggered
|
// Ref to track if initial load has been triggered
|
||||||
const initialLoadTriggeredRef = useRef(false);
|
const initialLoadTriggeredRef = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
// Check if this is the callback route - render dedicated component
|
||||||
// Check if this is a callback from Authentik (after login)
|
if (window.location.pathname === '/callback') {
|
||||||
if (window.location.pathname === '/callback') {
|
return <AuthCallback />;
|
||||||
handleCallback().then(() => {
|
}
|
||||||
window.location.href = '/'; // Redirect to home after login
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
// Load user data
|
// Load user data
|
||||||
getUser().then(setUser);
|
getUser().then(setUser);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,36 @@
|
||||||
import { User, UserManager } from 'oidc-client-ts';
|
import { User, UserManager } from 'oidc-client-ts';
|
||||||
import { oidcConfig } from './config';
|
import { oidcConfig } from './config';
|
||||||
|
import { parseOidcError, type AuthError } from './errors';
|
||||||
|
|
||||||
const userManager = new UserManager(oidcConfig);
|
const userManager = new UserManager(oidcConfig);
|
||||||
|
|
||||||
export const login = () => userManager.signinRedirect();
|
export const login = async (): Promise<void> => {
|
||||||
export const logout = () => userManager.signoutRedirect();
|
try {
|
||||||
export const handleCallback = () => userManager.signinRedirectCallback();
|
await userManager.signinRedirect();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Login redirect failed:', error);
|
||||||
|
throw parseOidcError(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const logout = async (): Promise<void> => {
|
||||||
|
try {
|
||||||
|
await userManager.signoutRedirect();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Logout redirect failed:', error);
|
||||||
|
throw parseOidcError(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const handleCallback = async (): Promise<User> => {
|
||||||
|
try {
|
||||||
|
const user = await userManager.signinRedirectCallback();
|
||||||
|
return user;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Callback handling failed:', error);
|
||||||
|
throw parseOidcError(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const getUser = async (): Promise<User | null> => {
|
export const getUser = async (): Promise<User | null> => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -16,3 +41,5 @@ export const getUser = async (): Promise<User | null> => {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type { AuthError };
|
||||||
|
|
|
||||||
60
crawler/frontend/src/auth/errors.ts
Normal file
60
crawler/frontend/src/auth/errors.ts
Normal file
|
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
111
crawler/frontend/src/components/AuthCallback.tsx
Normal file
111
crawler/frontend/src/components/AuthCallback.tsx
Normal file
|
|
@ -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<CallbackState>('processing');
|
||||||
|
const [error, setError] = useState<AuthError | null>(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 (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-background p-4">
|
||||||
|
<div className="w-full max-w-md">
|
||||||
|
<div className="bg-card border rounded-xl shadow-lg p-8">
|
||||||
|
{state === 'processing' && (
|
||||||
|
<div className="text-center space-y-4">
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<div className="p-4 bg-primary/10 rounded-full">
|
||||||
|
<Loader2 className="h-8 w-8 text-primary animate-spin" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h1 className="text-xl font-semibold">Completing Sign In</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Please wait while we verify your credentials...
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{state === 'success' && (
|
||||||
|
<div className="text-center space-y-4">
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<div className="p-4 bg-green-500/10 rounded-full">
|
||||||
|
<CheckCircle className="h-8 w-8 text-green-500" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h1 className="text-xl font-semibold">Welcome Back!</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Redirecting you to the dashboard...
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{state === 'error' && (
|
||||||
|
<div className="text-center space-y-6">
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<div className="p-4 bg-destructive/10 rounded-full">
|
||||||
|
<AlertCircle className="h-8 w-8 text-destructive" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h1 className="text-xl font-semibold">Sign In Failed</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
{error?.message || 'An unexpected error occurred.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col sm:flex-row gap-3 justify-center">
|
||||||
|
<Button onClick={handleRetry} className="gap-2">
|
||||||
|
Try Again
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" onClick={handleGoHome} className="gap-2">
|
||||||
|
<Home className="h-4 w-4" />
|
||||||
|
Go Home
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AuthCallback;
|
||||||
|
|
@ -1,43 +1,127 @@
|
||||||
import { login } from '@/auth/authService';
|
import { login, type AuthError } from '@/auth/authService';
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { DialogDescription } from '@radix-ui/react-dialog';
|
import { DialogDescription } from '@radix-ui/react-dialog';
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from './ui/dialog';
|
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from './ui/dialog';
|
||||||
|
import { Home, LogIn, AlertCircle, Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
interface ModalProps {
|
interface LoginModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Modal: React.FC<ModalProps> = ({
|
const LoginModal: React.FC<LoginModalProps> = ({ isOpen }) => {
|
||||||
isOpen,
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
}) => {
|
const [error, setError] = useState<AuthError | null>(null);
|
||||||
if (!isOpen) return null;
|
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
|
||||||
|
|
||||||
return (
|
if (!isOpen) return null;
|
||||||
<Dialog open={isOpen}>
|
|
||||||
<form>
|
|
||||||
<DialogContent className="sm:max-w-[425px]">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Login to Wrongmove</DialogTitle>
|
|
||||||
<DialogDescription>(We are currently in closed beta; ask Viktor to send you an invitation)</DialogDescription>
|
|
||||||
|
|
||||||
</DialogHeader>
|
const handleLogin = async () => {
|
||||||
<DialogFooter>
|
setIsLoading(true);
|
||||||
{isLoading && (
|
setError(null);
|
||||||
<div>Signing in. Please wait...</div>
|
try {
|
||||||
)
|
await login();
|
||||||
}
|
} catch (err) {
|
||||||
<Button onClick={
|
setError(err as AuthError);
|
||||||
() => {
|
setIsLoading(false);
|
||||||
setIsLoading(true)
|
}
|
||||||
login()
|
};
|
||||||
}} disabled={isLoading}>Login</Button>
|
|
||||||
</DialogFooter>
|
const handleRetry = () => {
|
||||||
</DialogContent>
|
setError(null);
|
||||||
</form>
|
handleLogin();
|
||||||
</Dialog>
|
};
|
||||||
)
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
setError(null);
|
||||||
|
setIsLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={isOpen}>
|
||||||
|
<DialogContent className="sm:max-w-[425px]">
|
||||||
|
<DialogHeader className="space-y-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 bg-primary/10 rounded-lg">
|
||||||
|
<Home className="h-6 w-6 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<DialogTitle className="text-xl">Wrongmove</DialogTitle>
|
||||||
|
<DialogDescription className="text-sm text-muted-foreground">
|
||||||
|
Your smart property search companion
|
||||||
|
</DialogDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="py-4 space-y-4">
|
||||||
|
{/* Beta Notice */}
|
||||||
|
<div className="bg-muted/50 border rounded-lg p-4 text-sm">
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
We are currently in closed beta. Please contact Viktor to request an invitation.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error State */}
|
||||||
|
{error && (
|
||||||
|
<div className="bg-destructive/10 border border-destructive/30 rounded-lg p-4 flex items-start gap-3">
|
||||||
|
<AlertCircle className="h-5 w-5 text-destructive shrink-0 mt-0.5" />
|
||||||
|
<div className="flex-1 space-y-2">
|
||||||
|
<p className="text-sm text-destructive">{error.message}</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleRetry}
|
||||||
|
className="text-destructive border-destructive/30 hover:bg-destructive/10"
|
||||||
|
>
|
||||||
|
Try Again
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={handleCancel}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Loading State */}
|
||||||
|
{isLoading && !error && (
|
||||||
|
<div className="flex items-center justify-center gap-3 py-4 text-muted-foreground">
|
||||||
|
<Loader2 className="h-5 w-5 animate-spin" />
|
||||||
|
<span>Redirecting to login...</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
{!error && (
|
||||||
|
<Button
|
||||||
|
onClick={handleLogin}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="w-full gap-2"
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
Signing in...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<LogIn className="h-4 w-4" />
|
||||||
|
Sign in with SSO
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Modal;
|
export default LoginModal;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue