refactor: update reset password and authentication flows

This commit removes several authentication-related components and simplifies the password reset process:

- Removed login-2fa component
- Deleted confirm-email page
- Updated reset password logic to use Drizzle ORM directly
- Removed unused authentication-related functions
- Simplified server-side authentication routes
This commit is contained in:
Mauricio Siu
2025-02-22 21:09:21 -06:00
parent 8ab6d6b282
commit b00c12965a
14 changed files with 214 additions and 404 deletions

View File

@@ -51,20 +51,9 @@ const baseAdmin: User = {
serversQuantity: 0, serversQuantity: 0,
stripeCustomerId: "", stripeCustomerId: "",
stripeSubscriptionId: "", stripeSubscriptionId: "",
accessedProjects: [],
accessedServices: [],
banExpires: new Date(), banExpires: new Date(),
banned: true, banned: true,
banReason: "", banReason: "",
canAccessToAPI: false,
canCreateProjects: false,
canDeleteProjects: false,
canDeleteServices: false,
canAccessToDocker: false,
canAccessToSSHKeys: false,
canCreateServices: false,
canAccessToTraefikFiles: false,
canAccessToGitProviders: false,
email: "", email: "",
expirationDate: "", expirationDate: "",
id: "", id: "",
@@ -73,7 +62,6 @@ const baseAdmin: User = {
createdAt2: new Date().toISOString(), createdAt2: new Date().toISOString(),
emailVerified: false, emailVerified: false,
image: "", image: "",
token: "",
updatedAt: new Date(), updatedAt: new Date(),
twoFactorEnabled: false, twoFactorEnabled: false,
}; };

View File

@@ -1,131 +0,0 @@
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { CardTitle } from "@/components/ui/card";
import {
InputOTP,
InputOTPGroup,
InputOTPSlot,
} from "@/components/ui/input-otp";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { REGEXP_ONLY_DIGITS } from "input-otp";
import { AlertTriangle } from "lucide-react";
import { useRouter } from "next/router";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
const Login2FASchema = z.object({
pin: z.string().min(6, {
message: "Pin is required",
}),
});
type Login2FA = z.infer<typeof Login2FASchema>;
interface Props {
authId: string;
}
export const Login2FA = ({ authId }: Props) => {
const { push } = useRouter();
const { mutateAsync, isLoading, isError, error } =
api.auth.verifyLogin2FA.useMutation();
const form = useForm<Login2FA>({
defaultValues: {
pin: "",
},
resolver: zodResolver(Login2FASchema),
});
useEffect(() => {
form.reset({
pin: "",
});
}, [form, form.reset, form.formState.isSubmitSuccessful]);
const onSubmit = async (data: Login2FA) => {
await mutateAsync({
pin: data.pin,
id: authId,
})
.then(() => {
toast.success("Signin successfully", {
duration: 2000,
});
push("/dashboard/projects");
})
.catch(() => {
toast.error("Signin failed", {
duration: 2000,
});
});
};
return (
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="grid w-full gap-4"
>
{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>
)}
<CardTitle className="text-xl font-bold">2FA Login</CardTitle>
<FormField
control={form.control}
name="pin"
render={({ field }) => (
<FormItem className="flex flex-col max-sm:items-center">
<FormLabel>Pin</FormLabel>
<FormControl>
<div className="flex">
<InputOTP
maxLength={6}
{...field}
pattern={REGEXP_ONLY_DIGITS}
>
<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>
</div>
</FormControl>
<FormDescription>
Please enter the 6 digits code provided by your authenticator
app.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<Button isLoading={isLoading} type="submit">
Submit 2FA
</Button>
</form>
</Form>
);
};

View File

@@ -72,7 +72,7 @@ export const ShowPaidMonitoring = ({
data, data,
isLoading, isLoading,
error: queryError, error: queryError,
} = api.user.getServerMetrics.useQuery( } = api.server.getServerMetrics.useQuery(
{ {
url: BASE_URL, url: BASE_URL,
token, token,

View File

@@ -44,6 +44,12 @@ const PinSchema = z.object({
}), }),
}); });
type TwoFactorSetupData = {
qrCodeUrl: string;
secret: string;
totpURI: string;
};
type PasswordForm = z.infer<typeof PasswordSchema>; type PasswordForm = z.infer<typeof PasswordSchema>;
type PinForm = z.infer<typeof PinSchema>; type PinForm = z.infer<typeof PinSchema>;

