mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-20 20:07:53 +00:00
Improve 2FA flow
This commit is contained in:
@@ -0,0 +1,120 @@
|
||||
import { useState } from "react";
|
||||
import { faCopy, faDownload } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { Button, Modal, ModalContent } from "../v2";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
recoveryCodes: string[];
|
||||
onDownloadComplete: () => void;
|
||||
};
|
||||
|
||||
export const RecoveryCodesDownload = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
recoveryCodes,
|
||||
onDownloadComplete
|
||||
}: Props) => {
|
||||
const [hasDownloaded, setHasDownloaded] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const downloadRecoveryCodes = () => {
|
||||
const content = [
|
||||
"Infisical Two-Factor Authentication Recovery Codes",
|
||||
`Generated on: ${new Date().toLocaleString()}`,
|
||||
"",
|
||||
"Important: Store these codes in a safe place. Each code can only be used once.",
|
||||
"If you lose access to your mobile authenticator, you can use these codes to regain access to your account.",
|
||||
"",
|
||||
"Recovery Codes:",
|
||||
...recoveryCodes.map((code, index) => `${index + 1}. ${code}`)
|
||||
].join("\n");
|
||||
|
||||
const blob = new Blob([content], { type: "text/plain" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `infisical-recovery-codes-${new Date().toISOString().split("T")[0]}.txt`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
setHasDownloaded(true);
|
||||
};
|
||||
|
||||
const copyToClipboard = async () => {
|
||||
const text = recoveryCodes.join("\n");
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch (err) {
|
||||
console.error("Failed to copy recovery codes:", err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
if (hasDownloaded) {
|
||||
onDownloadComplete();
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onOpenChange={hasDownloaded ? handleClose : () => {}}>
|
||||
<ModalContent title="Recovery Codes" className="max-w-md">
|
||||
<div className="space-y-4">
|
||||
<div className="rounded border border-yellow bg-yellow/10 p-2 px-3 text-xs text-yellow">
|
||||
Save these codes securely. Each can only be used once.
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
||||
<div className="grid grid-cols-2 gap-x-6 gap-y-2 font-mono text-sm">
|
||||
{recoveryCodes.map((code, index) => (
|
||||
<div key={code} className="flex items-center text-mineshaft-200">
|
||||
<span className="w-6 text-mineshaft-400">{index + 1}.</span>
|
||||
<span>{code}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
onClick={downloadRecoveryCodes}
|
||||
className="flex flex-1 items-center justify-center gap-2"
|
||||
colorSchema="primary"
|
||||
variant="solid"
|
||||
>
|
||||
<FontAwesomeIcon icon={faDownload} className="mr-2 h-4 w-4" />
|
||||
Download
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={copyToClipboard}
|
||||
className="flex flex-1 items-center justify-center gap-2"
|
||||
colorSchema="secondary"
|
||||
variant="outline"
|
||||
>
|
||||
<FontAwesomeIcon icon={faCopy} className="mr-2 h-4 w-4" />
|
||||
{copied ? "Copied!" : "Copy"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{hasDownloaded ? (
|
||||
<p className="text-center text-xs text-mineshaft-400">
|
||||
Recovery codes downloaded. You can now close this modal.
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-center text-xs text-mineshaft-400">
|
||||
Download the recovery codes to continue.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -7,6 +7,7 @@ import { useVerifyUserTotpRegistration } from "@app/hooks/api/users/mutation";
|
||||
|
||||
import { createNotification } from "../notifications";
|
||||
import { Button, ContentLoader, Input } from "../v2";
|
||||
import { RecoveryCodesDownload } from "./RecoveryCodesDownload";
|
||||
|
||||
type Props = {
|
||||
onComplete?: () => Promise<void>;
|
||||
@@ -19,20 +20,39 @@ const TotpRegistration = ({ onComplete, shouldCenterQr }: Props) => {
|
||||
useVerifyUserTotpRegistration();
|
||||
const [qrCodeUrl, setQrCodeUrl] = useState("");
|
||||
const [totp, setTotp] = useState("");
|
||||
const [showRecoveryModal, setShowRecoveryModal] = useState(false);
|
||||
const [recoveryCodes, setRecoveryCodes] = useState<string[]>([]);
|
||||
|
||||
const handleTotpVerify = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
await verifyUserTotp({
|
||||
totp
|
||||
});
|
||||
try {
|
||||
const result = await verifyUserTotp({
|
||||
totp
|
||||
});
|
||||
|
||||
createNotification({
|
||||
text: "Successfully configured mobile authenticator",
|
||||
type: "success"
|
||||
});
|
||||
createNotification({
|
||||
text: "Successfully configured mobile authenticator",
|
||||
type: "success"
|
||||
});
|
||||
|
||||
if (result.recoveryCodes) {
|
||||
setRecoveryCodes(result.recoveryCodes);
|
||||
setShowRecoveryModal(true);
|
||||
} else if (onComplete) {
|
||||
onComplete();
|
||||
}
|
||||
} catch {
|
||||
createNotification({
|
||||
text: "Failed to verify TOTP code",
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleRecoveryDownloadComplete = async () => {
|
||||
setShowRecoveryModal(false);
|
||||
if (onComplete) {
|
||||
onComplete();
|
||||
await onComplete();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -52,28 +72,37 @@ const TotpRegistration = ({ onComplete, shouldCenterQr }: Props) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex max-w-lg flex-col text-bunker-200">
|
||||
<div className="mb-8">
|
||||
1. Download a two-step verification app (Duo, Google Authenticator, etc.) and scan the QR
|
||||
code.
|
||||
</div>
|
||||
<div className={twMerge("mb-8 flex items-center", shouldCenterQr && "justify-center")}>
|
||||
<img src={qrCodeUrl} alt="registration-qr" />
|
||||
</div>
|
||||
<form onSubmit={handleTotpVerify}>
|
||||
<div className="mb-4">2. Enter the resulting verification code</div>
|
||||
<div className="mb-4 flex flex-row gap-2">
|
||||
<Input
|
||||
onChange={(e) => setTotp(e.target.value)}
|
||||
value={totp}
|
||||
placeholder="Verification code"
|
||||
/>
|
||||
<Button isLoading={isVerifyLoading} type="submit">
|
||||
Enable MFA
|
||||
</Button>
|
||||
<>
|
||||
<div className="flex max-w-lg flex-col text-bunker-200">
|
||||
<div className="mb-8">
|
||||
1. Download a two-step verification app (Duo, Google Authenticator, etc.) and scan the QR
|
||||
code.
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div className={twMerge("mb-8 flex items-center", shouldCenterQr && "justify-center")}>
|
||||
<img src={qrCodeUrl} alt="registration-qr" />
|
||||
</div>
|
||||
<form onSubmit={handleTotpVerify}>
|
||||
<div className="mb-4">2. Enter the resulting verification code</div>
|
||||
<div className="mb-4 flex flex-row gap-2">
|
||||
<Input
|
||||
onChange={(e) => setTotp(e.target.value)}
|
||||
value={totp}
|
||||
placeholder="Verification code"
|
||||
/>
|
||||
<Button isLoading={isVerifyLoading} type="submit">
|
||||
Enable MFA
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<RecoveryCodesDownload
|
||||
isOpen={showRecoveryModal}
|
||||
onClose={() => setShowRecoveryModal(false)}
|
||||
recoveryCodes={recoveryCodes}
|
||||
onDownloadComplete={handleRecoveryDownloadComplete}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user