Merge pull request #2056 from Dokploy/1834-user-invite-email-not-sending

feat(invitation): add email provider selection and notification handl…
This commit is contained in:
Mauricio Siu
2025-06-21 21:10:38 +02:00
committed by GitHub
3 changed files with 127 additions and 1 deletions

View File

@@ -41,6 +41,7 @@ const addInvitation = z.object({
.min(1, "Email is required") .min(1, "Email is required")
.email({ message: "Invalid email" }), .email({ message: "Invalid email" }),
role: z.enum(["member", "admin"]), role: z.enum(["member", "admin"]),
notificationId: z.string().optional(),
}); });
type AddInvitation = z.infer<typeof addInvitation>; type AddInvitation = z.infer<typeof addInvitation>;
@@ -49,6 +50,10 @@ export const AddInvitation = () => {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const utils = api.useUtils(); const utils = api.useUtils();
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const { data: isCloud } = api.settings.isCloud.useQuery();
const { data: emailProviders } =
api.notification.getEmailProviders.useQuery();
const { mutateAsync: sendInvitation } = api.user.sendInvitation.useMutation();
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const { data: activeOrganization } = authClient.useActiveOrganization(); const { data: activeOrganization } = authClient.useActiveOrganization();
@@ -56,6 +61,7 @@ export const AddInvitation = () => {
defaultValues: { defaultValues: {
email: "", email: "",
role: "member", role: "member",
notificationId: "",
}, },
resolver: zodResolver(addInvitation), resolver: zodResolver(addInvitation),
}); });
@@ -74,7 +80,20 @@ export const AddInvitation = () => {
if (result.error) { if (result.error) {
setError(result.error.message || ""); setError(result.error.message || "");
} else { } else {
toast.success("Invitation created"); if (!isCloud && data.notificationId) {
await sendInvitation({
invitationId: result.data.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); setError(null);
setOpen(false); setOpen(false);
} }
@@ -149,6 +168,47 @@ export const AddInvitation = () => {
); );
}} }}
/> />
{!isCloud && (
<FormField
control={form.control}
name="notificationId"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Email Provider</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select an email provider" />
</SelectTrigger>
</FormControl>
<SelectContent>
{emailProviders?.map((provider) => (
<SelectItem
key={provider.notificationId}
value={provider.notificationId}
>
{provider.name}
</SelectItem>
))}
<SelectItem value="none" disabled>
None
</SelectItem>
</SelectContent>
</Select>
<FormDescription>
Select the email provider to send the invitation
</FormDescription>
<FormMessage />
</FormItem>
);
}}
/>
)}
<DialogFooter className="flex w-full flex-row"> <DialogFooter className="flex w-full flex-row">
<Button <Button
isLoading={isLoading} isLoading={isLoading}

View File

@@ -446,4 +446,12 @@ export const notificationRouter = createTRPCRouter({
}); });
} }
}), }),
getEmailProviders: adminProcedure.query(async ({ ctx }) => {
return await db.query.notifications.findMany({
where: eq(notifications.organizationId, ctx.session.activeOrganizationId),
with: {
email: true,
},
});
}),
}); });

View File

@@ -1,10 +1,13 @@
import { import {
IS_CLOUD, IS_CLOUD,
createApiKey, createApiKey,
findAdmin,
findNotificationById,
findOrganizationById, findOrganizationById,
findUserById, findUserById,
getUserByToken, getUserByToken,
removeUserById, removeUserById,
sendEmailNotification,
updateUser, updateUser,
} from "@dokploy/server"; } from "@dokploy/server";
import { db } from "@dokploy/server/db"; import { db } from "@dokploy/server/db";
@@ -362,4 +365,59 @@ export const userRouter = createTRPCRouter({
return organizations.length; return organizations.length;
}), }),
sendInvitation: adminProcedure
.input(
z.object({
invitationId: z.string().min(1),
notificationId: z.string().min(1),
}),
)
.mutation(async ({ input, ctx }) => {
if (IS_CLOUD) {
return;
}
const notification = await findNotificationById(input.notificationId);
const email = notification.email;
const currentInvitation = await db.query.invitation.findFirst({
where: eq(invitation.id, input.invitationId),
});
if (!email) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Email notification not found",
});
}
const admin = await findAdmin();
const host =
process.env.NODE_ENV === "development"
? "http://localhost:3000"
: admin.user.host;
const inviteLink = `${host}/invitation?token=${input.invitationId}`;
const organization = await findOrganizationById(
ctx.session.activeOrganizationId,
);
try {
await sendEmailNotification(
{
...email,
toAddresses: [currentInvitation?.email || ""],
},
"Invitation to join organization",
`
<p>You are invited to join ${organization?.name || "organization"} on Dokploy. Click the link to accept the invitation: <a href="${inviteLink}">Accept Invitation</a></p>
`,
);
} catch (error) {
console.log(error);
throw error;
}
return inviteLink;
}),
}); });