Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
183 changes: 150 additions & 33 deletions apps/dokploy/components/dashboard/settings/users/add-invitation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,14 +34,36 @@ import {
} from "@/components/ui/select";
import { api } from "@/utils/api";

const addInvitation = z.object({
email: z
.string()
.min(1, "Email is required")
.email({ message: "Invalid email" }),
role: z.string().min(1, "Role is required"),
notificationId: z.string().optional(),
});
const addInvitation = z
.object({
mode: z.enum(["invitation", "credentials"]),
email: z
.string()
.min(1, "Email is required")
.email({ message: "Invalid email" }),
role: z.string().min(1, "Role is required"),
notificationId: z.string().optional(),
password: z.string().optional(),
confirmPassword: z.string().optional(),
})
.superRefine((data, ctx) => {
if (data.mode === "credentials") {
if (!data.password || data.password.length < 8) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Password must be at least 8 characters",
path: ["password"],
});
}
if (data.password !== data.confirmPassword) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Passwords do not match",
path: ["confirmPassword"],
});
}
}
});

type AddInvitation = z.infer<typeof addInvitation>;

Expand All @@ -54,61 +76,83 @@ export const AddInvitation = () => {
const { mutateAsync: inviteMember, isPending: isInviting } =
api.organization.inviteMember.useMutation();
const { mutateAsync: sendInvitation } = api.user.sendInvitation.useMutation();
const {
mutateAsync: createUserWithCredentials,
isPending: isCreatingWithCredentials,
} = api.user.createUserWithCredentials.useMutation();
const { data: customRoles } = api.customRole.all.useQuery();
const [error, setError] = useState<string | null>(null);

const form = useForm<AddInvitation>({
defaultValues: {
mode: "invitation",
email: "",
role: "member",
notificationId: "",
password: "",
confirmPassword: "",
},
resolver: zodResolver(addInvitation),
});

const mode = form.watch("mode");
useEffect(() => {
form.reset();
}, [form, form.formState.isSubmitSuccessful, form.reset]);

const onSubmit = async (data: AddInvitation) => {
try {
const result = await inviteMember({
email: data.email.toLowerCase(),
role: data.role,
});
if (data.mode === "credentials") {
await createUserWithCredentials({
email: data.email.toLowerCase(),
password: data.password!,
role: data.role,
});
toast.success("User created successfully");
utils.user.all.invalidate();
} else {
const result = await inviteMember({
email: data.email.toLowerCase(),
role: data.role,
});

if (!isCloud && data.notificationId) {
await sendInvitation({
invitationId: result!.id,
notificationId: data.notificationId || "",
})
.then(() => {
toast.success("Invitation created and email sent");
if (!isCloud && data.notificationId) {
await sendInvitation({
invitationId: result!.id,
notificationId: data.notificationId || "",
})
.catch((error: any) => {
toast.error(error.message);
});
} else {
toast.success("Invitation created");
.then(() => {
toast.success("Invitation created and email sent");
})
.catch((error: any) => {
toast.error(error.message);
});
} else {
toast.success("Invitation created");
}
utils.organization.allInvitations.invalidate();
}
setError(null);
setOpen(false);
} catch (error: any) {
setError(error.message || "Failed to create invitation");
setError(error.message || "Failed to create user");
}

utils.organization.allInvitations.invalidate();
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger className="" asChild>
<Button>
<PlusIcon className="h-4 w-4" /> Add Invitation
<PlusIcon className="h-4 w-4" /> Add User
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>Add Invitation</DialogTitle>
<DialogDescription>Invite a new user</DialogDescription>
<DialogTitle>Add User</DialogTitle>
<DialogDescription>
{mode === "credentials"
? "Create a new user with email and password"
: "Invite a new user via email"}
</DialogDescription>
</DialogHeader>
{error && <AlertBlock type="error">{error}</AlertBlock>}

Expand All @@ -118,6 +162,37 @@ export const AddInvitation = () => {
onSubmit={form.handleSubmit(onSubmit)}
className="grid w-full gap-4 "
>
{!isCloud && (
<FormField
control={form.control}
name="mode"
render={({ field }) => (
<FormItem>
<FormLabel>Method</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select method" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="invitation">
Invitation (send email)
</SelectItem>
<SelectItem value="credentials">
Credentials (set password directly)
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
)}

<FormField
control={form.control}
name="email"
Expand Down Expand Up @@ -172,7 +247,49 @@ export const AddInvitation = () => {
}}
/>

{!isCloud && (
{mode === "credentials" && (
<>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input
type="password"
placeholder="Password"
{...field}
/>
</FormControl>
<FormDescription>
Minimum 8 characters
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="confirmPassword"
render={({ field }) => (
<FormItem>
<FormLabel>Confirm Password</FormLabel>
<FormControl>
<Input
type="password"
placeholder="Confirm Password"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</>
)}

{!isCloud && mode === "invitation" && (
<FormField
control={form.control}
name="notificationId"
Expand Down Expand Up @@ -214,11 +331,11 @@ export const AddInvitation = () => {
)}
<DialogFooter className="flex w-full flex-row">
<Button
isLoading={isInviting}
isLoading={isInviting || isCreatingWithCredentials}
form="hook-form-add-invitation"
type="submit"
>
Create
{mode === "credentials" ? "Create User" : "Send Invitation"}
</Button>
</DialogFooter>
</form>
Expand Down
96 changes: 96 additions & 0 deletions apps/dokploy/server/api/routers/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,10 @@ import {
apiUpdateUser,
invitation,
member,
organizationRole,
user,
} from "@dokploy/server/db/schema";
import { nanoid } from "nanoid";
import {
hasPermission,
resolvePermissions,
Expand Down Expand Up @@ -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",
});
}
}
Comment on lines +586 to +600
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",
    });
}


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


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

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({
Expand Down