mirror of
https://github.com/Dokploy/dokploy
synced 2025-06-26 18:27:59 +00:00
feat: add self remove accounts
This commit is contained in:
@@ -26,10 +26,12 @@ import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { Disable2FA } from "./disable-2fa";
|
||||
import { Enable2FA } from "./enable-2fa";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
|
||||
const profileSchema = z.object({
|
||||
email: z.string(),
|
||||
password: z.string().nullable(),
|
||||
currentPassword: z.string().nullable(),
|
||||
image: z.string().optional(),
|
||||
});
|
||||
|
||||
@@ -52,7 +54,8 @@ const randomImages = [
|
||||
|
||||
export const ProfileForm = () => {
|
||||
const { data, refetch } = api.auth.get.useQuery();
|
||||
const { mutateAsync, isLoading } = api.auth.update.useMutation();
|
||||
const { mutateAsync, isLoading, isError, error } =
|
||||
api.auth.update.useMutation();
|
||||
const { t } = useTranslation("settings");
|
||||
const [gravatarHash, setGravatarHash] = useState<string | null>(null);
|
||||
|
||||
@@ -68,6 +71,7 @@ export const ProfileForm = () => {
|
||||
email: data?.email || "",
|
||||
password: "",
|
||||
image: data?.image || "",
|
||||
currentPassword: "",
|
||||
},
|
||||
resolver: zodResolver(profileSchema),
|
||||
});
|
||||
@@ -78,6 +82,7 @@ export const ProfileForm = () => {
|
||||
email: data?.email || "",
|
||||
password: "",
|
||||
image: data?.image || "",
|
||||
currentPassword: "",
|
||||
});
|
||||
|
||||
if (data.email) {
|
||||
@@ -94,6 +99,7 @@ export const ProfileForm = () => {
|
||||
email: values.email.toLowerCase(),
|
||||
password: values.password,
|
||||
image: values.image,
|
||||
currentPassword: values.currentPassword,
|
||||
})
|
||||
.then(async () => {
|
||||
await refetch();
|
||||
@@ -116,6 +122,8 @@ export const ProfileForm = () => {
|
||||
{!data?.is2FAEnabled ? <Enable2FA /> : <Disable2FA />}
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="grid gap-4">
|
||||
<div className="space-y-4">
|
||||
@@ -135,6 +143,24 @@ export const ProfileForm = () => {
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="currentPassword"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Current Password</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder={t("settings.profile.password")}
|
||||
{...field}
|
||||
value={field.value || ""}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { api } from "@/utils/api";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useTranslation } from "next-i18next";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { DialogAction } from "@/components/shared/dialog-action";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
const profileSchema = z.object({
|
||||
password: z.string().min(1, {
|
||||
message: "Password is required",
|
||||
}),
|
||||
});
|
||||
|
||||
type Profile = z.infer<typeof profileSchema>;
|
||||
|
||||
export const RemoveSelfAccount = () => {
|
||||
const { data } = api.auth.get.useQuery();
|
||||
const { mutateAsync, isLoading, error, isError } =
|
||||
api.auth.removeSelfAccount.useMutation();
|
||||
const { t } = useTranslation("settings");
|
||||
const router = useRouter();
|
||||
|
||||
const form = useForm<Profile>({
|
||||
defaultValues: {
|
||||
password: "",
|
||||
},
|
||||
resolver: zodResolver(profileSchema),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
form.reset({
|
||||
password: "",
|
||||
});
|
||||
}
|
||||
form.reset();
|
||||
}, [form, form.reset, data]);
|
||||
|
||||
const onSubmit = async (values: Profile) => {
|
||||
await mutateAsync({
|
||||
password: values.password,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("Profile Deleted");
|
||||
router.push("/");
|
||||
})
|
||||
.catch(() => {});
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="bg-transparent">
|
||||
<CardHeader className="flex flex-row gap-2 flex-wrap justify-between items-center">
|
||||
<div>
|
||||
<CardTitle className="text-xl">Remove Self Account</CardTitle>
|
||||
<CardDescription>
|
||||
If you want to remove your account, you can do it here
|
||||
</CardDescription>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={(e) => e.preventDefault()}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
className="grid gap-4"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("settings.profile.password")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder={t("settings.profile.password")}
|
||||
{...field}
|
||||
value={field.value || ""}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
<div>
|
||||
<DialogAction
|
||||
title="Are you sure you want to remove your account?"
|
||||
description="This action cannot be undone, all your projects/servers will be deleted."
|
||||
onClick={() => form.handleSubmit(onSubmit)()}
|
||||
>
|
||||
<Button type="button" isLoading={isLoading} variant="destructive">
|
||||
Remove
|
||||
</Button>
|
||||
</DialogAction>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -270,7 +270,7 @@ export const CreateServer = ({ stepper }: Props) => {
|
||||
<DialogFooter className="pt-5">
|
||||
<Button
|
||||
isLoading={form.formState.isSubmitting}
|
||||
// disabled={!canCreateMoreServers}
|
||||
disabled={!canCreateMoreServers}
|
||||
form="hook-form-add-server"
|
||||
type="submit"
|
||||
>
|
||||
|
||||
@@ -77,7 +77,7 @@ export const SettingsLayout = ({ children }: Props) => {
|
||||
{
|
||||
title: "Registry",
|
||||
label: "",
|
||||
icon: ListMusic,
|
||||
icon: GalleryVerticalEnd,
|
||||
href: "/dashboard/settings/registry",
|
||||
},
|
||||
|
||||
@@ -150,6 +150,7 @@ import {
|
||||
BoxesIcon,
|
||||
CreditCardIcon,
|
||||
Database,
|
||||
GalleryVerticalEnd,
|
||||
GitBranch,
|
||||
KeyIcon,
|
||||
KeyRound,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { GenerateToken } from "@/components/dashboard/settings/profile/generate-token";
|
||||
import { ProfileForm } from "@/components/dashboard/settings/profile/profile-form";
|
||||
import { RemoveSelfAccount } from "@/components/dashboard/settings/profile/remove-self-account";
|
||||
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
|
||||
import { SettingsLayout } from "@/components/layouts/settings-layout";
|
||||
import { appRouter } from "@/server/api/root";
|
||||
@@ -21,10 +22,14 @@ const Page = () => {
|
||||
enabled: !!data?.id && data?.rol === "user",
|
||||
},
|
||||
);
|
||||
|
||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||
return (
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
<ProfileForm />
|
||||
{(user?.canAccessToAPI || data?.rol === "admin") && <GenerateToken />}
|
||||
|
||||
{isCloud && <RemoveSelfAccount />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -134,7 +134,7 @@ const Register = ({ isCloud }: Props) => {
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{data && (
|
||||
{data?.type === "cloud" && (
|
||||
<AlertBlock type="success" className="mx-4 my-2">
|
||||
<span>
|
||||
Registration succesfuly, Please check your inbox or spam
|
||||
|
||||
@@ -20,6 +20,8 @@ import {
|
||||
getUserByToken,
|
||||
lucia,
|
||||
luciaToken,
|
||||
removeAdminByAuthId,
|
||||
removeUserByAuthId,
|
||||
sendDiscordNotification,
|
||||
sendEmailNotification,
|
||||
updateAuthById,
|
||||
@@ -59,14 +61,20 @@ export const authRouter = createTRPCRouter({
|
||||
if (IS_CLOUD) {
|
||||
await sendDiscordNotificationWelcome(newAdmin);
|
||||
await sendVerificationEmail(newAdmin.id);
|
||||
return true;
|
||||
return {
|
||||
status: "success",
|
||||
type: "cloud",
|
||||
};
|
||||
}
|
||||
const session = await lucia.createSession(newAdmin.id || "", {});
|
||||
ctx.res.appendHeader(
|
||||
"Set-Cookie",
|
||||
lucia.createSessionCookie(session.id).serialize(),
|
||||
);
|
||||
return true;
|
||||
return {
|
||||
status: "success",
|
||||
type: "selfhosted",
|
||||
};
|
||||
} catch (error) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
@@ -178,6 +186,20 @@ export const authRouter = createTRPCRouter({
|
||||
update: protectedProcedure
|
||||
.input(apiUpdateAuth)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const currentAuth = await findAuthByEmail(ctx.user.email);
|
||||
|
||||
if (input.password) {
|
||||
const correctPassword = bcrypt.compareSync(
|
||||
input.password,
|
||||
currentAuth?.password || "",
|
||||
);
|
||||
if (!correctPassword) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Current password is incorrect",
|
||||
});
|
||||
}
|
||||
}
|
||||
const auth = await updateAuthById(ctx.user.authId, {
|
||||
...(input.email && { email: input.email.toLowerCase() }),
|
||||
...(input.password && {
|
||||
@@ -188,6 +210,47 @@ export const authRouter = createTRPCRouter({
|
||||
|
||||
return auth;
|
||||
}),
|
||||
removeSelfAccount: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
password: z.string().min(1),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
if (!IS_CLOUD) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "This feature is only available in the cloud version",
|
||||
});
|
||||
}
|
||||
const currentAuth = await findAuthByEmail(ctx.user.email);
|
||||
|
||||
const correctPassword = bcrypt.compareSync(
|
||||
input.password,
|
||||
currentAuth?.password || "",
|
||||
);
|
||||
|
||||
if (!correctPassword) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Password is incorrect",
|
||||
});
|
||||
}
|
||||
const { req, res } = ctx;
|
||||
const { session } = await validateRequest(req, res);
|
||||
if (!session) return false;
|
||||
|
||||
await lucia.invalidateSession(session.id);
|
||||
res.setHeader("Set-Cookie", lucia.createBlankSessionCookie().serialize());
|
||||
|
||||
if (ctx.user.rol === "admin") {
|
||||
await removeAdminByAuthId(ctx.user.authId);
|
||||
} else {
|
||||
await removeUserByAuthId(ctx.user.authId);
|
||||
}
|
||||
|
||||
return true;
|
||||
}),
|
||||
|
||||
generateToken: protectedProcedure.mutation(async ({ ctx, input }) => {
|
||||
const auth = await findAuthById(ctx.user.authId);
|
||||
@@ -440,7 +503,7 @@ export const sendDiscordNotificationWelcome = async (newAdmin: Auth) => {
|
||||
webhookUrl: process.env.DISCORD_WEBHOOK_URL || "",
|
||||
},
|
||||
{
|
||||
title: "✅ New User Registered",
|
||||
title: " New User Registered",
|
||||
color: 0x00ff00,
|
||||
fields: [
|
||||
{
|
||||
|
||||
@@ -92,6 +92,7 @@ export const apiUpdateAuth = createSchema.partial().extend({
|
||||
email: z.string().nullable(),
|
||||
password: z.string().nullable(),
|
||||
image: z.string().optional(),
|
||||
currentPassword: z.string().nullable(),
|
||||
});
|
||||
|
||||
export const apiUpdateAuthByAdmin = createSchema.partial().extend({
|
||||
|
||||
@@ -88,6 +88,9 @@ export const isAdminPresent = async () => {
|
||||
export const findAdminByAuthId = async (authId: string) => {
|
||||
const admin = await db.query.admins.findFirst({
|
||||
where: eq(admins.authId, authId),
|
||||
with: {
|
||||
users: true,
|
||||
},
|
||||
});
|
||||
if (!admin) {
|
||||
throw new TRPCError({
|
||||
@@ -141,6 +144,24 @@ export const removeUserByAuthId = async (authId: string) => {
|
||||
.then((res) => res[0]);
|
||||
};
|
||||
|
||||
export const removeAdminByAuthId = async (authId: string) => {
|
||||
const admin = await findAdminByAuthId(authId);
|
||||
if (!admin) return null;
|
||||
|
||||
// First delete all associated users
|
||||
const users = admin.users;
|
||||
|
||||
for (const user of users) {
|
||||
await removeUserByAuthId(user.authId);
|
||||
}
|
||||
// Then delete the auth record which will cascade delete the admin
|
||||
return await db
|
||||
.delete(auth)
|
||||
.where(eq(auth.id, authId))
|
||||
.returning()
|
||||
.then((res) => res[0]);
|
||||
};
|
||||
|
||||
export const getDokployUrl = async () => {
|
||||
if (IS_CLOUD) {
|
||||
return "https://app.dokploy.com";
|
||||
|
||||
Reference in New Issue
Block a user