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 { z } from "zod";
|
||||||
import { Disable2FA } from "./disable-2fa";
|
import { Disable2FA } from "./disable-2fa";
|
||||||
import { Enable2FA } from "./enable-2fa";
|
import { Enable2FA } from "./enable-2fa";
|
||||||
|
import { AlertBlock } from "@/components/shared/alert-block";
|
||||||
|
|
||||||
const profileSchema = z.object({
|
const profileSchema = z.object({
|
||||||
email: z.string(),
|
email: z.string(),
|
||||||
password: z.string().nullable(),
|
password: z.string().nullable(),
|
||||||
|
currentPassword: z.string().nullable(),
|
||||||
image: z.string().optional(),
|
image: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -52,7 +54,8 @@ const randomImages = [
|
|||||||
|
|
||||||
export const ProfileForm = () => {
|
export const ProfileForm = () => {
|
||||||
const { data, refetch } = api.auth.get.useQuery();
|
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 { t } = useTranslation("settings");
|
||||||
const [gravatarHash, setGravatarHash] = useState<string | null>(null);
|
const [gravatarHash, setGravatarHash] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -68,6 +71,7 @@ export const ProfileForm = () => {
|
|||||||
email: data?.email || "",
|
email: data?.email || "",
|
||||||
password: "",
|
password: "",
|
||||||
image: data?.image || "",
|
image: data?.image || "",
|
||||||
|
currentPassword: "",
|
||||||
},
|
},
|
||||||
resolver: zodResolver(profileSchema),
|
resolver: zodResolver(profileSchema),
|
||||||
});
|
});
|
||||||
@@ -78,6 +82,7 @@ export const ProfileForm = () => {
|
|||||||
email: data?.email || "",
|
email: data?.email || "",
|
||||||
password: "",
|
password: "",
|
||||||
image: data?.image || "",
|
image: data?.image || "",
|
||||||
|
currentPassword: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
if (data.email) {
|
if (data.email) {
|
||||||
@@ -94,6 +99,7 @@ export const ProfileForm = () => {
|
|||||||
email: values.email.toLowerCase(),
|
email: values.email.toLowerCase(),
|
||||||
password: values.password,
|
password: values.password,
|
||||||
image: values.image,
|
image: values.image,
|
||||||
|
currentPassword: values.currentPassword,
|
||||||
})
|
})
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
await refetch();
|
await refetch();
|
||||||
@@ -116,6 +122,8 @@ export const ProfileForm = () => {
|
|||||||
{!data?.is2FAEnabled ? <Enable2FA /> : <Disable2FA />}
|
{!data?.is2FAEnabled ? <Enable2FA /> : <Disable2FA />}
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-2">
|
<CardContent className="space-y-2">
|
||||||
|
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
|
||||||
|
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)} className="grid gap-4">
|
<form onSubmit={form.handleSubmit(onSubmit)} className="grid gap-4">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@@ -135,6 +143,24 @@ export const ProfileForm = () => {
|
|||||||
</FormItem>
|
</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
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="password"
|
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">
|
<DialogFooter className="pt-5">
|
||||||
<Button
|
<Button
|
||||||
isLoading={form.formState.isSubmitting}
|
isLoading={form.formState.isSubmitting}
|
||||||
// disabled={!canCreateMoreServers}
|
disabled={!canCreateMoreServers}
|
||||||
form="hook-form-add-server"
|
form="hook-form-add-server"
|
||||||
type="submit"
|
type="submit"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ export const SettingsLayout = ({ children }: Props) => {
|
|||||||
{
|
{
|
||||||
title: "Registry",
|
title: "Registry",
|
||||||
label: "",
|
label: "",
|
||||||
icon: ListMusic,
|
icon: GalleryVerticalEnd,
|
||||||
href: "/dashboard/settings/registry",
|
href: "/dashboard/settings/registry",
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -150,6 +150,7 @@ import {
|
|||||||
BoxesIcon,
|
BoxesIcon,
|
||||||
CreditCardIcon,
|
CreditCardIcon,
|
||||||
Database,
|
Database,
|
||||||
|
GalleryVerticalEnd,
|
||||||
GitBranch,
|
GitBranch,
|
||||||
KeyIcon,
|
KeyIcon,
|
||||||
KeyRound,
|
KeyRound,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { GenerateToken } from "@/components/dashboard/settings/profile/generate-token";
|
import { GenerateToken } from "@/components/dashboard/settings/profile/generate-token";
|
||||||
import { ProfileForm } from "@/components/dashboard/settings/profile/profile-form";
|
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 { DashboardLayout } from "@/components/layouts/dashboard-layout";
|
||||||
import { SettingsLayout } from "@/components/layouts/settings-layout";
|
import { SettingsLayout } from "@/components/layouts/settings-layout";
|
||||||
import { appRouter } from "@/server/api/root";
|
import { appRouter } from "@/server/api/root";
|
||||||
@@ -21,10 +22,14 @@ const Page = () => {
|
|||||||
enabled: !!data?.id && data?.rol === "user",
|
enabled: !!data?.id && data?.rol === "user",
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4 w-full">
|
<div className="flex flex-col gap-4 w-full">
|
||||||
<ProfileForm />
|
<ProfileForm />
|
||||||
{(user?.canAccessToAPI || data?.rol === "admin") && <GenerateToken />}
|
{(user?.canAccessToAPI || data?.rol === "admin") && <GenerateToken />}
|
||||||
|
|
||||||
|
{isCloud && <RemoveSelfAccount />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -134,7 +134,7 @@ const Register = ({ isCloud }: Props) => {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{data && (
|
{data?.type === "cloud" && (
|
||||||
<AlertBlock type="success" className="mx-4 my-2">
|
<AlertBlock type="success" className="mx-4 my-2">
|
||||||
<span>
|
<span>
|
||||||
Registration succesfuly, Please check your inbox or spam
|
Registration succesfuly, Please check your inbox or spam
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ import {
|
|||||||
getUserByToken,
|
getUserByToken,
|
||||||
lucia,
|
lucia,
|
||||||
luciaToken,
|
luciaToken,
|
||||||
|
removeAdminByAuthId,
|
||||||
|
removeUserByAuthId,
|
||||||
sendDiscordNotification,
|
sendDiscordNotification,
|
||||||
sendEmailNotification,
|
sendEmailNotification,
|
||||||
updateAuthById,
|
updateAuthById,
|
||||||
@@ -59,14 +61,20 @@ export const authRouter = createTRPCRouter({
|
|||||||
if (IS_CLOUD) {
|
if (IS_CLOUD) {
|
||||||
await sendDiscordNotificationWelcome(newAdmin);
|
await sendDiscordNotificationWelcome(newAdmin);
|
||||||
await sendVerificationEmail(newAdmin.id);
|
await sendVerificationEmail(newAdmin.id);
|
||||||
return true;
|
return {
|
||||||
|
status: "success",
|
||||||
|
type: "cloud",
|
||||||
|
};
|
||||||
}
|
}
|
||||||
const session = await lucia.createSession(newAdmin.id || "", {});
|
const session = await lucia.createSession(newAdmin.id || "", {});
|
||||||
ctx.res.appendHeader(
|
ctx.res.appendHeader(
|
||||||
"Set-Cookie",
|
"Set-Cookie",
|
||||||
lucia.createSessionCookie(session.id).serialize(),
|
lucia.createSessionCookie(session.id).serialize(),
|
||||||
);
|
);
|
||||||
return true;
|
return {
|
||||||
|
status: "success",
|
||||||
|
type: "selfhosted",
|
||||||
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "BAD_REQUEST",
|
code: "BAD_REQUEST",
|
||||||
@@ -178,6 +186,20 @@ export const authRouter = createTRPCRouter({
|
|||||||
update: protectedProcedure
|
update: protectedProcedure
|
||||||
.input(apiUpdateAuth)
|
.input(apiUpdateAuth)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.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, {
|
const auth = await updateAuthById(ctx.user.authId, {
|
||||||
...(input.email && { email: input.email.toLowerCase() }),
|
...(input.email && { email: input.email.toLowerCase() }),
|
||||||
...(input.password && {
|
...(input.password && {
|
||||||
@@ -188,6 +210,47 @@ export const authRouter = createTRPCRouter({
|
|||||||
|
|
||||||
return auth;
|
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 }) => {
|
generateToken: protectedProcedure.mutation(async ({ ctx, input }) => {
|
||||||
const auth = await findAuthById(ctx.user.authId);
|
const auth = await findAuthById(ctx.user.authId);
|
||||||
@@ -440,7 +503,7 @@ export const sendDiscordNotificationWelcome = async (newAdmin: Auth) => {
|
|||||||
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: [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ export const apiUpdateAuth = createSchema.partial().extend({
|
|||||||
email: z.string().nullable(),
|
email: z.string().nullable(),
|
||||||
password: z.string().nullable(),
|
password: z.string().nullable(),
|
||||||
image: z.string().optional(),
|
image: z.string().optional(),
|
||||||
|
currentPassword: z.string().nullable(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const apiUpdateAuthByAdmin = createSchema.partial().extend({
|
export const apiUpdateAuthByAdmin = createSchema.partial().extend({
|
||||||
|
|||||||
@@ -88,6 +88,9 @@ export const isAdminPresent = async () => {
|
|||||||
export const findAdminByAuthId = async (authId: string) => {
|
export const findAdminByAuthId = async (authId: string) => {
|
||||||
const admin = await db.query.admins.findFirst({
|
const admin = await db.query.admins.findFirst({
|
||||||
where: eq(admins.authId, authId),
|
where: eq(admins.authId, authId),
|
||||||
|
with: {
|
||||||
|
users: true,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
if (!admin) {
|
if (!admin) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
@@ -141,6 +144,24 @@ export const removeUserByAuthId = async (authId: string) => {
|
|||||||
.then((res) => res[0]);
|
.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 () => {
|
export const getDokployUrl = async () => {
|
||||||
if (IS_CLOUD) {
|
if (IS_CLOUD) {
|
||||||
return "https://app.dokploy.com";
|
return "https://app.dokploy.com";
|
||||||
|
|||||||
Reference in New Issue
Block a user