Merge pull request #3687 from mhbdev/invite-user-with-initial-credentials

feat: add credentials-based user provisioning alongside invitation flow
This commit is contained in:
Mauricio Siu
2026-04-04 22:41:17 -06:00
committed by GitHub
3 changed files with 335 additions and 38 deletions

View File

@@ -34,14 +34,63 @@ 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((value, ctx) => {
if (value.mode !== "credentials") {
return;
}
if (!value.password) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Password is required",
path: ["password"],
});
} else if (value.password.length < 8) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Password must be at least 8 characters",
path: ["password"],
});
}
if (!value.confirmPassword) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Confirm password is required",
path: ["confirmPassword"],
});
} else if (value.confirmPassword.length < 8) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Password must be at least 8 characters",
path: ["confirmPassword"],
});
}
if (
value.password &&
value.confirmPassword &&
value.password !== value.confirmPassword
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Passwords do not match",
path: ["confirmPassword"],
});
}
});
type AddInvitation = z.infer<typeof addInvitation>;
@@ -54,50 +103,83 @@ export const AddInvitation = () => {
const { mutateAsync: inviteMember, isPending: isInviting } =
api.organization.inviteMember.useMutation();
const { mutateAsync: sendInvitation } = api.user.sendInvitation.useMutation();
const { mutateAsync: createUserWithCredentials, isPending: isCreating } =
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 (!isCloud && data.notificationId) {
await sendInvitation({
invitationId: result!.id,
notificationId: data.notificationId || "",
})
.then(() => {
toast.success("Invitation created and email sent");
})
.catch((error: any) => {
toast.error(error.message);
});
} else {
toast.success("Invitation created");
}
setError(null);
setOpen(false);
} catch (error: any) {
setError(error.message || "Failed to create invitation");
useEffect(() => {
if (isCloud && form.getValues("mode") === "credentials") {
form.setValue("mode", "invitation");
}
}, [form, isCloud]);
utils.organization.allInvitations.invalidate();
const onSubmit = async (data: AddInvitation) => {
setError(null);
try {
if (data.mode === "credentials") {
await createUserWithCredentials({
email: data.email.toLowerCase(),
password: data.password!,
role: data.role,
});
toast.success("User created with initial credentials");
setOpen(false);
} 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");
})
.catch((error: any) => {
toast.error(error.message);
});
} else {
toast.success("Invitation created");
}
setOpen(false);
}
} catch (error) {
const message =
error instanceof Error ? error.message : "Failed to create user";
setError(message);
toast.error(message);
} finally {
await Promise.all([
utils.organization.allInvitations.invalidate(),
utils.user.all.invalidate(),
]);
}
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger className="" asChild>
@@ -108,7 +190,11 @@ export const AddInvitation = () => {
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>Add Invitation</DialogTitle>
<DialogDescription>Invite a new user</DialogDescription>
<DialogDescription>
{mode === "credentials"
? "Create a user with initial credentials"
: "Invite a new user"}
</DialogDescription>
</DialogHeader>
{error && <AlertBlock type="error">{error}</AlertBlock>}
@@ -118,6 +204,43 @@ export const AddInvitation = () => {
onSubmit={form.handleSubmit(onSubmit)}
className="grid w-full gap-4 "
>
{!isCloud && (
<FormField
control={form.control}
name="mode"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Invite Method</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select invite method" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="invitation">
Invitation Link
</SelectItem>
<SelectItem value="credentials">
Initial Credentials
</SelectItem>
</SelectContent>
</Select>
<FormDescription>
Choose between invitation link flow or direct
credentials provisioning
</FormDescription>
<FormMessage />
</FormItem>
);
}}
/>
)}
<FormField
control={form.control}
name="email"
@@ -172,7 +295,7 @@ export const AddInvitation = () => {
}}
/>
{!isCloud && (
{!isCloud && mode === "invitation" && (
<FormField
control={form.control}
name="notificationId"
@@ -212,9 +335,57 @@ export const AddInvitation = () => {
}}
/>
)}
{!isCloud && mode === "credentials" && (
<>
<FormField
control={form.control}
name="password"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input
type="password"
placeholder="Enter initial password"
{...field}
/>
</FormControl>
<FormDescription>
The user can sign in with this password immediately
</FormDescription>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="confirmPassword"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Confirm Password</FormLabel>
<FormControl>
<Input
type="password"
placeholder="Confirm initial password"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
</>
)}
<DialogFooter className="flex w-full flex-row">
<Button
isLoading={isInviting}
isLoading={isInviting || isCreating}
form="hook-form-add-invitation"
type="submit"
>

View File

@@ -1,5 +1,6 @@
import {
createApiKey,
createOrganizationUserWithCredentials,
findNotificationById,
findOrganizationById,
findUserById,
@@ -565,6 +566,37 @@ export const userRouter = createTRPCRouter({
return organizations.length;
}),
createUserWithCredentials: withPermission("member", "create")
.input(
z.object({
email: z.string().email(),
password: z.string().min(8),
role: z.string().min(1),
}),
)
.mutation(async ({ input, ctx }) => {
if (IS_CLOUD) {
throw new TRPCError({
code: "FORBIDDEN",
message:
"Creating users with initial credentials is only available in self-hosted mode",
});
}
if (!ctx.session.activeOrganizationId) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Active organization is required",
});
}
return await createOrganizationUserWithCredentials({
organizationId: ctx.session.activeOrganizationId,
email: input.email,
password: input.password,
role: input.role,
});
}),
sendInvitation: withPermission("member", "create")
.input(
z.object({

View File

@@ -1,6 +1,13 @@
import { db } from "@dokploy/server/db";
import { apikey, member, user } from "@dokploy/server/db/schema";
import {
account,
apikey,
invitation,
member,
user,
} from "@dokploy/server/db/schema";
import { TRPCError } from "@trpc/server";
import * as bcrypt from "bcrypt";
import { and, eq } from "drizzle-orm";
import { auth } from "../lib/auth";
@@ -389,6 +396,93 @@ export const findMemberById = async (
return result;
};
export const createOrganizationUserWithCredentials = async ({
organizationId,
email,
password,
role,
}: {
organizationId: string;
email: string;
password: string;
role: string;
}) => {
const normalizedEmail = email.trim().toLowerCase();
const now = new Date();
return await db.transaction(async (tx) => {
const existingUser = await tx.query.user.findFirst({
where: eq(user.email, normalizedEmail),
columns: {
id: true,
},
});
if (existingUser) {
throw new TRPCError({
code: "BAD_REQUEST",
message:
"This email already has an account. Use the invitation link flow for existing users.",
});
}
const createdUser = await tx
.insert(user)
.values({
email: normalizedEmail,
emailVerified: false,
updatedAt: now,
})
.returning({
id: user.id,
email: user.email,
})
.then((res) => res[0]);
if (!createdUser) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to create user",
});
}
await tx.insert(account).values({
userId: createdUser.id,
providerId: "credential",
password: bcrypt.hashSync(password, 10),
createdAt: now,
updatedAt: now,
});
await tx.insert(member).values({
organizationId,
userId: createdUser.id,
role,
createdAt: now,
isDefault: true,
});
await tx
.update(invitation)
.set({
status: "canceled",
})
.where(
and(
eq(invitation.organizationId, organizationId),
eq(invitation.email, normalizedEmail),
eq(invitation.status, "pending"),
),
);
return {
userId: createdUser.id,
email: createdUser.email,
role,
};
});
};
export const updateUser = async (userId: string, userData: Partial<User>) => {
// Validate email if it's being updated
if (userData.email !== undefined) {