Skip to content

Commit 6c834a9

Browse files
authored
Merge pull request #3687 from mhbdev/invite-user-with-initial-credentials
feat: add credentials-based user provisioning alongside invitation flow
2 parents 31fdf69 + 2af420e commit 6c834a9

3 files changed

Lines changed: 331 additions & 34 deletions

File tree

apps/dokploy/components/dashboard/settings/users/add-invitation.tsx

Lines changed: 204 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -34,14 +34,63 @@ import {
3434
} from "@/components/ui/select";
3535
import { api } from "@/utils/api";
3636

37-
const addInvitation = z.object({
38-
email: z
39-
.string()
40-
.min(1, "Email is required")
41-
.email({ message: "Invalid email" }),
42-
role: z.string().min(1, "Role is required"),
43-
notificationId: z.string().optional(),
44-
});
37+
const addInvitation = z
38+
.object({
39+
mode: z.enum(["invitation", "credentials"]),
40+
email: z
41+
.string()
42+
.min(1, "Email is required")
43+
.email({ message: "Invalid email" }),
44+
role: z.string().min(1, "Role is required"),
45+
notificationId: z.string().optional(),
46+
password: z.string().optional(),
47+
confirmPassword: z.string().optional(),
48+
})
49+
.superRefine((value, ctx) => {
50+
if (value.mode !== "credentials") {
51+
return;
52+
}
53+
54+
if (!value.password) {
55+
ctx.addIssue({
56+
code: z.ZodIssueCode.custom,
57+
message: "Password is required",
58+
path: ["password"],
59+
});
60+
} else if (value.password.length < 8) {
61+
ctx.addIssue({
62+
code: z.ZodIssueCode.custom,
63+
message: "Password must be at least 8 characters",
64+
path: ["password"],
65+
});
66+
}
67+
68+
if (!value.confirmPassword) {
69+
ctx.addIssue({
70+
code: z.ZodIssueCode.custom,
71+
message: "Confirm password is required",
72+
path: ["confirmPassword"],
73+
});
74+
} else if (value.confirmPassword.length < 8) {
75+
ctx.addIssue({
76+
code: z.ZodIssueCode.custom,
77+
message: "Password must be at least 8 characters",
78+
path: ["confirmPassword"],
79+
});
80+
}
81+
82+
if (
83+
value.password &&
84+
value.confirmPassword &&
85+
value.password !== value.confirmPassword
86+
) {
87+
ctx.addIssue({
88+
code: z.ZodIssueCode.custom,
89+
message: "Passwords do not match",
90+
path: ["confirmPassword"],
91+
});
92+
}
93+
});
4594

4695
type AddInvitation = z.infer<typeof addInvitation>;
4796

@@ -54,50 +103,83 @@ export const AddInvitation = () => {
54103
const { mutateAsync: inviteMember, isPending: isInviting } =
55104
api.organization.inviteMember.useMutation();
56105
const { mutateAsync: sendInvitation } = api.user.sendInvitation.useMutation();
106+
const { mutateAsync: createUserWithCredentials, isPending: isCreating } =
107+
api.user.createUserWithCredentials.useMutation();
57108
const { data: customRoles } = api.customRole.all.useQuery();
58109
const [error, setError] = useState<string | null>(null);
59110

60111
const form = useForm<AddInvitation>({
61112
defaultValues: {
113+
mode: "invitation",
62114
email: "",
63115
role: "member",
64116
notificationId: "",
117+
password: "",
118+
confirmPassword: "",
65119
},
66120
resolver: zodResolver(addInvitation),
67121
});
122+
123+
const mode = form.watch("mode");
124+
68125
useEffect(() => {
69126
form.reset();
70127
}, [form, form.formState.isSubmitSuccessful, form.reset]);
71128

129+
useEffect(() => {
130+
if (isCloud && form.getValues("mode") === "credentials") {
131+
form.setValue("mode", "invitation");
132+
}
133+
}, [form, isCloud]);
134+
72135
const onSubmit = async (data: AddInvitation) => {
136+
setError(null);
137+
73138
try {
74-
const result = await inviteMember({
75-
email: data.email.toLowerCase(),
76-
role: data.role,
77-
});
139+
if (data.mode === "credentials") {
140+
await createUserWithCredentials({
141+
email: data.email.toLowerCase(),
142+
password: data.password!,
143+
role: data.role,
144+
});
145+
toast.success("User created with initial credentials");
146+
setOpen(false);
147+
} else {
148+
const result = await inviteMember({
149+
email: data.email.toLowerCase(),
150+
role: data.role,
151+
});
78152

79-
if (!isCloud && data.notificationId) {
80-
await sendInvitation({
81-
invitationId: result!.id,
82-
notificationId: data.notificationId || "",
83-
})
84-
.then(() => {
85-
toast.success("Invitation created and email sent");
153+
if (!isCloud && data.notificationId) {
154+
await sendInvitation({
155+
invitationId: result!.id,
156+
notificationId: data.notificationId || "",
86157
})
87-
.catch((error: any) => {
88-
toast.error(error.message);
89-
});
90-
} else {
91-
toast.success("Invitation created");
158+
.then(() => {
159+
toast.success("Invitation created and email sent");
160+
})
161+
.catch((error: any) => {
162+
toast.error(error.message);
163+
});
164+
} else {
165+
toast.success("Invitation created");
166+
}
167+
168+
setOpen(false);
92169
}
93-
setError(null);
94-
setOpen(false);
95-
} catch (error: any) {
96-
setError(error.message || "Failed to create invitation");
170+
} catch (error) {
171+
const message =
172+
error instanceof Error ? error.message : "Failed to create user";
173+
setError(message);
174+
toast.error(message);
175+
} finally {
176+
await Promise.all([
177+
utils.organization.allInvitations.invalidate(),
178+
utils.user.all.invalidate(),
179+
]);
97180
}
98-
99-
utils.organization.allInvitations.invalidate();
100181
};
182+
101183
return (
102184
<Dialog open={open} onOpenChange={setOpen}>
103185
<DialogTrigger className="" asChild>
@@ -108,7 +190,11 @@ export const AddInvitation = () => {
108190
<DialogContent className="sm:max-w-2xl">
109191
<DialogHeader>
110192
<DialogTitle>Add Invitation</DialogTitle>
111-
<DialogDescription>Invite a new user</DialogDescription>
193+
<DialogDescription>
194+
{mode === "credentials"
195+
? "Create a user with initial credentials"
196+
: "Invite a new user"}
197+
</DialogDescription>
112198
</DialogHeader>
113199
{error && <AlertBlock type="error">{error}</AlertBlock>}
114200

@@ -118,6 +204,43 @@ export const AddInvitation = () => {
118204
onSubmit={form.handleSubmit(onSubmit)}
119205
className="grid w-full gap-4 "
120206
>
207+
{!isCloud && (
208+
<FormField
209+
control={form.control}
210+
name="mode"
211+
render={({ field }) => {
212+
return (
213+
<FormItem>
214+
<FormLabel>Invite Method</FormLabel>
215+
<Select
216+
onValueChange={field.onChange}
217+
defaultValue={field.value}
218+
>
219+
<FormControl>
220+
<SelectTrigger>
221+
<SelectValue placeholder="Select invite method" />
222+
</SelectTrigger>
223+
</FormControl>
224+
<SelectContent>
225+
<SelectItem value="invitation">
226+
Invitation Link
227+
</SelectItem>
228+
<SelectItem value="credentials">
229+
Initial Credentials
230+
</SelectItem>
231+
</SelectContent>
232+
</Select>
233+
<FormDescription>
234+
Choose between invitation link flow or direct
235+
credentials provisioning
236+
</FormDescription>
237+
<FormMessage />
238+
</FormItem>
239+
);
240+
}}
241+
/>
242+
)}
243+
121244
<FormField
122245
control={form.control}
123246
name="email"
@@ -172,7 +295,7 @@ export const AddInvitation = () => {
172295
}}
173296
/>
174297

175-
{!isCloud && (
298+
{!isCloud && mode === "invitation" && (
176299
<FormField
177300
control={form.control}
178301
name="notificationId"
@@ -212,9 +335,57 @@ export const AddInvitation = () => {
212335
}}
213336
/>
214337
)}
338+
339+
{!isCloud && mode === "credentials" && (
340+
<>
341+
<FormField
342+
control={form.control}
343+
name="password"
344+
render={({ field }) => {
345+
return (
346+
<FormItem>
347+
<FormLabel>Password</FormLabel>
348+
<FormControl>
349+
<Input
350+
type="password"
351+
placeholder="Enter initial password"
352+
{...field}
353+
/>
354+
</FormControl>
355+
<FormDescription>
356+
The user can sign in with this password immediately
357+
</FormDescription>
358+
<FormMessage />
359+
</FormItem>
360+
);
361+
}}
362+
/>
363+
364+
<FormField
365+
control={form.control}
366+
name="confirmPassword"
367+
render={({ field }) => {
368+
return (
369+
<FormItem>
370+
<FormLabel>Confirm Password</FormLabel>
371+
<FormControl>
372+
<Input
373+
type="password"
374+
placeholder="Confirm initial password"
375+
{...field}
376+
/>
377+
</FormControl>
378+
<FormMessage />
379+
</FormItem>
380+
);
381+
}}
382+
/>
383+
</>
384+
)}
385+
215386
<DialogFooter className="flex w-full flex-row">
216387
<Button
217-
isLoading={isInviting}
388+
isLoading={isInviting || isCreating}
218389
form="hook-form-add-invitation"
219390
type="submit"
220391
>

apps/dokploy/server/api/routers/user.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import {
22
createApiKey,
3+
createOrganizationUserWithCredentials,
34
findNotificationById,
45
findOrganizationById,
56
findUserById,
@@ -565,6 +566,37 @@ export const userRouter = createTRPCRouter({
565566

566567
return organizations.length;
567568
}),
569+
createUserWithCredentials: withPermission("member", "create")
570+
.input(
571+
z.object({
572+
email: z.string().email(),
573+
password: z.string().min(8),
574+
role: z.string().min(1),
575+
}),
576+
)
577+
.mutation(async ({ input, ctx }) => {
578+
if (IS_CLOUD) {
579+
throw new TRPCError({
580+
code: "FORBIDDEN",
581+
message:
582+
"Creating users with initial credentials is only available in self-hosted mode",
583+
});
584+
}
585+
586+
if (!ctx.session.activeOrganizationId) {
587+
throw new TRPCError({
588+
code: "BAD_REQUEST",
589+
message: "Active organization is required",
590+
});
591+
}
592+
593+
return await createOrganizationUserWithCredentials({
594+
organizationId: ctx.session.activeOrganizationId,
595+
email: input.email,
596+
password: input.password,
597+
role: input.role,
598+
});
599+
}),
568600
sendInvitation: withPermission("member", "create")
569601
.input(
570602
z.object({

0 commit comments

Comments
 (0)