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.
This commit is contained in:
Mauricio Siu
2025-07-13 11:44:46 -06:00
parent a43b8ee2d2
commit a5911e2bac
9 changed files with 127 additions and 52 deletions

View File

@@ -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<string | null>(null);
const {
mutateAsync: createInvitation,
isLoading,
error,
} = api.user.createInvitation.useMutation();
const { data: activeOrganization } = authClient.useActiveOrganization();
const form = useForm<AddInvitation>({
@@ -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 (
<Dialog open={open} onOpenChange={setOpen}>
@@ -114,7 +100,7 @@ export const AddInvitation = () => {
<DialogTitle>Add Invitation</DialogTitle>
<DialogDescription>Invite a new user</DialogDescription>
</DialogHeader>
{error && <AlertBlock type="error">{error}</AlertBlock>}
{error && <AlertBlock type="error">{error.message}</AlertBlock>}
<Form {...form}>
<form

View File

@@ -64,7 +64,6 @@ BEGIN
END $$;
--> 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

View File

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

View File

@@ -726,8 +726,8 @@
{
"idx": 103,
"version": "7",
"when": 1752387187927,
"tag": "0103_swift_christian_walker",
"when": 1752428260850,
"tag": "0103_brainy_nehzno",
"breakpoints": true
}
]

View File

@@ -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: `
<p>You are invited to join ${organization?.name || "organization"} on Dokploy. Click the link to accept the invitation: <a href="${inviteLink}">Accept Invitation</a></p>
`,
});
} 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",
`
<p>You are invited to join ${organization?.name || "organization"} on Dokploy. Click the link to accept the invitation: <a href="${inviteLink}">Accept Invitation</a></p>
`,
);
}
}),
sendInvitation: adminProcedure
.input(
z.object({

View File

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

View File

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

View File

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

View File

@@ -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) => {