-
-
Notifications
You must be signed in to change notification settings - Fork 2.4k
feat(users): add credentials-based user provisioning for self-hosted #4102
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: canary
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -21,7 +21,10 @@ import { | |||||
| apiUpdateUser, | ||||||
| invitation, | ||||||
| member, | ||||||
| organizationRole, | ||||||
| user, | ||||||
| } from "@dokploy/server/db/schema"; | ||||||
| import { nanoid } from "nanoid"; | ||||||
| import { | ||||||
| hasPermission, | ||||||
| resolvePermissions, | ||||||
|
|
@@ -556,6 +559,99 @@ export const userRouter = createTRPCRouter({ | |||||
|
|
||||||
| return organizations.length; | ||||||
| }), | ||||||
| createUserWithCredentials: withPermission("member", "create") | ||||||
| .input( | ||||||
| z.object({ | ||||||
| email: z.string().email(), | ||||||
| password: z.string().min(8, "Password must be at least 8 characters"), | ||||||
| role: z.string().min(1, "Role is required"), | ||||||
| }), | ||||||
| ) | ||||||
| .mutation(async ({ input, ctx }) => { | ||||||
| if (IS_CLOUD) { | ||||||
| throw new TRPCError({ | ||||||
| code: "FORBIDDEN", | ||||||
| message: | ||||||
| "This feature is only available for self-hosted instances", | ||||||
| }); | ||||||
| } | ||||||
|
|
||||||
| const orgId = ctx.session.activeOrganizationId; | ||||||
| const email = input.email.toLowerCase(); | ||||||
|
|
||||||
| const existingUser = await db.query.user.findFirst({ | ||||||
| where: eq(user.email, email), | ||||||
| }); | ||||||
|
|
||||||
| if (existingUser) { | ||||||
| const existingMember = await db.query.member.findFirst({ | ||||||
| where: and( | ||||||
| eq(member.organizationId, orgId), | ||||||
| eq(member.userId, existingUser.id), | ||||||
| ), | ||||||
| }); | ||||||
|
|
||||||
| if (existingMember) { | ||||||
| throw new TRPCError({ | ||||||
| code: "CONFLICT", | ||||||
| message: "User is already a member of this organization", | ||||||
| }); | ||||||
| } | ||||||
| } | ||||||
|
|
||||||
| if (!["owner", "admin", "member"].includes(input.role)) { | ||||||
| const customRole = await db.query.organizationRole.findFirst({ | ||||||
| where: and( | ||||||
| eq(organizationRole.organizationId, orgId), | ||||||
| eq(organizationRole.role, input.role), | ||||||
| ), | ||||||
| }); | ||||||
|
|
||||||
| if (!customRole) { | ||||||
| throw new TRPCError({ | ||||||
| code: "NOT_FOUND", | ||||||
| message: `Role "${input.role}" not found`, | ||||||
| }); | ||||||
| } | ||||||
| } | ||||||
|
Comment on lines
+602
to
+616
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The built-in role allow-list check ( Consider explicitly forbidding role assignments that exceed the caller's own role (e.g. an admin cannot assign "owner"): const callerRole = ctx.user.role; // "owner" | "admin" | "member"
if (input.role === "owner" && callerRole !== "owner") {
throw new TRPCError({
code: "FORBIDDEN",
message: "Only owners can create users with the owner role",
});
} |
||||||
|
|
||||||
| const now = new Date(); | ||||||
| const userId = nanoid(); | ||||||
|
|
||||||
| await db.transaction(async (tx) => { | ||||||
| await tx.insert(user).values({ | ||||||
| id: userId, | ||||||
| email, | ||||||
| emailVerified: true, | ||||||
| updatedAt: now, | ||||||
| }); | ||||||
|
|
||||||
| await tx.insert(account).values({ | ||||||
| accountId: nanoid(), | ||||||
| providerId: "credential", | ||||||
| userId, | ||||||
| password: bcrypt.hashSync(input.password, 10), | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Use the async form instead:
Suggested change
|
||||||
| createdAt: now, | ||||||
| updatedAt: now, | ||||||
| }); | ||||||
|
|
||||||
| await tx.insert(member).values({ | ||||||
| userId, | ||||||
| organizationId: orgId, | ||||||
| role: input.role as any, | ||||||
| createdAt: now, | ||||||
| }); | ||||||
| }); | ||||||
|
|
||||||
| await audit(ctx, { | ||||||
| action: "create", | ||||||
| resourceType: "user", | ||||||
| resourceId: userId, | ||||||
| resourceName: email, | ||||||
| metadata: { type: "createUserWithCredentials", role: input.role }, | ||||||
| }); | ||||||
| }), | ||||||
|
|
||||||
| sendInvitation: withPermission("member", "create") | ||||||
| .input( | ||||||
| z.object({ | ||||||
|
|
||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When
existingUseris found but they are not yet a member of the current organization, the code does nothing and falls through to the transaction that inserts a newuserrow with the sameemail. This will always throw a database unique-constraint violation (on the email column) rather than a friendly error message.The intent is ambiguous: should the endpoint add the pre-existing user to the org, or simply reject the request? Either way, the current code produces an unhandled DB error for any email address that already exists in the system (even if from a completely different organization).
A minimal fix is to throw a clear TRPC error when
existingUseris found but is not yet a member: