Skip to content

feat(users): add credentials-based user provisioning for self-hosted#4102

Open
rhulha wants to merge 1 commit intoDokploy:canaryfrom
rhulha:feat/credentials-user-provisioning
Open

feat(users): add credentials-based user provisioning for self-hosted#4102
rhulha wants to merge 1 commit intoDokploy:canaryfrom
rhulha:feat/credentials-user-provisioning

Conversation

@rhulha
Copy link
Copy Markdown

@rhulha rhulha commented Mar 29, 2026

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:

  • You created a dedicated branch based on the canary branch.
  • You have read the suggestions in the CONTRIBUTING.md file https://github.com/Dokploy/dokploy/blob/canary/CONTRIBUTING.md#pull-request
  • You have tested this PR in your local instance. If you have not tested it yet, please do so before submitting. This helps avoid wasting maintainers' time reviewing code that has not been verified by you.

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.

  • P1 — existing user causes a raw DB error: When existingUser is found but is not yet a member of the current org, the code falls through and tries to insert a new user row with the same email address, which will always throw a database unique-constraint violation. A guard to throw a descriptive CONFLICT error (or add the existing user as a member) is needed.
  • P2 — bcrypt.hashSync blocks the event loop: The synchronous hashing call can stall all in-flight requests for ~50–200 ms; bcrypt.hash (async) should be used instead.
  • P2 — admin can assign the "owner" role: The built-in role allow-list does not prevent an admin from creating a new 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

Filename Overview
apps/dokploy/server/api/routers/user.ts Adds createUserWithCredentials mutation with a P1 bug: when a user with the given email already exists globally (but not in the current org) the duplicate-insert will fail with a raw DB unique-constraint error. Also uses bcrypt.hashSync (blocks the event loop) and allows admins to assign the owner role.
apps/dokploy/components/dashboard/settings/users/add-invitation.tsx Extends the invitation dialog with a new Credentials mode (email + password) hidden on cloud. Zod validation, form reset, and loading state are all handled correctly. No issues found.

Reviews (1): Last reviewed commit: "feat(users): add credentials-based user ..." | Re-trigger Greptile

Greptile also left 3 inline comments on this PR.

(2/5) Greptile learns from your feedback when you react with thumbs up/down!

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.
@rhulha rhulha requested a review from Siumauricio as a code owner March 29, 2026 20:21
@dosubot dosubot bot added size:L This PR changes 100-499 lines, ignoring generated files. enhancement New feature or request labels Mar 29, 2026
Comment on lines +586 to +600
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",
});
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 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),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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:

Suggested change
password: bcrypt.hashSync(input.password, 10),
password: await bcrypt.hash(input.password, 10),

Comment on lines +602 to +616
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`,
});
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 "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",
    });
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request size:L This PR changes 100-499 lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant