mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-06-15 20:25:23 +02:00
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:
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user