View File

@@ -80,7 +80,7 @@ export const UpdateServerIp = ({ children }: Props) => {
}) })
.then(async () => { .then(async () => {
toast.success("Server IP Updated"); toast.success("Server IP Updated");
await utils.admin.one.invalidate(); await utils.user.get.invalidate();
setIsOpen(false); setIsOpen(false);
}) })
.catch(() => { .catch(() => {

View File

@@ -1,7 +1,7 @@
import { buffer } from "node:stream/consumers"; import { buffer } from "node:stream/consumers";
import { db } from "@/server/db"; import { db } from "@/server/db";
import { organization, server, users_temp } from "@/server/db/schema"; import { organization, server, users_temp } from "@/server/db/schema";
import { findUserById, type Server } from "@dokploy/server"; import { type Server, findUserById } from "@dokploy/server";
import { asc, eq } from "drizzle-orm"; import { asc, eq } from "drizzle-orm";
import type { NextApiRequest, NextApiResponse } from "next"; import type { NextApiRequest, NextApiResponse } from "next";
import Stripe from "stripe"; import Stripe from "stripe";

View File

@@ -1,96 +0,0 @@
import { OnboardingLayout } from "@/components/layouts/onboarding-layout";
import { Logo } from "@/components/shared/logo";
import { CardDescription, CardTitle } from "@/components/ui/card";
import { db } from "@/server/db";
import { auth } from "@/server/db/schema";
import { IS_CLOUD, updateAuthById } from "@dokploy/server";
import { isBefore } from "date-fns";
import { eq } from "drizzle-orm";
import type { GetServerSidePropsContext } from "next";
import Link from "next/link";
import type { ReactElement } from "react";
export default function Home() {
return (
<div className="flex h-screen w-full items-center justify-center ">
<div className="flex flex-col items-center gap-4 w-full">
<Link href="/" className="flex flex-row items-center gap-2">
<Logo />
<span className="font-medium text-sm">Dokploy</span>
</Link>
<CardTitle className="text-2xl font-bold">Email Confirmed</CardTitle>
<CardDescription>
Congratulations, your email is confirmed.
</CardDescription>
<div>
<Link href="/" className="w-full text-primary">
Click here to login
</Link>
</div>
</div>
</div>
);
}
Home.getLayout = (page: ReactElement) => {
return <OnboardingLayout>{page}</OnboardingLayout>;
};
export async function getServerSideProps(context: GetServerSidePropsContext) {
if (!IS_CLOUD) {
return {
redirect: {
permanent: true,
destination: "/",
},
};
}
const { token } = context.query;
if (typeof token !== "string") {
return {
redirect: {
permanent: true,
destination: "/",
},
};
}
const authR = await db.query.auth.findFirst({
where: eq(auth.confirmationToken, token),
});
if (
!authR ||
authR?.confirmationToken === null ||
authR?.confirmationExpiresAt === null
) {
return {
redirect: {
permanent: true,
destination: "/",
},
};
}
const isExpired = isBefore(new Date(authR.confirmationExpiresAt), new Date());
if (isExpired) {
return {
redirect: {
permanent: true,
destination: "/",
},
};
}
await updateAuthById(authR.id, {
confirmationToken: null,
confirmationExpiresAt: null,
});
return {
props: {
token: authR.confirmationToken,
},
};
}

View File

@@ -85,6 +85,7 @@ export default function Home({ IS_CLOUD }: Props) {
return; return;
} }
// @ts-ignore
if (data?.twoFactorRedirect as boolean) { if (data?.twoFactorRedirect as boolean) {
setTwoFactorCode(""); setTwoFactorCode("");
setIsTwoFactor(true); setIsTwoFactor(true);

View File

@@ -1,4 +1,3 @@
import { Login2FA } from "@/components/auth/login-2fa";
import { OnboardingLayout } from "@/components/layouts/onboarding-layout"; 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";
@@ -126,9 +125,7 @@ export default function Home() {
</div> </div>
</form> </form>
</Form> </Form>
) : ( ) : null}
<Login2FA authId={temp.authId} />
)}
<div className="flex flex-row justify-between flex-wrap"> <div className="flex flex-row justify-between flex-wrap">
<div className="mt-4 text-center text-sm flex flex-row justify-center gap-2"> <div className="mt-4 text-center text-sm flex flex-row justify-center gap-2">

View File

@@ -1,6 +1,8 @@
import { findAdmin } from "@dokploy/server"; import { findAdmin } from "@dokploy/server";
import { updateAuthById } from "@dokploy/server";
import { generateRandomPassword } from "@dokploy/server"; import { generateRandomPassword } from "@dokploy/server";
import { db } from "@dokploy/server/db";
import { account } from "@dokploy/server/db/schema";
import { eq } from "drizzle-orm";
(async () => { (async () => {
try { try {
@@ -8,9 +10,12 @@ import { generateRandomPassword } from "@dokploy/server";
const result = await findAdmin(); const result = await findAdmin();
const update = await updateAuthById(result.authId, { const update = await db
password: randomPassword.hashedPassword, .update(account)
}); .set({
password: randomPassword.hashedPassword,
})
.where(eq(account.userId, result.userId));
if (update) { if (update) {
console.log("Password reset successful"); console.log("Password reset successful");

View File

@@ -14,13 +14,11 @@ import {
IS_CLOUD, IS_CLOUD,
findUserById, findUserById,
getUserByToken, getUserByToken,
sendDiscordNotification,
sendEmailNotification, sendEmailNotification,
validateRequest, validateRequest,
} from "@dokploy/server"; } from "@dokploy/server";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import * as bcrypt from "bcrypt"; import * as bcrypt from "bcrypt";
import { isBefore } from "date-fns";
import { and, eq } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import { nanoid } from "nanoid"; import { nanoid } from "nanoid";
import { z } from "zod"; import { z } from "zod";
@@ -321,157 +319,64 @@ export const authRouter = createTRPCRouter({
`, `,
); );
}), }),
resetPassword: publicProcedure
.input(
z.object({
resetPasswordToken: z.string().min(1),
password: z.string().min(1),
}),
)
.mutation(async ({ input }) => {
if (!IS_CLOUD) {
throw new TRPCError({
code: "NOT_FOUND",
message: "This feature is only available in the cloud version",
});
}
const authR = await db.query.auth.findFirst({
where: eq(auth.resetPasswordToken, input.resetPasswordToken),
});
if (!authR || authR.resetPasswordExpiresAt === null) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Token not found",
});
}
const isExpired = isBefore(
new Date(authR.resetPasswordExpiresAt),
new Date(),
);
if (isExpired) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Token expired",
});
}
await updateAuthById(authR.id, {
resetPasswordExpiresAt: null,
resetPasswordToken: null,
password: bcrypt.hashSync(input.password, 10),
});
return true;
}),
confirmEmail: adminProcedure
.input(
z.object({
confirmationToken: z.string().min(1),
}),
)
.mutation(async ({ input }) => {
if (!IS_CLOUD) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Functionality not available in cloud version",
});
}
const authR = await db.query.auth.findFirst({
where: eq(auth.confirmationToken, input.confirmationToken),
});
if (!authR || authR.confirmationExpiresAt === null) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Token not found",
});
}
if (authR.confirmationToken !== input.confirmationToken) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Confirmation Token not found",
});
}
const isExpired = isBefore(
new Date(authR.confirmationExpiresAt),
new Date(),
);
if (isExpired) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Confirmation Token expired",
});
}
1;
await updateAuthById(authR.id, {
confirmationToken: null,
confirmationExpiresAt: null,
});
return true;
}),
}); });
export const sendVerificationEmail = async (authId: string) => { // export const sendVerificationEmail = async (authId: string) => {
const token = nanoid(); // const token = nanoid();
const result = await updateAuthById(authId, { // const result = await updateAuthById(authId, {
confirmationToken: token, // confirmationToken: token,
confirmationExpiresAt: new Date( // confirmationExpiresAt: new Date(
new Date().getTime() + 24 * 60 * 60 * 1000, // new Date().getTime() + 24 * 60 * 60 * 1000,
).toISOString(), // ).toISOString(),
}); // });
if (!result) { // if (!result) {
throw new TRPCError({ // throw new TRPCError({
code: "BAD_REQUEST", // code: "BAD_REQUEST",
message: "User not found", // message: "User not found",
}); // });
} // }
await sendEmailNotification( // await sendEmailNotification(
{ // {
fromAddress: process.env.SMTP_FROM_ADDRESS || "", // fromAddress: process.env.SMTP_FROM_ADDRESS || "",
toAddresses: [result?.email], // toAddresses: [result?.email],
smtpServer: process.env.SMTP_SERVER || "", // smtpServer: process.env.SMTP_SERVER || "",
smtpPort: Number(process.env.SMTP_PORT), // smtpPort: Number(process.env.SMTP_PORT),
username: process.env.SMTP_USERNAME || "", // username: process.env.SMTP_USERNAME || "",
password: process.env.SMTP_PASSWORD || "", // password: process.env.SMTP_PASSWORD || "",
}, // },
"Confirm your email | Dokploy", // "Confirm your email | Dokploy",
` // `
Welcome to Dokploy! // Welcome to Dokploy!
Please confirm your email by clicking the link below: // Please confirm your email by clicking the link below:
<a href="${WEBSITE_URL}/confirm-email?token=${result?.confirmationToken}"> // <a href="${WEBSITE_URL}/confirm-email?token=${result?.confirmationToken}">
Confirm Email // Confirm Email
</a> // </a>
`, // `,
); // );
return true; // return true;
}; // };
export const sendDiscordNotificationWelcome = async (newAdmin: Auth) => { // export const sendDiscordNotificationWelcome = async (newAdmin: Auth) => {
await sendDiscordNotification( // await sendDiscordNotification(
{ // {
webhookUrl: process.env.DISCORD_WEBHOOK_URL || "", // webhookUrl: process.env.DISCORD_WEBHOOK_URL || "",
}, // },
{ // {
title: "New User Registered", // title: "New User Registered",
color: 0x00ff00, // color: 0x00ff00,
fields: [ // fields: [
{ // {
name: "Email", // name: "Email",
value: newAdmin.email, // value: newAdmin.email,
inline: true, // inline: true,
}, // },
], // ],
timestamp: newAdmin.createdAt, // timestamp: newAdmin.createdAt,
footer: { // footer: {
text: "Dokploy User Registration Notification", // text: "Dokploy User Registration Notification",
}, // },
}, // },
); // );
}; // };

