mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-07-19 21:05:21 +02:00
* feat: update dependencies and enhance UI components in Dokploy - Updated various package versions in pnpm-lock.yaml to improve compatibility and performance, including React and Tailwind CSS. - Modified components.json to change the styling to "radix-nova" and added new properties for icon library and menu color. - Refactored multiple components to use updated class names for better styling consistency and responsiveness. - Removed unused Radix UI components from package.json to streamline dependencies. - Adjusted layout and styling in several dashboard components for improved user experience and visual appeal. - Enhanced tooltip and form item components for better accessibility and usability. * refactor: enhance UI components in HandleCertificate and SidebarLogo - Updated the HandleCertificate component to improve dialog content height and textarea styling for better usability. - Adjusted the SidebarLogo component layout to enhance alignment and spacing, ensuring a more consistent appearance across the sidebar. - Implemented responsive design adjustments to textarea elements, preventing overflow and improving user experience. * refactor: improve UI consistency and styling across dashboard components - Updated Card components in ShowDeployments, ShowSchedules, and ShowVolumeBackups to remove unnecessary border styles for a cleaner look. - Enhanced TabsList components in ShowProviderForm and ShowProviderFormCompose by adding a variant for improved visual distinction. - Adjusted spacing in AdvancedEnvironmentSelector for better layout and readability. - Removed redundant border classes in Service component for a more streamlined design. * [autofix.ci] apply automated fixes * chore: update package dependencies and refactor email rendering - Upgraded React and React DOM to version 19.2.7 across multiple packages for improved performance and compatibility. - Updated TSX to version 4.22.4 in various package.json files. - Refactored email rendering from `renderAsync` to `render` in notification utilities and email templates for consistency. - Adjusted Tailwind configuration usage in email templates to utilize a centralized configuration file. These changes enhance the overall stability and maintainability of the codebase. * refactor: update badge variants across dashboard components - Changed badge variant from "outline-solid" to "outline" in multiple components including columns, show-domains, and various deployment tables for consistency in styling. - Updated button variants in billing and project templates to align with the new badge styling. - Enhanced input components to support password generation and error messaging, improving user experience. These changes streamline the UI and ensure a cohesive design across the application. * refactor: clean up calendar component imports and structure - Removed redundant imports of icons from lucide-react and streamlined the import statements for better readability. - Adjusted the placement of type imports to enhance code organization. These changes improve the maintainability and clarity of the calendar component. * [autofix.ci] apply automated fixes --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
467 lines
13 KiB
TypeScript
467 lines
13 KiB
TypeScript
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
|
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,
|
|
InputOTPSeparator,
|
|
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={`${code}-${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 gap-2">
|
|
<FormLabel>Verification Code</FormLabel>
|
|
<InputOTP
|
|
maxLength={6}
|
|
value={otpValue}
|
|
onChange={setOtpValue}
|
|
autoFocus
|
|
>
|
|
<InputOTPGroup>
|
|
<InputOTPSlot index={0} />
|
|
<InputOTPSlot index={1} />
|
|
<InputOTPSlot index={2} />
|
|
</InputOTPGroup>
|
|
<InputOTPSeparator />
|
|
<InputOTPGroup>
|
|
<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>
|
|
);
|
|
};
|