Skip to content

Commit 06969f4

Browse files
committed
feat: Add passkey autofill to login and enhance passkey management error handling.
1 parent d1dff54 commit 06969f4

9 files changed

Lines changed: 576 additions & 166 deletions

File tree

client/src/Pages/Music/BottomPlayer/index.jsx

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,8 +49,6 @@ const BottomPlayer = () => {
4949
getRecommendations();
5050
}, [getRecommendations]);
5151

52-
console.log(currentSong);
53-
5452
if (!currentSong) return null;
5553

5654
return (

client/src/components/Auth/Login.jsx

Lines changed: 85 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,10 @@ import { Form, FormControl, FormField, FormItem, FormMessage } from '@/component
44
import { Input } from '@/components/ui/input';
55
import { useProfile } from '@/Context/Context';
66
import { yupResolver } from '@hookform/resolvers/yup';
7+
import { startAuthentication, browserSupportsWebAuthnAutofill } from '@simplewebauthn/browser';
78
import axios from 'axios';
89
import { Eye, EyeOff, KeyRound, Loader2Icon } from 'lucide-react';
9-
import { useEffect, useState } from 'react';
10+
import { useCallback, useEffect, useRef, useState } from 'react';
1011
import { useForm } from 'react-hook-form';
1112
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
1213
import { toast } from 'sonner';
@@ -34,6 +35,7 @@ const Login = () => {
3435
const navigate = useNavigate();
3536
const [searchParams] = useSearchParams();
3637
const returnPath = searchParams.get('returnTo') || '/feed';
38+
const passkeyAuthStarted = useRef(false);
3739

3840
const form = useForm({
3941
resolver: yupResolver(validationSchema),
@@ -62,10 +64,84 @@ const Login = () => {
6264
setUserData(data.data);
6365
}
6466
} catch (error) {
65-
console.log(error);
67+
console.debug('Failed to fetch IP info:', error);
6668
}
6769
};
6870

71+
// Handle passkey authentication with conditional UI (autofill)
72+
const handlePasskeyAuth = useCallback(
73+
async (assertionResponse, email) => {
74+
try {
75+
setLoading(true);
76+
const verificationRes = await axios.post(
77+
`${import.meta.env.VITE_API_URL}/api/auth/passkey/authenticate/verify`,
78+
{
79+
assertionResponse,
80+
email,
81+
userData,
82+
},
83+
{ withCredentials: true }
84+
);
85+
86+
if (verificationRes.data.verified) {
87+
await getProfile();
88+
toast.success('Logged in with passkey!');
89+
navigate(returnPath);
90+
}
91+
} catch (error) {
92+
console.error('Passkey auth error:', error);
93+
toast.error(error.response?.data?.message || 'Passkey authentication failed');
94+
} finally {
95+
setLoading(false);
96+
}
97+
},
98+
[userData, getProfile, navigate, returnPath]
99+
);
100+
101+
// Initialize conditional UI (passkey autofill) on mount
102+
useEffect(() => {
103+
let isMounted = true;
104+
105+
const initConditionalUI = async () => {
106+
if (passkeyAuthStarted.current) return;
107+
108+
try {
109+
const supportsAutofill = await browserSupportsWebAuthnAutofill();
110+
if (!supportsAutofill || !isMounted) return;
111+
112+
passkeyAuthStarted.current = true;
113+
114+
const optionsRes = await axios.post(
115+
`${import.meta.env.VITE_API_URL}/api/auth/passkey/authenticate/conditional`
116+
);
117+
118+
if (!isMounted) return;
119+
120+
const assertionResponse = await startAuthentication({
121+
optionsJSON: optionsRes.data,
122+
useBrowserAutofill: true,
123+
});
124+
125+
if (assertionResponse && isMounted) {
126+
await handlePasskeyAuth(assertionResponse, null);
127+
}
128+
} catch (error) {
129+
// AbortError is expected when user navigates away or doesn't select a passkey
130+
if (error.name !== 'AbortError' && error.name !== 'NotAllowedError') {
131+
console.error('Passkey autofill error:', error.message);
132+
}
133+
passkeyAuthStarted.current = false;
134+
}
135+
};
136+
137+
initConditionalUI();
138+
139+
return () => {
140+
isMounted = false;
141+
passkeyAuthStarted.current = false;
142+
};
143+
}, [handlePasskeyAuth]);
144+
69145
const handleFormSubmit = async (data) => {
70146
setLoading(true);
71147
try {
@@ -175,7 +251,12 @@ const Login = () => {
175251
render={({ field }) => (
176252
<FormItem>
177253
<FormControl>
178-
<Input {...field} type='email' placeholder='Email' />
254+
<Input
255+
{...field}
256+
type='email'
257+
placeholder='Email'
258+
autoComplete='username webauthn'
259+
/>
179260
</FormControl>
180261
<FormMessage />
181262
</FormItem>
@@ -193,6 +274,7 @@ const Login = () => {
193274
{...field}
194275
type={showPassword ? 'text' : 'password'}
195276
placeholder='Password'
277+
autoComplete='current-password'
196278
/>
197279
<Button
198280
type='button'

client/src/components/Auth/PassKeyLogin.jsx

Lines changed: 106 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -22,21 +22,42 @@ import { cn } from '@/lib/utils';
2222
import { yupResolver } from '@hookform/resolvers/yup';
2323
import { startAuthentication } from '@simplewebauthn/browser';
2424
import axios from 'axios';
25-
import { KeyRound, Loader2 } from 'lucide-react';
26-
import { useContext, useState } from 'react';
25+
import { KeyRound, Loader2, AlertCircle, ShieldCheck } from 'lucide-react';
26+
import { useContext, useState, useEffect } from 'react';
2727
import { useForm } from 'react-hook-form';
2828
import { useNavigate } from 'react-router-dom';
2929
import { toast } from 'sonner';
3030
import * as yup from 'yup';
3131
import DotPattern from '../ui/dot-pattern';
32-
import { useEffect } from 'react';
3332

3433
const schema = yup
3534
.object({
3635
email: yup.string().email('Please enter a valid email').required('Email is required'),
3736
})
3837
.required();
3938

39+
// WebAuthn error messages for better UX
40+
const getWebAuthnErrorMessage = (error) => {
41+
const name = error?.name || '';
42+
const message = error?.message || '';
43+
44+
switch (name) {
45+
case 'NotAllowedError':
46+
if (message.includes('timed out')) {
47+
return 'Authentication timed out. Please try again.';
48+
}
49+
return 'Authentication was cancelled or not allowed. Please try again.';
50+
case 'SecurityError':
51+
return 'Security error. Make sure you are using HTTPS.';
52+
case 'NotSupportedError':
53+
return 'Passkeys are not supported on this device or browser.';
54+
case 'AbortError':
55+
return 'The operation was cancelled.';
56+
default:
57+
return null; // Return null for API errors to use the server message
58+
}
59+
};
60+
4061
export const PasskeyLogin = () => {
4162
const { getProfile } = useContext(Context);
4263
const navigate = useNavigate();
@@ -64,7 +85,8 @@ export const PasskeyLogin = () => {
6485
setUserData(data.data);
6586
}
6687
} catch (error) {
67-
console.log(error);
88+
// Silently fail - IP info is optional
89+
console.debug('Failed to fetch IP info:', error);
6890
}
6991
};
7092

@@ -73,69 +95,72 @@ export const PasskeyLogin = () => {
7395
setError('');
7496

7597
try {
98+
// Step 1: Get authentication options from server
7699
const options = await axios.post(
77100
`${import.meta.env.VITE_API_URL}/api/auth/passkey/authenticate`,
101+
{ email: data.email }
102+
);
103+
104+
if (!options.data) {
105+
throw new Error('Failed to get authentication options');
106+
}
107+
108+
// Step 2: Authenticate using WebAuthn API
109+
const assertionResponse = await startAuthentication({
110+
optionsJSON: options.data,
111+
});
112+
113+
// Step 3: Verify with server
114+
const verificationRes = await axios.post(
115+
`${import.meta.env.VITE_API_URL}/api/auth/passkey/authenticate/verify`,
78116
{
117+
assertionResponse: assertionResponse,
79118
email: data.email,
80-
}
119+
userData: userData,
120+
},
121+
{ withCredentials: true }
81122
);
82123

83-
if (options.data) {
84-
const assertionResponse = await startAuthentication({
85-
optionsJSON: options.data,
86-
});
87-
88-
const verificationRes = await axios.post(
89-
`${import.meta.env.VITE_API_URL}/api/auth/passkey/authenticate/verify`,
90-
{
91-
assertionResponse: assertionResponse,
92-
email: data.email,
93-
userData: userData,
94-
},
95-
{
96-
withCredentials: true,
97-
}
98-
);
99-
100-
const verificationResult = verificationRes.data;
101-
102-
if (verificationResult.verified) {
103-
await getProfile();
104-
toast.success('Login successful');
105-
navigate('/feed');
106-
}
124+
if (verificationRes.data.verified) {
125+
await getProfile();
126+
toast.success('Login successful!');
127+
navigate('/feed');
128+
} else {
129+
setError('Authentication verification failed. Please try again.');
107130
}
108131
} catch (error) {
109-
if (error?.response?.data?.message) {
132+
console.error('Passkey login error:', error);
133+
134+
// Check for WebAuthn-specific errors first
135+
const webAuthnError = getWebAuthnErrorMessage(error);
136+
if (webAuthnError) {
137+
setError(webAuthnError);
138+
} else if (error?.response?.data?.message) {
110139
setError(error.response.data.message);
140+
} else {
141+
setError('An unexpected error occurred. Please try again.');
111142
}
112143
} finally {
113144
setIsLoading(false);
114145
}
115146
};
116147

117148
return (
118-
<div className='flex items-center justify-center max-h-svh h-svh overflow-hidden relative'>
119-
<DotPattern
120-
className={cn('[mask-image:radial-gradient(500px_circle_at_center,white,transparent)]')}
121-
/>
122-
<div className='absolute -top-24 -left-24 w-96 h-96 rounded-full bg-purple-600/30 filter blur-[100px] animate-pulse'></div>
123-
<div
124-
className='absolute -bottom-32 -right-32 w-96 h-96 rounded-full bg-pink-600/20 filter blur-[120px] animate-pulse'
125-
style={{ animationDelay: '2s' }}
126-
></div>
127-
<div
128-
className='absolute top-1/3 right-1/4 w-64 h-64 rounded-full bg-blue-600/20 filter blur-[80px] animate-pulse'
129-
style={{ animationDelay: '1s' }}
130-
></div>
131-
<Card className='w-full max-w-md mx-auto z-10'>
149+
<div className='flex items-center justify-center max-h-svh h-svh overflow-hidden relative bg-[#050505]'>
150+
{/* Subtle glow */}
151+
<div className='absolute top-1/4 left-1/4 w-96 h-96 bg-emerald-500/5 rounded-full blur-[120px]' />
152+
<div className='absolute bottom-1/4 right-1/4 w-96 h-96 bg-teal-500/5 rounded-full blur-[120px]' />
153+
<Card className='w-full max-w-md mx-auto z-10 bg-white/[0.03] border-white/[0.08] backdrop-blur-sm'>
132154
<CardHeader className='flex flex-col gap-3'>
133155
<div className='flex items-center gap-2'>
134-
<KeyRound className='w-6 h-6 text-primary' />
156+
<div className='p-2 rounded-lg bg-primary/10'>
157+
<KeyRound className='w-6 h-6 text-primary' />
158+
</div>
135159
<CardTitle>Login with Passkey</CardTitle>
136160
</div>
137161
<CardDescription className='mt-2'>
138-
Use your device's biometric authentication or security key to sign in
162+
Use your device's biometric authentication (Face ID, Touch ID, Windows Hello) or
163+
security key to sign in securely.
139164
</CardDescription>
140165
</CardHeader>
141166
<CardContent>
@@ -148,7 +173,12 @@ export const PasskeyLogin = () => {
148173
<FormItem>
149174
<FormLabel>Email</FormLabel>
150175
<FormControl>
151-
<Input placeholder='Enter your email' type='email' {...field} />
176+
<Input
177+
placeholder='Enter your email'
178+
type='email'
179+
autoComplete='email webauthn'
180+
{...field}
181+
/>
152182
</FormControl>
153183
<FormMessage />
154184
</FormItem>
@@ -157,20 +187,41 @@ export const PasskeyLogin = () => {
157187

158188
{error && (
159189
<Alert variant='destructive'>
190+
<AlertCircle className='h-4 w-4' />
160191
<AlertDescription>{error}</AlertDescription>
161192
</Alert>
162193
)}
163194

164-
<Button type='submit' className='w-full' disabled={isLoading}>
165-
{isLoading && <Loader2 className='w-6 h-6 mr-2 animate-spin' />}
166-
{isLoading ? 'Authenticating...' : 'Login with Passkey'}
167-
</Button>
168-
<Button variant='ghost' className='w-full' onClick={() => navigate('/login')}>
169-
Login with Password
170-
</Button>
195+
<div className='space-y-3'>
196+
<Button type='submit' className='w-full' disabled={isLoading}>
197+
{isLoading ? (
198+
<>
199+
<Loader2 className='w-5 h-5 mr-2 animate-spin' />
200+
Authenticating...
201+
</>
202+
) : (
203+
<>
204+
<ShieldCheck className='w-5 h-5 mr-2' />
205+
Continue with Passkey
206+
</>
207+
)}
208+
</Button>
209+
<Button
210+
type='button'
211+
variant='ghost'
212+
className='w-full'
213+
onClick={() => navigate('/login')}
214+
>
215+
Login with Password
216+
</Button>
217+
</div>
171218
</form>
172219
</Form>
173-
<CardFooter className='flex flex-col mt-3'></CardFooter>
220+
<CardFooter className='flex flex-col mt-3 px-0'>
221+
<p className='text-xs text-muted-foreground text-center'>
222+
Passkeys provide a more secure, phishing-resistant way to sign in without passwords.
223+
</p>
224+
</CardFooter>
174225
</CardContent>
175226
</Card>
176227
</div>

0 commit comments

Comments
 (0)