mirror of
https://github.com/fosrl/pangolin.git
synced 2025-08-28 21:58:15 +02:00
style consistency changes to add security key form
This commit is contained in:
parent
0a97d91aed
commit
3505342a8d
4 changed files with 645 additions and 329 deletions
|
@ -1143,8 +1143,7 @@
|
||||||
"securityKeyNameRequired": "Name is required",
|
"securityKeyNameRequired": "Name is required",
|
||||||
"securityKeyRemove": "Remove",
|
"securityKeyRemove": "Remove",
|
||||||
"securityKeyLastUsed": "Last used: {date}",
|
"securityKeyLastUsed": "Last used: {date}",
|
||||||
"securityKeyNameLabel": "Name",
|
"securityKeyNameLabel": "Security Key Name",
|
||||||
"securityKeyNamePlaceholder": "Enter a name for this security key",
|
|
||||||
"securityKeyRegisterSuccess": "Security key registered successfully",
|
"securityKeyRegisterSuccess": "Security key registered successfully",
|
||||||
"securityKeyRegisterError": "Failed to register security key",
|
"securityKeyRegisterError": "Failed to register security key",
|
||||||
"securityKeyRemoveSuccess": "Security key removed successfully",
|
"securityKeyRemoveSuccess": "Security key removed successfully",
|
||||||
|
@ -1152,7 +1151,7 @@
|
||||||
"securityKeyLoadError": "Failed to load security keys",
|
"securityKeyLoadError": "Failed to load security keys",
|
||||||
"securityKeyLogin": "Sign in with security key",
|
"securityKeyLogin": "Sign in with security key",
|
||||||
"securityKeyAuthError": "Failed to authenticate with security key",
|
"securityKeyAuthError": "Failed to authenticate with security key",
|
||||||
"securityKeyRecommendation": "Tip: Register a backup security key on another device to ensure you always have access to your account.",
|
"securityKeyRecommendation": "Register a backup security key on another device to ensure you always have access to your account.",
|
||||||
"registering": "Registering...",
|
"registering": "Registering...",
|
||||||
"securityKeyPrompt": "Please verify your identity using your security key. Make sure your security key is connected and ready.",
|
"securityKeyPrompt": "Please verify your identity using your security key. Make sure your security key is connected and ready.",
|
||||||
"securityKeyBrowserNotSupported": "Your browser doesn't support security keys. Please use a modern browser like Chrome, Firefox, or Safari.",
|
"securityKeyBrowserNotSupported": "Your browser doesn't support security keys. Please use a modern browser like Chrome, Firefox, or Safari.",
|
||||||
|
@ -1163,5 +1162,16 @@
|
||||||
"twoFactorRequired": "Two-factor authentication is required to register a security key.",
|
"twoFactorRequired": "Two-factor authentication is required to register a security key.",
|
||||||
"twoFactor": "Two-Factor Authentication",
|
"twoFactor": "Two-Factor Authentication",
|
||||||
"adminEnabled2FaOnYourAccount": "Your administrator has enabled two-factor authentication for {email}. Please complete the setup process to continue.",
|
"adminEnabled2FaOnYourAccount": "Your administrator has enabled two-factor authentication for {email}. Please complete the setup process to continue.",
|
||||||
"continueToApplication": "Continue to Application"
|
"continueToApplication": "Continue to Application",
|
||||||
|
"securityKeyAdd": "Add Security Key",
|
||||||
|
"securityKeyRegisterTitle": "Register New Security Key",
|
||||||
|
"securityKeyRegisterDescription": "Connect your security key and enter a name to identify it",
|
||||||
|
"securityKeyTwoFactorRequired": "Two-Factor Authentication Required",
|
||||||
|
"securityKeyTwoFactorDescription": "Please enter your two-factor authentication code to register the security key",
|
||||||
|
"securityKeyTwoFactorRemoveDescription": "Please enter your two-factor authentication code to remove the security key",
|
||||||
|
"securityKeyTwoFactorCode": "Two-Factor Code",
|
||||||
|
"securityKeyRemoveTitle": "Remove Security Key",
|
||||||
|
"securityKeyRemoveDescription": "Enter your password to remove the security key \"{name}\"",
|
||||||
|
"securityKeyNoKeysRegistered": "No security keys registered",
|
||||||
|
"securityKeyNoKeysDescription": "Add a security key to enhance your account security"
|
||||||
}
|
}
|
||||||
|
|
2
package-lock.json
generated
2
package-lock.json
generated
|
@ -33,9 +33,9 @@
|
||||||
"@radix-ui/react-toast": "1.2.14",
|
"@radix-ui/react-toast": "1.2.14",
|
||||||
"@react-email/components": "0.3.1",
|
"@react-email/components": "0.3.1",
|
||||||
"@react-email/render": "^1.1.2",
|
"@react-email/render": "^1.1.2",
|
||||||
|
"@react-email/tailwind": "1.2.1",
|
||||||
"@simplewebauthn/browser": "^13.1.0",
|
"@simplewebauthn/browser": "^13.1.0",
|
||||||
"@simplewebauthn/server": "^9.0.3",
|
"@simplewebauthn/server": "^9.0.3",
|
||||||
"@react-email/tailwind": "1.2.1",
|
|
||||||
"@tailwindcss/forms": "^0.5.10",
|
"@tailwindcss/forms": "^0.5.10",
|
||||||
"@tanstack/react-table": "8.21.3",
|
"@tanstack/react-table": "8.21.3",
|
||||||
"arctic": "^3.7.0",
|
"arctic": "^3.7.0",
|
||||||
|
|
|
@ -107,7 +107,8 @@ async function clearChallenge(sessionId: string) {
|
||||||
|
|
||||||
export const registerSecurityKeyBody = z.object({
|
export const registerSecurityKeyBody = z.object({
|
||||||
name: z.string().min(1),
|
name: z.string().min(1),
|
||||||
password: z.string().min(1)
|
password: z.string().min(1),
|
||||||
|
code: z.string().optional()
|
||||||
}).strict();
|
}).strict();
|
||||||
|
|
||||||
export const verifyRegistrationBody = z.object({
|
export const verifyRegistrationBody = z.object({
|
||||||
|
@ -143,7 +144,7 @@ export async function startRegistration(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { name, password } = parsedBody.data;
|
const { name, password, code } = parsedBody.data;
|
||||||
const user = req.user as User;
|
const user = req.user as User;
|
||||||
|
|
||||||
// Only allow internal users to use security keys
|
// Only allow internal users to use security keys
|
||||||
|
@ -163,6 +164,39 @@ export async function startRegistration(
|
||||||
return next(unauthorized());
|
return next(unauthorized());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If user has 2FA enabled, require and verify the code
|
||||||
|
if (user.twoFactorEnabled) {
|
||||||
|
if (!code) {
|
||||||
|
return response<{ codeRequested: boolean }>(res, {
|
||||||
|
data: { codeRequested: true },
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "Two-factor authentication required",
|
||||||
|
status: HttpCode.ACCEPTED
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const validOTP = await verifyTotpCode(
|
||||||
|
code,
|
||||||
|
user.twoFactorSecret!,
|
||||||
|
user.userId
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!validOTP) {
|
||||||
|
if (config.getRawConfig().app.log_failed_attempts) {
|
||||||
|
logger.info(
|
||||||
|
`Two-factor code incorrect. Email: ${user.email}. IP: ${req.ip}.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.UNAUTHORIZED,
|
||||||
|
"The two-factor code you entered is incorrect"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Get existing security keys for user
|
// Get existing security keys for user
|
||||||
const existingSecurityKeys = await db
|
const existingSecurityKeys = await db
|
||||||
.select()
|
.select()
|
||||||
|
|
|
@ -15,23 +15,25 @@ import {
|
||||||
FormField,
|
FormField,
|
||||||
FormItem,
|
FormItem,
|
||||||
FormLabel,
|
FormLabel,
|
||||||
FormMessage,
|
FormMessage
|
||||||
} from "@app/components/ui/form";
|
} from "@app/components/ui/form";
|
||||||
import { Input } from "@app/components/ui/input";
|
import { Input } from "@app/components/ui/input";
|
||||||
import { Alert, AlertDescription } from "@app/components/ui/alert";
|
import { Alert, AlertDescription } from "@app/components/ui/alert";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Credenza,
|
||||||
DialogContent,
|
CredenzaBody,
|
||||||
DialogDescription,
|
CredenzaClose,
|
||||||
DialogFooter,
|
CredenzaContent,
|
||||||
DialogHeader,
|
CredenzaDescription,
|
||||||
DialogTitle,
|
CredenzaFooter,
|
||||||
} from "@app/components/ui/dialog";
|
CredenzaHeader,
|
||||||
|
CredenzaTitle
|
||||||
|
} from "@app/components/Credenza";
|
||||||
import { startRegistration } from "@simplewebauthn/browser";
|
import { startRegistration } from "@simplewebauthn/browser";
|
||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
import { Card, CardContent } from "@app/components/ui/card";
|
import { Card, CardContent } from "@app/components/ui/card";
|
||||||
import { Badge } from "@app/components/ui/badge";
|
import { Badge } from "@app/components/ui/badge";
|
||||||
import { Loader2, KeyRound, Trash2, Plus, Shield } from "lucide-react";
|
import { Loader2, KeyRound, Trash2, Plus, Shield, Info } from "lucide-react";
|
||||||
import { cn } from "@app/lib/cn";
|
import { cn } from "@app/lib/cn";
|
||||||
|
|
||||||
type SecurityKeyFormProps = {
|
type SecurityKeyFormProps = {
|
||||||
|
@ -53,6 +55,7 @@ type DeleteSecurityKeyData = {
|
||||||
type RegisterFormValues = {
|
type RegisterFormValues = {
|
||||||
name: string;
|
name: string;
|
||||||
password: string;
|
password: string;
|
||||||
|
code?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type DeleteFormValues = {
|
type DeleteFormValues = {
|
||||||
|
@ -70,30 +73,47 @@ type FieldProps = {
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function SecurityKeyForm({ open, setOpen }: SecurityKeyFormProps) {
|
export default function SecurityKeyForm({
|
||||||
|
open,
|
||||||
|
setOpen
|
||||||
|
}: SecurityKeyFormProps) {
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
const { env } = useEnvContext();
|
const { env } = useEnvContext();
|
||||||
const api = createApiClient({ env });
|
const api = createApiClient({ env });
|
||||||
const [securityKeys, setSecurityKeys] = useState<SecurityKey[]>([]);
|
const [securityKeys, setSecurityKeys] = useState<SecurityKey[]>([]);
|
||||||
const [isRegistering, setIsRegistering] = useState(false);
|
const [isRegistering, setIsRegistering] = useState(false);
|
||||||
const [showRegisterDialog, setShowRegisterDialog] = useState(false);
|
const [dialogState, setDialogState] = useState<
|
||||||
const [selectedSecurityKey, setSelectedSecurityKey] = useState<DeleteSecurityKeyData | null>(null);
|
"list" | "register" | "register2fa" | "delete" | "delete2fa"
|
||||||
const [show2FADialog, setShow2FADialog] = useState(false);
|
>("list");
|
||||||
|
const [selectedSecurityKey, setSelectedSecurityKey] =
|
||||||
|
useState<DeleteSecurityKeyData | null>(null);
|
||||||
const [deleteInProgress, setDeleteInProgress] = useState(false);
|
const [deleteInProgress, setDeleteInProgress] = useState(false);
|
||||||
const [pendingDeleteCredentialId, setPendingDeleteCredentialId] = useState<string | null>(null);
|
const [pendingDeleteCredentialId, setPendingDeleteCredentialId] = useState<
|
||||||
const [pendingDeletePassword, setPendingDeletePassword] = useState<string | null>(null);
|
string | null
|
||||||
|
>(null);
|
||||||
|
const [pendingDeletePassword, setPendingDeletePassword] = useState<
|
||||||
|
string | null
|
||||||
|
>(null);
|
||||||
|
const [pendingRegisterData, setPendingRegisterData] = useState<{
|
||||||
|
name: string;
|
||||||
|
password: string;
|
||||||
|
} | null>(null);
|
||||||
|
const [register2FAForm, setRegister2FAForm] = useState<{ code: string }>({
|
||||||
|
code: ""
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadSecurityKeys();
|
loadSecurityKeys();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const registerSchema = z.object({
|
const registerSchema = z.object({
|
||||||
name: z.string().min(1, { message: t('securityKeyNameRequired') }),
|
name: z.string().min(1, { message: t("securityKeyNameRequired") }),
|
||||||
password: z.string().min(1, { message: t('passwordRequired') }),
|
password: z.string().min(1, { message: t("passwordRequired") }),
|
||||||
|
code: z.string().optional()
|
||||||
});
|
});
|
||||||
|
|
||||||
const deleteSchema = z.object({
|
const deleteSchema = z.object({
|
||||||
password: z.string().min(1, { message: t('passwordRequired') }),
|
password: z.string().min(1, { message: t("passwordRequired") }),
|
||||||
code: z.string().optional()
|
code: z.string().optional()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -102,7 +122,8 @@ export default function SecurityKeyForm({ open, setOpen }: SecurityKeyFormProps)
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
name: "",
|
name: "",
|
||||||
password: "",
|
password: "",
|
||||||
},
|
code: ""
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const deleteForm = useForm<DeleteFormValues>({
|
const deleteForm = useForm<DeleteFormValues>({
|
||||||
|
@ -110,7 +131,7 @@ export default function SecurityKeyForm({ open, setOpen }: SecurityKeyFormProps)
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
password: "",
|
password: "",
|
||||||
code: ""
|
code: ""
|
||||||
},
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const loadSecurityKeys = async () => {
|
const loadSecurityKeys = async () => {
|
||||||
|
@ -120,7 +141,7 @@ export default function SecurityKeyForm({ open, setOpen }: SecurityKeyFormProps)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast({
|
toast({
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
description: formatAxiosError(error, t('securityKeyLoadError')),
|
description: formatAxiosError(error, t("securityKeyLoadError"))
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -131,26 +152,32 @@ export default function SecurityKeyForm({ open, setOpen }: SecurityKeyFormProps)
|
||||||
if (!window.PublicKeyCredential) {
|
if (!window.PublicKeyCredential) {
|
||||||
toast({
|
toast({
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
description: t('securityKeyBrowserNotSupported', {
|
description: t("securityKeyBrowserNotSupported", {
|
||||||
defaultValue: "Your browser doesn't support security keys. Please use a modern browser like Chrome, Firefox, or Safari."
|
defaultValue:
|
||||||
|
"Your browser doesn't support security keys. Please use a modern browser like Chrome, Firefox, or Safari."
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsRegistering(true);
|
setIsRegistering(true);
|
||||||
const startRes = await api.post("/auth/security-key/register/start", {
|
const startRes = await api.post(
|
||||||
|
"/auth/security-key/register/start",
|
||||||
|
{
|
||||||
name: values.name,
|
name: values.name,
|
||||||
password: values.password,
|
password: values.password,
|
||||||
});
|
code: values.code
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
if (startRes.status === 202) {
|
// If 2FA is required
|
||||||
toast({
|
if (startRes.status === 202 && startRes.data.data?.codeRequested) {
|
||||||
variant: "destructive",
|
setPendingRegisterData({
|
||||||
description: t('twoFactorRequired', {
|
name: values.name,
|
||||||
defaultValue: "Two-factor authentication is required to register a security key."
|
password: values.password
|
||||||
})
|
|
||||||
});
|
});
|
||||||
|
setDialogState("register2fa");
|
||||||
|
setIsRegistering(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -160,59 +187,66 @@ export default function SecurityKeyForm({ open, setOpen }: SecurityKeyFormProps)
|
||||||
const credential = await startRegistration(options);
|
const credential = await startRegistration(options);
|
||||||
|
|
||||||
await api.post("/auth/security-key/register/verify", {
|
await api.post("/auth/security-key/register/verify", {
|
||||||
credential,
|
credential
|
||||||
});
|
});
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
description: t('securityKeyRegisterSuccess', {
|
description: t("securityKeyRegisterSuccess", {
|
||||||
defaultValue: "Security key registered successfully"
|
defaultValue: "Security key registered successfully"
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
registerForm.reset();
|
registerForm.reset();
|
||||||
setShowRegisterDialog(false);
|
setDialogState("list");
|
||||||
await loadSecurityKeys();
|
await loadSecurityKeys();
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
if (error.name === 'NotAllowedError') {
|
if (error.name === "NotAllowedError") {
|
||||||
if (error.message.includes('denied permission')) {
|
if (error.message.includes("denied permission")) {
|
||||||
toast({
|
toast({
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
description: t('securityKeyPermissionDenied', {
|
description: t("securityKeyPermissionDenied", {
|
||||||
defaultValue: "Please allow access to your security key to continue registration."
|
defaultValue:
|
||||||
|
"Please allow access to your security key to continue registration."
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
toast({
|
toast({
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
description: t('securityKeyRemovedTooQuickly', {
|
description: t("securityKeyRemovedTooQuickly", {
|
||||||
defaultValue: "Please keep your security key connected until the registration process completes."
|
defaultValue:
|
||||||
|
"Please keep your security key connected until the registration process completes."
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else if (error.name === 'NotSupportedError') {
|
} else if (error.name === "NotSupportedError") {
|
||||||
toast({
|
toast({
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
description: t('securityKeyNotSupported', {
|
description: t("securityKeyNotSupported", {
|
||||||
defaultValue: "Your security key may not be compatible. Please try a different security key."
|
defaultValue:
|
||||||
|
"Your security key may not be compatible. Please try a different security key."
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
toast({
|
toast({
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
description: t('securityKeyUnknownError', {
|
description: t("securityKeyUnknownError", {
|
||||||
defaultValue: "There was a problem registering your security key. Please try again."
|
defaultValue:
|
||||||
|
"There was a problem registering your security key. Please try again."
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
throw error; // Re-throw to be caught by outer catch
|
throw error; // Re-throw to be caught by outer catch
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Security key registration error:', error);
|
console.error("Security key registration error:", error);
|
||||||
toast({
|
toast({
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
description: formatAxiosError(error, t('securityKeyRegisterError', {
|
description: formatAxiosError(
|
||||||
|
error,
|
||||||
|
t("securityKeyRegisterError", {
|
||||||
defaultValue: "Failed to register security key"
|
defaultValue: "Failed to register security key"
|
||||||
}))
|
})
|
||||||
|
)
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setIsRegistering(false);
|
setIsRegistering(false);
|
||||||
|
@ -224,33 +258,42 @@ export default function SecurityKeyForm({ open, setOpen }: SecurityKeyFormProps)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setDeleteInProgress(true);
|
setDeleteInProgress(true);
|
||||||
const encodedCredentialId = encodeURIComponent(selectedSecurityKey.credentialId);
|
const encodedCredentialId = encodeURIComponent(
|
||||||
const response = await api.delete(`/auth/security-key/${encodedCredentialId}`, {
|
selectedSecurityKey.credentialId
|
||||||
|
);
|
||||||
|
const response = await api.delete(
|
||||||
|
`/auth/security-key/${encodedCredentialId}`,
|
||||||
|
{
|
||||||
data: {
|
data: {
|
||||||
password: values.password,
|
password: values.password,
|
||||||
code: values.code
|
code: values.code
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
);
|
||||||
|
|
||||||
// If 2FA is required
|
// If 2FA is required
|
||||||
if (response.status === 202 && response.data.data.codeRequested) {
|
if (response.status === 202 && response.data.data.codeRequested) {
|
||||||
setPendingDeleteCredentialId(encodedCredentialId);
|
setPendingDeleteCredentialId(encodedCredentialId);
|
||||||
setPendingDeletePassword(values.password);
|
setPendingDeletePassword(values.password);
|
||||||
setShow2FADialog(true);
|
setDialogState("delete2fa");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
description: t('securityKeyRemoveSuccess')
|
description: t("securityKeyRemoveSuccess")
|
||||||
});
|
});
|
||||||
|
|
||||||
deleteForm.reset();
|
deleteForm.reset();
|
||||||
setSelectedSecurityKey(null);
|
setSelectedSecurityKey(null);
|
||||||
|
setDialogState("list");
|
||||||
await loadSecurityKeys();
|
await loadSecurityKeys();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast({
|
toast({
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
description: formatAxiosError(error, t('securityKeyRemoveError')),
|
description: formatAxiosError(
|
||||||
|
error,
|
||||||
|
t("securityKeyRemoveError")
|
||||||
|
)
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setDeleteInProgress(false);
|
setDeleteInProgress(false);
|
||||||
|
@ -262,33 +305,128 @@ export default function SecurityKeyForm({ open, setOpen }: SecurityKeyFormProps)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setDeleteInProgress(true);
|
setDeleteInProgress(true);
|
||||||
await api.delete(`/auth/security-key/${pendingDeleteCredentialId}`, {
|
await api.delete(
|
||||||
|
`/auth/security-key/${pendingDeleteCredentialId}`,
|
||||||
|
{
|
||||||
data: {
|
data: {
|
||||||
password: pendingDeletePassword,
|
password: pendingDeletePassword,
|
||||||
code: values.code
|
code: values.code
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
);
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
description: t('securityKeyRemoveSuccess')
|
description: t("securityKeyRemoveSuccess")
|
||||||
});
|
});
|
||||||
|
|
||||||
deleteForm.reset();
|
deleteForm.reset();
|
||||||
setSelectedSecurityKey(null);
|
setSelectedSecurityKey(null);
|
||||||
setShow2FADialog(false);
|
setDialogState("list");
|
||||||
setPendingDeleteCredentialId(null);
|
setPendingDeleteCredentialId(null);
|
||||||
setPendingDeletePassword(null);
|
setPendingDeletePassword(null);
|
||||||
await loadSecurityKeys();
|
await loadSecurityKeys();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast({
|
toast({
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
description: formatAxiosError(error, t('securityKeyRemoveError')),
|
description: formatAxiosError(
|
||||||
|
error,
|
||||||
|
t("securityKeyRemoveError")
|
||||||
|
)
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setDeleteInProgress(false);
|
setDeleteInProgress(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleRegister2FASubmit = async (values: { code: string }) => {
|
||||||
|
if (!pendingRegisterData) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsRegistering(true);
|
||||||
|
const startRes = await api.post(
|
||||||
|
"/auth/security-key/register/start",
|
||||||
|
{
|
||||||
|
name: pendingRegisterData.name,
|
||||||
|
password: pendingRegisterData.password,
|
||||||
|
code: values.code
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const options = startRes.data.data;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const credential = await startRegistration(options);
|
||||||
|
|
||||||
|
await api.post("/auth/security-key/register/verify", {
|
||||||
|
credential
|
||||||
|
});
|
||||||
|
|
||||||
|
toast({
|
||||||
|
description: t("securityKeyRegisterSuccess", {
|
||||||
|
defaultValue: "Security key registered successfully"
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
registerForm.reset();
|
||||||
|
setDialogState("list");
|
||||||
|
setPendingRegisterData(null);
|
||||||
|
setRegister2FAForm({ code: "" });
|
||||||
|
await loadSecurityKeys();
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.name === "NotAllowedError") {
|
||||||
|
if (error.message.includes("denied permission")) {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
description: t("securityKeyPermissionDenied", {
|
||||||
|
defaultValue:
|
||||||
|
"Please allow access to your security key to continue registration."
|
||||||
|
})
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
description: t("securityKeyRemovedTooQuickly", {
|
||||||
|
defaultValue:
|
||||||
|
"Please keep your security key connected until the registration process completes."
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (error.name === "NotSupportedError") {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
description: t("securityKeyNotSupported", {
|
||||||
|
defaultValue:
|
||||||
|
"Your security key may not be compatible. Please try a different security key."
|
||||||
|
})
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
description: t("securityKeyUnknownError", {
|
||||||
|
defaultValue:
|
||||||
|
"There was a problem registering your security key. Please try again."
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw error; // Re-throw to be caught by outer catch
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Security key registration error:", error);
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
description: formatAxiosError(
|
||||||
|
error,
|
||||||
|
t("securityKeyRegisterError", {
|
||||||
|
defaultValue: "Failed to register security key"
|
||||||
|
})
|
||||||
|
)
|
||||||
|
});
|
||||||
|
setRegister2FAForm({ code: "" });
|
||||||
|
} finally {
|
||||||
|
setIsRegistering(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const onOpenChange = (open: boolean) => {
|
const onOpenChange = (open: boolean) => {
|
||||||
if (open) {
|
if (open) {
|
||||||
loadSecurityKeys();
|
loadSecurityKeys();
|
||||||
|
@ -296,60 +434,89 @@ export default function SecurityKeyForm({ open, setOpen }: SecurityKeyFormProps)
|
||||||
registerForm.reset();
|
registerForm.reset();
|
||||||
deleteForm.reset();
|
deleteForm.reset();
|
||||||
setSelectedSecurityKey(null);
|
setSelectedSecurityKey(null);
|
||||||
setShowRegisterDialog(false);
|
setDialogState("list");
|
||||||
|
setPendingRegisterData(null);
|
||||||
|
setRegister2FAForm({ code: "" });
|
||||||
}
|
}
|
||||||
setOpen(open);
|
setOpen(open);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Credenza open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="sm:max-w-[500px]">
|
<CredenzaContent>
|
||||||
<DialogHeader>
|
{dialogState === "list" && (
|
||||||
<DialogTitle className="flex items-center gap-2">
|
<>
|
||||||
<Shield className="h-5 w-5" />
|
<CredenzaHeader>
|
||||||
{t('securityKeyManage')}
|
<CredenzaTitle className="flex items-center gap-2">
|
||||||
</DialogTitle>
|
{t("securityKeyManage")}
|
||||||
<DialogDescription>
|
</CredenzaTitle>
|
||||||
{t('securityKeyDescription')}
|
<CredenzaDescription>
|
||||||
</DialogDescription>
|
{t("securityKeyDescription")}
|
||||||
</DialogHeader>
|
</CredenzaDescription>
|
||||||
|
</CredenzaHeader>
|
||||||
|
<CredenzaBody>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h3 className="text-sm font-medium text-muted-foreground">{t('securityKeyList')}</h3>
|
<h3 className="text-sm font-medium text-muted-foreground">
|
||||||
|
{t("securityKeyList")}
|
||||||
|
</h3>
|
||||||
<Button
|
<Button
|
||||||
className="h-8 w-8 p-0"
|
onClick={() =>
|
||||||
onClick={() => setShowRegisterDialog(true)}
|
setDialogState("register")
|
||||||
|
}
|
||||||
|
className="gap-2"
|
||||||
>
|
>
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
|
{t("securityKeyAdd")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{securityKeys.length > 0 ? (
|
{securityKeys.length > 0 ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{securityKeys.map((securityKey) => (
|
{securityKeys.map((securityKey) => (
|
||||||
<Card key={securityKey.credentialId}>
|
<Card
|
||||||
|
key={
|
||||||
|
securityKey.credentialId
|
||||||
|
}
|
||||||
|
>
|
||||||
<CardContent className="flex items-center justify-between p-4">
|
<CardContent className="flex items-center justify-between p-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-secondary">
|
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-secondary">
|
||||||
<KeyRound className="h-4 w-4 text-secondary-foreground" />
|
<KeyRound className="h-4 w-4 text-secondary-foreground" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium">{securityKey.name}</p>
|
<p className="font-medium">
|
||||||
|
{
|
||||||
|
securityKey.name
|
||||||
|
}
|
||||||
|
</p>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{t('securityKeyLastUsed', {
|
{t(
|
||||||
date: new Date(securityKey.lastUsed).toLocaleDateString()
|
"securityKeyLastUsed",
|
||||||
})}
|
{
|
||||||
|
date: new Date(
|
||||||
|
securityKey.lastUsed
|
||||||
|
).toLocaleDateString()
|
||||||
|
}
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
className="h-8 w-8 p-0 text-white hover:text-white/80"
|
className="h-8 w-8 p-0 text-white hover:text-white/80"
|
||||||
onClick={() => setSelectedSecurityKey({
|
onClick={() => {
|
||||||
credentialId: securityKey.credentialId,
|
setSelectedSecurityKey(
|
||||||
|
{
|
||||||
|
credentialId:
|
||||||
|
securityKey.credentialId,
|
||||||
name: securityKey.name
|
name: securityKey.name
|
||||||
})}
|
}
|
||||||
|
);
|
||||||
|
setDialogState(
|
||||||
|
"delete"
|
||||||
|
);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
@ -360,42 +527,66 @@ export default function SecurityKeyForm({ open, setOpen }: SecurityKeyFormProps)
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col items-center justify-center py-8 text-center">
|
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||||
<Shield className="mb-2 h-12 w-12 text-muted-foreground" />
|
<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-sm text-muted-foreground">
|
||||||
<p className="text-xs text-muted-foreground">Add a security key to enhance your account security</p>
|
{t("securityKeyNoKeysRegistered")}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t("securityKeyNoKeysDescription")}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{securityKeys.length === 1 && (
|
{securityKeys.length === 1 && (
|
||||||
<Alert variant="default">
|
<Alert variant="default">
|
||||||
<AlertDescription>{t('securityKeyRecommendation')}</AlertDescription>
|
<Info className="h-4 w-4" />
|
||||||
|
<AlertDescription>
|
||||||
|
{t("securityKeyRecommendation")}
|
||||||
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</CredenzaBody>
|
||||||
</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>
|
|
||||||
|
|
||||||
|
{dialogState === "register" && (
|
||||||
|
<>
|
||||||
|
<CredenzaHeader>
|
||||||
|
<CredenzaTitle>
|
||||||
|
{t("securityKeyRegisterTitle")}
|
||||||
|
</CredenzaTitle>
|
||||||
|
<CredenzaDescription>
|
||||||
|
{t("securityKeyRegisterDescription")}
|
||||||
|
</CredenzaDescription>
|
||||||
|
</CredenzaHeader>
|
||||||
|
<CredenzaBody>
|
||||||
<Form {...registerForm}>
|
<Form {...registerForm}>
|
||||||
<form onSubmit={registerForm.handleSubmit(handleRegisterSecurityKey)} className="space-y-4">
|
<form
|
||||||
|
onSubmit={registerForm.handleSubmit(
|
||||||
|
handleRegisterSecurityKey
|
||||||
|
)}
|
||||||
|
className="space-y-4"
|
||||||
|
id="form"
|
||||||
|
>
|
||||||
<FormField
|
<FormField
|
||||||
control={registerForm.control}
|
control={registerForm.control}
|
||||||
name="name"
|
name="name"
|
||||||
render={({ field }: FieldProps) => (
|
render={({ field }: FieldProps) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>{t('securityKeyNameLabel')}</FormLabel>
|
<FormLabel>
|
||||||
|
{t(
|
||||||
|
"securityKeyNameLabel"
|
||||||
|
)}
|
||||||
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
{...field}
|
{...field}
|
||||||
placeholder={t('securityKeyNamePlaceholder')}
|
placeholder={t(
|
||||||
disabled={isRegistering}
|
"securityKeyNamePlaceholder"
|
||||||
|
)}
|
||||||
|
disabled={
|
||||||
|
isRegistering
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
|
@ -407,181 +598,262 @@ export default function SecurityKeyForm({ open, setOpen }: SecurityKeyFormProps)
|
||||||
name="password"
|
name="password"
|
||||||
render={({ field }: FieldProps) => (
|
render={({ field }: FieldProps) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>{t('password')}</FormLabel>
|
<FormLabel>
|
||||||
|
{t("password")}
|
||||||
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
{...field}
|
{...field}
|
||||||
type="password"
|
type="password"
|
||||||
disabled={isRegistering}
|
disabled={
|
||||||
|
isRegistering
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
</form>
|
||||||
<DialogFooter>
|
</Form>
|
||||||
|
</CredenzaBody>
|
||||||
|
<CredenzaFooter>
|
||||||
|
<CredenzaClose asChild>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
variant="outline"
|
||||||
className="border border-input bg-transparent text-foreground hover:bg-accent hover:text-accent-foreground"
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
registerForm.reset();
|
registerForm.reset();
|
||||||
setShowRegisterDialog(false);
|
setDialogState("list");
|
||||||
}}
|
}}
|
||||||
disabled={isRegistering}
|
disabled={isRegistering}
|
||||||
>
|
>
|
||||||
{t('cancel')}
|
{t("cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
|
</CredenzaClose>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
form="form"
|
||||||
disabled={isRegistering}
|
disabled={isRegistering}
|
||||||
className={cn(
|
className={cn(
|
||||||
"min-w-[100px]",
|
"min-w-[100px]",
|
||||||
isRegistering && "cursor-not-allowed opacity-50"
|
isRegistering &&
|
||||||
|
"cursor-not-allowed opacity-50"
|
||||||
)}
|
)}
|
||||||
|
loading={isRegistering}
|
||||||
>
|
>
|
||||||
{isRegistering ? (
|
{t("securityKeyRegister")}
|
||||||
<>
|
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
||||||
{t('registering')}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
t('securityKeyRegister')
|
|
||||||
)}
|
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</CredenzaFooter>
|
||||||
</form>
|
</>
|
||||||
</Form>
|
)}
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
<Dialog open={!!selectedSecurityKey} onOpenChange={(open) => !open && setSelectedSecurityKey(null)}>
|
{dialogState === "register2fa" && (
|
||||||
<DialogContent className="sm:max-w-[400px]">
|
<>
|
||||||
<DialogHeader>
|
<CredenzaHeader>
|
||||||
<DialogTitle className="flex items-center gap-2 text-destructive">
|
<CredenzaTitle>
|
||||||
<Trash2 className="h-4 w-4" />
|
{t("securityKeyTwoFactorRequired")}
|
||||||
Remove Security Key
|
</CredenzaTitle>
|
||||||
</DialogTitle>
|
<CredenzaDescription>
|
||||||
<DialogDescription>
|
{t("securityKeyTwoFactorDescription")}
|
||||||
Enter your password to remove the security key "{selectedSecurityKey?.name}"
|
</CredenzaDescription>
|
||||||
</DialogDescription>
|
</CredenzaHeader>
|
||||||
</DialogHeader>
|
<CredenzaBody>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium">
|
||||||
|
{t("securityKeyTwoFactorCode")}
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={register2FAForm.code}
|
||||||
|
onChange={(e) =>
|
||||||
|
setRegister2FAForm({
|
||||||
|
code: e.target.value
|
||||||
|
})
|
||||||
|
}
|
||||||
|
maxLength={6}
|
||||||
|
disabled={isRegistering}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CredenzaBody>
|
||||||
|
<CredenzaFooter>
|
||||||
|
<CredenzaClose asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
setRegister2FAForm({ code: "" });
|
||||||
|
setDialogState("list");
|
||||||
|
setPendingRegisterData(null);
|
||||||
|
}}
|
||||||
|
disabled={isRegistering}
|
||||||
|
>
|
||||||
|
{t("cancel")}
|
||||||
|
</Button>
|
||||||
|
</CredenzaClose>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
className="min-w-[100px]"
|
||||||
|
disabled={
|
||||||
|
isRegistering ||
|
||||||
|
register2FAForm.code.length !== 6
|
||||||
|
}
|
||||||
|
loading={isRegistering}
|
||||||
|
onClick={() =>
|
||||||
|
handleRegister2FASubmit({
|
||||||
|
code: register2FAForm.code
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t("securityKeyRegister")}
|
||||||
|
</Button>
|
||||||
|
</CredenzaFooter>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{dialogState === "delete" && (
|
||||||
|
<>
|
||||||
|
<CredenzaHeader>
|
||||||
|
<CredenzaTitle className="flex items-center gap-2">
|
||||||
|
{t("securityKeyRemoveTitle")}
|
||||||
|
</CredenzaTitle>
|
||||||
|
<CredenzaDescription>
|
||||||
|
{t("securityKeyRemoveDescription", { name: selectedSecurityKey!.name! })}
|
||||||
|
</CredenzaDescription>
|
||||||
|
</CredenzaHeader>
|
||||||
|
<CredenzaBody>
|
||||||
<Form {...deleteForm}>
|
<Form {...deleteForm}>
|
||||||
<form onSubmit={deleteForm.handleSubmit(handleDeleteSecurityKey)} className="space-y-4">
|
<form
|
||||||
|
onSubmit={deleteForm.handleSubmit(
|
||||||
|
handleDeleteSecurityKey
|
||||||
|
)}
|
||||||
|
className="space-y-4"
|
||||||
|
id="delete-form"
|
||||||
|
>
|
||||||
<FormField
|
<FormField
|
||||||
control={deleteForm.control}
|
control={deleteForm.control}
|
||||||
name="password"
|
name="password"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>{t('password')}</FormLabel>
|
<FormLabel>
|
||||||
|
{t("password")}
|
||||||
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
{...field}
|
{...field}
|
||||||
type="password"
|
type="password"
|
||||||
disabled={deleteInProgress}
|
disabled={
|
||||||
|
deleteInProgress
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
</form>
|
||||||
<DialogFooter>
|
</Form>
|
||||||
|
</CredenzaBody>
|
||||||
|
<CredenzaFooter>
|
||||||
|
<CredenzaClose asChild>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
variant="outline"
|
||||||
className="border border-input bg-transparent text-foreground hover:bg-accent hover:text-accent-foreground"
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
deleteForm.reset();
|
deleteForm.reset();
|
||||||
setSelectedSecurityKey(null);
|
setSelectedSecurityKey(null);
|
||||||
|
setDialogState("list");
|
||||||
}}
|
}}
|
||||||
disabled={deleteInProgress}
|
disabled={deleteInProgress}
|
||||||
>
|
>
|
||||||
{t('cancel')}
|
{t("cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
|
</CredenzaClose>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
form="delete-form"
|
||||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
disabled={deleteInProgress}
|
disabled={deleteInProgress}
|
||||||
|
loading={deleteInProgress}
|
||||||
>
|
>
|
||||||
{deleteInProgress ? (
|
{t("securityKeyRemove")}
|
||||||
<>
|
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
||||||
{t('securityKeyRemoving')}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
t('securityKeyRemove')
|
|
||||||
)}
|
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</CredenzaFooter>
|
||||||
</form>
|
</>
|
||||||
</Form>
|
)}
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
<Dialog open={show2FADialog} onOpenChange={(open) => !open && setShow2FADialog(false)}>
|
|
||||||
<DialogContent className="sm:max-w-[400px]">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Two-Factor Authentication Required</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
Please enter your two-factor authentication code to remove the security key
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
|
{dialogState === "delete2fa" && (
|
||||||
|
<>
|
||||||
|
<CredenzaHeader>
|
||||||
|
<CredenzaTitle>
|
||||||
|
{t("securityKeyTwoFactorRequired")}
|
||||||
|
</CredenzaTitle>
|
||||||
|
<CredenzaDescription>
|
||||||
|
{t("securityKeyTwoFactorRemoveDescription")}
|
||||||
|
</CredenzaDescription>
|
||||||
|
</CredenzaHeader>
|
||||||
|
<CredenzaBody>
|
||||||
<Form {...deleteForm}>
|
<Form {...deleteForm}>
|
||||||
<form onSubmit={deleteForm.handleSubmit(handle2FASubmit)} className="space-y-4">
|
<form
|
||||||
|
onSubmit={deleteForm.handleSubmit(
|
||||||
|
handle2FASubmit
|
||||||
|
)}
|
||||||
|
className="space-y-4"
|
||||||
|
id="delete2fa-form"
|
||||||
|
>
|
||||||
<FormField
|
<FormField
|
||||||
control={deleteForm.control}
|
control={deleteForm.control}
|
||||||
name="code"
|
name="code"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Two-Factor Code</FormLabel>
|
<FormLabel>
|
||||||
|
{t("securityKeyTwoFactorCode")}
|
||||||
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
{...field}
|
{...field}
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Enter your 6-digit code"
|
maxLength={6}
|
||||||
disabled={deleteInProgress}
|
disabled={
|
||||||
|
deleteInProgress
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
</form>
|
||||||
<DialogFooter>
|
</Form>
|
||||||
|
</CredenzaBody>
|
||||||
|
<CredenzaFooter>
|
||||||
|
<CredenzaClose asChild>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
variant="outline"
|
||||||
className="border border-input bg-transparent text-foreground hover:bg-accent hover:text-accent-foreground"
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
deleteForm.reset();
|
deleteForm.reset();
|
||||||
setShow2FADialog(false);
|
setDialogState("list");
|
||||||
setPendingDeleteCredentialId(null);
|
setPendingDeleteCredentialId(null);
|
||||||
setPendingDeletePassword(null);
|
setPendingDeletePassword(null);
|
||||||
}}
|
}}
|
||||||
disabled={deleteInProgress}
|
disabled={deleteInProgress}
|
||||||
>
|
>
|
||||||
{t('cancel')}
|
{t("cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
|
</CredenzaClose>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
form="delete2fa-form"
|
||||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
disabled={deleteInProgress}
|
disabled={deleteInProgress}
|
||||||
|
loading={deleteInProgress}
|
||||||
>
|
>
|
||||||
{deleteInProgress ? (
|
{t("securityKeyRemove")}
|
||||||
<>
|
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
||||||
{t('securityKeyRemoving')}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
t('securityKeyRemove')
|
|
||||||
)}
|
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</CredenzaFooter>
|
||||||
</form>
|
</>
|
||||||
</Form>
|
)}
|
||||||
</DialogContent>
|
</CredenzaContent>
|
||||||
</Dialog>
|
</Credenza>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
Loading…
Add table
Add a link
Reference in a new issue