View File

@@ -37,6 +37,7 @@ import {
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { observable } from "@trpc/server/observable"; import { observable } from "@trpc/server/observable";
import { and, desc, eq, getTableColumns, isNotNull, sql } from "drizzle-orm"; import { and, desc, eq, getTableColumns, isNotNull, sql } from "drizzle-orm";
import { z } from "zod";
export const serverRouter = createTRPCRouter({ export const serverRouter = createTRPCRouter({
create: protectedProcedure create: protectedProcedure
@@ -378,4 +379,62 @@ export const serverRouter = createTRPCRouter({
const ip = await getPublicIpWithFallback(); const ip = await getPublicIpWithFallback();
return ip; return ip;
}), }),
getServerMetrics: protectedProcedure
.input(
z.object({
url: z.string(),
token: z.string(),
dataPoints: z.string(),
}),
)
.query(async ({ input }) => {
try {
const url = new URL(input.url);
url.searchParams.append("limit", input.dataPoints);
const response = await fetch(url.toString(), {
headers: {
Authorization: `Bearer ${input.token}`,
},
});
if (!response.ok) {
throw new Error(
`Error ${response.status}: ${response.statusText}. Ensure the container is running and this service is included in the monitoring configuration.`,
);
}
const data = await response.json();
if (!Array.isArray(data) || data.length === 0) {
throw new Error(
[
"No monitoring data available. This could be because:",
"",
"1. You don't have setup the monitoring service, you can do in web server section.",
"2. If you already have setup the monitoring service, wait a few minutes and refresh the page.",
].join("\n"),
);
}
return data as {
cpu: string;
cpuModel: string;
cpuCores: number;
cpuPhysicalCores: number;
cpuSpeed: number;
os: string;
distro: string;
kernel: string;
arch: string;
memUsed: string;
memUsedGB: string;
memTotal: string;
uptime: number;
diskUsed: string;
totalDisk: string;
networkIn: string;
networkOut: string;
timestamp: string;
}[];
} catch (error) {
throw error;
}
}),
}); });

