mirror of
https://github.com/Dokploy/dokploy
synced 2025-06-26 18:27:59 +00:00
feat: enhance two-factor authentication and auth client implementation
This commit is contained in:
@@ -75,6 +75,7 @@ const baseAdmin: User = {
|
|||||||
image: "",
|
image: "",
|
||||||
token: "",
|
token: "",
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
|
twoFactorEnabled: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import {
|
|||||||
|
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { authClient } from "@/lib/auth";
|
import { authClient } from "@/lib/auth-client";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { PlusIcon, SquarePen } from "lucide-react";
|
import { PlusIcon, SquarePen } from "lucide-react";
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import {
|
|||||||
CommandList,
|
CommandList,
|
||||||
CommandSeparator,
|
CommandSeparator,
|
||||||
} from "@/components/ui/command";
|
} from "@/components/ui/command";
|
||||||
import { authClient } from "@/lib/auth";
|
import { authClient } from "@/lib/auth-client";
|
||||||
import {
|
import {
|
||||||
type Services,
|
type Services,
|
||||||
extractServices,
|
extractServices,
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { authClient } from "@/lib/auth";
|
import { authClient } from "@/lib/auth-client";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|||||||
@@ -1,52 +1,131 @@
|
|||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogAction,
|
|
||||||
AlertDialogCancel,
|
|
||||||
AlertDialogContent,
|
AlertDialogContent,
|
||||||
AlertDialogDescription,
|
AlertDialogDescription,
|
||||||
AlertDialogFooter,
|
|
||||||
AlertDialogHeader,
|
AlertDialogHeader,
|
||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
AlertDialogTrigger,
|
AlertDialogTrigger,
|
||||||
} from "@/components/ui/alert-dialog";
|
} from "@/components/ui/alert-dialog";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/components/ui/form";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { authClient } from "@/lib/auth-client";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
const PasswordSchema = z.object({
|
||||||
|
password: z.string().min(8, {
|
||||||
|
message: "Password is required",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
type PasswordForm = z.infer<typeof PasswordSchema>;
|
||||||
|
|
||||||
export const Disable2FA = () => {
|
export const Disable2FA = () => {
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
const { mutateAsync, isLoading } = api.auth.disable2FA.useMutation();
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const form = useForm<PasswordForm>({
|
||||||
|
resolver: zodResolver(PasswordSchema),
|
||||||
|
defaultValues: {
|
||||||
|
password: "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmit = async (formData: PasswordForm) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await authClient.twoFactor.disable({
|
||||||
|
password: formData.password,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
form.setError("password", {
|
||||||
|
message: result.error.message,
|
||||||
|
});
|
||||||
|
toast.error(result.error.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success("2FA disabled successfully");
|
||||||
|
utils.auth.get.invalidate();
|
||||||
|
} catch (error) {
|
||||||
|
form.setError("password", {
|
||||||
|
message: "Connection error. Please try again.",
|
||||||
|
});
|
||||||
|
toast.error("Connection error. Please try again.");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AlertDialog>
|
<AlertDialog>
|
||||||
<AlertDialogTrigger asChild>
|
<AlertDialogTrigger asChild>
|
||||||
<Button variant="destructive" isLoading={isLoading}>
|
<Button variant="destructive">Disable 2FA</Button>
|
||||||
Disable 2FA
|
|
||||||
</Button>
|
|
||||||
</AlertDialogTrigger>
|
</AlertDialogTrigger>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
|
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
|
||||||
<AlertDialogDescription>
|
<AlertDialogDescription>
|
||||||
This action cannot be undone. This will permanently delete the 2FA
|
This action cannot be undone. This will permanently disable
|
||||||
|
Two-Factor Authentication for your account.
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
|
||||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
<Form {...form}>
|
||||||
<AlertDialogAction
|
<form
|
||||||
onClick={async () => {
|
onSubmit={form.handleSubmit(handleSubmit)}
|
||||||
await mutateAsync()
|
className="space-y-4"
|
||||||
.then(() => {
|
|
||||||
utils.auth.get.invalidate();
|
|
||||||
toast.success("2FA Disabled");
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
toast.error("Error disabling 2FA");
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
Confirm
|
<FormField
|
||||||
</AlertDialogAction>
|
control={form.control}
|
||||||
</AlertDialogFooter>
|
name="password"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Password</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
placeholder="Enter your password"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
Enter your password to disable 2FA
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="flex justify-end gap-4">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
form.reset();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" variant="destructive" isLoading={isLoading}>
|
||||||
|
Disable 2FA
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -17,144 +17,315 @@ import {
|
|||||||
FormLabel,
|
FormLabel,
|
||||||
FormMessage,
|
FormMessage,
|
||||||
} from "@/components/ui/form";
|
} from "@/components/ui/form";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
import {
|
import {
|
||||||
InputOTP,
|
InputOTP,
|
||||||
InputOTPGroup,
|
InputOTPGroup,
|
||||||
InputOTPSlot,
|
InputOTPSlot,
|
||||||
} from "@/components/ui/input-otp";
|
} from "@/components/ui/input-otp";
|
||||||
|
import { authClient } from "@/lib/auth-client";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { AlertTriangle, Fingerprint } from "lucide-react";
|
import { Fingerprint, QrCode } from "lucide-react";
|
||||||
import { useEffect } from "react";
|
import QRCode from "qrcode";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
const Enable2FASchema = z.object({
|
const PasswordSchema = z.object({
|
||||||
|
password: z.string().min(8, {
|
||||||
|
message: "Password is required",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const PinSchema = z.object({
|
||||||
pin: z.string().min(6, {
|
pin: z.string().min(6, {
|
||||||
message: "Pin is required",
|
message: "Pin is required",
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
type Enable2FA = z.infer<typeof Enable2FASchema>;
|
type PasswordForm = z.infer<typeof PasswordSchema>;
|
||||||
|
type PinForm = z.infer<typeof PinSchema>;
|
||||||
|
|
||||||
|
type TwoFactorEnableResponse = {
|
||||||
|
totpURI: string;
|
||||||
|
backupCodes: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type TwoFactorSetupData = {
|
||||||
|
qrCodeUrl: string;
|
||||||
|
secret: string;
|
||||||
|
totpURI: string;
|
||||||
|
};
|
||||||
|
|
||||||
export const Enable2FA = () => {
|
export const Enable2FA = () => {
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
|
const { data: session } = authClient.useSession();
|
||||||
|
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 { data } = api.auth.generate2FASecret.useQuery(undefined, {
|
const handlePasswordSubmit = async (formData: PasswordForm) => {
|
||||||
refetchOnWindowFocus: false,
|
setIsPasswordLoading(true);
|
||||||
|
try {
|
||||||
|
const { data: enableData } = await authClient.twoFactor.enable({
|
||||||
|
password: formData.password,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!enableData) {
|
||||||
|
throw new Error("No data received from server");
|
||||||
|
}
|
||||||
|
|
||||||
|
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 verifying password",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsPasswordLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleVerifySubmit = async (formData: PinForm) => {
|
||||||
|
try {
|
||||||
|
const result = await authClient.twoFactor.verifyTotp({
|
||||||
|
code: formData.pin,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
if (result.error.code === "INVALID_TWO_FACTOR_AUTHENTICATION") {
|
||||||
|
pinForm.setError("pin", {
|
||||||
|
message: "Invalid code. Please try again.",
|
||||||
|
});
|
||||||
|
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.auth.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;
|
||||||
|
|
||||||
|
pinForm.setError("pin", {
|
||||||
|
message: errorMessage,
|
||||||
|
});
|
||||||
|
toast.error(errorMessage);
|
||||||
|
} else {
|
||||||
|
pinForm.setError("pin", {
|
||||||
|
message: "Error verifying code",
|
||||||
|
});
|
||||||
|
toast.error("Error verifying 2FA code");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const passwordForm = useForm<PasswordForm>({
|
||||||
|
resolver: zodResolver(PasswordSchema),
|
||||||
|
defaultValues: {
|
||||||
|
password: "",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { mutateAsync, isLoading, error, isError } =
|
const pinForm = useForm<PinForm>({
|
||||||
api.auth.verify2FASetup.useMutation();
|
resolver: zodResolver(PinSchema),
|
||||||
|
|
||||||
const form = useForm<Enable2FA>({
|
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
pin: "",
|
pin: "",
|
||||||
},
|
},
|
||||||
resolver: zodResolver(Enable2FASchema),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
form.reset({
|
if (!isDialogOpen) {
|
||||||
pin: "",
|
setStep("password");
|
||||||
});
|
setData(null);
|
||||||
}, [form, form.reset, form.formState.isSubmitSuccessful]);
|
setBackupCodes([]);
|
||||||
|
passwordForm.reset();
|
||||||
|
pinForm.reset();
|
||||||
|
}
|
||||||
|
}, [isDialogOpen, passwordForm, pinForm]);
|
||||||
|
|
||||||
const onSubmit = async (formData: Enable2FA) => {
|
|
||||||
await mutateAsync({
|
|
||||||
pin: formData.pin,
|
|
||||||
secret: data?.secret || "",
|
|
||||||
})
|
|
||||||
.then(async () => {
|
|
||||||
toast.success("2FA Verified");
|
|
||||||
utils.auth.get.invalidate();
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
toast.error("Error verifying the 2FA");
|
|
||||||
});
|
|
||||||
};
|
|
||||||
return (
|
return (
|
||||||
<Dialog>
|
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button variant="ghost">
|
<Button variant="ghost">
|
||||||
<Fingerprint className="size-4 text-muted-foreground" />
|
<Fingerprint className="size-4 text-muted-foreground" />
|
||||||
Enable 2FA
|
Enable 2FA
|
||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent className="max-h-screen max-sm:overflow-y-auto sm:max-w-xl ">
|
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>2FA Setup</DialogTitle>
|
<DialogTitle>2FA Setup</DialogTitle>
|
||||||
<DialogDescription>Add a 2FA to your account</DialogDescription>
|
<DialogDescription>
|
||||||
|
{step === "password"
|
||||||
|
? "Enter your password to begin 2FA setup"
|
||||||
|
: "Scan the QR code and verify with your authenticator app"}
|
||||||
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
{isError && (
|
|
||||||
<div className="flex flex-row gap-4 rounded-lg items-center bg-red-50 p-2 dark:bg-red-950">
|
|
||||||
<AlertTriangle className="text-red-600 dark:text-red-400" />
|
|
||||||
<span className="text-sm text-red-600 dark:text-red-400">
|
|
||||||
{error?.message}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<Form {...form}>
|
|
||||||
<form
|
|
||||||
id="hook-form-add-2FA"
|
|
||||||
onSubmit={form.handleSubmit(onSubmit)}
|
|
||||||
className="grid sm:grid-cols-2 w-full gap-4"
|
|
||||||
>
|
|
||||||
<div className="flex flex-col gap-4 justify-center items-center">
|
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
{data?.qrCodeUrl ? "Scan the QR code to add 2FA" : ""}
|
|
||||||
</span>
|
|
||||||
<img
|
|
||||||
src={data?.qrCodeUrl}
|
|
||||||
alt="qrCode"
|
|
||||||
className="rounded-lg w-fit"
|
|
||||||
/>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<span className="text-sm text-muted-foreground text-center">
|
|
||||||
{data?.secret ? `Secret: ${data?.secret}` : ""}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<FormField
|
{step === "password" ? (
|
||||||
control={form.control}
|
<Form {...passwordForm}>
|
||||||
name="pin"
|
<form
|
||||||
render={({ field }) => (
|
id="password-form"
|
||||||
<FormItem className="flex flex-col justify-center max-sm:items-center">
|
onSubmit={passwordForm.handleSubmit(handlePasswordSubmit)}
|
||||||
<FormLabel>Pin</FormLabel>
|
className="space-y-4"
|
||||||
<FormControl>
|
|
||||||
<InputOTP maxLength={6} {...field}>
|
|
||||||
<InputOTPGroup>
|
|
||||||
<InputOTPSlot index={0} />
|
|
||||||
<InputOTPSlot index={1} />
|
|
||||||
<InputOTPSlot index={2} />
|
|
||||||
<InputOTPSlot index={3} />
|
|
||||||
<InputOTPSlot index={4} />
|
|
||||||
<InputOTPSlot index={5} />
|
|
||||||
</InputOTPGroup>
|
|
||||||
</InputOTP>
|
|
||||||
</FormControl>
|
|
||||||
<FormDescription className="max-md:text-center">
|
|
||||||
Please enter the 6 digits code provided by your
|
|
||||||
authenticator app.
|
|
||||||
</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<Button
|
|
||||||
isLoading={isLoading}
|
|
||||||
form="hook-form-add-2FA"
|
|
||||||
type="submit"
|
|
||||||
>
|
>
|
||||||
Submit 2FA
|
<FormField
|
||||||
</Button>
|
control={passwordForm.control}
|
||||||
</DialogFooter>
|
name="password"
|
||||||
</Form>
|
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>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="w-full"
|
||||||
|
isLoading={isPasswordLoading}
|
||||||
|
>
|
||||||
|
Continue
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
) : (
|
||||||
|
<Form {...pinForm}>
|
||||||
|
<form
|
||||||
|
id="pin-form"
|
||||||
|
onSubmit={pinForm.handleSubmit(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>
|
||||||
|
<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">
|
||||||
|
<h4 className="font-medium">Backup Codes</h4>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={pinForm.control}
|
||||||
|
name="pin"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex flex-col justify-center items-center">
|
||||||
|
<FormLabel>Verification Code</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<InputOTP maxLength={6} {...field}>
|
||||||
|
<InputOTPGroup>
|
||||||
|
<InputOTPSlot index={0} />
|
||||||
|
<InputOTPSlot index={1} />
|
||||||
|
<InputOTPSlot index={2} />
|
||||||
|
<InputOTPSlot index={3} />
|
||||||
|
<InputOTPSlot index={4} />
|
||||||
|
<InputOTPSlot index={5} />
|
||||||
|
</InputOTPGroup>
|
||||||
|
</InputOTP>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
Enter the 6-digit code from your authenticator app
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="w-full"
|
||||||
|
isLoading={isPasswordLoading}
|
||||||
|
>
|
||||||
|
Enable 2FA
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
)}
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { AlertBlock } from "@/components/shared/alert-block";
|
import { AlertBlock } from "@/components/shared/alert-block";
|
||||||
|
import { DialogAction } from "@/components/shared/dialog-action";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@@ -17,6 +18,7 @@ import {
|
|||||||
} from "@/components/ui/form";
|
} from "@/components/ui/form";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||||
|
import { authClient } from "@/lib/auth-client";
|
||||||
import { generateSHA256Hash } from "@/lib/utils";
|
import { generateSHA256Hash } from "@/lib/utils";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
@@ -54,6 +56,9 @@ const randomImages = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export const ProfileForm = () => {
|
export const ProfileForm = () => {
|
||||||
|
const utils = api.useUtils();
|
||||||
|
const { mutateAsync: disable2FA, isLoading: isDisabling } =
|
||||||
|
api.auth.disable2FA.useMutation();
|
||||||
const { data, refetch, isLoading } = api.auth.get.useQuery();
|
const { data, refetch, isLoading } = api.auth.get.useQuery();
|
||||||
const {
|
const {
|
||||||
mutateAsync,
|
mutateAsync,
|
||||||
@@ -130,7 +135,7 @@ export const ProfileForm = () => {
|
|||||||
{t("settings.profile.description")}
|
{t("settings.profile.description")}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
{!data?.is2FAEnabled ? <Enable2FA /> : <Disable2FA />}
|
{!data?.user.twoFactorEnabled ? <Enable2FA /> : <Disable2FA />}
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
<CardContent className="space-y-2 py-8 border-t">
|
<CardContent className="space-y-2 py-8 border-t">
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
import { AlertBlock } from "@/components/shared/alert-block";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
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 {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { authClient } from "@/lib/auth-client";
|
||||||
|
import { api } from "@/utils/api";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { PlusIcon } from "lucide-react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
const addInvitation = z.object({
|
||||||
|
email: z
|
||||||
|
.string()
|
||||||
|
.min(1, "Email is required")
|
||||||
|
.email({ message: "Invalid email" }),
|
||||||
|
role: z.enum(["member", "admin"]),
|
||||||
|
});
|
||||||
|
|
||||||
|
type AddInvitation = z.infer<typeof addInvitation>;
|
||||||
|
|
||||||
|
export const AddInvitation = () => {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const utils = api.useUtils();
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const { data: activeOrganization } = authClient.useActiveOrganization();
|
||||||
|
|
||||||
|
const form = useForm<AddInvitation>({
|
||||||
|
defaultValues: {
|
||||||
|
email: "",
|
||||||
|
role: "member",
|
||||||
|
},
|
||||||
|
resolver: zodResolver(addInvitation),
|
||||||
|
});
|
||||||
|
useEffect(() => {
|
||||||
|
form.reset();
|
||||||
|
}, [form, form.formState.isSubmitSuccessful, form.reset]);
|
||||||
|
|
||||||
|
const onSubmit = async (data: AddInvitation) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
const result = await authClient.organization.inviteMember({
|
||||||
|
email: data.email.toLowerCase(),
|
||||||
|
role: data.role,
|
||||||
|
organizationId: activeOrganization?.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
setError(result.error.message || "");
|
||||||
|
} else {
|
||||||
|
toast.success("Invitation created");
|
||||||
|
setError(null);
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.organization.allInvitations.invalidate();
|
||||||
|
setIsLoading(false);
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogTrigger className="" asChild>
|
||||||
|
<Button>
|
||||||
|
<PlusIcon className="h-4 w-4" /> Add Invitation
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Add Invitation</DialogTitle>
|
||||||
|
<DialogDescription>Invite a new user</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
{error && <AlertBlock type="error">{error}</AlertBlock>}
|
||||||
|
|
||||||
|
<Form {...form}>
|
||||||
|
<form
|
||||||
|
id="hook-form-add-invitation"
|
||||||
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
|
className="grid w-full gap-4 "
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="email"
|
||||||
|
render={({ field }) => {
|
||||||
|
return (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Email</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder={"email@dokploy.com"} {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
This will be the email of the new user
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="role"
|
||||||
|
render={({ field }) => {
|
||||||
|
return (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Role</FormLabel>
|
||||||
|
<Select
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
defaultValue={field.value}
|
||||||
|
>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select a role" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="member">Member</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormDescription>
|
||||||
|
Select the role for the new user
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<DialogFooter className="flex w-full flex-row">
|
||||||
|
<Button
|
||||||
|
isLoading={isLoading}
|
||||||
|
form="hook-form-add-invitation"
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
Create
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -19,7 +19,7 @@ import {
|
|||||||
FormMessage,
|
FormMessage,
|
||||||
} from "@/components/ui/form";
|
} from "@/components/ui/form";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { authClient } from "@/lib/auth";
|
import { authClient } from "@/lib/auth-client";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { PlusIcon } from "lucide-react";
|
import { PlusIcon } from "lucide-react";
|
||||||
|
|||||||
@@ -0,0 +1,191 @@
|
|||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCaption,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import { authClient } from "@/lib/auth-client";
|
||||||
|
import { api } from "@/utils/api";
|
||||||
|
import { format } from "date-fns";
|
||||||
|
import { Mail, MoreHorizontal, Users } from "lucide-react";
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { AddInvitation } from "./add-invitation";
|
||||||
|
|
||||||
|
export const ShowInvitations = () => {
|
||||||
|
const { data, isLoading, refetch } =
|
||||||
|
api.organization.allInvitations.useQuery();
|
||||||
|
const { mutateAsync, isLoading: isRemoving } =
|
||||||
|
api.admin.removeUser.useMutation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full">
|
||||||
|
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
|
||||||
|
<div className="rounded-xl bg-background shadow-md ">
|
||||||
|
<CardHeader className="">
|
||||||
|
<CardTitle className="text-xl flex flex-row gap-2">
|
||||||
|
<Mail className="size-6 text-muted-foreground self-center" />
|
||||||
|
Invitations
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Create invitations to your organization.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2 py-8 border-t">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex flex-row gap-2 items-center justify-center text-sm text-muted-foreground min-h-[25vh]">
|
||||||
|
<span>Loading...</span>
|
||||||
|
<Loader2 className="animate-spin size-4" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{data?.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center gap-3 min-h-[25vh] justify-center">
|
||||||
|
<Users className="size-8 self-center text-muted-foreground" />
|
||||||
|
<span className="text-base text-muted-foreground">
|
||||||
|
Invite users to your organization
|
||||||
|
</span>
|
||||||
|
<AddInvitation />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-4 min-h-[25vh]">
|
||||||
|
<Table>
|
||||||
|
<TableCaption>See all invitations</TableCaption>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-[100px]">Email</TableHead>
|
||||||
|
<TableHead className="text-center">Role</TableHead>
|
||||||
|
<TableHead className="text-center">Status</TableHead>
|
||||||
|
<TableHead className="text-center">
|
||||||
|
Expires At
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="text-right">Actions</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{data?.map((invitation) => {
|
||||||
|
return (
|
||||||
|
<TableRow key={invitation.id}>
|
||||||
|
<TableCell className="w-[100px]">
|
||||||
|
{invitation.email}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
invitation.role === "owner"
|
||||||
|
? "default"
|
||||||
|
: "secondary"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{invitation.role}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
invitation.status === "pending"
|
||||||
|
? "default"
|
||||||
|
: invitation.status === "canceled"
|
||||||
|
? "destructive"
|
||||||
|
: "secondary"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{invitation.status}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
{format(new Date(invitation.expiresAt), "PPpp")}
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
<TableCell className="text-right flex justify-end">
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
>
|
||||||
|
<span className="sr-only">Open menu</span>
|
||||||
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuLabel>
|
||||||
|
Actions
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
|
||||||
|
{/* <DropdownMenuItem
|
||||||
|
className="w-full cursor-pointer"
|
||||||
|
onSelect={(e) => {
|
||||||
|
copy(
|
||||||
|
`${origin}/invitation?token=${user.user.token}`,
|
||||||
|
);
|
||||||
|
toast.success(
|
||||||
|
"Invitation Copied to clipboard",
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Copy Invitation
|
||||||
|
</DropdownMenuItem> */}
|
||||||
|
{invitation.status === "pending" && (
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="w-full cursor-pointer"
|
||||||
|
onSelect={async (e) => {
|
||||||
|
const result =
|
||||||
|
await authClient.organization.cancelInvitation(
|
||||||
|
{
|
||||||
|
invitationId: invitation.id,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
toast.error(result.error.message);
|
||||||
|
} else {
|
||||||
|
toast.success("Invitation deleted");
|
||||||
|
refetch();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel Invitation
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
|
||||||
|
<div className="flex flex-row gap-2 flex-wrap w-full justify-end mr-4">
|
||||||
|
<AddInvitation />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -153,7 +153,7 @@ export const ShowUsers = () => {
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{user.user.isRegistered && (
|
{user.role !== "owner" && (
|
||||||
<AddUserPermissions
|
<AddUserPermissions
|
||||||
userId={user.userId}
|
userId={user.userId}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -495,7 +495,7 @@ import {
|
|||||||
DropdownMenuShortcut,
|
DropdownMenuShortcut,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { authClient } from "@/lib/auth";
|
import { authClient } from "@/lib/auth-client";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { AddOrganization } from "../dashboard/organization/handle-organization";
|
import { AddOrganization } from "../dashboard/organization/handle-organization";
|
||||||
import { DialogAction } from "../shared/dialog-action";
|
import { DialogAction } from "../shared/dialog-action";
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { authClient } from "@/lib/auth";
|
import { authClient } from "@/lib/auth-client";
|
||||||
import { Languages } from "@/lib/languages";
|
import { Languages } from "@/lib/languages";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import useLocale from "@/utils/hooks/use-locale";
|
import useLocale from "@/utils/hooks/use-locale";
|
||||||
|
|||||||
@@ -26,7 +26,8 @@ WITH inserted_users AS (
|
|||||||
"serversQuantity",
|
"serversQuantity",
|
||||||
"expirationDate",
|
"expirationDate",
|
||||||
"createdAt",
|
"createdAt",
|
||||||
"two_factor_enabled"
|
"two_factor_enabled",
|
||||||
|
"isRegistered"
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
a."adminId",
|
a."adminId",
|
||||||
@@ -52,7 +53,8 @@ WITH inserted_users AS (
|
|||||||
a."serversQuantity",
|
a."serversQuantity",
|
||||||
NOW() + INTERVAL '1 year',
|
NOW() + INTERVAL '1 year',
|
||||||
NOW(),
|
NOW(),
|
||||||
COALESCE(auth."is2FAEnabled", false)
|
COALESCE(auth."is2FAEnabled", false),
|
||||||
|
true
|
||||||
FROM admin a
|
FROM admin a
|
||||||
JOIN auth ON auth.id = a."authId"
|
JOIN auth ON auth.id = a."authId"
|
||||||
RETURNING *
|
RETURNING *
|
||||||
@@ -141,7 +143,8 @@ inserted_members AS (
|
|||||||
"accesedProjects",
|
"accesedProjects",
|
||||||
"accesedServices",
|
"accesedServices",
|
||||||
"expirationDate",
|
"expirationDate",
|
||||||
"two_factor_enabled"
|
"two_factor_enabled",
|
||||||
|
"isRegistered"
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
u."userId",
|
u."userId",
|
||||||
@@ -163,7 +166,8 @@ inserted_members AS (
|
|||||||
COALESCE(u."accesedProjects", '{}'),
|
COALESCE(u."accesedProjects", '{}'),
|
||||||
COALESCE(u."accesedServices", '{}'),
|
COALESCE(u."accesedServices", '{}'),
|
||||||
NOW() + INTERVAL '1 year',
|
NOW() + INTERVAL '1 year',
|
||||||
COALESCE(auth."is2FAEnabled", false)
|
COALESCE(auth."is2FAEnabled", false),
|
||||||
|
COALESCE(u."isRegistered", false)
|
||||||
FROM "user" u
|
FROM "user" u
|
||||||
JOIN admin a ON u."adminId" = a."adminId"
|
JOIN admin a ON u."adminId" = a."adminId"
|
||||||
JOIN auth ON auth.id = u."authId"
|
JOIN auth ON auth.id = u."authId"
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { organizationClient } from "better-auth/client/plugins";
|
import { organizationClient } from "better-auth/client/plugins";
|
||||||
|
import { twoFactorClient } from "better-auth/client/plugins";
|
||||||
import { createAuthClient } from "better-auth/react";
|
import { createAuthClient } from "better-auth/react";
|
||||||
|
|
||||||
export const authClient = createAuthClient({
|
export const authClient = createAuthClient({
|
||||||
// baseURL: "http://localhost:3000", // the base url of your auth server
|
// baseURL: "http://localhost:3000", // the base url of your auth server
|
||||||
plugins: [organizationClient()],
|
plugins: [organizationClient(), twoFactorClient()],
|
||||||
});
|
});
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { authClient } from "@/lib/auth";
|
import { authClient } from "@/lib/auth-client";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
|
|
||||||
export const AcceptInvitation = () => {
|
export const AcceptInvitation = () => {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { ShowInvitations } from "@/components/dashboard/settings/users/show-invitations";
|
||||||
import { ShowUsers } from "@/components/dashboard/settings/users/show-users";
|
import { ShowUsers } from "@/components/dashboard/settings/users/show-users";
|
||||||
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
|
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
|
||||||
|
|
||||||
@@ -12,6 +13,7 @@ const Page = () => {
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4 w-full">
|
<div className="flex flex-col gap-4 w-full">
|
||||||
<ShowUsers />
|
<ShowUsers />
|
||||||
|
<ShowInvitations />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,17 +3,24 @@ import { OnboardingLayout } from "@/components/layouts/onboarding-layout";
|
|||||||
import { AlertBlock } from "@/components/shared/alert-block";
|
import { AlertBlock } from "@/components/shared/alert-block";
|
||||||
import { Logo } from "@/components/shared/logo";
|
import { Logo } from "@/components/shared/logo";
|
||||||
import { Button, buttonVariants } from "@/components/ui/button";
|
import { Button, buttonVariants } from "@/components/ui/button";
|
||||||
import { CardContent } from "@/components/ui/card";
|
import { CardContent, CardDescription } from "@/components/ui/card";
|
||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
FormControl,
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
FormField,
|
FormField,
|
||||||
FormItem,
|
FormItem,
|
||||||
FormLabel,
|
FormLabel,
|
||||||
FormMessage,
|
FormMessage,
|
||||||
} from "@/components/ui/form";
|
} from "@/components/ui/form";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { authClient } from "@/lib/auth";
|
import {
|
||||||
|
InputOTP,
|
||||||
|
InputOTPGroup,
|
||||||
|
InputOTPSlot,
|
||||||
|
} from "@/components/ui/input-otp";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { authClient } from "@/lib/auth-client";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { IS_CLOUD, auth, isAdminPresent } from "@dokploy/server";
|
import { IS_CLOUD, auth, isAdminPresent } from "@dokploy/server";
|
||||||
@@ -21,110 +28,118 @@ import { validateRequest } from "@dokploy/server/lib/auth";
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { Session, getSessionCookie } from "better-auth";
|
import { Session, getSessionCookie } from "better-auth";
|
||||||
import { betterFetch } from "better-auth/react";
|
import { betterFetch } from "better-auth/react";
|
||||||
|
import base32 from "hi-base32";
|
||||||
|
import { REGEXP_ONLY_DIGITS } from "input-otp";
|
||||||
import type { GetServerSidePropsContext } from "next";
|
import type { GetServerSidePropsContext } from "next";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
|
import { TOTP } from "otpauth";
|
||||||
import { type ReactElement, useEffect, useState } from "react";
|
import { type ReactElement, useEffect, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
const loginSchema = z.object({
|
const LoginSchema = z.object({
|
||||||
email: z
|
email: z.string().email(),
|
||||||
.string()
|
password: z.string().min(8),
|
||||||
.min(1, {
|
|
||||||
message: "Email is required",
|
|
||||||
})
|
|
||||||
.email({
|
|
||||||
message: "Email must be a valid email",
|
|
||||||
}),
|
|
||||||
|
|
||||||
password: z
|
|
||||||
.string()
|
|
||||||
.min(1, {
|
|
||||||
message: "Password is required",
|
|
||||||
})
|
|
||||||
.min(8, {
|
|
||||||
message: "Password must be at least 8 characters",
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
type Login = z.infer<typeof loginSchema>;
|
const TwoFactorSchema = z.object({
|
||||||
|
code: z.string().min(6),
|
||||||
|
});
|
||||||
|
|
||||||
type AuthResponse = {
|
type LoginForm = z.infer<typeof LoginSchema>;
|
||||||
is2FAEnabled: boolean;
|
type TwoFactorForm = z.infer<typeof TwoFactorSchema>;
|
||||||
authId: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
IS_CLOUD: boolean;
|
IS_CLOUD: boolean;
|
||||||
}
|
}
|
||||||
export default function Home({ IS_CLOUD }: Props) {
|
export default function Home({ IS_CLOUD }: Props) {
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const [isError, setIsError] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [temp, setTemp] = useState<AuthResponse>({
|
|
||||||
is2FAEnabled: false,
|
|
||||||
authId: "",
|
|
||||||
});
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const form = useForm<Login>({
|
const [isLoginLoading, setIsLoginLoading] = useState(false);
|
||||||
|
const [isTwoFactorLoading, setIsTwoFactorLoading] = useState(false);
|
||||||
|
const [isTwoFactor, setIsTwoFactor] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [twoFactorCode, setTwoFactorCode] = useState("");
|
||||||
|
|
||||||
|
const loginForm = useForm<LoginForm>({
|
||||||
|
resolver: zodResolver(LoginSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
email: "siumauricio@hotmail.com",
|
email: "siumauricio@hotmail.com",
|
||||||
password: "Password123",
|
password: "Password123",
|
||||||
},
|
},
|
||||||
resolver: zodResolver(loginSchema),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
const onSubmit = async (values: LoginForm) => {
|
||||||
form.reset();
|
setIsLoginLoading(true);
|
||||||
}, [form, form.reset, form.formState.isSubmitSuccessful]);
|
try {
|
||||||
|
const { data, error } = await authClient.signIn.email({
|
||||||
const onSubmit = async (values: Login) => {
|
email: values.email,
|
||||||
setIsLoading(true);
|
password: values.password,
|
||||||
const { data, error } = await authClient.signIn.email({
|
|
||||||
email: values.email,
|
|
||||||
password: values.password,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!error) {
|
|
||||||
// if (data) {
|
|
||||||
// setTemp(data);
|
|
||||||
// } else {
|
|
||||||
toast.success("Successfully signed in", {
|
|
||||||
duration: 2000,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
toast.error(error.message);
|
||||||
|
setError(error.message || "An error occurred while logging in");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data?.twoFactorRedirect as boolean) {
|
||||||
|
setTwoFactorCode("");
|
||||||
|
setIsTwoFactor(true);
|
||||||
|
toast.info("Please enter your 2FA code");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success("Logged in successfully");
|
||||||
router.push("/dashboard/projects");
|
router.push("/dashboard/projects");
|
||||||
// }
|
} catch (error) {
|
||||||
} else {
|
toast.error("An error occurred while logging in");
|
||||||
setIsError(true);
|
} finally {
|
||||||
setError(error.message ?? "Error to signup");
|
setIsLoginLoading(false);
|
||||||
toast.error("Error to sign up", {
|
}
|
||||||
description: error.message,
|
};
|
||||||
});
|
|
||||||
|
const onTwoFactorSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (twoFactorCode.length !== 6) {
|
||||||
|
toast.error("Please enter a valid 6-digit code");
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsLoading(false);
|
setIsTwoFactorLoading(true);
|
||||||
// await mutateAsync({
|
try {
|
||||||
// email: values.email.toLowerCase(),
|
const { data, error } = await authClient.twoFactor.verifyTotp({
|
||||||
// password: values.password,
|
code: twoFactorCode.replace(/\s/g, ""),
|
||||||
// })
|
});
|
||||||
// .then((data) => {
|
|
||||||
// if (data.is2FAEnabled) {
|
if (error) {
|
||||||
// setTemp(data);
|
toast.error(error.message);
|
||||||
// } else {
|
setError(error.message || "An error occurred while verifying 2FA code");
|
||||||
// toast.success("Successfully signed in", {
|
return;
|
||||||
// duration: 2000,
|
}
|
||||||
// });
|
|
||||||
// router.push("/dashboard/projects");
|
toast.success("Logged in successfully");
|
||||||
// }
|
router.push("/dashboard/projects");
|
||||||
// })
|
} catch (error) {
|
||||||
// .catch(() => {
|
toast.error("An error occurred while verifying 2FA code");
|
||||||
// toast.error("Signin failed", {
|
} finally {
|
||||||
// duration: 2000,
|
setIsTwoFactorLoading(false);
|
||||||
// });
|
}
|
||||||
// });
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const convertBase32ToHex = (base32Secret: string) => {
|
||||||
|
try {
|
||||||
|
// Usar asBytes() para obtener los bytes directamente
|
||||||
|
const bytes = base32.decode.asBytes(base32Secret.toUpperCase());
|
||||||
|
// Convertir bytes a hex
|
||||||
|
return Buffer.from(bytes).toString("hex");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error converting base32 to hex:", error);
|
||||||
|
return base32Secret; // Fallback al valor original si hay error
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-col space-y-2 text-center">
|
<div className="flex flex-col space-y-2 text-center">
|
||||||
@@ -138,55 +153,109 @@ export default function Home({ IS_CLOUD }: Props) {
|
|||||||
Enter your email and password to sign in
|
Enter your email and password to sign in
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{isError && (
|
{error && (
|
||||||
<AlertBlock type="error" className="my-2">
|
<AlertBlock type="error" className="my-2">
|
||||||
<span>{error}</span>
|
<span>{error}</span>
|
||||||
</AlertBlock>
|
</AlertBlock>
|
||||||
)}
|
)}
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
{!temp.is2FAEnabled ? (
|
{!isTwoFactor ? (
|
||||||
<Form {...form}>
|
<Form {...loginForm}>
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)} className="grid gap-4">
|
<form
|
||||||
<div className="space-y-4">
|
onSubmit={loginForm.handleSubmit(onSubmit)}
|
||||||
<FormField
|
className="space-y-4"
|
||||||
control={form.control}
|
id="login-form"
|
||||||
name="email"
|
>
|
||||||
render={({ field }) => (
|
<FormField
|
||||||
<FormItem>
|
control={loginForm.control}
|
||||||
<FormLabel>Email</FormLabel>
|
name="email"
|
||||||
<FormControl>
|
render={({ field }) => (
|
||||||
<Input placeholder="Email" {...field} />
|
<FormItem>
|
||||||
</FormControl>
|
<FormLabel>Email</FormLabel>
|
||||||
<FormMessage />
|
<FormControl>
|
||||||
</FormItem>
|
<Input placeholder="john@example.com" {...field} />
|
||||||
)}
|
</FormControl>
|
||||||
/>
|
<FormMessage />
|
||||||
<FormField
|
</FormItem>
|
||||||
control={form.control}
|
)}
|
||||||
name="password"
|
/>
|
||||||
render={({ field }) => (
|
<FormField
|
||||||
<FormItem>
|
control={loginForm.control}
|
||||||
<FormLabel>Password</FormLabel>
|
name="password"
|
||||||
<FormControl>
|
render={({ field }) => (
|
||||||
<Input
|
<FormItem>
|
||||||
type="password"
|
<FormLabel>Password</FormLabel>
|
||||||
placeholder="Password"
|
<FormControl>
|
||||||
{...field}
|
<Input
|
||||||
/>
|
type="password"
|
||||||
</FormControl>
|
placeholder="Enter your password"
|
||||||
<FormMessage />
|
{...field}
|
||||||
</FormItem>
|
/>
|
||||||
)}
|
</FormControl>
|
||||||
/>
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
<Button type="submit" isLoading={isLoading} className="w-full">
|
)}
|
||||||
Login
|
/>
|
||||||
</Button>
|
<Button
|
||||||
</div>
|
className="w-full"
|
||||||
|
type="submit"
|
||||||
|
isLoading={isLoginLoading}
|
||||||
|
>
|
||||||
|
Login
|
||||||
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
) : (
|
) : (
|
||||||
<Login2FA authId={temp.authId} />
|
<form
|
||||||
|
onSubmit={onTwoFactorSubmit}
|
||||||
|
className="space-y-4"
|
||||||
|
id="two-factor-form"
|
||||||
|
autoComplete="off"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label>2FA Code</Label>
|
||||||
|
<InputOTP
|
||||||
|
value={twoFactorCode}
|
||||||
|
onChange={setTwoFactorCode}
|
||||||
|
maxLength={6}
|
||||||
|
pattern={REGEXP_ONLY_DIGITS}
|
||||||
|
autoComplete="off"
|
||||||
|
>
|
||||||
|
<InputOTPGroup>
|
||||||
|
<InputOTPSlot index={0} className="border-border" />
|
||||||
|
<InputOTPSlot index={1} className="border-border" />
|
||||||
|
<InputOTPSlot index={2} className="border-border" />
|
||||||
|
<InputOTPSlot index={3} className="border-border" />
|
||||||
|
<InputOTPSlot index={4} className="border-border" />
|
||||||
|
<InputOTPSlot index={5} className="border-border" />
|
||||||
|
</InputOTPGroup>
|
||||||
|
</InputOTP>
|
||||||
|
<CardDescription>
|
||||||
|
Enter the 6-digit code from your authenticator app
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="w-full"
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setIsTwoFactor(false);
|
||||||
|
setTwoFactorCode("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
className="w-full"
|
||||||
|
type="submit"
|
||||||
|
isLoading={isTwoFactorLoading}
|
||||||
|
>
|
||||||
|
Verify
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex flex-row justify-between flex-wrap">
|
<div className="flex flex-row justify-between flex-wrap">
|
||||||
|
|||||||
@@ -26,8 +26,8 @@ import { useRouter } from "next/router";
|
|||||||
import { type ReactElement, useEffect } from "react";
|
import { type ReactElement, useEffect } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { z } from "zod";
|
|
||||||
import superjson from "superjson";
|
import superjson from "superjson";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
const registerSchema = z
|
const registerSchema = z
|
||||||
.object({
|
.object({
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import {
|
|||||||
FormMessage,
|
FormMessage,
|
||||||
} from "@/components/ui/form";
|
} from "@/components/ui/form";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { authClient } from "@/lib/auth";
|
import { authClient } from "@/lib/auth-client";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { IS_CLOUD, isAdminPresent, validateRequest } from "@dokploy/server";
|
import { IS_CLOUD, isAdminPresent, validateRequest } from "@dokploy/server";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { db } from "@/server/db";
|
import { db } from "@/server/db";
|
||||||
import { member, organization } from "@/server/db/schema";
|
import { invitation, member, organization } from "@/server/db/schema";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { desc, eq } from "drizzle-orm";
|
import { desc, eq } from "drizzle-orm";
|
||||||
import { nanoid } from "nanoid";
|
import { nanoid } from "nanoid";
|
||||||
@@ -83,4 +83,10 @@ export const organizationRouter = createTRPCRouter({
|
|||||||
.where(eq(organization.id, input.organizationId));
|
.where(eq(organization.id, input.organizationId));
|
||||||
return result;
|
return result;
|
||||||
}),
|
}),
|
||||||
|
allInvitations: adminProcedure.query(async ({ ctx }) => {
|
||||||
|
return await db.query.invitation.findMany({
|
||||||
|
where: eq(invitation.organizationId, ctx.session.activeOrganizationId),
|
||||||
|
orderBy: [desc(invitation.status)],
|
||||||
|
});
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -28,6 +28,7 @@
|
|||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@better-auth/utils":"0.2.3",
|
||||||
"@oslojs/encoding":"1.1.0",
|
"@oslojs/encoding":"1.1.0",
|
||||||
"@oslojs/crypto":"1.0.1",
|
"@oslojs/crypto":"1.0.1",
|
||||||
"drizzle-dbml-generator":"0.10.0",
|
"drizzle-dbml-generator":"0.10.0",
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export const auth = betterAuth({
|
|||||||
provider: "pg",
|
provider: "pg",
|
||||||
schema: schema,
|
schema: schema,
|
||||||
}),
|
}),
|
||||||
|
appName: "Dokploy",
|
||||||
socialProviders: {
|
socialProviders: {
|
||||||
github: {
|
github: {
|
||||||
clientId: process.env.GITHUB_CLIENT_ID as string,
|
clientId: process.env.GITHUB_CLIENT_ID as string,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { randomBytes } from "node:crypto";
|
import { randomBytes } from "node:crypto";
|
||||||
|
import { createOTP } from "@better-auth/utils/otp";
|
||||||
import { db } from "@dokploy/server/db";
|
import { db } from "@dokploy/server/db";
|
||||||
import { users_temp } from "@dokploy/server/db/schema";
|
import { users_temp } from "@dokploy/server/db/schema";
|
||||||
import { getPublicIpWithFallback } from "@dokploy/server/wss/utils";
|
import { getPublicIpWithFallback } from "@dokploy/server/wss/utils";
|
||||||
@@ -29,26 +30,41 @@ export const findAuthById = async (authId: string) => {
|
|||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const generate2FASecret = async (userId: string) => {
|
const generateBase32Secret = () => {
|
||||||
const user = await findUserById(userId);
|
// Generamos 32 bytes (256 bits) para asegurar que tengamos suficiente longitud
|
||||||
|
const buffer = randomBytes(32);
|
||||||
|
// Convertimos directamente a hex para Better Auth
|
||||||
|
const hex = buffer.toString("hex");
|
||||||
|
// También necesitamos la versión base32 para el QR code
|
||||||
|
const base32 = encode.encode(buffer).replace(/=/g, "").substring(0, 32);
|
||||||
|
return {
|
||||||
|
hex,
|
||||||
|
base32,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const base32_secret = generateBase32Secret();
|
export const generate2FASecret = () => {
|
||||||
|
const secret = "46JMUCG4NJ3CIU6LQAIVFWUW";
|
||||||
|
|
||||||
const totp = new TOTP({
|
const totp = new TOTP({
|
||||||
issuer: "Dokploy",
|
issuer: "Dokploy",
|
||||||
label: `${user?.email}`,
|
label: "siumauricio@hotmail.com",
|
||||||
algorithm: "SHA1",
|
algorithm: "SHA1",
|
||||||
digits: 6,
|
digits: 6,
|
||||||
secret: base32_secret,
|
secret: secret,
|
||||||
});
|
});
|
||||||
|
|
||||||
const otpauth_url = totp.toString();
|
// Convertir los bytes del secreto a hex
|
||||||
|
const secretBytes = totp.secret.bytes;
|
||||||
|
const hexSecret = Buffer.from(secretBytes).toString("hex");
|
||||||
|
|
||||||
const qrUrl = await QRCode.toDataURL(otpauth_url);
|
console.log("Secret bytes:", secretBytes);
|
||||||
|
console.log("Hex secret:", hexSecret);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
qrCodeUrl: qrUrl,
|
secret,
|
||||||
secret: base32_secret,
|
hexSecret,
|
||||||
|
totp,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -59,6 +75,7 @@ export const verify2FA = async (auth: User, secret: string, pin: string) => {
|
|||||||
algorithm: "SHA1",
|
algorithm: "SHA1",
|
||||||
digits: 6,
|
digits: 6,
|
||||||
secret: secret,
|
secret: secret,
|
||||||
|
period: 30,
|
||||||
});
|
});
|
||||||
|
|
||||||
const delta = totp.validate({ token: pin });
|
const delta = totp.validate({ token: pin });
|
||||||
@@ -72,8 +89,124 @@ export const verify2FA = async (auth: User, secret: string, pin: string) => {
|
|||||||
return auth;
|
return auth;
|
||||||
};
|
};
|
||||||
|
|
||||||
const generateBase32Secret = () => {
|
const convertBase32ToHex = (base32Secret: string) => {
|
||||||
const buffer = randomBytes(15);
|
try {
|
||||||
const base32 = encode.encode(buffer).replace(/=/g, "").substring(0, 24);
|
// Asegurarnos de que la longitud sea múltiplo de 8 agregando padding
|
||||||
return base32;
|
let paddedSecret = base32Secret;
|
||||||
|
while (paddedSecret.length % 8 !== 0) {
|
||||||
|
paddedSecret += "=";
|
||||||
|
}
|
||||||
|
|
||||||
|
const bytes = encode.decode.asBytes(paddedSecret.toUpperCase());
|
||||||
|
let hex = Buffer.from(bytes).toString("hex");
|
||||||
|
|
||||||
|
// Asegurarnos de que el hex tenga al menos 32 caracteres (16 bytes)
|
||||||
|
while (hex.length < 32) {
|
||||||
|
hex += "0";
|
||||||
|
}
|
||||||
|
|
||||||
|
return hex;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error converting base32 to hex:", error);
|
||||||
|
return base32Secret;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Para probar
|
||||||
|
// const testSecret = "46JMUCG4NJ3CIU6LQAIVFWUW";
|
||||||
|
// console.log("Original:", testSecret);
|
||||||
|
// console.log("Converted:", convertBase32ToHex(testSecret));
|
||||||
|
// console.log(
|
||||||
|
// "Length in bytes:",
|
||||||
|
// Buffer.from(convertBase32ToHex(testSecret), "hex").length,
|
||||||
|
// );
|
||||||
|
// console.log(generate2FASecret().secret.secret);
|
||||||
|
|
||||||
|
// // Para probar
|
||||||
|
// const testResult = generate2FASecret();
|
||||||
|
// console.log("\nResultados:");
|
||||||
|
// console.log("Original base32:", testResult.secret);
|
||||||
|
// console.log("Hex convertido:", testResult.hexSecret);
|
||||||
|
// console.log(
|
||||||
|
// "Longitud en bytes:",
|
||||||
|
// Buffer.from(testResult.hexSecret, "hex").length,
|
||||||
|
// );
|
||||||
|
export const symmetricDecrypt = async ({ key, data }) => {
|
||||||
|
const keyAsBytes = await createHash("SHA-256").digest(key);
|
||||||
|
const dataAsBytes = hexToBytes(data);
|
||||||
|
const chacha = managedNonce(xchacha20poly1305)(new Uint8Array(keyAsBytes));
|
||||||
|
return new TextDecoder().decode(chacha.decrypt(dataAsBytes));
|
||||||
|
};
|
||||||
|
export const migrateExistingSecret = async (
|
||||||
|
existingBase32Secret: string,
|
||||||
|
encryptionKey: string,
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
// 1. Primero asegurarnos que el secreto base32 tenga el padding correcto
|
||||||
|
let paddedSecret = existingBase32Secret;
|
||||||
|
while (paddedSecret.length % 8 !== 0) {
|
||||||
|
paddedSecret += "=";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Decodificar el base32 a bytes usando hi-base32
|
||||||
|
const bytes = encode.decode.asBytes(paddedSecret.toUpperCase());
|
||||||
|
|
||||||
|
// 3. Convertir los bytes a hex
|
||||||
|
const hexSecret = Buffer.from(bytes).toString("hex");
|
||||||
|
|
||||||
|
// 4. Encriptar el secreto hex usando Better Auth
|
||||||
|
const encryptedSecret = await symmetricEncrypt({
|
||||||
|
key: encryptionKey,
|
||||||
|
data: hexSecret,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 5. Crear TOTP con el secreto original para validación
|
||||||
|
const originalTotp = new TOTP({
|
||||||
|
issuer: "Dokploy",
|
||||||
|
label: "migration-test",
|
||||||
|
algorithm: "SHA1",
|
||||||
|
digits: 6,
|
||||||
|
secret: existingBase32Secret,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 6. Generar un código de prueba con el secreto original
|
||||||
|
const testCode = originalTotp.generate();
|
||||||
|
|
||||||
|
// 7. Validar que el código funcione con el secreto original
|
||||||
|
const isValid = originalTotp.validate({ token: testCode }) !== null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
originalSecret: existingBase32Secret,
|
||||||
|
hexSecret,
|
||||||
|
encryptedSecret, // Este es el valor que debes guardar en la base de datos
|
||||||
|
isValid,
|
||||||
|
testCode,
|
||||||
|
secretLength: hexSecret.length,
|
||||||
|
};
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const errorMessage =
|
||||||
|
error instanceof Error ? error.message : "Unknown error";
|
||||||
|
console.error("Error durante la migración:", errorMessage);
|
||||||
|
throw new Error(`Error al migrar el secreto: ${errorMessage}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// // Ejemplo de uso con el secreto de prueba
|
||||||
|
// const testMigration = await migrateExistingSecret(
|
||||||
|
// "46JMUCG4NJ3CIU6LQAIVFWUW",
|
||||||
|
// process.env.BETTER_AUTH_SECRET || "your-encryption-key",
|
||||||
|
// );
|
||||||
|
// console.log("\nPrueba de migración:");
|
||||||
|
// console.log("Secreto original (base32):", testMigration.originalSecret);
|
||||||
|
// console.log("Secreto convertido (hex):", testMigration.hexSecret);
|
||||||
|
// console.log("Secreto encriptado:", testMigration.encryptedSecret);
|
||||||
|
// console.log("Longitud del secreto hex:", testMigration.secretLength);
|
||||||
|
// console.log("¿Conversión válida?:", testMigration.isValid);
|
||||||
|
// console.log("Código de prueba:", testMigration.testCode);
|
||||||
|
const secret = "46JMUCG4NJ3CIU6LQAIVFWUW";
|
||||||
|
const isValid = createOTP(secret, {
|
||||||
|
digits: 6,
|
||||||
|
period: 30,
|
||||||
|
}).verify("123456");
|
||||||
|
|
||||||
|
console.log(isValid.then((isValid) => console.log(isValid)));
|
||||||
|
|||||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@@ -549,6 +549,9 @@ importers:
|
|||||||
|
|
||||||
packages/server:
|
packages/server:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@better-auth/utils':
|
||||||
|
specifier: 0.2.3
|
||||||
|
version: 0.2.3
|
||||||
'@faker-js/faker':
|
'@faker-js/faker':
|
||||||
specifier: ^8.4.1
|
specifier: ^8.4.1
|
||||||
version: 8.4.1
|
version: 8.4.1
|
||||||
|
|||||||
Reference in New Issue
Block a user