diff --git a/app/menus/UserMenu.tsx b/app/menus/UserMenu.tsx index f46361aec..9a1d73d11 100644 --- a/app/menus/UserMenu.tsx +++ b/app/menus/UserMenu.tsx @@ -8,6 +8,7 @@ import OverflowMenuButton from "~/components/ContextMenu/OverflowMenuButton"; import Template from "~/components/ContextMenu/Template"; import usePolicy from "~/hooks/usePolicy"; import useStores from "~/hooks/useStores"; +import useToasts from "~/hooks/useToasts"; type Props = { user: User; @@ -20,6 +21,7 @@ function UserMenu({ user }: Props) { modal: true, }); const can = usePolicy(user.id); + const { showToast } = useToasts(); const handlePromote = React.useCallback( (ev: React.SyntheticEvent) => { @@ -113,6 +115,22 @@ function UserMenu({ user }: Props) { [users, user] ); + const handleResendInvite = React.useCallback( + async (ev: React.SyntheticEvent) => { + ev.preventDefault(); + + try { + await users.resendInvite(user); + showToast(t(`Invite was resent to ${user.name}`), { type: "success" }); + } catch (err) { + showToast(t(`An error occurred while sending the invite`), { + type: "error", + }); + } + }, + [users, user, t, showToast] + ); + const handleActivate = React.useCallback( (ev: React.SyntheticEvent) => { ev.preventDefault(); @@ -152,6 +170,12 @@ function UserMenu({ user }: Props) { onClick: handlePromote, visible: can.promote && user.role !== "admin", }, + { + type: "button", + title: t("Resend invite"), + onClick: handleResendInvite, + visible: can.resendInvite, + }, { type: "separator", }, diff --git a/app/stores/UsersStore.ts b/app/stores/UsersStore.ts index 55efe98bb..a7248b557 100644 --- a/app/stores/UsersStore.ts +++ b/app/stores/UsersStore.ts @@ -135,6 +135,13 @@ export default class UsersStore extends BaseStore { return res.data; }; + @action + resendInvite = async (user: User) => { + return client.post(`/users.resendInvite`, { + id: user.id, + }); + }; + @action fetchCounts = async (teamId: string): Promise => { const res = await client.post(`/users.count`, { diff --git a/server/policies/user.ts b/server/policies/user.ts index 4d88df50f..d7a416e0e 100644 --- a/server/policies/user.ts +++ b/server/policies/user.ts @@ -80,6 +80,20 @@ allow(User, "promote", User, (actor, user) => { throw AdminRequiredError(); }); +allow(User, "resendInvite", User, (actor, user) => { + if (!user || user.teamId !== actor.teamId) { + return false; + } + if (!user.isInvited) { + return false; + } + if (actor.isAdmin) { + return true; + } + + throw AdminRequiredError(); +}); + allow(User, "demote", User, (actor, user) => { if (!user || user.teamId !== actor.teamId) { return false; diff --git a/server/routes/api/__snapshots__/users.test.ts.snap b/server/routes/api/__snapshots__/users.test.ts.snap index 0686a9f91..62fdbcc26 100644 --- a/server/routes/api/__snapshots__/users.test.ts.snap +++ b/server/routes/api/__snapshots__/users.test.ts.snap @@ -25,6 +25,7 @@ Object { "promote": true, "read": true, "readDetails": true, + "resendInvite": true, "suspend": true, "update": false, }, @@ -78,6 +79,7 @@ Object { "promote": true, "read": true, "readDetails": true, + "resendInvite": true, "suspend": true, "update": false, }, @@ -113,6 +115,7 @@ Object { "promote": true, "read": true, "readDetails": true, + "resendInvite": true, "suspend": true, "update": false, }, @@ -148,6 +151,7 @@ Object { "promote": true, "read": true, "readDetails": true, + "resendInvite": true, "suspend": true, "update": false, }, @@ -201,6 +205,7 @@ Object { "promote": false, "read": true, "readDetails": true, + "resendInvite": true, "suspend": true, "update": false, }, @@ -263,6 +268,7 @@ Object { "promote": false, "read": true, "readDetails": true, + "resendInvite": true, "suspend": true, "update": false, }, diff --git a/server/routes/api/users.ts b/server/routes/api/users.ts index 8d53795b2..d692a76d1 100644 --- a/server/routes/api/users.ts +++ b/server/routes/api/users.ts @@ -3,6 +3,8 @@ import { Op, WhereOptions } from "sequelize"; import userDestroyer from "@server/commands/userDestroyer"; import userInviter from "@server/commands/userInviter"; import userSuspender from "@server/commands/userSuspender"; +import InviteEmail from "@server/emails/templates/InviteEmail"; +import logger from "@server/logging/logger"; import auth from "@server/middlewares/authentication"; import { Event, User, Team } from "@server/models"; import { can, authorize } from "@server/policies"; @@ -306,6 +308,36 @@ router.post("users.invite", auth(), async (ctx) => { }; }); +router.post("users.resendInvite", auth(), async (ctx) => { + const { id } = ctx.body; + const actor = ctx.state.user; + + const user = await User.findByPk(id); + authorize(actor, "resendInvite", user); + + await InviteEmail.schedule({ + to: user.email, + name: user.name, + actorName: actor.name, + actorEmail: actor.email, + teamName: actor.team.name, + teamUrl: actor.team.url, + }); + + if (process.env.NODE_ENV === "development") { + logger.info( + "email", + `Sign in immediately: ${ + process.env.URL + }/auth/email.callback?token=${user.getEmailSigninToken()}` + ); + } + + ctx.body = { + success: true, + }; +}); + router.post("users.delete", auth(), async (ctx) => { const { confirmation, id } = ctx.body; assertPresent(confirmation, "confirmation is required"); diff --git a/shared/i18n/locales/en_US/translation.json b/shared/i18n/locales/en_US/translation.json index fe195f941..18e9e5e36 100644 --- a/shared/i18n/locales/en_US/translation.json +++ b/shared/i18n/locales/en_US/translation.json @@ -289,10 +289,12 @@ "Are you sure you want to make {{ userName }} a member?": "Are you sure you want to make {{ userName }} a member?", "Are you sure you want to make {{ userName }} a read-only viewer? They will not be able to edit any content": "Are you sure you want to make {{ userName }} a read-only viewer? They will not be able to edit any content", "Are you sure you want to suspend this account? Suspended users will be prevented from logging in.": "Are you sure you want to suspend this account? Suspended users will be prevented from logging in.", + "An error occurred while sending the invite": "An error occurred while sending the invite", "User options": "User options", "Make {{ userName }} a member": "Make {{ userName }} a member", "Make {{ userName }} a viewer": "Make {{ userName }} a viewer", "Make {{ userName }} an admin…": "Make {{ userName }} an admin…", + "Resend invite": "Resend invite", "Revoke invite": "Revoke invite", "Activate account": "Activate account", "Suspend account": "Suspend account",