From a5911e2bac33e9913083faba9732b110198a3b28 Mon Sep 17 00:00:00 2001 From: Mauricio Siu <47042324+Siumauricio@users.noreply.github.com> Date: Sun, 13 Jul 2025 11:44:46 -0600 Subject: [PATCH] feat(invitation): refactor invitation creation process and enhance error handling - Replaced the existing invitation creation logic with a new mutation that integrates role and organization checks. - Updated the invitation form to handle errors more effectively, displaying error messages directly from the API response. - Introduced a new `member_role` table to manage user roles with associated permissions, ensuring better role management. - Enhanced SQL migration scripts to create default roles for organizations and update existing member roles accordingly. - Improved the user router to include a new `createInvitation` procedure for streamlined invitation management. --- .../settings/users/add-invitation.tsx | 44 ++++------ ...tian_walker.sql => 0103_brainy_nehzno.sql} | 9 +- apps/dokploy/drizzle/meta/0103_snapshot.json | 20 +++-- apps/dokploy/drizzle/meta/_journal.json | 4 +- apps/dokploy/server/api/routers/user.ts | 83 ++++++++++++++++++- apps/dokploy/server/api/trpc.ts | 1 - packages/server/src/db/schema/account.ts | 7 +- packages/server/src/db/schema/user.ts | 7 +- packages/server/src/lib/auth.ts | 4 +- 9 files changed, 127 insertions(+), 52 deletions(-) rename apps/dokploy/drizzle/{0103_swift_christian_walker.sql => 0103_brainy_nehzno.sql} (96%) diff --git a/apps/dokploy/components/dashboard/settings/users/add-invitation.tsx b/apps/dokploy/components/dashboard/settings/users/add-invitation.tsx index 4d8110132..44adfb6db 100644 --- a/apps/dokploy/components/dashboard/settings/users/add-invitation.tsx +++ b/apps/dokploy/components/dashboard/settings/users/add-invitation.tsx @@ -50,12 +50,14 @@ export const AddInvitation = () => { const [open, setOpen] = useState(false); const utils = api.useUtils(); const { data: roles } = api.role.all.useQuery(); - const [isLoading, setIsLoading] = useState(false); const { data: isCloud } = api.settings.isCloud.useQuery(); const { data: emailProviders } = api.notification.getEmailProviders.useQuery(); - const { mutateAsync: sendInvitation } = api.user.sendInvitation.useMutation(); - const [error, setError] = useState(null); + const { + mutateAsync: createInvitation, + isLoading, + error, + } = api.user.createInvitation.useMutation(); const { data: activeOrganization } = authClient.useActiveOrganization(); const form = useForm({ @@ -71,36 +73,20 @@ export const AddInvitation = () => { }, [form, form.formState.isSubmitSuccessful, form.reset]); const onSubmit = async (data: AddInvitation) => { - setIsLoading(true); - const result = await authClient.organization.inviteMember({ + await createInvitation({ email: data.email.toLowerCase(), role: data.role, - organizationId: activeOrganization?.id, - }); - - if (result.error) { - setError(result.error.message || ""); - } else { - if (!isCloud && data.notificationId) { - await sendInvitation({ - invitationId: result.data.id, - notificationId: data.notificationId || "", - }) - .then(() => { - toast.success("Invitation created and email sent"); - }) - .catch((error: any) => { - toast.error(error.message); - }); - } else { + organizationId: activeOrganization?.id || "", + notificationId: data.notificationId || "", + }) + .then(() => { toast.success("Invitation created"); - } - setError(null); - setOpen(false); - } + }) + .catch((error: any) => { + toast.error(error.message); + }); utils.organization.allInvitations.invalidate(); - setIsLoading(false); }; return ( @@ -114,7 +100,7 @@ export const AddInvitation = () => { Add Invitation Invite a new user - {error && {error}} + {error && {error.message}}
statement-breakpoint ALTER TABLE "user_temp" RENAME TO "users";--> statement-breakpoint ALTER TABLE "users" DROP CONSTRAINT "user_temp_email_unique";--> statement-breakpoint @@ -88,6 +87,9 @@ ALTER TABLE "two_factor" DROP CONSTRAINT "two_factor_user_id_user_temp_id_fk"; --> statement-breakpoint ALTER TABLE "schedule" DROP CONSTRAINT "schedule_userId_user_temp_id_fk"; --> statement-breakpoint +ALTER TABLE "users" ALTER COLUMN "created_at" SET NOT NULL;--> statement-breakpoint +ALTER TABLE "users" ALTER COLUMN "updated_at" SET DEFAULT now();--> statement-breakpoint +ALTER TABLE "member" ALTER COLUMN "role" DROP NOT NULL;--> statement-breakpoint ALTER TABLE "member" ADD COLUMN "roleId" text;--> statement-breakpoint ALTER TABLE "member_role" ADD CONSTRAINT "member_role_organizationId_organization_id_fk" FOREIGN KEY ("organizationId") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint ALTER TABLE "backup" ADD CONSTRAINT "backup_userId_users_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint @@ -111,13 +113,13 @@ BEGIN WHERE id = mem.id; END LOOP; END $$; -ALTER TABLE "member" ALTER COLUMN "roleId" SET NOT NULL; ALTER TABLE "organization" ADD CONSTRAINT "organization_owner_id_users_id_fk" FOREIGN KEY ("owner_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint ALTER TABLE "two_factor" ADD CONSTRAINT "two_factor_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint ALTER TABLE "schedule" ADD CONSTRAINT "schedule_userId_users_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint + --> statement-breakpoint CREATE TABLE "web_server" ( "webServerId" text PRIMARY KEY NOT NULL, @@ -160,7 +162,7 @@ INNER JOIN "organization" o ON u.id = o.owner_id LIMIT 1; -ALTER TABLE "users" DROP COLUMN "created_at";--> statement-breakpoint +ALTER TABLE "users" DROP COLUMN "createdAt";--> statement-breakpoint ALTER TABLE "users" DROP COLUMN "serverIp";--> statement-breakpoint ALTER TABLE "users" DROP COLUMN "certificateType";--> statement-breakpoint ALTER TABLE "users" DROP COLUMN "https";--> statement-breakpoint @@ -173,7 +175,6 @@ ALTER TABLE "users" DROP COLUMN "metricsConfig";--> statement-breakpoint ALTER TABLE "users" DROP COLUMN "cleanupCacheApplications";--> statement-breakpoint ALTER TABLE "users" DROP COLUMN "cleanupCacheOnPreviews";--> statement-breakpoint ALTER TABLE "users" DROP COLUMN "cleanupCacheOnCompose";--> statement-breakpoint -ALTER TABLE "member" DROP COLUMN "role";--> statement-breakpoint ALTER TABLE "member" DROP COLUMN "canCreateProjects";--> statement-breakpoint ALTER TABLE "member" DROP COLUMN "canAccessToSSHKeys";--> statement-breakpoint ALTER TABLE "member" DROP COLUMN "canCreateServices";--> statement-breakpoint diff --git a/apps/dokploy/drizzle/meta/0103_snapshot.json b/apps/dokploy/drizzle/meta/0103_snapshot.json index 0e2630dfa..52dac5f5e 100644 --- a/apps/dokploy/drizzle/meta/0103_snapshot.json +++ b/apps/dokploy/drizzle/meta/0103_snapshot.json @@ -1,5 +1,5 @@ { - "id": "56c5008e-c689-4a20-9f3d-06a06e9a5e39", + "id": "6b7b9d76-9e2d-4251-9a3e-8a337076714e", "prevId": "218e3c9b-ef86-4665-98af-56d65282b73b", "version": "7", "dialect": "postgresql", @@ -852,11 +852,12 @@ "primaryKey": false, "notNull": true }, - "createdAt": { - "name": "createdAt", - "type": "text", + "created_at": { + "name": "created_at", + "type": "timestamp", "primaryKey": false, - "notNull": true + "notNull": true, + "default": "now()" }, "two_factor_enabled": { "name": "two_factor_enabled", @@ -904,7 +905,8 @@ "name": "updated_at", "type": "timestamp", "primaryKey": false, - "notNull": true + "notNull": true, + "default": "now()" }, "role": { "name": "role", @@ -5151,6 +5153,12 @@ "primaryKey": false, "notNull": true }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, "roleId": { "name": "roleId", "type": "text", diff --git a/apps/dokploy/drizzle/meta/_journal.json b/apps/dokploy/drizzle/meta/_journal.json index 182d394dd..e2ba74d11 100644 --- a/apps/dokploy/drizzle/meta/_journal.json +++ b/apps/dokploy/drizzle/meta/_journal.json @@ -726,8 +726,8 @@ { "idx": 103, "version": "7", - "when": 1752387187927, - "tag": "0103_swift_christian_walker", + "when": 1752428260850, + "tag": "0103_brainy_nehzno", "breakpoints": true } ] diff --git a/apps/dokploy/server/api/routers/user.ts b/apps/dokploy/server/api/routers/user.ts index 35fcbf87b..30fed14c2 100644 --- a/apps/dokploy/server/api/routers/user.ts +++ b/apps/dokploy/server/api/routers/user.ts @@ -29,6 +29,7 @@ import { protectedProcedure, publicProcedure, } from "../trpc"; +import { sendEmail } from "@dokploy/server/verification/send-verification-email"; const apiCreateApiKey = z.object({ name: z.string().min(1), @@ -86,7 +87,10 @@ export const userRouter = createTRPCRouter({ // Allow access if: // 1. User is requesting their own information // 2. User has owner role (admin permissions) AND user is in the same organization - if (memberResult.userId !== ctx.user.id && ctx.user.role !== "owner") { + if ( + memberResult.userId !== ctx.user.id && + ctx.user.role?.name !== "owner" + ) { throw new TRPCError({ code: "UNAUTHORIZED", message: "You are not authorized to access this user", @@ -363,6 +367,83 @@ export const userRouter = createTRPCRouter({ return organizations.length; }), + createInvitation: adminProcedure + .input( + z.object({ + email: z.string().email(), + role: z.string(), + organizationId: z.string(), + notificationId: z.string(), + }), + ) + .mutation(async ({ input, ctx }) => { + const organization = await findOrganizationById(input.organizationId); + if (organization?.ownerId !== ctx.user.ownerId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not allowed to create invitations", + }); + } + const invitationResult = await db + .insert(invitation) + .values({ + email: input.email, + role: input.role, + organizationId: input.organizationId, + status: "pending", + // 24 hours + expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24), + inviterId: ctx.user.id, + }) + .returning() + .then(([invitation]) => invitation); + + const webServer = await findWebServer(); + + let host = ""; + + if (process.env.NODE_ENV === "development") { + host = "http://localhost:3000"; + } else { + host = webServer.host || ""; + } + + if (IS_CLOUD) { + host = "https://app.dokploy.com"; + } + + const inviteLink = `${host}/invitation?token=${invitationResult?.id}`; + if (IS_CLOUD) { + await sendEmail({ + email: invitationResult?.email || "", + subject: "Invitation to join organization", + text: ` +

You are invited to join ${organization?.name || "organization"} on Dokploy. Click the link to accept the invitation: Accept Invitation

+ `, + }); + } else if (input.notificationId) { + const notification = await findNotificationById(input.notificationId); + + const email = notification.email; + + if (!email) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Email notification not found", + }); + } + await sendEmailNotification( + { + ...email, + toAddresses: [invitationResult?.email || ""], + }, + "Invitation to join organization", + ` +

You are invited to join ${organization?.name || "organization"} on Dokploy. Click the link to accept the invitation: Accept Invitation

+ `, + ); + } + }), sendInvitation: adminProcedure .input( z.object({ diff --git a/apps/dokploy/server/api/trpc.ts b/apps/dokploy/server/api/trpc.ts index e89a71cf7..fa7e740b8 100644 --- a/apps/dokploy/server/api/trpc.ts +++ b/apps/dokploy/server/api/trpc.ts @@ -206,7 +206,6 @@ export const cliProcedure = t.procedure.use(({ ctx, next }) => { }); export const adminProcedure = t.procedure.use(({ ctx, next }) => { - console.log("adminProcedure", ctx.session, ctx.user); if ( !ctx.session || !ctx.user || diff --git a/packages/server/src/db/schema/account.ts b/packages/server/src/db/schema/account.ts index ee08d693c..e0e4066dd 100644 --- a/packages/server/src/db/schema/account.ts +++ b/packages/server/src/db/schema/account.ts @@ -92,6 +92,7 @@ export const member = pgTable("member", { userId: text("user_id") .notNull() .references(() => users.id, { onDelete: "cascade" }), + role: text("role"), roleId: text("roleId").references(() => role.roleId, { onDelete: "cascade" }), createdAt: timestamp("created_at").notNull(), teamId: text("team_id"), @@ -122,12 +123,14 @@ export const memberRelations = relations(member, ({ one }) => ({ })); export const invitation = pgTable("invitation", { - id: text("id").primaryKey(), + id: text("id") + .primaryKey() + .$defaultFn(() => nanoid()), organizationId: text("organization_id") .notNull() .references(() => organization.id, { onDelete: "cascade" }), email: text("email").notNull(), - role: text("role").$type<"owner" | "member" | "admin">(), + role: text("role"), status: text("status").notNull(), expiresAt: timestamp("expires_at").notNull(), inviterId: text("inviter_id") diff --git a/packages/server/src/db/schema/user.ts b/packages/server/src/db/schema/user.ts index aa452c96e..28e9abf94 100644 --- a/packages/server/src/db/schema/user.ts +++ b/packages/server/src/db/schema/user.ts @@ -32,10 +32,7 @@ export const users = pgTable("users", { expirationDate: text("expirationDate") .notNull() .$defaultFn(() => new Date().toISOString()), - createdAt: text("createdAt") - .notNull() - .$defaultFn(() => new Date().toISOString()), - // createdAt: timestamp("created_at").defaultNow(), + createdAt: timestamp("created_at").notNull().defaultNow(), // Auth twoFactorEnabled: boolean("two_factor_enabled"), email: text("email").notNull().unique(), @@ -44,7 +41,7 @@ export const users = pgTable("users", { banned: boolean("banned"), banReason: text("ban_reason"), banExpires: timestamp("ban_expires"), - updatedAt: timestamp("updated_at").notNull(), + updatedAt: timestamp("updated_at").notNull().defaultNow(), role: text("role").notNull().default("user"), // Metrics enablePaidFeatures: boolean("enablePaidFeatures").notNull().default(false), diff --git a/packages/server/src/lib/auth.ts b/packages/server/src/lib/auth.ts index b7662a7aa..276a816e0 100644 --- a/packages/server/src/lib/auth.ts +++ b/packages/server/src/lib/auth.ts @@ -135,6 +135,7 @@ const { handler, api } = betterAuth({ context?.request?.headers?.get("x-dokploy-token"); if (xDokployToken) { const user = await getUserByToken(xDokployToken); + if (!user) { throw new APIError("BAD_REQUEST", { message: "User not found", @@ -220,8 +221,6 @@ const { handler, api } = betterAuth({ }, }); - console.log(member); - return { data: { ...session, @@ -294,6 +293,7 @@ const { handler, api } = betterAuth({ export const auth = { handler, createApiKey: api.createApiKey, + createInvitation: api.createInvitation, }; export const validateRequest = async (request: IncomingMessage) => {