feat: add self remove accounts

This commit is contained in:
Mauricio Siu
2024-12-15 18:45:02 -06:00
parent 9d37876bc4
commit afedeede16
9 changed files with 254 additions and 7 deletions

View File

@@ -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"

View File

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

View File

@@ -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"
>

View File

@@ -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,

View File

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

View File

@@ -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

View File

@@ -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: [
{

View File

@@ -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({

View File

@@ -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";