Add biometric login button to login page
- Add biometric authentication button with Face ID/Touch ID/Windows Hello support - Check WebAuthn support and platform authenticator availability on mount - Handle biometric login flow with proper error handling - Show biometric button only when device supports it - Add loading states and user-friendly error messages 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import {
|
||||
Box,
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
CircularProgress,
|
||||
Link as MuiLink,
|
||||
} from '@mui/material';
|
||||
import { Visibility, VisibilityOff, Google, Apple } from '@mui/icons-material';
|
||||
import { Visibility, VisibilityOff, Google, Apple, Fingerprint } from '@mui/icons-material';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { motion } from 'framer-motion';
|
||||
@@ -23,6 +23,8 @@ import * as z from 'zod';
|
||||
import { useAuth } from '@/lib/auth/AuthContext';
|
||||
import { MFAVerificationDialog } from '@/components/auth/MFAVerificationDialog';
|
||||
import { tokenStorage } from '@/lib/utils/tokenStorage';
|
||||
import { biometricApi } from '@/lib/api/biometric';
|
||||
import { startAuthentication } from '@simplewebauthn/browser';
|
||||
import Link from 'next/link';
|
||||
|
||||
const loginSchema = z.object({
|
||||
@@ -36,6 +38,8 @@ export default function LoginPage() {
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isBiometricLoading, setIsBiometricLoading] = useState(false);
|
||||
const [isBiometricSupported, setIsBiometricSupported] = useState(false);
|
||||
const [mfaRequired, setMfaRequired] = useState(false);
|
||||
const [mfaData, setMfaData] = useState<{ userId: string; mfaMethod: 'totp' | 'email' } | null>(null);
|
||||
const { login } = useAuth();
|
||||
@@ -45,10 +49,64 @@ export default function LoginPage() {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
watch,
|
||||
} = useForm<LoginFormData>({
|
||||
resolver: zodResolver(loginSchema),
|
||||
});
|
||||
|
||||
const email = watch('email');
|
||||
|
||||
// Check biometric support on mount
|
||||
useEffect(() => {
|
||||
checkBiometricSupport();
|
||||
}, []);
|
||||
|
||||
const checkBiometricSupport = async () => {
|
||||
const supported = biometricApi.isSupported();
|
||||
if (supported) {
|
||||
const available = await biometricApi.isPlatformAuthenticatorAvailable();
|
||||
setIsBiometricSupported(available);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBiometricLogin = async () => {
|
||||
setError(null);
|
||||
setIsBiometricLoading(true);
|
||||
|
||||
try {
|
||||
// Get authentication options from server
|
||||
const options = await biometricApi.getAuthenticationOptions(email || undefined);
|
||||
|
||||
// Start WebAuthn authentication (triggers Face ID/Touch ID/Windows Hello)
|
||||
const authenticationResponse = await startAuthentication(options);
|
||||
|
||||
// Send response to server for verification and get tokens
|
||||
const result = await biometricApi.verifyAuthentication(
|
||||
authenticationResponse,
|
||||
email || undefined,
|
||||
{
|
||||
deviceId: authenticationResponse.id.substring(0, 10),
|
||||
platform: navigator.userAgent,
|
||||
}
|
||||
);
|
||||
|
||||
// Store tokens and navigate
|
||||
tokenStorage.setTokens(result.tokens.accessToken, result.tokens.refreshToken);
|
||||
router.push('/');
|
||||
} catch (err: any) {
|
||||
console.error('Biometric login failed:', err);
|
||||
if (err.name === 'NotAllowedError') {
|
||||
setError('Biometric authentication was cancelled');
|
||||
} else if (err.name === 'NotSupportedError') {
|
||||
setError('Biometric authentication is not supported on this device');
|
||||
} else {
|
||||
setError(err.response?.data?.message || err.message || 'Biometric login failed. Please try again.');
|
||||
}
|
||||
} finally {
|
||||
setIsBiometricLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onSubmit = async (data: LoginFormData) => {
|
||||
setError(null);
|
||||
setIsLoading(true);
|
||||
@@ -229,6 +287,20 @@ export default function LoginPage() {
|
||||
Continue with Apple
|
||||
</Button>
|
||||
|
||||
{isBiometricSupported && (
|
||||
<Button
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
startIcon={isBiometricLoading ? <CircularProgress size={20} /> : <Fingerprint />}
|
||||
size="large"
|
||||
disabled={isLoading || isBiometricLoading}
|
||||
onClick={handleBiometricLogin}
|
||||
sx={{ mt: 2 }}
|
||||
>
|
||||
{isBiometricLoading ? 'Authenticating...' : 'Sign in with Biometrics'}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Box sx={{ mt: 3, textAlign: 'center' }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Don't have an account?{' '}
|
||||
|
||||
Reference in New Issue
Block a user