feat: enhance two-factor authentication and auth client implementation

This commit is contained in:
Mauricio Siu
2025-02-17 00:07:36 -06:00
parent 0e8e92c715
commit 7abe060fcf
25 changed files with 1103 additions and 270 deletions

View File

@@ -75,6 +75,7 @@ const baseAdmin: User = {
image: "",
token: "",
updatedAt: new Date(),
twoFactorEnabled: false,
};
beforeEach(() => {

View File

@@ -21,7 +21,7 @@ import {
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { authClient } from "@/lib/auth";
import { authClient } from "@/lib/auth-client";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { PlusIcon, SquarePen } from "lucide-react";

View File

@@ -18,7 +18,7 @@ import {
CommandList,
CommandSeparator,
} from "@/components/ui/command";
import { authClient } from "@/lib/auth";
import { authClient } from "@/lib/auth-client";
import {
type Services,
extractServices,

View File

@@ -10,7 +10,7 @@ import {
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { authClient } from "@/lib/auth";
import { authClient } from "@/lib/auth-client";
import { api } from "@/utils/api";
import { format } from "date-fns";
import { useEffect, useState } from "react";

View File

@@ -1,52 +1,131 @@
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
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 { zodResolver } from "@hookform/resolvers/zod";
import { useState } from "react";
import { useForm } from "react-hook-form";
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 = () => {
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 (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive" isLoading={isLoading}>
Disable 2FA
</Button>
<Button variant="destructive">Disable 2FA</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
<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>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={async () => {
await mutateAsync()
.then(() => {
utils.auth.get.invalidate();
toast.success("2FA Disabled");
})
.catch(() => {
toast.error("Error disabling 2FA");
});
}}
<Form {...form}>
<form
onSubmit={form.handleSubmit(handleSubmit)}
className="space-y-4"
>
Confirm
</AlertDialogAction>
</AlertDialogFooter>
<FormField
control={form.control}
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>
</AlertDialog>
);

View File

@@ -17,144 +17,315 @@ import {
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import {
InputOTP,
InputOTPGroup,
InputOTPSlot,
} from "@/components/ui/input-otp";
import { authClient } from "@/lib/auth-client";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { AlertTriangle, Fingerprint } from "lucide-react";
import { useEffect } from "react";
import { 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";
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, {
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 = () => {
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, {
refetchOnWindowFocus: false,
const handlePasswordSubmit = async (formData: PasswordForm) => {
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 } =
api.auth.verify2FASetup.useMutation();
const form = useForm<Enable2FA>({
const pinForm = useForm<PinForm>({
resolver: zodResolver(PinSchema),
defaultValues: {
pin: "",
},
resolver: zodResolver(Enable2FASchema),
});
useEffect(() => {
form.reset({
pin: "",
});
}, [form, form.reset, form.formState.isSubmitSuccessful]);
if (!isDialogOpen) {
setStep("password");
setData(null);
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 (
<Dialog>
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogTrigger asChild>
<Button variant="ghost">
<Fingerprint className="size-4 text-muted-foreground" />
Enable 2FA
</Button>
</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>
<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>
{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
control={form.control}
name="pin"
render={({ field }) => (
<FormItem className="flex flex-col justify-center max-sm:items-center">
<FormLabel>Pin</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 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"
{step === "password" ? (
<Form {...passwordForm}>
<form
id="password-form"
onSubmit={passwordForm.handleSubmit(handlePasswordSubmit)}
className="space-y-4"
>
Submit 2FA
</Button>
</DialogFooter>
</Form>
<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>
)}
/>
<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>
</Dialog>
);

View File

@@ -1,4 +1,5 @@
import { AlertBlock } from "@/components/shared/alert-block";
import { DialogAction } from "@/components/shared/dialog-action";
import { Button } from "@/components/ui/button";
import {
Card,
@@ -17,6 +18,7 @@ import {
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { authClient } from "@/lib/auth-client";
import { generateSHA256Hash } from "@/lib/utils";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
@@ -54,6 +56,9 @@ const randomImages = [
];
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 {
mutateAsync,
@@ -130,7 +135,7 @@ export const ProfileForm = () => {
{t("settings.profile.description")}
</CardDescription>
</div>
{!data?.is2FAEnabled ? <Enable2FA /> : <Disable2FA />}
{!data?.user.twoFactorEnabled ? <Enable2FA /> : <Disable2FA />}
</CardHeader>
<CardContent className="space-y-2 py-8 border-t">

View File

@@ -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>
);
};

View File

@@ -19,7 +19,7 @@ import {
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { authClient } from "@/lib/auth";
import { authClient } from "@/lib/auth-client";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { PlusIcon } from "lucide-react";

View File

@@ -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>
);
};

View File

@@ -153,7 +153,7 @@ export const ShowUsers = () => {
</DropdownMenuItem>
)}
{user.user.isRegistered && (
{user.role !== "owner" && (
<AddUserPermissions
userId={user.userId}
/>

View File

@@ -495,7 +495,7 @@ import {
DropdownMenuShortcut,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { authClient } from "@/lib/auth";
import { authClient } from "@/lib/auth-client";
import { toast } from "sonner";
import { AddOrganization } from "../dashboard/organization/handle-organization";
import { DialogAction } from "../shared/dialog-action";

View File

@@ -15,7 +15,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { authClient } from "@/lib/auth";
import { authClient } from "@/lib/auth-client";
import { Languages } from "@/lib/languages";
import { api } from "@/utils/api";
import useLocale from "@/utils/hooks/use-locale";

View File

@@ -26,7 +26,8 @@ WITH inserted_users AS (
"serversQuantity",
"expirationDate",
"createdAt",
"two_factor_enabled"
"two_factor_enabled",
"isRegistered"
)
SELECT
a."adminId",
@@ -52,7 +53,8 @@ WITH inserted_users AS (
a."serversQuantity",
NOW() + INTERVAL '1 year',
NOW(),
COALESCE(auth."is2FAEnabled", false)
COALESCE(auth."is2FAEnabled", false),
true
FROM admin a
JOIN auth ON auth.id = a."authId"
RETURNING *
@@ -141,7 +143,8 @@ inserted_members AS (
"accesedProjects",
"accesedServices",
"expirationDate",
"two_factor_enabled"
"two_factor_enabled",
"isRegistered"
)
SELECT
u."userId",
@@ -163,7 +166,8 @@ inserted_members AS (
COALESCE(u."accesedProjects", '{}'),
COALESCE(u."accesedServices", '{}'),
NOW() + INTERVAL '1 year',
COALESCE(auth."is2FAEnabled", false)
COALESCE(auth."is2FAEnabled", false),
COALESCE(u."isRegistered", false)
FROM "user" u
JOIN admin a ON u."adminId" = a."adminId"
JOIN auth ON auth.id = u."authId"

View File

@@ -1,7 +1,8 @@
import { organizationClient } from "better-auth/client/plugins";
import { twoFactorClient } from "better-auth/client/plugins";
import { createAuthClient } from "better-auth/react";
export const authClient = createAuthClient({
// baseURL: "http://localhost:3000", // the base url of your auth server
plugins: [organizationClient()],
plugins: [organizationClient(), twoFactorClient()],
});

View File

@@ -1,5 +1,5 @@
import { Button } from "@/components/ui/button";
import { authClient } from "@/lib/auth";
import { authClient } from "@/lib/auth-client";
import { useRouter } from "next/router";
export const AcceptInvitation = () => {

View File

@@ -1,3 +1,4 @@
import { ShowInvitations } from "@/components/dashboard/settings/users/show-invitations";
import { ShowUsers } from "@/components/dashboard/settings/users/show-users";
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
@@ -12,6 +13,7 @@ const Page = () => {
return (
<div className="flex flex-col gap-4 w-full">
<ShowUsers />
<ShowInvitations />
</div>
);
};

View File

@@ -3,17 +3,24 @@ import { OnboardingLayout } from "@/components/layouts/onboarding-layout";
import { AlertBlock } from "@/components/shared/alert-block";
import { Logo } from "@/components/shared/logo";
import { Button, buttonVariants } from "@/components/ui/button";
import { CardContent } from "@/components/ui/card";
import { CardContent, CardDescription } from "@/components/ui/card";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
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 { api } from "@/utils/api";
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 { Session, getSessionCookie } from "better-auth";
import { betterFetch } from "better-auth/react";
import base32 from "hi-base32";
import { REGEXP_ONLY_DIGITS } from "input-otp";
import type { GetServerSidePropsContext } from "next";
import Link from "next/link";
import { useRouter } from "next/router";
import { TOTP } from "otpauth";
import { type ReactElement, useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
const loginSchema = z.object({
email: z
.string()
.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",
}),
const LoginSchema = z.object({
email: z.string().email(),
password: z.string().min(8),
});
type Login = z.infer<typeof loginSchema>;
const TwoFactorSchema = z.object({
code: z.string().min(6),
});
type AuthResponse = {
is2FAEnabled: boolean;
authId: string;
};
type LoginForm = z.infer<typeof LoginSchema>;
type TwoFactorForm = z.infer<typeof TwoFactorSchema>;
interface Props {
IS_CLOUD: boolean;
}
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 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: {
email: "siumauricio@hotmail.com",
password: "Password123",
},
resolver: zodResolver(loginSchema),
});
useEffect(() => {
form.reset();
}, [form, form.reset, form.formState.isSubmitSuccessful]);
const onSubmit = async (values: Login) => {
setIsLoading(true);
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,
const onSubmit = async (values: LoginForm) => {
setIsLoginLoading(true);
try {
const { data, error } = await authClient.signIn.email({
email: values.email,
password: values.password,
});
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");
// }
} else {
setIsError(true);
setError(error.message ?? "Error to signup");
toast.error("Error to sign up", {
description: error.message,
});
} catch (error) {
toast.error("An error occurred while logging in");
} finally {
setIsLoginLoading(false);
}
};
const onTwoFactorSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (twoFactorCode.length !== 6) {
toast.error("Please enter a valid 6-digit code");
return;
}
setIsLoading(false);
// await mutateAsync({
// email: values.email.toLowerCase(),
// password: values.password,
// })
// .then((data) => {
// if (data.is2FAEnabled) {
// setTemp(data);
// } else {
// toast.success("Successfully signed in", {
// duration: 2000,
// });
// router.push("/dashboard/projects");
// }
// })
// .catch(() => {
// toast.error("Signin failed", {
// duration: 2000,
// });
// });
setIsTwoFactorLoading(true);
try {
const { data, error } = await authClient.twoFactor.verifyTotp({
code: twoFactorCode.replace(/\s/g, ""),
});
if (error) {
toast.error(error.message);
setError(error.message || "An error occurred while verifying 2FA code");
return;
}
toast.success("Logged in successfully");
router.push("/dashboard/projects");
} catch (error) {
toast.error("An error occurred while verifying 2FA code");
} finally {
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 (
<>
<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
</p>
</div>
{isError && (
{error && (
<AlertBlock type="error" className="my-2">
<span>{error}</span>
</AlertBlock>
)}
<CardContent className="p-0">
{!temp.is2FAEnabled ? (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="grid gap-4">
<div className="space-y-4">
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input placeholder="Email" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input
type="password"
placeholder="Password"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" isLoading={isLoading} className="w-full">
Login
</Button>
</div>
{!isTwoFactor ? (
<Form {...loginForm}>
<form
onSubmit={loginForm.handleSubmit(onSubmit)}
className="space-y-4"
id="login-form"
>
<FormField
control={loginForm.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input placeholder="john@example.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={loginForm.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input
type="password"
placeholder="Enter your password"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
className="w-full"
type="submit"
isLoading={isLoginLoading}
>
Login
</Button>
</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">

View File

@@ -26,8 +26,8 @@ import { useRouter } from "next/router";
import { type ReactElement, useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import superjson from "superjson";
import { z } from "zod";
const registerSchema = z
.object({

View File

@@ -17,7 +17,7 @@ import {
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { authClient } from "@/lib/auth";
import { authClient } from "@/lib/auth-client";
import { api } from "@/utils/api";
import { IS_CLOUD, isAdminPresent, validateRequest } from "@dokploy/server";
import { zodResolver } from "@hookform/resolvers/zod";

View File

@@ -1,5 +1,5 @@
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 { desc, eq } from "drizzle-orm";
import { nanoid } from "nanoid";
@@ -83,4 +83,10 @@ export const organizationRouter = createTRPCRouter({
.where(eq(organization.id, input.organizationId));
return result;
}),
allInvitations: adminProcedure.query(async ({ ctx }) => {
return await db.query.invitation.findMany({
where: eq(invitation.organizationId, ctx.session.activeOrganizationId),
orderBy: [desc(invitation.status)],
});
}),
});

View File

@@ -28,6 +28,7 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@better-auth/utils":"0.2.3",
"@oslojs/encoding":"1.1.0",
"@oslojs/crypto":"1.0.1",
"drizzle-dbml-generator":"0.10.0",

View File

@@ -16,6 +16,7 @@ export const auth = betterAuth({
provider: "pg",
schema: schema,
}),
appName: "Dokploy",
socialProviders: {
github: {
clientId: process.env.GITHUB_CLIENT_ID as string,

View File

@@ -1,4 +1,5 @@
import { randomBytes } from "node:crypto";
import { createOTP } from "@better-auth/utils/otp";
import { db } from "@dokploy/server/db";
import { users_temp } from "@dokploy/server/db/schema";
import { getPublicIpWithFallback } from "@dokploy/server/wss/utils";
@@ -29,26 +30,41 @@ export const findAuthById = async (authId: string) => {
return result;
};
export const generate2FASecret = async (userId: string) => {
const user = await findUserById(userId);
const generateBase32Secret = () => {
// 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({
issuer: "Dokploy",
label: `${user?.email}`,
label: "siumauricio@hotmail.com",
algorithm: "SHA1",
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 {
qrCodeUrl: qrUrl,
secret: base32_secret,
secret,
hexSecret,
totp,
};
};
@@ -59,6 +75,7 @@ export const verify2FA = async (auth: User, secret: string, pin: string) => {
algorithm: "SHA1",
digits: 6,
secret: secret,
period: 30,
});
const delta = totp.validate({ token: pin });
@@ -72,8 +89,124 @@ export const verify2FA = async (auth: User, secret: string, pin: string) => {
return auth;
};
const generateBase32Secret = () => {
const buffer = randomBytes(15);
const base32 = encode.encode(buffer).replace(/=/g, "").substring(0, 24);
return base32;
const convertBase32ToHex = (base32Secret: string) => {
try {
// Asegurarnos de que la longitud sea múltiplo de 8 agregando padding
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
View File

@@ -549,6 +549,9 @@ importers:
packages/server:
dependencies:
'@better-auth/utils':
specifier: 0.2.3
version: 0.2.3
'@faker-js/faker':
specifier: ^8.4.1
version: 8.4.1