feat(passkeys): Add password verification for passkey management

- Add password verification requirement when registering passkeys
- Add password verification requirement when deleting passkeys
- Add support for 2FA verification if enabled
- Add new delete confirmation dialog with password field
- Add recommendation message when only one passkey is registered
- Improve dialog styling and user experience
- Fix type issues with WebAuthn credential descriptors

Security: This change ensures that sensitive passkey operations require
password verification, similar to 2FA management, preventing unauthorized
modifications to authentication methods.
This commit is contained in:
Adrian Astles
2025-07-03 22:57:29 +08:00
parent db76558944
commit f31717145f
3 changed files with 328 additions and 89 deletions

View File

@@ -44,21 +44,41 @@ type Passkey = {
lastUsed: string;
};
type DeletePasskeyData = {
credentialId: string;
name: string;
};
export default function PasskeyForm({ open, setOpen }: PasskeyFormProps) {
const [loading, setLoading] = useState(false);
const [passkeys, setPasskeys] = useState<Passkey[]>([]);
const [step, setStep] = useState<"list" | "register" | "delete">("list");
const [selectedPasskey, setSelectedPasskey] = useState<DeletePasskeyData | null>(null);
const { user } = useUserContext();
const t = useTranslations();
const api = createApiClient(useEnvContext());
const registerSchema = z.object({
name: z.string().min(1, { message: t('passkeyNameRequired') })
name: z.string().min(1, { message: t('passkeyNameRequired') }),
password: z.string().min(1, { message: t('passwordRequired') })
});
const form = useForm<z.infer<typeof registerSchema>>({
const deleteSchema = z.object({
password: z.string().min(1, { message: t('passwordRequired') })
});
const registerForm = useForm<z.infer<typeof registerSchema>>({
resolver: zodResolver(registerSchema),
defaultValues: {
name: ""
name: "",
password: ""
}
});
const deleteForm = useForm<z.infer<typeof deleteSchema>>({
resolver: zodResolver(deleteSchema),
defaultValues: {
password: ""
}
});
@@ -87,8 +107,21 @@ export default function PasskeyForm({ open, setOpen }: PasskeyFormProps) {
// Start registration
const startRes = await api.post("/auth/passkey/register/start", {
name: values.name
name: values.name,
password: values.password
});
// Handle 2FA if required
if (startRes.data.data.codeRequested) {
// TODO: Handle 2FA verification
toast({
title: "2FA Required",
description: "Two-factor authentication is required to register a passkey.",
variant: "destructive"
});
return;
}
const options = startRes.data.data;
// Create passkey
@@ -104,9 +137,12 @@ export default function PasskeyForm({ open, setOpen }: PasskeyFormProps) {
description: t('passkeyRegisterSuccess')
});
// Reset form and go back to list
registerForm.reset();
setStep("list");
// Reload passkeys
await loadPasskeys();
form.reset();
} catch (error) {
toast({
title: "Error",
@@ -118,17 +154,26 @@ export default function PasskeyForm({ open, setOpen }: PasskeyFormProps) {
}
};
const handleDeletePasskey = async (credentialId: string) => {
const handleDeletePasskey = async (values: z.infer<typeof deleteSchema>) => {
if (!selectedPasskey) return;
try {
setLoading(true);
const encodedCredentialId = encodeURIComponent(credentialId);
await api.delete(`/auth/passkey/${encodedCredentialId}`);
const encodedCredentialId = encodeURIComponent(selectedPasskey.credentialId);
await api.delete(`/auth/passkey/${encodedCredentialId}`, {
data: { password: values.password }
});
toast({
title: "Success",
description: t('passkeyRemoveSuccess')
});
// Reset form and go back to list
deleteForm.reset();
setStep("list");
setSelectedPasskey(null);
// Reload passkeys
await loadPasskeys();
} catch (error) {
@@ -142,90 +187,225 @@ export default function PasskeyForm({ open, setOpen }: PasskeyFormProps) {
}
};
function reset() {
registerForm.reset();
deleteForm.reset();
setStep("list");
setSelectedPasskey(null);
setLoading(false);
}
return (
<Credenza open={open} onOpenChange={setOpen}>
<CredenzaContent>
<CredenzaHeader>
<CredenzaTitle>{t('passkeyManage')}</CredenzaTitle>
<CredenzaDescription>
<Credenza
open={open}
onOpenChange={(val) => {
setOpen(val);
if (!val) reset();
}}
>
<CredenzaContent className="max-w-md">
<CredenzaHeader className="space-y-2 pb-4 border-b">
<CredenzaTitle className="text-2xl font-semibold tracking-tight">{t('passkeyManage')}</CredenzaTitle>
<CredenzaDescription className="text-sm text-muted-foreground">
{t('passkeyDescription')}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
<div className="space-y-6">
<div className="space-y-4">
<h3 className="font-semibold">{t('passkeyList')}</h3>
{passkeys.length === 0 ? (
<p className="text-sm text-gray-500">
{t('passkeyNone')}
</p>
) : (
<div className="space-y-2">
{passkeys.map((passkey) => (
<div
key={passkey.credentialId}
className="flex items-center justify-between p-3 border rounded-lg"
>
<div>
<p className="font-medium">{passkey.name}</p>
<p className="text-sm text-gray-500">
{t('passkeyLastUsed', {
date: new Date(passkey.lastUsed).toLocaleDateString()
})}
</p>
</div>
<CredenzaBody className="py-6">
<div className="space-y-8">
{step === "list" && (
<>
<div className="space-y-4">
<h3 className="text-lg font-medium leading-none tracking-tight">{t('passkeyList')}</h3>
{passkeys.length === 0 ? (
<div className="flex h-[120px] items-center justify-center rounded-lg border border-dashed">
<p className="text-sm text-muted-foreground">
{t('passkeyNone')}
</p>
</div>
) : (
<div className="space-y-3">
{passkeys.map((passkey) => (
<div
key={passkey.credentialId}
className="flex items-center justify-between p-4 border rounded-lg bg-card hover:bg-accent/50 transition-colors"
>
<div>
<p className="font-medium">{passkey.name}</p>
<p className="text-sm text-muted-foreground mt-0.5">
{t('passkeyLastUsed', {
date: new Date(passkey.lastUsed).toLocaleDateString()
})}
</p>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => {
setSelectedPasskey({
credentialId: passkey.credentialId,
name: passkey.name
});
setStep("delete");
}}
disabled={loading}
className="hover:bg-destructive hover:text-destructive-foreground"
>
{t('passkeyRemove')}
</Button>
</div>
))}
{passkeys.length === 1 && (
<div className="flex p-4 text-sm text-amber-600 bg-amber-50 dark:bg-amber-900/10 rounded-lg">
{t('passkeyRecommendation')}
</div>
)}
</div>
)}
</div>
<div>
<Button
onClick={() => setStep("register")}
className="w-full"
>
{t('passkeyRegister')}
</Button>
</div>
</>
)}
{step === "register" && (
<div className="space-y-4">
<h3 className="text-lg font-medium leading-none tracking-tight">{t('passkeyRegister')}</h3>
<Form {...registerForm}>
<form
onSubmit={registerForm.handleSubmit(handleRegisterPasskey)}
className="space-y-4"
>
<FormField
control={registerForm.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel className="text-sm font-medium">{t('passkeyNameLabel')}</FormLabel>
<FormControl>
<Input
className="w-full"
placeholder={t('passkeyNamePlaceholder')}
{...field}
/>
</FormControl>
<FormMessage className="text-sm" />
</FormItem>
)}
/>
<FormField
control={registerForm.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel className="text-sm font-medium">{t('password')}</FormLabel>
<FormControl>
<Input
type="password"
className="w-full"
{...field}
/>
</FormControl>
<FormMessage className="text-sm" />
</FormItem>
)}
/>
<div className="flex gap-3 pt-2">
<Button
type="button"
variant="outline"
className="flex-1"
onClick={() => setStep("list")}
disabled={loading}
>
Cancel
</Button>
<Button
type="submit"
className="flex-1"
loading={loading}
disabled={loading}
>
{t('passkeyRegister')}
</Button>
</div>
</form>
</Form>
</div>
)}
{step === "delete" && selectedPasskey && (
<div className="space-y-4">
<div className="space-y-2">
<h3 className="text-lg font-medium leading-none tracking-tight">Remove Passkey</h3>
<p className="text-sm text-muted-foreground">
Enter your password to remove the passkey "{selectedPasskey.name}"
</p>
</div>
<Form {...deleteForm}>
<form
onSubmit={deleteForm.handleSubmit(handleDeletePasskey)}
className="space-y-4"
>
<FormField
control={deleteForm.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel className="text-sm font-medium">{t('password')}</FormLabel>
<FormControl>
<Input
type="password"
className="w-full"
{...field}
/>
</FormControl>
<FormMessage className="text-sm" />
</FormItem>
)}
/>
<div className="flex gap-3 pt-2">
<Button
type="button"
variant="outline"
className="flex-1"
onClick={() => {
setStep("list");
setSelectedPasskey(null);
}}
disabled={loading}
>
Cancel
</Button>
<Button
type="submit"
variant="destructive"
size="sm"
onClick={() => handleDeletePasskey(passkey.credentialId)}
className="flex-1"
loading={loading}
disabled={loading}
>
{t('passkeyRemove')}
</Button>
</div>
))}
</div>
)}
</div>
<div className="space-y-4">
<h3 className="font-semibold">{t('passkeyRegister')}</h3>
<Form {...form}>
<form
onSubmit={form.handleSubmit(handleRegisterPasskey)}
className="space-y-4"
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>{t('passkeyNameLabel')}</FormLabel>
<FormControl>
<Input
placeholder={t('passkeyNamePlaceholder')}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
type="submit"
loading={loading}
disabled={loading}
>
{t('passkeyRegister')}
</Button>
</form>
</Form>
</div>
</form>
</Form>
</div>
)}
</div>
</CredenzaBody>
<CredenzaFooter>
<CredenzaFooter className="border-t pt-4">
<CredenzaClose asChild>
<Button variant="outline">Close</Button>
<Button variant="outline" className="w-full sm:w-auto">Close</Button>
</CredenzaClose>
</CredenzaFooter>
</CredenzaContent>