mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-07-04 05:25:22 +02:00
463 lines
13 KiB
TypeScript
463 lines
13 KiB
TypeScript
import { zodResolver } from "@hookform/resolvers/zod";
|
|
import copy from "copy-to-clipboard";
|
|
import { CopyIcon, DownloadIcon, Fingerprint, QrCode } from "lucide-react";
|
|
import QRCode from "qrcode";
|
|
import { useEffect, useState } from "react";
|
|
import { useForm } from "react-hook-form";
|
|
import { toast } from "sonner";
|
|
import { z } from "zod";
|
|
import { Button } from "@/components/ui/button";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogTrigger,
|
|
} from "@/components/ui/dialog";
|
|
import {
|
|
Form,
|
|
FormControl,
|
|
FormDescription,
|
|
FormField,
|
|
FormItem,
|
|
FormLabel,
|
|
FormMessage,
|
|
} from "@/components/ui/form";
|
|
import { Input } from "@/components/ui/input";
|
|
import {
|
|
InputOTP,
|
|
InputOTPGroup,
|
|
InputOTPSlot,
|
|
} from "@/components/ui/input-otp";
|
|
import {
|
|
Tooltip,
|
|
TooltipContent,
|
|
TooltipProvider,
|
|
TooltipTrigger,
|
|
} from "@/components/ui/tooltip";
|
|
import { authClient } from "@/lib/auth-client";
|
|
import { api } from "@/utils/api";
|
|
|
|
const PasswordSchema = z.object({
|
|
password: z.string().min(8, {
|
|
message: "Password is required",
|
|
}),
|
|
issuer: z.string().optional(),
|
|
});
|
|
|
|
const PinSchema = z.object({
|
|
pin: z.string().min(6, {
|
|
message: "Pin is required",
|
|
}),
|
|
});
|
|
|
|
type TwoFactorSetupData = {
|
|
qrCodeUrl: string;
|
|
secret: string;
|
|
totpURI: string;
|
|
};
|
|
|
|
type PasswordForm = z.infer<typeof PasswordSchema>;
|
|
type PinForm = z.infer<typeof PinSchema>;
|
|
|
|
export const USERNAME_PLACEHOLDER = "%username%";
|
|
export const DATE_PLACEHOLDER = "%date%";
|
|
export const BACKUP_CODES_PLACEHOLDER = "%backupCodes%";
|
|
|
|
export const backupCodeTemplate = `Dokploy - BACKUP VERIFICATION CODES
|
|
|
|
Points to note
|
|
--------------
|
|
# Each code can be used only once.
|
|
# Do not share these codes with anyone.
|
|
|
|
Generated codes
|
|
---------------
|
|
Username: ${USERNAME_PLACEHOLDER}
|
|
Generated on: ${DATE_PLACEHOLDER}
|
|
|
|
|
|
${BACKUP_CODES_PLACEHOLDER}
|
|
`;
|
|
|
|
export const Enable2FA = () => {
|
|
const utils = api.useUtils();
|
|
const [data, setData] = useState<TwoFactorSetupData | null>(null);
|
|
const [backupCodes, setBackupCodes] = useState<string[]>([]);
|
|
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
|
const [step, setStep] = useState<"password" | "verify">("password");
|
|
const [isPasswordLoading, setIsPasswordLoading] = useState(false);
|
|
const [otpValue, setOtpValue] = useState("");
|
|
const { data: currentUser } = api.user.get.useQuery();
|
|
|
|
const handleVerifySubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
try {
|
|
const result = await authClient.twoFactor.verifyTotp({
|
|
code: otpValue,
|
|
});
|
|
|
|
if (result.error) {
|
|
if (result.error.code === "INVALID_TWO_FACTOR_AUTHENTICATION") {
|
|
toast.error("Invalid verification code");
|
|
return;
|
|
}
|
|
|
|
throw result.error;
|
|
}
|
|
|
|
if (!result.data) {
|
|
throw new Error("No response received from server");
|
|
}
|
|
|
|
toast.success("2FA configured successfully");
|
|
utils.user.get.invalidate();
|
|
setIsDialogOpen(false);
|
|
} catch (error) {
|
|
if (error instanceof Error) {
|
|
const errorMessage =
|
|
error.message === "Failed to fetch"
|
|
? "Connection error. Please check your internet connection."
|
|
: error.message;
|
|
|
|
toast.error(errorMessage);
|
|
} else {
|
|
toast.error("Error verifying 2FA code", {
|
|
description: error instanceof Error ? error.message : "Unknown error",
|
|
});
|
|
}
|
|
}
|
|
};
|
|
|
|
const passwordForm = useForm<PasswordForm>({
|
|
resolver: zodResolver(PasswordSchema),
|
|
defaultValues: {
|
|
password: "",
|
|
},
|
|
});
|
|
|
|
const pinForm = useForm<PinForm>({
|
|
resolver: zodResolver(PinSchema),
|
|
defaultValues: {
|
|
pin: "",
|
|
},
|
|
});
|
|
|
|
useEffect(() => {
|
|
if (!isDialogOpen) {
|
|
setStep("password");
|
|
setData(null);
|
|
setBackupCodes([]);
|
|
setOtpValue("");
|
|
passwordForm.reset({
|
|
password: "",
|
|
issuer: "",
|
|
});
|
|
}
|
|
}, [isDialogOpen, passwordForm]);
|
|
|
|
useEffect(() => {
|
|
if (step === "verify") {
|
|
setOtpValue("");
|
|
}
|
|
}, [step]);
|
|
|
|
const handlePasswordSubmit = async (formData: PasswordForm) => {
|
|
setIsPasswordLoading(true);
|
|
try {
|
|
const { data: enableData, error } = await authClient.twoFactor.enable({
|
|
password: formData.password,
|
|
issuer: formData.issuer,
|
|
});
|
|
|
|
if (!enableData) {
|
|
throw new Error(error?.message || "Error enabling 2FA");
|
|
}
|
|
|
|
if (enableData.backupCodes) {
|
|
setBackupCodes(enableData.backupCodes);
|
|
}
|
|
|
|
if (enableData.totpURI) {
|
|
const qrCodeUrl = await QRCode.toDataURL(enableData.totpURI);
|
|
|
|
setData({
|
|
qrCodeUrl,
|
|
secret: enableData.totpURI.split("secret=")[1]?.split("&")[0] || "",
|
|
totpURI: enableData.totpURI,
|
|
});
|
|
|
|
setStep("verify");
|
|
toast.success("Scan the QR code with your authenticator app");
|
|
} else {
|
|
throw new Error("No TOTP URI received from server");
|
|
}
|
|
} catch (error) {
|
|
toast.error(
|
|
error instanceof Error ? error.message : "Error setting up 2FA",
|
|
);
|
|
passwordForm.setError("password", {
|
|
message:
|
|
error instanceof Error ? error.message : "Error setting up 2FA",
|
|
});
|
|
} finally {
|
|
setIsPasswordLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleDownloadBackupCodes = () => {
|
|
if (!backupCodes || backupCodes.length === 0) {
|
|
toast.error("No backup codes to download.");
|
|
return;
|
|
}
|
|
|
|
const backupCodesFormatted = backupCodes
|
|
.map((code, index) => ` ${index + 1}. ${code}`)
|
|
.join("\n");
|
|
|
|
const date = new Date();
|
|
const year = date.getFullYear();
|
|
const month = String(date.getMonth() + 1).padStart(2, "0");
|
|
const day = String(date.getDate()).padStart(2, "0");
|
|
const filename = `dokploy-2fa-backup-codes-${year}${month}${day}.txt`;
|
|
|
|
const backupCodesText = backupCodeTemplate
|
|
.replace(USERNAME_PLACEHOLDER, currentUser?.user?.email || "unknown")
|
|
.replace(DATE_PLACEHOLDER, date.toLocaleString())
|
|
.replace(BACKUP_CODES_PLACEHOLDER, backupCodesFormatted);
|
|
|
|
const blob = new Blob([backupCodesText], { type: "text/plain" });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement("a");
|
|
a.href = url;
|
|
a.download = filename;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
URL.revokeObjectURL(url);
|
|
};
|
|
|
|
const handleCopyBackupCodes = () => {
|
|
const date = new Date();
|
|
|
|
const backupCodesFormatted = backupCodes
|
|
.map((code, index) => ` ${index + 1}. ${code}`)
|
|
.join("\n");
|
|
|
|
const backupCodesText = backupCodeTemplate
|
|
.replace(USERNAME_PLACEHOLDER, currentUser?.user?.email || "unknown")
|
|
.replace(DATE_PLACEHOLDER, date.toLocaleString())
|
|
.replace(BACKUP_CODES_PLACEHOLDER, backupCodesFormatted);
|
|
|
|
copy(backupCodesText);
|
|
toast.success("Backup codes copied to clipboard");
|
|
};
|
|
|
|
return (
|
|
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
|
<DialogTrigger asChild>
|
|
<Button variant="ghost">
|
|
<Fingerprint className="size-4 text-muted-foreground" />
|
|
Enable 2FA
|
|
</Button>
|
|
</DialogTrigger>
|
|
<DialogContent className="sm:max-w-xl">
|
|
<DialogHeader>
|
|
<DialogTitle>2FA Setup</DialogTitle>
|
|
<DialogDescription>
|
|
{step === "password"
|
|
? "Enter your password to begin 2FA setup"
|
|
: "Scan the QR code and verify with your authenticator app"}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
{step === "password" ? (
|
|
<Form {...passwordForm}>
|
|
<form
|
|
id="password-form"
|
|
onSubmit={passwordForm.handleSubmit(handlePasswordSubmit)}
|
|
className="space-y-4"
|
|
>
|
|
<FormField
|
|
control={passwordForm.control}
|
|
name="password"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>Password</FormLabel>
|
|
<FormControl>
|
|
<Input
|
|
type="password"
|
|
placeholder="Enter your password"
|
|
{...field}
|
|
/>
|
|
</FormControl>
|
|
<FormDescription>
|
|
Enter your password to enable 2FA
|
|
</FormDescription>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
<FormField
|
|
control={passwordForm.control}
|
|
name="issuer"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>Issuer</FormLabel>
|
|
<FormControl>
|
|
<Input
|
|
type="text"
|
|
placeholder="Enter your issuer"
|
|
{...field}
|
|
/>
|
|
</FormControl>
|
|
<FormDescription>
|
|
Use a custom issuer to identify the service you're
|
|
authenticating with.
|
|
</FormDescription>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
<Button
|
|
type="submit"
|
|
className="w-full"
|
|
isLoading={isPasswordLoading}
|
|
>
|
|
Continue
|
|
</Button>
|
|
</form>
|
|
</Form>
|
|
) : (
|
|
<Form {...pinForm}>
|
|
<form onSubmit={handleVerifySubmit} className="space-y-6">
|
|
<div className="flex flex-col gap-6 justify-center items-center">
|
|
{data?.qrCodeUrl ? (
|
|
<>
|
|
<div className="flex flex-col items-center gap-4 p-6 border rounded-lg">
|
|
<QrCode className="size-5 text-muted-foreground" />
|
|
<span className="text-sm font-medium">
|
|
Scan this QR code with your authenticator app
|
|
</span>
|
|
{/** biome-ignore lint/performance/noImgElement: This is a valid use case for an img element */}
|
|
<img
|
|
src={data.qrCodeUrl}
|
|
alt="2FA QR Code"
|
|
className="rounded-lg w-48 h-48"
|
|
/>
|
|
<div className="flex flex-col gap-2 text-center">
|
|
<span className="text-sm text-muted-foreground">
|
|
Can't scan the QR code?
|
|
</span>
|
|
<span className="text-xs font-mono bg-muted p-2 rounded">
|
|
{data.secret}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{backupCodes && backupCodes.length > 0 && (
|
|
<div className="w-full space-y-3 border rounded-lg p-4">
|
|
<div className="flex items-center justify-between">
|
|
<h4 className="font-medium">Backup Codes</h4>
|
|
<div className="flex items-center gap-2">
|
|
<TooltipProvider>
|
|
<Tooltip delayDuration={0}>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="icon"
|
|
onClick={handleCopyBackupCodes}
|
|
>
|
|
<CopyIcon className="size-4" />
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>
|
|
<p>Copy</p>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
|
|
<TooltipProvider>
|
|
<Tooltip delayDuration={0}>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="icon"
|
|
onClick={handleDownloadBackupCodes}
|
|
>
|
|
<DownloadIcon className="size-4" />
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>
|
|
<p>Download</p>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
</div>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-2">
|
|
{backupCodes.map((code, index) => (
|
|
<code
|
|
key={index}
|
|
className="bg-muted p-2 rounded text-sm font-mono"
|
|
>
|
|
{code}
|
|
</code>
|
|
))}
|
|
</div>
|
|
<p className="text-sm text-muted-foreground">
|
|
Save these backup codes in a secure place. You can use
|
|
them to access your account if you lose access to your
|
|
authenticator device.
|
|
</p>
|
|
</div>
|
|
)}
|
|
</>
|
|
) : (
|
|
<div className="flex items-center justify-center w-full h-48 bg-muted rounded-lg">
|
|
<QrCode className="size-8 text-muted-foreground animate-pulse" />
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex flex-col justify-center items-center">
|
|
<FormLabel>Verification Code</FormLabel>
|
|
<InputOTP
|
|
maxLength={6}
|
|
value={otpValue}
|
|
onChange={setOtpValue}
|
|
autoComplete="off"
|
|
>
|
|
<InputOTPGroup>
|
|
<InputOTPSlot index={0} />
|
|
<InputOTPSlot index={1} />
|
|
<InputOTPSlot index={2} />
|
|
<InputOTPSlot index={3} />
|
|
<InputOTPSlot index={4} />
|
|
<InputOTPSlot index={5} />
|
|
</InputOTPGroup>
|
|
</InputOTP>
|
|
<FormDescription>
|
|
Enter the 6-digit code from your authenticator app
|
|
</FormDescription>
|
|
</div>
|
|
|
|
<Button
|
|
type="submit"
|
|
className="w-full"
|
|
isLoading={isPasswordLoading}
|
|
disabled={otpValue.length !== 6}
|
|
>
|
|
Enable 2FA
|
|
</Button>
|
|
</form>
|
|
</Form>
|
|
)}
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
};
|