wrongmove/crawler/frontend/src/components/LoginModal.tsx
Viktor Barzin a8b7eace48
Add passkey (WebAuthn) authentication with self-registration
Enable users to sign up and sign in using passkeys (biometrics/security
keys) without needing a manually-created Authentik account. The existing
SSO login remains as an alternative.

Backend:
- Add WebAuthn registration/authentication endpoints via py-webauthn
- Issue HS256 JWTs for passkey users, with Redis-backed challenge storage
- Dual JWT verification in auth middleware (issuer-based routing: passkey
  HS256 vs Authentik RS256)
- PasskeyCredential model + migration making user.password nullable
- UserRepository with full CRUD for users and credentials

Frontend:
- AuthUser type abstraction unifying OIDC and passkey users
- Passkey service using @simplewebauthn/browser for WebAuthn ceremonies
- LoginModal redesigned with Sign In / Sign Up tabs
- Type migration from oidc-client-ts User to AuthUser across all services
  and components
2026-02-07 00:34:47 +00:00

189 lines
6.4 KiB
TypeScript

import { login, type AuthError } from '@/auth/authService';
import { registerPasskey, loginWithPasskey } from '@/auth/passkeyService';
import type { AuthUser } from '@/auth/types';
import { Button } from "@/components/ui/button";
import { DialogDescription } from '@radix-ui/react-dialog';
import React, { useState } from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from './ui/dialog';
import { Tabs, TabsContent, TabsList, TabsTrigger } from './ui/tabs';
import { Home, LogIn, AlertCircle, Loader2, Fingerprint, Mail } from 'lucide-react';
interface LoginModalProps {
isOpen: boolean;
onPasskeyLogin: (user: AuthUser) => void;
}
const LoginModal: React.FC<LoginModalProps> = ({ isOpen, onPasskeyLogin }) => {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [email, setEmail] = useState('');
if (!isOpen) return null;
const handleSSOLogin = async () => {
setIsLoading(true);
setError(null);
try {
await login();
} catch (err) {
setError((err as AuthError).message);
setIsLoading(false);
}
};
const handlePasskeyLogin = async () => {
setIsLoading(true);
setError(null);
try {
const user = await loginWithPasskey();
onPasskeyLogin(user);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setIsLoading(false);
}
};
const handlePasskeyRegister = async () => {
if (!email.trim()) {
setError('Please enter your email address');
return;
}
setIsLoading(true);
setError(null);
try {
const user = await registerPasskey(email.trim());
onPasskeyLogin(user);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setIsLoading(false);
}
};
const clearError = () => setError(null);
return (
<Dialog open={isOpen}>
<DialogContent className="sm:max-w-[425px]" showCloseButton={false}>
<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-2">
{/* Error State */}
{error && (
<div className="bg-destructive/10 border border-destructive/30 rounded-lg p-4 flex items-start gap-3 mb-4">
<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}</p>
<Button
size="sm"
variant="ghost"
onClick={clearError}
>
Dismiss
</Button>
</div>
</div>
)}
<Tabs defaultValue="signin">
<TabsList>
<TabsTrigger value="signin">Sign In</TabsTrigger>
<TabsTrigger value="signup">Sign Up</TabsTrigger>
</TabsList>
<TabsContent value="signin" className="space-y-4 pt-2">
<Button
onClick={handlePasskeyLogin}
disabled={isLoading}
className="w-full gap-2"
size="lg"
>
{isLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Fingerprint className="h-4 w-4" />
)}
Sign in with Passkey
</Button>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background px-2 text-muted-foreground">or</span>
</div>
</div>
<Button
onClick={handleSSOLogin}
disabled={isLoading}
variant="outline"
className="w-full gap-2"
size="lg"
>
{isLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<LogIn className="h-4 w-4" />
)}
Sign in with SSO
</Button>
</TabsContent>
<TabsContent value="signup" className="space-y-4 pt-2">
<div className="space-y-2">
<div className="relative">
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<input
type="email"
placeholder="Email address"
value={email}
onChange={(e) => setEmail(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handlePasskeyRegister();
}}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 pl-10 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
/>
</div>
</div>
<Button
onClick={handlePasskeyRegister}
disabled={isLoading || !email.trim()}
className="w-full gap-2"
size="lg"
>
{isLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Fingerprint className="h-4 w-4" />
)}
Create account with Passkey
</Button>
<p className="text-xs text-muted-foreground text-center">
A passkey uses your device's biometrics or security key for secure, passwordless authentication.
</p>
</TabsContent>
</Tabs>
</div>
</DialogContent>
</Dialog>
);
};
export default LoginModal;