refactor: rename passkeyChallenge to webauthnChallenge

- Renamed table for consistency with webauthnCredentials
- Created migration script 1.8.1.ts for table rename
- Updated schema definitions in SQLite and PostgreSQL
- Maintains WebAuthn standard naming convention
This commit is contained in:
Adrian Astles
2025-07-03 21:53:07 +08:00
parent baee745d3c
commit db76558944
19 changed files with 1735 additions and 387 deletions

View File

@@ -26,7 +26,7 @@ import { LoginResponse } from "@server/routers/auth";
import { useRouter } from "next/navigation";
import { AxiosResponse } from "axios";
import { formatAxiosError } from "@app/lib/api";
import { LockIcon } from "lucide-react";
import { LockIcon, FingerprintIcon } from "lucide-react";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import {
@@ -41,6 +41,7 @@ import Image from "next/image";
import { GenerateOidcUrlResponse } from "@server/routers/idp";
import { Separator } from "./ui/separator";
import { useTranslations } from "next-intl";
import { startAuthentication } from "@simplewebauthn/browser";
export type LoginFormIDP = {
idpId: number;
@@ -165,6 +166,52 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
}
}
async function loginWithPasskey() {
try {
setLoading(true);
setError(null);
const email = form.getValues().email;
// Start passkey authentication
const startRes = await api.post("/auth/passkey/authenticate/start", {
email: email || undefined
});
if (!startRes) {
setError(t('passkeyAuthError'));
return;
}
const { tempSessionId, ...options } = startRes.data.data;
// Perform passkey authentication
const credential = await startAuthentication(options);
// Verify authentication
const verifyRes = await api.post(
"/auth/passkey/authenticate/verify",
{ credential },
{
headers: {
'X-Temp-Session-Id': tempSessionId
}
}
);
if (verifyRes) {
if (onLogin) {
await onLogin();
}
}
} catch (e) {
console.error(e);
setError(formatAxiosError(e, t('passkeyAuthError')));
} finally {
setLoading(false);
}
}
return (
<div className="space-y-4">
{!mfaRequested && (
@@ -321,6 +368,18 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
{t('login')}
</Button>
<Button
type="button"
variant="outline"
className="w-full"
onClick={loginWithPasskey}
loading={loading}
disabled={loading}
>
<FingerprintIcon className="w-4 h-4 mr-2" />
{t('passkeyLogin')}
</Button>
{hasIdp && (
<>
<div className="relative my-4">

View File

@@ -0,0 +1,234 @@
"use client";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useUserContext } from "@app/hooks/useUserContext";
import { useTranslations } from "next-intl";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast";
import { formatAxiosError } from "@app/lib/api";
import { startRegistration } from "@simplewebauthn/browser";
import {
Credenza,
CredenzaBody,
CredenzaClose,
CredenzaContent,
CredenzaDescription,
CredenzaFooter,
CredenzaHeader,
CredenzaTitle
} from "@app/components/Credenza";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@app/components/ui/form";
import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
type PasskeyFormProps = {
open: boolean;
setOpen: (val: boolean) => void;
};
type Passkey = {
credentialId: string;
name: string;
dateCreated: string;
lastUsed: string;
};
export default function PasskeyForm({ open, setOpen }: PasskeyFormProps) {
const [loading, setLoading] = useState(false);
const [passkeys, setPasskeys] = useState<Passkey[]>([]);
const { user } = useUserContext();
const t = useTranslations();
const api = createApiClient(useEnvContext());
const registerSchema = z.object({
name: z.string().min(1, { message: t('passkeyNameRequired') })
});
const form = useForm<z.infer<typeof registerSchema>>({
resolver: zodResolver(registerSchema),
defaultValues: {
name: ""
}
});
useEffect(() => {
if (open) {
loadPasskeys();
}
}, [open]);
const loadPasskeys = async () => {
try {
const response = await api.get("/auth/passkey/list");
setPasskeys(response.data.data);
} catch (error) {
toast({
title: "Error",
description: formatAxiosError(error, t('passkeyLoadError')),
variant: "destructive"
});
}
};
const handleRegisterPasskey = async (values: z.infer<typeof registerSchema>) => {
try {
setLoading(true);
// Start registration
const startRes = await api.post("/auth/passkey/register/start", {
name: values.name
});
const options = startRes.data.data;
// Create passkey
const credential = await startRegistration(options);
// Verify registration
await api.post("/auth/passkey/register/verify", {
credential
});
toast({
title: "Success",
description: t('passkeyRegisterSuccess')
});
// Reload passkeys
await loadPasskeys();
form.reset();
} catch (error) {
toast({
title: "Error",
description: formatAxiosError(error, t('passkeyRegisterError')),
variant: "destructive"
});
} finally {
setLoading(false);
}
};
const handleDeletePasskey = async (credentialId: string) => {
try {
setLoading(true);
const encodedCredentialId = encodeURIComponent(credentialId);
await api.delete(`/auth/passkey/${encodedCredentialId}`);
toast({
title: "Success",
description: t('passkeyRemoveSuccess')
});
// Reload passkeys
await loadPasskeys();
} catch (error) {
toast({
title: "Error",
description: formatAxiosError(error, t('passkeyRemoveError')),
variant: "destructive"
});
} finally {
setLoading(false);
}
};
return (
<Credenza open={open} onOpenChange={setOpen}>
<CredenzaContent>
<CredenzaHeader>
<CredenzaTitle>{t('passkeyManage')}</CredenzaTitle>
<CredenzaDescription>
{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>
<Button
variant="destructive"
size="sm"
onClick={() => handleDeletePasskey(passkey.credentialId)}
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>
</div>
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">Close</Button>
</CredenzaClose>
</CredenzaFooter>
</CredenzaContent>
</Credenza>
);
}

View File

@@ -21,12 +21,12 @@ import { useState } from "react";
import { useUserContext } from "@app/hooks/useUserContext";
import Disable2FaForm from "./Disable2FaForm";
import Enable2FaForm from "./Enable2FaForm";
import PasskeyForm from "./PasskeyForm";
import SupporterStatus from "./SupporterStatus";
import { UserType } from "@server/types/UserTypes";
import LocaleSwitcher from '@app/components/LocaleSwitcher';
import { useTranslations } from "next-intl";
export default function ProfileIcon() {
const { setTheme, theme } = useTheme();
const { env } = useEnvContext();
@@ -40,6 +40,7 @@ export default function ProfileIcon() {
const [openEnable2fa, setOpenEnable2fa] = useState(false);
const [openDisable2fa, setOpenDisable2fa] = useState(false);
const [openPasskey, setOpenPasskey] = useState(false);
const t = useTranslations();
@@ -73,6 +74,7 @@ export default function ProfileIcon() {
<>
<Enable2FaForm open={openEnable2fa} setOpen={setOpenEnable2fa} />
<Disable2FaForm open={openDisable2fa} setOpen={setOpenDisable2fa} />
<PasskeyForm open={openPasskey} setOpen={setOpenPasskey} />
<div className="flex items-center md:gap-2 grow min-w-0 gap-2 md:gap-0">
<span className="truncate max-w-full font-medium min-w-0">
@@ -130,6 +132,11 @@ export default function ProfileIcon() {
<span>{t('otpDisable')}</span>
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={() => setOpenPasskey(true)}
>
<span>{t('passkeyManage')}</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}