View File

@@ -143,4 +143,63 @@ export const userRouter = createTRPCRouter({
}, },
}); });
}), }),
getContainerMetrics: protectedProcedure
.input(
z.object({
url: z.string(),
token: z.string(),
appName: z.string(),
dataPoints: z.string(),
}),
)
.query(async ({ input }) => {
try {
if (!input.appName) {
throw new Error(
[
"No Application Selected:",
"",
"Make Sure to select an application to monitor.",
].join("\n"),
);
}
const url = new URL(`${input.url}/metrics/containers`);
url.searchParams.append("limit", input.dataPoints);
url.searchParams.append("appName", input.appName);
const response = await fetch(url.toString(), {
headers: {
Authorization: `Bearer ${input.token}`,
},
});
if (!response.ok) {
throw new Error(
`Error ${response.status}: ${response.statusText}. Please verify that the application "${input.appName}" is running and this service is included in the monitoring configuration.`,
);
}
const data = await response.json();
if (!Array.isArray(data) || data.length === 0) {
throw new Error(
[
`No monitoring data available for "${input.appName}". This could be because:`,
"",
"1. The container was recently started - wait a few minutes for data to be collected",
"2. The container is not running - verify its status",
"3. The service is not included in your monitoring configuration",
].join("\n"),
);
}
return data as {
containerId: string;
containerName: string;
containerImage: string;
containerLabels: string;
containerCommand: string;
containerCreated: string;
}[];
} catch (error) {
throw error;
}
}),
}); });

View File

@@ -108,6 +108,23 @@ export const isAdminPresent = async () => {
return true; return true;
}; };
export const findAdmin = async () => {
const admin = await db.query.member.findFirst({
where: eq(member.role, "owner"),
with: {
user: true,
},
});
if (!admin) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Admin not found",
});
}
return admin;
};
export const getUserByToken = async (token: string) => { export const getUserByToken = async (token: string) => {
const user = await db.query.invitation.findFirst({ const user = await db.query.invitation.findFirst({
where: eq(invitation.id, token), where: eq(invitation.id, token),
@@ -154,8 +171,8 @@ export const getDokployUrl = async () => {
} }
const admin = await findAdmin(); const admin = await findAdmin();
if (admin.host) { if (admin.user.host) {
return `https://${admin.host}`; return `https://${admin.user.host}`;
} }
return `http://${admin.serverIp}:${process.env.PORT}`; return `http://${admin.user.serverIp}:${process.env.PORT}`;
}; };