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';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
CircularProgress,
|
CircularProgress,
|
||||||
Link as MuiLink,
|
Link as MuiLink,
|
||||||
} from '@mui/material';
|
} 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 { useForm } from 'react-hook-form';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
@@ -23,6 +23,8 @@ import * as z from 'zod';
|
|||||||
import { useAuth } from '@/lib/auth/AuthContext';
|
import { useAuth } from '@/lib/auth/AuthContext';
|
||||||
import { MFAVerificationDialog } from '@/components/auth/MFAVerificationDialog';
|
import { MFAVerificationDialog } from '@/components/auth/MFAVerificationDialog';
|
||||||
import { tokenStorage } from '@/lib/utils/tokenStorage';
|
import { tokenStorage } from '@/lib/utils/tokenStorage';
|
||||||
|
import { biometricApi } from '@/lib/api/biometric';
|
||||||
|
import { startAuthentication } from '@simplewebauthn/browser';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
const loginSchema = z.object({
|
const loginSchema = z.object({
|
||||||
@@ -36,6 +38,8 @@ export default function LoginPage() {
|
|||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [isBiometricLoading, setIsBiometricLoading] = useState(false);
|
||||||
|
const [isBiometricSupported, setIsBiometricSupported] = useState(false);
|
||||||
const [mfaRequired, setMfaRequired] = useState(false);
|
const [mfaRequired, setMfaRequired] = useState(false);
|
||||||
const [mfaData, setMfaData] = useState<{ userId: string; mfaMethod: 'totp' | 'email' } | null>(null);
|
const [mfaData, setMfaData] = useState<{ userId: string; mfaMethod: 'totp' | 'email' } | null>(null);
|
||||||
const { login } = useAuth();
|
const { login } = useAuth();
|
||||||
@@ -45,10 +49,64 @@ export default function LoginPage() {
|
|||||||
register,
|
register,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
formState: { errors },
|
formState: { errors },
|
||||||
|
watch,
|
||||||
} = useForm<LoginFormData>({
|
} = useForm<LoginFormData>({
|
||||||
resolver: zodResolver(loginSchema),
|
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) => {
|
const onSubmit = async (data: LoginFormData) => {
|
||||||
setError(null);
|
setError(null);
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
@@ -229,6 +287,20 @@ export default function LoginPage() {
|
|||||||
Continue with Apple
|
Continue with Apple
|
||||||
</Button>
|
</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' }}>
|
<Box sx={{ mt: 3, textAlign: 'center' }}>
|
||||||
<Typography variant="body2" color="text.secondary">
|
<Typography variant="body2" color="text.secondary">
|
||||||
Don't have an account?{' '}
|
Don't have an account?{' '}
|
||||||
|
|||||||
Reference in New Issue
Block a user