mirror of
https://github.com/fosrl/pangolin.git
synced 2026-02-10 20:02:26 +00:00
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:
@@ -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">
|
||||
|
||||
234
src/components/PasskeyForm.tsx
Normal file
234
src/components/PasskeyForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 />
|
||||
</>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user