refactor: update database foreign key constraints and user management

This commit is contained in:
Mauricio Siu
2025-02-17 00:30:15 -06:00
parent 8c28223343
commit c7d47a6003
7 changed files with 4967 additions and 178 deletions

View File

@@ -1,129 +0,0 @@
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 { 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 addUser = z.object({
email: z
.string()
.min(1, "Email is required")
.email({ message: "Invalid email" }),
});
type AddUser = z.infer<typeof addUser>;
export const AddUser = () => {
const [open, setOpen] = useState(false);
const utils = api.useUtils();
const { data: activeOrganization } = authClient.useActiveOrganization();
const { mutateAsync, isError, error, isLoading } =
api.admin.createUserInvitation.useMutation();
const form = useForm<AddUser>({
defaultValues: {
email: "",
},
resolver: zodResolver(addUser),
});
useEffect(() => {
form.reset();
}, [form, form.formState.isSubmitSuccessful, form.reset]);
const onSubmit = async (data: AddUser) => {
const result = await authClient.organization.inviteMember({
email: data.email.toLowerCase(),
role: "member",
organizationId: activeOrganization?.id,
});
console.log(result);
// await mutateAsync({
// email: data.email.toLowerCase(),
// })
// .then(async () => {
// toast.success("Invitation created");
// await utils.user.all.invalidate();
// setOpen(false);
// })
// .catch(() => {
// toast.error("Error creating the invitation");
// });
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger className="" asChild>
<Button>
<PlusIcon className="h-4 w-4" /> Add User
</Button>
</DialogTrigger>
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-2xl">
<DialogHeader>
<DialogTitle>Add User</DialogTitle>
<DialogDescription>Invite a new user</DialogDescription>
</DialogHeader>
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
<Form {...form}>
<form
id="hook-form-add-user"
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>
);
}}
/>
<DialogFooter className="flex w-full flex-row">
<Button
isLoading={isLoading}
form="hook-form-add-user"
type="submit"
>
Create
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@@ -27,18 +27,16 @@ import { api } from "@/utils/api";
import copy from "copy-to-clipboard"; import copy from "copy-to-clipboard";
import { format } from "date-fns"; import { format } from "date-fns";
import { MoreHorizontal, Users } from "lucide-react"; import { MoreHorizontal, Users } from "lucide-react";
import { useEffect, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { AddUserPermissions } from "./add-permissions"; import { AddUserPermissions } from "./add-permissions";
import { AddUser } from "./add-user";
import { DialogAction } from "@/components/shared/dialog-action"; import { DialogAction } from "@/components/shared/dialog-action";
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
import { authClient } from "@/lib/auth-client";
export const ShowUsers = () => { export const ShowUsers = () => {
const { data: isCloud } = api.settings.isCloud.useQuery();
const { data, isLoading, refetch } = api.user.all.useQuery(); const { data, isLoading, refetch } = api.user.all.useQuery();
const { mutateAsync, isLoading: isRemoving } = const { mutateAsync, isLoading: isRemoving } = api.user.remove.useMutation();
api.admin.removeUser.useMutation();
return ( return (
<div className="w-full"> <div className="w-full">
@@ -67,7 +65,6 @@ export const ShowUsers = () => {
<span className="text-base text-muted-foreground"> <span className="text-base text-muted-foreground">
Invite users to your Dokploy account Invite users to your Dokploy account
</span> </span>
<AddUser />
</div> </div>
) : ( ) : (
<div className="flex flex-col gap-4 min-h-[25vh]"> <div className="flex flex-col gap-4 min-h-[25vh]">
@@ -88,36 +85,37 @@ export const ShowUsers = () => {
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{data?.map((user) => { {data?.map((member) => {
return ( return (
<TableRow key={user.userId}> <TableRow key={member.id}>
<TableCell className="w-[100px]"> <TableCell className="w-[100px]">
{user.user.email} {member.user.email}
</TableCell> </TableCell>
<TableCell className="text-center"> <TableCell className="text-center">
<Badge <Badge
variant={ variant={
user.role === "owner" member.role === "owner"
? "default" ? "default"
: "secondary" : "secondary"
} }
> >
{user.role} {member.role}
</Badge> </Badge>
</TableCell> </TableCell>
<TableCell className="text-center"> <TableCell className="text-center">
{user.user.twoFactorEnabled {member.user.twoFactorEnabled
? "Enabled" ? "Enabled"
: "Disabled"} : "Disabled"}
</TableCell> </TableCell>
<TableCell className="text-center"> <TableCell className="text-center">
{user.user.isRegistered || user.role === "owner" {member.user.isRegistered ||
member.role === "owner"
? "Registered" ? "Registered"
: "Not Registered"} : "Not Registered"}
</TableCell> </TableCell>
<TableCell className="text-right"> <TableCell className="text-right">
<span className="text-sm text-muted-foreground"> <span className="text-sm text-muted-foreground">
{format(new Date(user.createdAt), "PPpp")} {format(new Date(member.createdAt), "PPpp")}
</span> </span>
</TableCell> </TableCell>
@@ -136,13 +134,13 @@ export const ShowUsers = () => {
<DropdownMenuLabel> <DropdownMenuLabel>
Actions Actions
</DropdownMenuLabel> </DropdownMenuLabel>
{!user.user.isRegistered && {!member.user.isRegistered &&
user.role !== "owner" && ( member.role !== "owner" && (
<DropdownMenuItem <DropdownMenuItem
className="w-full cursor-pointer" className="w-full cursor-pointer"
onSelect={(e) => { onSelect={(e) => {
copy( copy(
`${origin}/invitation?token=${user.user.token}`, `${origin}/invitation?token=${member.user.token}`,
); );
toast.success( toast.success(
"Invitation Copied to clipboard", "Invitation Copied to clipboard",
@@ -153,32 +151,53 @@ export const ShowUsers = () => {
</DropdownMenuItem> </DropdownMenuItem>
)} )}
{user.role !== "owner" && ( {member.role !== "owner" && (
<AddUserPermissions <AddUserPermissions
userId={user.userId} userId={member.user.id}
/> />
)} )}
{user.role !== "owner" && ( {member.role !== "owner" && (
<DialogAction <DialogAction
title="Delete User" title="Delete User"
description="Are you sure you want to delete this user?" description="Are you sure you want to delete this user?"
type="destructive" type="destructive"
onClick={async () => { onClick={async () => {
await mutateAsync({ if (isCloud) {
userId: user.userId, const { error } =
}) await authClient.organization.removeMember(
.then(() => { {
memberIdOrEmail:
member.user.id,
},
);
if (!error) {
toast.success( toast.success(
"User deleted successfully", "User deleted successfully",
); );
refetch(); refetch();
}) } else {
.catch(() => {
toast.error( toast.error(
"Error deleting destination", "Error deleting user",
); );
}); }
} else {
await mutateAsync({
userId: member.user.id,
})
.then(() => {
toast.success(
"User deleted successfully",
);
refetch();
})
.catch(() => {
toast.error(
"Error deleting destination",
);
});
}
}} }}
> >
<DropdownMenuItem <DropdownMenuItem
@@ -197,10 +216,6 @@ export const ShowUsers = () => {
})} })}
</TableBody> </TableBody>
</Table> </Table>
<div className="flex flex-row gap-2 flex-wrap w-full justify-end mr-4">
<AddUser />
</div>
</div> </div>
)} )}
</> </>

View File

@@ -0,0 +1,18 @@
ALTER TABLE "account" DROP CONSTRAINT "account_user_id_user_temp_id_fk";
--> statement-breakpoint
ALTER TABLE "invitation" DROP CONSTRAINT "invitation_organization_id_organization_id_fk";
--> statement-breakpoint
ALTER TABLE "invitation" DROP CONSTRAINT "invitation_inviter_id_user_temp_id_fk";
--> statement-breakpoint
ALTER TABLE "member" DROP CONSTRAINT "member_organization_id_organization_id_fk";
--> statement-breakpoint
ALTER TABLE "member" DROP CONSTRAINT "member_user_id_user_temp_id_fk";
--> statement-breakpoint
ALTER TABLE "organization" DROP CONSTRAINT "organization_owner_id_user_temp_id_fk";
--> statement-breakpoint
ALTER TABLE "account" ADD CONSTRAINT "account_user_id_user_temp_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user_temp"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "invitation" ADD CONSTRAINT "invitation_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "invitation" ADD CONSTRAINT "invitation_inviter_id_user_temp_id_fk" FOREIGN KEY ("inviter_id") REFERENCES "public"."user_temp"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "member" ADD CONSTRAINT "member_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "member" ADD CONSTRAINT "member_user_id_user_temp_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user_temp"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "organization" ADD CONSTRAINT "organization_owner_id_user_temp_id_fk" FOREIGN KEY ("owner_id") REFERENCES "public"."user_temp"("id") ON DELETE cascade ON UPDATE no action;

File diff suppressed because it is too large Load Diff

View File

@@ -519,6 +519,13 @@
"when": 1739740193879, "when": 1739740193879,
"tag": "0073_polite_miss_america", "tag": "0073_polite_miss_america",
"breakpoints": true "breakpoints": true
},
{
"idx": 74,
"version": "7",
"when": 1739773539709,
"tag": "0074_lowly_jack_power",
"breakpoints": true
} }
] ]
} }

View File

@@ -2,11 +2,13 @@ import { apiFindOneUser, apiFindOneUserByAuth } from "@/server/db/schema";
import { import {
findUserByAuthId, findUserByAuthId,
findUserById, findUserById,
IS_CLOUD,
removeUserById,
updateUser, updateUser,
verify2FA, verify2FA,
} from "@dokploy/server"; } from "@dokploy/server";
import { db } from "@dokploy/server/db"; import { db } from "@dokploy/server/db";
import { apiUpdateUser, member } from "@dokploy/server/db/schema"; import { account, apiUpdateUser, member } from "@dokploy/server/db/schema";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { z } from "zod"; import { z } from "zod";
@@ -44,19 +46,17 @@ export const userRouter = createTRPCRouter({
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
return await updateUser(ctx.user.id, input); return await updateUser(ctx.user.id, input);
}), }),
verify2FASetup: protectedProcedure
remove: protectedProcedure
.input( .input(
z.object({ z.object({
secret: z.string(), userId: z.string(),
pin: z.string(),
}), }),
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ input, ctx }) => {
const user = await findUserById(ctx.user.id); if (IS_CLOUD) {
await verify2FA(user, input.secret, input.pin); return true;
await updateUser(user.id, { }
secret: input.secret, return await removeUserById(input.userId);
});
return user;
}), }),
}); });

View File

@@ -15,7 +15,7 @@ export const account = pgTable("account", {
providerId: text("provider_id").notNull(), providerId: text("provider_id").notNull(),
userId: text("user_id") userId: text("user_id")
.notNull() .notNull()
.references(() => users_temp.id), .references(() => users_temp.id, { onDelete: "cascade" }),
accessToken: text("access_token"), accessToken: text("access_token"),
refreshToken: text("refresh_token"), refreshToken: text("refresh_token"),
idToken: text("id_token"), idToken: text("id_token"),
@@ -59,7 +59,7 @@ export const organization = pgTable("organization", {
metadata: text("metadata"), metadata: text("metadata"),
ownerId: text("owner_id") ownerId: text("owner_id")
.notNull() .notNull()
.references(() => users_temp.id), .references(() => users_temp.id, { onDelete: "cascade" }),
}); });
export const organizationRelations = relations( export const organizationRelations = relations(
@@ -80,10 +80,10 @@ export const member = pgTable("member", {
.$defaultFn(() => nanoid()), .$defaultFn(() => nanoid()),
organizationId: text("organization_id") organizationId: text("organization_id")
.notNull() .notNull()
.references(() => organization.id), .references(() => organization.id, { onDelete: "cascade" }),
userId: text("user_id") userId: text("user_id")
.notNull() .notNull()
.references(() => users_temp.id), .references(() => users_temp.id, { onDelete: "cascade" }),
role: text("role").notNull().$type<"owner" | "member" | "admin">(), role: text("role").notNull().$type<"owner" | "member" | "admin">(),
createdAt: timestamp("created_at").notNull(), createdAt: timestamp("created_at").notNull(),
}); });
@@ -103,14 +103,14 @@ export const invitation = pgTable("invitation", {
id: text("id").primaryKey(), id: text("id").primaryKey(),
organizationId: text("organization_id") organizationId: text("organization_id")
.notNull() .notNull()
.references(() => organization.id), .references(() => organization.id, { onDelete: "cascade" }),
email: text("email").notNull(), email: text("email").notNull(),
role: text("role").$type<"owner" | "member" | "admin">(), role: text("role").$type<"owner" | "member" | "admin">(),
status: text("status").notNull(), status: text("status").notNull(),
expiresAt: timestamp("expires_at").notNull(), expiresAt: timestamp("expires_at").notNull(),
inviterId: text("inviter_id") inviterId: text("inviter_id")
.notNull() .notNull()
.references(() => users_temp.id), .references(() => users_temp.id, { onDelete: "cascade" }),
}); });
export const invitationRelations = relations(invitation, ({ one }) => ({ export const invitationRelations = relations(invitation, ({ one }) => ({