mirror of
https://github.com/fosrl/pangolin.git
synced 2025-08-15 07:02:34 +02:00
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:
parent
db76558944
commit
f31717145f
3 changed files with 328 additions and 89 deletions
|
@ -1149,5 +1149,6 @@
|
||||||
"passkeyRemoveError": "Failed to remove passkey",
|
"passkeyRemoveError": "Failed to remove passkey",
|
||||||
"passkeyLoadError": "Failed to load passkeys",
|
"passkeyLoadError": "Failed to load passkeys",
|
||||||
"passkeyLogin": "Login with Passkey",
|
"passkeyLogin": "Login with Passkey",
|
||||||
"passkeyAuthError": "Failed to authenticate with passkey"
|
"passkeyAuthError": "Failed to authenticate with passkey",
|
||||||
|
"passkeyRecommendation": "Consider registering another passkey on a different device to ensure you don't get locked out of your account."
|
||||||
}
|
}
|
||||||
|
|
|
@ -22,8 +22,14 @@ import type {
|
||||||
VerifiedRegistrationResponse,
|
VerifiedRegistrationResponse,
|
||||||
VerifiedAuthenticationResponse
|
VerifiedAuthenticationResponse
|
||||||
} from "@simplewebauthn/server";
|
} from "@simplewebauthn/server";
|
||||||
|
import type {
|
||||||
|
AuthenticatorTransport,
|
||||||
|
PublicKeyCredentialDescriptorJSON
|
||||||
|
} from "@simplewebauthn/types";
|
||||||
import config from "@server/lib/config";
|
import config from "@server/lib/config";
|
||||||
import { UserType } from "@server/types/UserTypes";
|
import { UserType } from "@server/types/UserTypes";
|
||||||
|
import { verifyPassword } from "@server/auth/password";
|
||||||
|
import { unauthorized } from "@server/auth/unauthorizedResponse";
|
||||||
|
|
||||||
// The RP ID is the domain name of your application
|
// The RP ID is the domain name of your application
|
||||||
const rpID = new URL(config.getRawConfig().app.dashboard_url).hostname;
|
const rpID = new URL(config.getRawConfig().app.dashboard_url).hostname;
|
||||||
|
@ -89,7 +95,8 @@ async function clearChallenge(sessionId: string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const registerPasskeyBody = z.object({
|
export const registerPasskeyBody = z.object({
|
||||||
name: z.string().min(1)
|
name: z.string().min(1),
|
||||||
|
password: z.string().min(1)
|
||||||
}).strict();
|
}).strict();
|
||||||
|
|
||||||
export const verifyRegistrationBody = z.object({
|
export const verifyRegistrationBody = z.object({
|
||||||
|
@ -104,6 +111,10 @@ export const verifyAuthenticationBody = z.object({
|
||||||
credential: z.any()
|
credential: z.any()
|
||||||
}).strict();
|
}).strict();
|
||||||
|
|
||||||
|
export const deletePasskeyBody = z.object({
|
||||||
|
password: z.string().min(1)
|
||||||
|
}).strict();
|
||||||
|
|
||||||
export async function startRegistration(
|
export async function startRegistration(
|
||||||
req: Request,
|
req: Request,
|
||||||
res: Response,
|
res: Response,
|
||||||
|
@ -120,7 +131,7 @@ export async function startRegistration(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { name } = parsedBody.data;
|
const { name, password } = parsedBody.data;
|
||||||
const user = req.user as User;
|
const user = req.user as User;
|
||||||
|
|
||||||
// Only allow internal users to use passkeys
|
// Only allow internal users to use passkeys
|
||||||
|
@ -134,6 +145,23 @@ export async function startRegistration(
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Verify password
|
||||||
|
const validPassword = await verifyPassword(password, user.passwordHash!);
|
||||||
|
if (!validPassword) {
|
||||||
|
return next(unauthorized());
|
||||||
|
}
|
||||||
|
|
||||||
|
// If user has 2FA enabled, require a code
|
||||||
|
if (user.twoFactorEnabled) {
|
||||||
|
return response<{ codeRequested: boolean }>(res, {
|
||||||
|
data: { codeRequested: true },
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "Two-factor authentication required",
|
||||||
|
status: HttpCode.ACCEPTED
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Get existing passkeys for user
|
// Get existing passkeys for user
|
||||||
const existingPasskeys = await db
|
const existingPasskeys = await db
|
||||||
.select()
|
.select()
|
||||||
|
@ -141,10 +169,10 @@ export async function startRegistration(
|
||||||
.where(eq(passkeys.userId, user.userId));
|
.where(eq(passkeys.userId, user.userId));
|
||||||
|
|
||||||
const excludeCredentials = existingPasskeys.map(key => ({
|
const excludeCredentials = existingPasskeys.map(key => ({
|
||||||
id: Buffer.from(key.credentialId, 'base64'),
|
id: Buffer.from(key.credentialId, 'base64').toString('base64url'),
|
||||||
type: 'public-key' as const,
|
type: 'public-key' as const,
|
||||||
transports: key.transports ? JSON.parse(key.transports) : undefined
|
transports: key.transports ? JSON.parse(key.transports) as AuthenticatorTransport[] : undefined
|
||||||
}));
|
} satisfies PublicKeyCredentialDescriptorJSON));
|
||||||
|
|
||||||
const options: GenerateRegistrationOptionsOpts = {
|
const options: GenerateRegistrationOptionsOpts = {
|
||||||
rpName,
|
rpName,
|
||||||
|
@ -164,11 +192,11 @@ export async function startRegistration(
|
||||||
// Store challenge in database
|
// Store challenge in database
|
||||||
await storeChallenge(req.session.sessionId, registrationOptions.challenge, name, user.userId);
|
await storeChallenge(req.session.sessionId, registrationOptions.challenge, name, user.userId);
|
||||||
|
|
||||||
return response(res, {
|
return response<typeof registrationOptions>(res, {
|
||||||
data: registrationOptions,
|
data: registrationOptions,
|
||||||
success: true,
|
success: true,
|
||||||
error: false,
|
error: false,
|
||||||
message: "Registration options generated",
|
message: "Registration options generated successfully",
|
||||||
status: HttpCode.OK
|
status: HttpCode.OK
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
@ -176,7 +204,7 @@ export async function startRegistration(
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.INTERNAL_SERVER_ERROR,
|
HttpCode.INTERNAL_SERVER_ERROR,
|
||||||
"Failed to generate registration options"
|
"Failed to start registration"
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -326,6 +354,19 @@ export async function deletePasskey(
|
||||||
const credentialId = decodeURIComponent(encodedCredentialId);
|
const credentialId = decodeURIComponent(encodedCredentialId);
|
||||||
const user = req.user as User;
|
const user = req.user as User;
|
||||||
|
|
||||||
|
const parsedBody = deletePasskeyBody.safeParse(req.body);
|
||||||
|
|
||||||
|
if (!parsedBody.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedBody.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { password } = parsedBody.data;
|
||||||
|
|
||||||
// Only allow internal users to use passkeys
|
// Only allow internal users to use passkeys
|
||||||
if (user.type !== UserType.Internal) {
|
if (user.type !== UserType.Internal) {
|
||||||
return next(
|
return next(
|
||||||
|
@ -337,6 +378,23 @@ export async function deletePasskey(
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Verify password
|
||||||
|
const validPassword = await verifyPassword(password, user.passwordHash!);
|
||||||
|
if (!validPassword) {
|
||||||
|
return next(unauthorized());
|
||||||
|
}
|
||||||
|
|
||||||
|
// If user has 2FA enabled, require a code
|
||||||
|
if (user.twoFactorEnabled) {
|
||||||
|
return response<{ codeRequested: boolean }>(res, {
|
||||||
|
data: { codeRequested: true },
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "Two-factor authentication required",
|
||||||
|
status: HttpCode.ACCEPTED
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
await db
|
await db
|
||||||
.delete(passkeys)
|
.delete(passkeys)
|
||||||
.where(and(
|
.where(and(
|
||||||
|
@ -424,7 +482,7 @@ export async function startAuthentication(
|
||||||
allowCredentials = userPasskeys.map(key => ({
|
allowCredentials = userPasskeys.map(key => ({
|
||||||
id: Buffer.from(key.credentialId, 'base64'),
|
id: Buffer.from(key.credentialId, 'base64'),
|
||||||
type: 'public-key' as const,
|
type: 'public-key' as const,
|
||||||
transports: key.transports ? JSON.parse(key.transports) : undefined
|
transports: key.transports ? JSON.parse(key.transports) as AuthenticatorTransport[] : undefined
|
||||||
}));
|
}));
|
||||||
} else {
|
} else {
|
||||||
// If no email provided, allow any passkey (for resident key authentication)
|
// If no email provided, allow any passkey (for resident key authentication)
|
||||||
|
@ -546,7 +604,7 @@ export async function verifyAuthentication(
|
||||||
credentialID: Buffer.from(passkey.credentialId, 'base64'),
|
credentialID: Buffer.from(passkey.credentialId, 'base64'),
|
||||||
credentialPublicKey: Buffer.from(passkey.publicKey, 'base64'),
|
credentialPublicKey: Buffer.from(passkey.publicKey, 'base64'),
|
||||||
counter: passkey.signCount,
|
counter: passkey.signCount,
|
||||||
transports: passkey.transports ? JSON.parse(passkey.transports) : undefined
|
transports: passkey.transports ? JSON.parse(passkey.transports) as AuthenticatorTransport[] : undefined
|
||||||
},
|
},
|
||||||
requireUserVerification: false
|
requireUserVerification: false
|
||||||
});
|
});
|
||||||
|
|
|
@ -44,21 +44,41 @@ type Passkey = {
|
||||||
lastUsed: string;
|
lastUsed: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type DeletePasskeyData = {
|
||||||
|
credentialId: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
|
||||||
export default function PasskeyForm({ open, setOpen }: PasskeyFormProps) {
|
export default function PasskeyForm({ open, setOpen }: PasskeyFormProps) {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [passkeys, setPasskeys] = useState<Passkey[]>([]);
|
const [passkeys, setPasskeys] = useState<Passkey[]>([]);
|
||||||
|
const [step, setStep] = useState<"list" | "register" | "delete">("list");
|
||||||
|
const [selectedPasskey, setSelectedPasskey] = useState<DeletePasskeyData | null>(null);
|
||||||
const { user } = useUserContext();
|
const { user } = useUserContext();
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
const api = createApiClient(useEnvContext());
|
const api = createApiClient(useEnvContext());
|
||||||
|
|
||||||
const registerSchema = z.object({
|
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),
|
resolver: zodResolver(registerSchema),
|
||||||
defaultValues: {
|
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
|
// Start registration
|
||||||
const startRes = await api.post("/auth/passkey/register/start", {
|
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;
|
const options = startRes.data.data;
|
||||||
|
|
||||||
// Create passkey
|
// Create passkey
|
||||||
|
@ -104,9 +137,12 @@ export default function PasskeyForm({ open, setOpen }: PasskeyFormProps) {
|
||||||
description: t('passkeyRegisterSuccess')
|
description: t('passkeyRegisterSuccess')
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Reset form and go back to list
|
||||||
|
registerForm.reset();
|
||||||
|
setStep("list");
|
||||||
|
|
||||||
// Reload passkeys
|
// Reload passkeys
|
||||||
await loadPasskeys();
|
await loadPasskeys();
|
||||||
form.reset();
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast({
|
toast({
|
||||||
title: "Error",
|
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 {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const encodedCredentialId = encodeURIComponent(credentialId);
|
const encodedCredentialId = encodeURIComponent(selectedPasskey.credentialId);
|
||||||
await api.delete(`/auth/passkey/${encodedCredentialId}`);
|
await api.delete(`/auth/passkey/${encodedCredentialId}`, {
|
||||||
|
data: { password: values.password }
|
||||||
|
});
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: "Success",
|
title: "Success",
|
||||||
description: t('passkeyRemoveSuccess')
|
description: t('passkeyRemoveSuccess')
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Reset form and go back to list
|
||||||
|
deleteForm.reset();
|
||||||
|
setStep("list");
|
||||||
|
setSelectedPasskey(null);
|
||||||
|
|
||||||
// Reload passkeys
|
// Reload passkeys
|
||||||
await loadPasskeys();
|
await loadPasskeys();
|
||||||
} catch (error) {
|
} 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 (
|
return (
|
||||||
<Credenza open={open} onOpenChange={setOpen}>
|
<Credenza
|
||||||
<CredenzaContent>
|
open={open}
|
||||||
<CredenzaHeader>
|
onOpenChange={(val) => {
|
||||||
<CredenzaTitle>{t('passkeyManage')}</CredenzaTitle>
|
setOpen(val);
|
||||||
<CredenzaDescription>
|
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')}
|
{t('passkeyDescription')}
|
||||||
</CredenzaDescription>
|
</CredenzaDescription>
|
||||||
</CredenzaHeader>
|
</CredenzaHeader>
|
||||||
<CredenzaBody>
|
<CredenzaBody className="py-6">
|
||||||
<div className="space-y-6">
|
<div className="space-y-8">
|
||||||
|
{step === "list" && (
|
||||||
|
<>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h3 className="font-semibold">{t('passkeyList')}</h3>
|
<h3 className="text-lg font-medium leading-none tracking-tight">{t('passkeyList')}</h3>
|
||||||
{passkeys.length === 0 ? (
|
{passkeys.length === 0 ? (
|
||||||
<p className="text-sm text-gray-500">
|
<div className="flex h-[120px] items-center justify-center rounded-lg border border-dashed">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
{t('passkeyNone')}
|
{t('passkeyNone')}
|
||||||
</p>
|
</p>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-3">
|
||||||
{passkeys.map((passkey) => (
|
{passkeys.map((passkey) => (
|
||||||
<div
|
<div
|
||||||
key={passkey.credentialId}
|
key={passkey.credentialId}
|
||||||
className="flex items-center justify-between p-3 border rounded-lg"
|
className="flex items-center justify-between p-4 border rounded-lg bg-card hover:bg-accent/50 transition-colors"
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium">{passkey.name}</p>
|
<p className="font-medium">{passkey.name}</p>
|
||||||
<p className="text-sm text-gray-500">
|
<p className="text-sm text-muted-foreground mt-0.5">
|
||||||
{t('passkeyLastUsed', {
|
{t('passkeyLastUsed', {
|
||||||
date: new Date(passkey.lastUsed).toLocaleDateString()
|
date: new Date(passkey.lastUsed).toLocaleDateString()
|
||||||
})}
|
})}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
variant="destructive"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => handleDeletePasskey(passkey.credentialId)}
|
onClick={() => {
|
||||||
|
setSelectedPasskey({
|
||||||
|
credentialId: passkey.credentialId,
|
||||||
|
name: passkey.name
|
||||||
|
});
|
||||||
|
setStep("delete");
|
||||||
|
}}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
|
className="hover:bg-destructive hover:text-destructive-foreground"
|
||||||
>
|
>
|
||||||
{t('passkeyRemove')}
|
{t('passkeyRemove')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Button
|
||||||
|
onClick={() => setStep("register")}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
{t('passkeyRegister')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === "register" && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h3 className="font-semibold">{t('passkeyRegister')}</h3>
|
<h3 className="text-lg font-medium leading-none tracking-tight">{t('passkeyRegister')}</h3>
|
||||||
<Form {...form}>
|
<Form {...registerForm}>
|
||||||
<form
|
<form
|
||||||
onSubmit={form.handleSubmit(handleRegisterPasskey)}
|
onSubmit={registerForm.handleSubmit(handleRegisterPasskey)}
|
||||||
className="space-y-4"
|
className="space-y-4"
|
||||||
>
|
>
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={registerForm.control}
|
||||||
name="name"
|
name="name"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>{t('passkeyNameLabel')}</FormLabel>
|
<FormLabel className="text-sm font-medium">{t('passkeyNameLabel')}</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
|
className="w-full"
|
||||||
placeholder={t('passkeyNamePlaceholder')}
|
placeholder={t('passkeyNamePlaceholder')}
|
||||||
{...field}
|
{...field}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage className="text-sm" />
|
||||||
</FormItem>
|
</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
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
className="flex-1"
|
||||||
loading={loading}
|
loading={loading}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
>
|
>
|
||||||
{t('passkeyRegister')}
|
{t('passkeyRegister')}
|
||||||
</Button>
|
</Button>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</CredenzaBody>
|
</CredenzaBody>
|
||||||
<CredenzaFooter>
|
<CredenzaFooter className="border-t pt-4">
|
||||||
<CredenzaClose asChild>
|
<CredenzaClose asChild>
|
||||||
<Button variant="outline">Close</Button>
|
<Button variant="outline" className="w-full sm:w-auto">Close</Button>
|
||||||
</CredenzaClose>
|
</CredenzaClose>
|
||||||
</CredenzaFooter>
|
</CredenzaFooter>
|
||||||
</CredenzaContent>
|
</CredenzaContent>
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue