feat(users): add credentials-based user provisioning for self-hosted#4102
feat(users): add credentials-based user provisioning for self-hosted#4102rhulha wants to merge 1 commit intoDokploy:canaryfrom
Conversation
Allow organization owners/admins to add new users by setting an email and password directly, without requiring an email provider or invitation flow. The new "Credentials" method creates the user account and organization membership in a single transaction. The existing invitation flow is unchanged. The new method is self-hosted only — cloud instances are blocked at the API level.
| 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", | ||
| }); | ||
| } | ||
| } |
There was a problem hiding this comment.
Existing user silently falls through to duplicate-insert
When existingUser is 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 new user row with the same email. 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 existingUser is found but is not yet a member:
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",
});
}
// User exists globally but is not in this org — reject instead of
// attempting a duplicate insert that will fail with a DB error.
throw new TRPCError({
code: "CONFLICT",
message: "A user with this email already exists",
});
}| accountId: nanoid(), | ||
| providerId: "credential", | ||
| userId, | ||
| password: bcrypt.hashSync(input.password, 10), |
There was a problem hiding this comment.
Synchronous bcrypt blocks the event loop
bcrypt.hashSync is a CPU-bound, blocking call that will hold the Node.js event loop for the entire duration of hashing (which at cost factor 10 is typically 50–200 ms). Under any meaningful concurrency this will stall all other in-flight requests.
Use the async form instead:
| password: bcrypt.hashSync(input.password, 10), | |
| password: await bcrypt.hash(input.password, 10), |
| 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`, | ||
| }); | ||
| } | ||
| } |
There was a problem hiding this comment.
"owner" role can be assigned by an admin
The built-in role allow-list check (["owner", "admin", "member"].includes(input.role)) lets a caller pass "owner" as the role without any additional validation. Because this endpoint is gated by withPermission("member", "create"), which both admins and owners pass, an admin can use this endpoint to create a new user with the "owner" role — effectively escalating their privileges.
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",
});
}
What is this PR about?
Allow organization owners/admins to add new users by setting an email and password directly, without requiring an email provider or invitation flow. The new "Credentials" method creates the user account and organization membership in a single transaction.
The existing invitation flow is unchanged. The new method is self-hosted only — cloud instances are blocked at the API level.
Checklist
Before submitting this PR, please make sure that:
canarybranch.Issues related (if applicable)
#3686
closes #123
Greptile Summary
This PR adds a "Credentials" provisioning path so self-hosted org owners/admins can create new users directly with an email and password, bypassing the invitation/email flow. The frontend changes are clean and well-structured. The server-side mutation has one definite runtime bug and two lower-priority concerns.
existingUseris found but is not yet a member of the current org, the code falls through and tries to insert a newuserrow with the same email address, which will always throw a database unique-constraint violation. A guard to throw a descriptiveCONFLICTerror (or add the existing user as a member) is needed.bcrypt.hashSyncblocks the event loop: The synchronous hashing call can stall all in-flight requests for ~50–200 ms;bcrypt.hash(async) should be used instead.owner-role user, which is a privilege escalation path. Caller-role validation would mitigate this.Confidence Score: 3/5
Not safe to merge until the existing-user duplicate-insert bug is fixed; it will produce an unhandled DB error for any email already present in the system.
One confirmed P1 logic bug (existing global user silently falls through to a failing DB insert) must be resolved before this feature is usable. The P2 findings (sync bcrypt, admin→owner role escalation) are worth addressing but do not block the happy path.
apps/dokploy/server/api/routers/user.ts — the createUserWithCredentials mutation
Important Files Changed
Reviews (1): Last reviewed commit: "feat(users): add credentials-based user ..." | Re-trigger Greptile
(2/5) Greptile learns from your feedback when you react with thumbs up/down!