mirror of
https://github.com/fosrl/pangolin.git
synced 2026-02-10 20:02:26 +00:00
improved security key management interface, also updated locales
This commit is contained in:
@@ -66,6 +66,7 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
|
||||
const hasIdp = idps && idps.length > 0;
|
||||
|
||||
const [mfaRequested, setMfaRequested] = useState(false);
|
||||
const [showSecurityKeyPrompt, setShowSecurityKeyPrompt] = useState(false);
|
||||
|
||||
const t = useTranslations();
|
||||
|
||||
@@ -95,49 +96,63 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
|
||||
}
|
||||
});
|
||||
|
||||
async function initiateSecurityKeyAuth() {
|
||||
setShowSecurityKeyPrompt(true);
|
||||
setError(null);
|
||||
await loginWithSecurityKey();
|
||||
setShowSecurityKeyPrompt(false);
|
||||
}
|
||||
|
||||
async function onSubmit(values: any) {
|
||||
const { email, password } = form.getValues();
|
||||
const { code } = mfaForm.getValues();
|
||||
|
||||
setLoading(true);
|
||||
|
||||
const res = await api
|
||||
.post<AxiosResponse<LoginResponse>>("/auth/login", {
|
||||
try {
|
||||
const res = await api.post<AxiosResponse<LoginResponse>>("/auth/login", {
|
||||
email,
|
||||
password,
|
||||
code
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
setError(
|
||||
formatAxiosError(e, t('loginError'))
|
||||
);
|
||||
});
|
||||
|
||||
if (res) {
|
||||
setError(null);
|
||||
if (res) {
|
||||
setError(null);
|
||||
const data = res.data.data;
|
||||
|
||||
const data = res.data.data;
|
||||
|
||||
if (data?.codeRequested) {
|
||||
setMfaRequested(true);
|
||||
setLoading(false);
|
||||
mfaForm.reset();
|
||||
return;
|
||||
}
|
||||
|
||||
if (data?.emailVerificationRequired) {
|
||||
if (redirect) {
|
||||
router.push(`/auth/verify-email?redirect=${redirect}`);
|
||||
} else {
|
||||
router.push("/auth/verify-email");
|
||||
if (data?.usePasskey) {
|
||||
await initiateSecurityKeyAuth();
|
||||
return;
|
||||
}
|
||||
|
||||
if (data?.codeRequested) {
|
||||
setMfaRequested(true);
|
||||
setLoading(false);
|
||||
mfaForm.reset();
|
||||
return;
|
||||
}
|
||||
|
||||
if (data?.emailVerificationRequired) {
|
||||
if (redirect) {
|
||||
router.push(`/auth/verify-email?redirect=${redirect}`);
|
||||
} else {
|
||||
router.push("/auth/verify-email");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (onLogin) {
|
||||
await onLogin();
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
const errorMessage = formatAxiosError(e, t('loginError'));
|
||||
if (errorMessage.includes("Please use your security key")) {
|
||||
await initiateSecurityKeyAuth();
|
||||
return;
|
||||
}
|
||||
|
||||
if (onLogin) {
|
||||
await onLogin();
|
||||
}
|
||||
setError(errorMessage);
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
@@ -166,26 +181,28 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
|
||||
}
|
||||
}
|
||||
|
||||
async function loginWithPasskey() {
|
||||
async function loginWithSecurityKey() {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const email = form.getValues().email;
|
||||
|
||||
// Start passkey authentication
|
||||
// Start WebAuthn authentication
|
||||
const startRes = await api.post("/auth/passkey/authenticate/start", {
|
||||
email: email || undefined
|
||||
});
|
||||
|
||||
if (!startRes) {
|
||||
setError(t('passkeyAuthError'));
|
||||
setError(t('securityKeyAuthError', {
|
||||
defaultValue: "Failed to start security key authentication"
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
const { tempSessionId, ...options } = startRes.data.data;
|
||||
|
||||
// Perform passkey authentication
|
||||
// Perform WebAuthn authentication
|
||||
const credential = await startAuthentication(options);
|
||||
|
||||
// Verify authentication
|
||||
@@ -206,7 +223,9 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setError(formatAxiosError(e, t('passkeyAuthError')));
|
||||
setError(formatAxiosError(e, t('securityKeyAuthError', {
|
||||
defaultValue: "Security key authentication failed"
|
||||
})));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -214,6 +233,17 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{showSecurityKeyPrompt && (
|
||||
<Alert>
|
||||
<FingerprintIcon className="w-5 h-5 mr-2" />
|
||||
<AlertDescription>
|
||||
{t('securityKeyPrompt', {
|
||||
defaultValue: "Please verify your identity using your security key. Make sure your security key is connected and ready."
|
||||
})}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{!mfaRequested && (
|
||||
<>
|
||||
<Form {...form}>
|
||||
@@ -362,7 +392,7 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
|
||||
form="form"
|
||||
className="w-full"
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
disabled={loading || showSecurityKeyPrompt}
|
||||
>
|
||||
<LockIcon className="w-4 h-4 mr-2" />
|
||||
{t('login')}
|
||||
@@ -372,12 +402,14 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={loginWithPasskey}
|
||||
onClick={initiateSecurityKeyAuth}
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
disabled={loading || showSecurityKeyPrompt}
|
||||
>
|
||||
<FingerprintIcon className="w-4 h-4 mr-2" />
|
||||
{t('passkeyLogin')}
|
||||
{t('securityKeyLogin', {
|
||||
defaultValue: "Sign in with security key"
|
||||
})}
|
||||
</Button>
|
||||
|
||||
{hasIdp && (
|
||||
|
||||
@@ -1,414 +0,0 @@
|
||||
"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;
|
||||
};
|
||||
|
||||
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') }),
|
||||
password: z.string().min(1, { message: t('passwordRequired') })
|
||||
});
|
||||
|
||||
const deleteSchema = z.object({
|
||||
password: z.string().min(1, { message: t('passwordRequired') })
|
||||
});
|
||||
|
||||
const registerForm = useForm<z.infer<typeof registerSchema>>({
|
||||
resolver: zodResolver(registerSchema),
|
||||
defaultValues: {
|
||||
name: "",
|
||||
password: ""
|
||||
}
|
||||
});
|
||||
|
||||
const deleteForm = useForm<z.infer<typeof deleteSchema>>({
|
||||
resolver: zodResolver(deleteSchema),
|
||||
defaultValues: {
|
||||
password: ""
|
||||
}
|
||||
});
|
||||
|
||||
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,
|
||||
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
|
||||
const credential = await startRegistration(options);
|
||||
|
||||
// Verify registration
|
||||
await api.post("/auth/passkey/register/verify", {
|
||||
credential
|
||||
});
|
||||
|
||||
toast({
|
||||
title: "Success",
|
||||
description: t('passkeyRegisterSuccess')
|
||||
});
|
||||
|
||||
// Reset form and go back to list
|
||||
registerForm.reset();
|
||||
setStep("list");
|
||||
|
||||
// Reload passkeys
|
||||
await loadPasskeys();
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: formatAxiosError(error, t('passkeyRegisterError')),
|
||||
variant: "destructive"
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeletePasskey = async (values: z.infer<typeof deleteSchema>) => {
|
||||
if (!selectedPasskey) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
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) {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: formatAxiosError(error, t('passkeyRemoveError')),
|
||||
variant: "destructive"
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
function reset() {
|
||||
registerForm.reset();
|
||||
deleteForm.reset();
|
||||
setStep("list");
|
||||
setSelectedPasskey(null);
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<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 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"
|
||||
className="flex-1"
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
>
|
||||
{t('passkeyRemove')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CredenzaBody>
|
||||
<CredenzaFooter className="border-t pt-4">
|
||||
<CredenzaClose asChild>
|
||||
<Button variant="outline" className="w-full sm:w-auto">Close</Button>
|
||||
</CredenzaClose>
|
||||
</CredenzaFooter>
|
||||
</CredenzaContent>
|
||||
</Credenza>
|
||||
);
|
||||
}
|
||||
@@ -21,7 +21,7 @@ import { useState } from "react";
|
||||
import { useUserContext } from "@app/hooks/useUserContext";
|
||||
import Disable2FaForm from "./Disable2FaForm";
|
||||
import Enable2FaForm from "./Enable2FaForm";
|
||||
import PasskeyForm from "./PasskeyForm";
|
||||
import SecurityKeyForm from "./SecurityKeyForm";
|
||||
import SupporterStatus from "./SupporterStatus";
|
||||
import { UserType } from "@server/types/UserTypes";
|
||||
import LocaleSwitcher from '@app/components/LocaleSwitcher';
|
||||
@@ -40,7 +40,7 @@ export default function ProfileIcon() {
|
||||
|
||||
const [openEnable2fa, setOpenEnable2fa] = useState(false);
|
||||
const [openDisable2fa, setOpenDisable2fa] = useState(false);
|
||||
const [openPasskey, setOpenPasskey] = useState(false);
|
||||
const [openSecurityKey, setOpenSecurityKey] = useState(false);
|
||||
|
||||
const t = useTranslations();
|
||||
|
||||
@@ -74,7 +74,7 @@ export default function ProfileIcon() {
|
||||
<>
|
||||
<Enable2FaForm open={openEnable2fa} setOpen={setOpenEnable2fa} />
|
||||
<Disable2FaForm open={openDisable2fa} setOpen={setOpenDisable2fa} />
|
||||
<PasskeyForm open={openPasskey} setOpen={setOpenPasskey} />
|
||||
<SecurityKeyForm open={openSecurityKey} setOpen={setOpenSecurityKey} />
|
||||
|
||||
<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">
|
||||
@@ -133,9 +133,9 @@ export default function ProfileIcon() {
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onClick={() => setOpenPasskey(true)}
|
||||
onClick={() => setOpenSecurityKey(true)}
|
||||
>
|
||||
<span>{t('passkeyManage')}</span>
|
||||
<span>{t('securityKeyManage')}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
|
||||
409
src/components/SecurityKeyForm.tsx
Normal file
409
src/components/SecurityKeyForm.tsx
Normal file
@@ -0,0 +1,409 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { formatAxiosError } from "@app/lib/api";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@app/components/ui/form";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import { Alert, AlertDescription } from "@app/components/ui/alert";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@app/components/ui/dialog";
|
||||
import { startRegistration } from "@simplewebauthn/browser";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { Card, CardContent } from "@app/components/ui/card";
|
||||
import { Badge } from "@app/components/ui/badge";
|
||||
import { Loader2, KeyRound, Trash2, Plus, Shield } from "lucide-react";
|
||||
import { cn } from "@app/lib/cn";
|
||||
|
||||
type SecurityKeyFormProps = {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
};
|
||||
|
||||
type SecurityKey = {
|
||||
credentialId: string;
|
||||
name: string;
|
||||
lastUsed: string;
|
||||
};
|
||||
|
||||
type DeleteSecurityKeyData = {
|
||||
credentialId: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
type RegisterFormValues = {
|
||||
name: string;
|
||||
password: string;
|
||||
};
|
||||
|
||||
type DeleteFormValues = {
|
||||
password: string;
|
||||
};
|
||||
|
||||
type FieldProps = {
|
||||
field: {
|
||||
value: string;
|
||||
onChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
onBlur: () => void;
|
||||
name: string;
|
||||
ref: React.Ref<HTMLInputElement>;
|
||||
};
|
||||
};
|
||||
|
||||
export default function SecurityKeyForm({ open, setOpen }: SecurityKeyFormProps) {
|
||||
const t = useTranslations();
|
||||
const { env } = useEnvContext();
|
||||
const api = createApiClient({ env });
|
||||
const [securityKeys, setSecurityKeys] = useState<SecurityKey[]>([]);
|
||||
const [isRegistering, setIsRegistering] = useState(false);
|
||||
const [showRegisterDialog, setShowRegisterDialog] = useState(false);
|
||||
const [selectedSecurityKey, setSelectedSecurityKey] = useState<DeleteSecurityKeyData | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadSecurityKeys();
|
||||
}, []);
|
||||
|
||||
const registerSchema = z.object({
|
||||
name: z.string().min(1, { message: t('securityKeyNameRequired') }),
|
||||
password: z.string().min(1, { message: t('passwordRequired') }),
|
||||
});
|
||||
|
||||
const deleteSchema = z.object({
|
||||
password: z.string().min(1, { message: t('passwordRequired') }),
|
||||
});
|
||||
|
||||
const registerForm = useForm<RegisterFormValues>({
|
||||
resolver: zodResolver(registerSchema),
|
||||
defaultValues: {
|
||||
name: "",
|
||||
password: "",
|
||||
},
|
||||
});
|
||||
|
||||
const deleteForm = useForm<DeleteFormValues>({
|
||||
resolver: zodResolver(deleteSchema),
|
||||
defaultValues: {
|
||||
password: "",
|
||||
},
|
||||
});
|
||||
|
||||
const loadSecurityKeys = async () => {
|
||||
try {
|
||||
const response = await api.get("/auth/passkey/list");
|
||||
setSecurityKeys(response.data.data);
|
||||
} catch (error) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
description: formatAxiosError(error, t('securityKeyLoadError')),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleRegisterSecurityKey = async (values: RegisterFormValues) => {
|
||||
try {
|
||||
setIsRegistering(true);
|
||||
const startRes = await api.post("/auth/passkey/register/start", {
|
||||
name: values.name,
|
||||
password: values.password,
|
||||
});
|
||||
|
||||
if (startRes.status === 202) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
description: "Two-factor authentication is required to register a security key.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const options = startRes.data.data;
|
||||
const credential = await startRegistration(options);
|
||||
|
||||
await api.post("/auth/passkey/register/verify", {
|
||||
credential,
|
||||
});
|
||||
|
||||
toast({
|
||||
description: t('securityKeyRegisterSuccess')
|
||||
});
|
||||
|
||||
registerForm.reset();
|
||||
setShowRegisterDialog(false);
|
||||
await loadSecurityKeys();
|
||||
} catch (error) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
description: formatAxiosError(error, t('securityKeyRegisterError')),
|
||||
});
|
||||
} finally {
|
||||
setIsRegistering(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteSecurityKey = async (values: DeleteFormValues) => {
|
||||
if (!selectedSecurityKey) return;
|
||||
|
||||
try {
|
||||
const encodedCredentialId = encodeURIComponent(selectedSecurityKey.credentialId);
|
||||
await api.delete(`/auth/passkey/${encodedCredentialId}`, {
|
||||
data: {
|
||||
password: values.password,
|
||||
}
|
||||
});
|
||||
|
||||
toast({
|
||||
description: t('securityKeyRemoveSuccess')
|
||||
});
|
||||
|
||||
deleteForm.reset();
|
||||
setSelectedSecurityKey(null);
|
||||
await loadSecurityKeys();
|
||||
} catch (error) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
description: formatAxiosError(error, t('securityKeyRemoveError')),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const onOpenChange = (open: boolean) => {
|
||||
if (open) {
|
||||
loadSecurityKeys();
|
||||
} else {
|
||||
registerForm.reset();
|
||||
deleteForm.reset();
|
||||
setSelectedSecurityKey(null);
|
||||
setShowRegisterDialog(false);
|
||||
}
|
||||
setOpen(open);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Shield className="h-5 w-5" />
|
||||
{t('securityKeyManage')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('securityKeyDescription')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium text-muted-foreground">{t('securityKeyList')}</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
{securityKeys.length > 0 && (
|
||||
<Badge className="text-xs">
|
||||
{securityKeys.length} {securityKeys.length === 1 ? 'key' : 'keys'}
|
||||
</Badge>
|
||||
)}
|
||||
<Button
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={() => setShowRegisterDialog(true)}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{securityKeys.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{securityKeys.map((securityKey) => (
|
||||
<Card key={securityKey.credentialId}>
|
||||
<CardContent className="flex items-center justify-between p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-secondary">
|
||||
<KeyRound className="h-4 w-4 text-secondary-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">{securityKey.name}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('securityKeyLastUsed', {
|
||||
date: new Date(securityKey.lastUsed).toLocaleDateString()
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
className="h-8 w-8 p-0 text-white hover:text-white/80"
|
||||
onClick={() => setSelectedSecurityKey({
|
||||
credentialId: securityKey.credentialId,
|
||||
name: securityKey.name
|
||||
})}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||
<Shield className="mb-2 h-12 w-12 text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground">No security keys registered</p>
|
||||
<p className="text-xs text-muted-foreground">Add a security key to enhance your account security</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{securityKeys.length === 1 && (
|
||||
<Alert variant="default">
|
||||
<AlertDescription>{t('securityKeyRecommendation')}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={showRegisterDialog} onOpenChange={setShowRegisterDialog}>
|
||||
<DialogContent className="sm:max-w-[400px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Register New Security Key</DialogTitle>
|
||||
<DialogDescription>
|
||||
Connect your security key and enter a name to identify it
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...registerForm}>
|
||||
<form onSubmit={registerForm.handleSubmit(handleRegisterSecurityKey)} className="space-y-4">
|
||||
<FormField
|
||||
control={registerForm.control}
|
||||
name="name"
|
||||
render={({ field }: FieldProps) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('securityKeyNameLabel')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
placeholder={t('securityKeyNamePlaceholder')}
|
||||
disabled={isRegistering}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={registerForm.control}
|
||||
name="password"
|
||||
render={({ field }: FieldProps) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('password')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
type="password"
|
||||
disabled={isRegistering}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
className="border border-input bg-transparent text-foreground hover:bg-accent hover:text-accent-foreground"
|
||||
onClick={() => {
|
||||
registerForm.reset();
|
||||
setShowRegisterDialog(false);
|
||||
}}
|
||||
disabled={isRegistering}
|
||||
>
|
||||
{t('cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isRegistering}
|
||||
className={cn(
|
||||
"min-w-[100px]",
|
||||
isRegistering && "cursor-not-allowed opacity-50"
|
||||
)}
|
||||
>
|
||||
{isRegistering ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
{t('registering')}
|
||||
</>
|
||||
) : (
|
||||
t('securityKeyRegister')
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={!!selectedSecurityKey} onOpenChange={(open) => !open && setSelectedSecurityKey(null)}>
|
||||
<DialogContent className="sm:max-w-[400px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-destructive">
|
||||
<Trash2 className="h-4 w-4" />
|
||||
Remove Security Key
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Enter your password to remove the security key "{selectedSecurityKey?.name}"
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...deleteForm}>
|
||||
<form onSubmit={deleteForm.handleSubmit(handleDeleteSecurityKey)} className="space-y-4">
|
||||
<FormField
|
||||
control={deleteForm.control}
|
||||
name="password"
|
||||
render={({ field }: FieldProps) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('password')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} type="password" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
className="border border-input bg-transparent text-foreground hover:bg-accent hover:text-accent-foreground"
|
||||
onClick={() => {
|
||||
deleteForm.reset();
|
||||
setSelectedSecurityKey(null);
|
||||
}}
|
||||
>
|
||||
{t('cancel')}
|
||||
</Button>
|
||||
<Button type="submit" className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
|
||||
{t('securityKeyRemove')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user