From 86cfd62afa23b0ea40f7f0030d68e4b7bab710b5 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Wed, 25 Dec 2024 19:58:26 +0900 Subject: [PATCH] feat: Allow users to change email in-app (#8119) --- app/components/ConfirmationDialog.tsx | 9 +- app/components/UserDialogs.tsx | 74 ++++++++++- app/menus/UserMenu.tsx | 26 +++- app/scenes/Settings/Notifications.tsx | 17 +-- app/scenes/Settings/Profile.tsx | 26 +++- server/commands/userProvisioner.test.ts | 2 +- server/commands/userProvisioner.ts | 4 +- .../emails/templates/ConfirmUpdateEmail.tsx | 73 +++++++++++ server/errors.ts | 2 +- server/models/Team.ts | 12 +- server/models/User.ts | 29 +++++ server/routes/api/teams/teams.ts | 14 ++- .../users/__snapshots__/users.test.ts.snap | 27 ++++ server/routes/api/users/schema.ts | 20 +++ server/routes/api/users/users.test.ts | 82 ++++++++++++ server/routes/api/users/users.ts | 119 +++++++++++++++++- server/utils/jwt.ts | 33 +++++ shared/i18n/locales/en_US/translation.json | 10 +- shared/utils/routeHelpers.ts | 4 + 19 files changed, 543 insertions(+), 40 deletions(-) create mode 100644 server/emails/templates/ConfirmUpdateEmail.tsx diff --git a/app/components/ConfirmationDialog.tsx b/app/components/ConfirmationDialog.tsx index 299ebd3964..7475dfbf58 100644 --- a/app/components/ConfirmationDialog.tsx +++ b/app/components/ConfirmationDialog.tsx @@ -8,8 +8,8 @@ import Text from "~/components/Text"; import useStores from "~/hooks/useStores"; type Props = { - /** Callback when the dialog is submitted */ - onSubmit: () => Promise | void; + /** Callback when the dialog is submitted. Return false to prevent closing. */ + onSubmit: () => Promise | void; /** Text to display on the submit button */ submitText?: string; /** Text to display while the form is saving */ @@ -38,7 +38,10 @@ const ConfirmationDialog: React.FC = ({ ev.preventDefault(); setIsSaving(true); try { - await onSubmit(); + const res = await onSubmit(); + if (res === false) { + return; + } dialogs.closeAllModals(); } catch (err) { toast.error(err.message); diff --git a/app/components/UserDialogs.tsx b/app/components/UserDialogs.tsx index 74aac725eb..cbf6028583 100644 --- a/app/components/UserDialogs.tsx +++ b/app/components/UserDialogs.tsx @@ -1,10 +1,14 @@ import * as React from "react"; -import { useTranslation } from "react-i18next"; +import { Trans, useTranslation } from "react-i18next"; +import { toast } from "sonner"; import { UserRole } from "@shared/types"; import User from "~/models/User"; import ConfirmationDialog from "~/components/ConfirmationDialog"; import Input from "~/components/Input"; +import useCurrentUser from "~/hooks/useCurrentUser"; import useStores from "~/hooks/useStores"; +import { client } from "~/utils/ApiClient"; +import Text from "./Text"; type Props = { user: User; @@ -85,7 +89,11 @@ export function UserSuspendDialog({ user, onSubmit }: Props) { }; return ( - + {t( "Are you sure you want to suspend {{ userName }}? Suspended users will be prevented from logging in.", { @@ -123,6 +131,68 @@ export function UserChangeNameDialog({ user, onSubmit }: Props) { onChange={handleChange} error={!name ? t("Name can't be empty") : undefined} value={name} + autoSelect + required + flex + /> + + ); +} + +export function UserChangeEmailDialog({ user, onSubmit }: Props) { + const { t } = useTranslation(); + const actor = useCurrentUser(); + const [email, setEmail] = React.useState(user.email); + const [error, setError] = React.useState(); + + const handleSubmit = async () => { + try { + await client.post(`/users.updateEmail`, { id: user.id, email }); + onSubmit(); + toast.info( + actor.id === user.id + ? t("Check your email to verify the new address.") + : t("The email will be changed once verified.") + ); + return true; + } catch (err) { + setError(err.message); + return false; + } + }; + + const handleChange = (ev: React.ChangeEvent) => { + setEmail(ev.target.value); + }; + + return ( + + + {actor.id === user.id ? ( + + You will receive an email to verify your new address. It must be + unique in the workspace. + + ) : ( + + A confirmation email will be sent to the new address before it is + changed. + + )} + + diff --git a/app/menus/UserMenu.tsx b/app/menus/UserMenu.tsx index 8a1026c24e..5cc58638da 100644 --- a/app/menus/UserMenu.tsx +++ b/app/menus/UserMenu.tsx @@ -11,6 +11,7 @@ import Template from "~/components/ContextMenu/Template"; import { UserSuspendDialog, UserChangeNameDialog, + UserChangeEmailDialog, } from "~/components/UserDialogs"; import { actionToMenuItem } from "~/actions"; import { @@ -49,6 +50,22 @@ function UserMenu({ user }: Props) { [dialogs, t, user] ); + const handleChangeEmail = React.useCallback( + (ev: React.SyntheticEvent) => { + ev.preventDefault(); + dialogs.openModal({ + title: t("Change email"), + content: ( + + ), + }); + }, + [dialogs, t, user] + ); + const handleSuspend = React.useCallback( (ev: React.SyntheticEvent) => { ev.preventDefault(); @@ -117,7 +134,13 @@ function UserMenu({ user }: Props) { type: "button", title: `${t("Change name")}…`, onClick: handleChangeName, - visible: can.update && user.role !== "admin", + visible: can.update, + }, + { + type: "button", + title: `${t("Change email")}…`, + onClick: handleChangeEmail, + visible: can.update, }, { type: "button", @@ -144,6 +167,7 @@ function UserMenu({ user }: Props) { { type: "button", title: `${t("Suspend user")}…`, + dangerous: true, onClick: handleSuspend, visible: !user.isInvited && !user.isSuspended, }, diff --git a/app/scenes/Settings/Notifications.tsx b/app/scenes/Settings/Notifications.tsx index 162b09d825..479c6564eb 100644 --- a/app/scenes/Settings/Notifications.tsx +++ b/app/scenes/Settings/Notifications.tsx @@ -19,19 +19,22 @@ import { toast } from "sonner"; import { NotificationEventType } from "@shared/types"; import Flex from "~/components/Flex"; import Heading from "~/components/Heading"; -import Input from "~/components/Input"; import Notice from "~/components/Notice"; import Scene from "~/components/Scene"; import Switch from "~/components/Switch"; import Text from "~/components/Text"; import env from "~/env"; +import useCurrentTeam from "~/hooks/useCurrentTeam"; import useCurrentUser from "~/hooks/useCurrentUser"; +import usePolicy from "~/hooks/usePolicy"; import isCloudHosted from "~/utils/isCloudHosted"; import SettingRow from "./components/SettingRow"; function Notifications() { const user = useCurrentUser(); + const team = useCurrentTeam(); const { t } = useTranslation(); + const can = usePolicy(team.id); const options = [ { @@ -161,17 +164,7 @@ function Notifications() { Manage when and where you receive email notifications. - {env.EMAIL_ENABLED ? ( - - - - ) : ( + {env.EMAIL_ENABLED && can.manage && ( The email integration is currently disabled. Please set the diff --git a/app/scenes/Settings/Profile.tsx b/app/scenes/Settings/Profile.tsx index 5c5f127b53..cc04c58f09 100644 --- a/app/scenes/Settings/Profile.tsx +++ b/app/scenes/Settings/Profile.tsx @@ -8,14 +8,18 @@ import Heading from "~/components/Heading"; import Input from "~/components/Input"; import Scene from "~/components/Scene"; import Text from "~/components/Text"; +import { UserChangeEmailDialog } from "~/components/UserDialogs"; +import env from "~/env"; import useCurrentUser from "~/hooks/useCurrentUser"; +import useStores from "~/hooks/useStores"; import ImageInput from "./components/ImageInput"; import SettingRow from "./components/SettingRow"; const Profile = () => { const user = useCurrentUser(); + const { dialogs } = useStores(); const form = React.useRef(null); - const [name, setName] = React.useState(user.name || ""); + const [name, setName] = React.useState(user.name); const { t } = useTranslation(); const handleSubmit = async (ev: React.SyntheticEvent) => { @@ -29,6 +33,15 @@ const Profile = () => { } }; + const handleChangeEmail = () => { + dialogs.openModal({ + title: t("Change email"), + content: ( + + ), + }); + }; + const handleNameChange = (ev: React.ChangeEvent) => { setName(ev.target.value); }; @@ -81,6 +94,17 @@ const Profile = () => { /> + {env.EMAIL_ENABLED && ( + + + + )} + diff --git a/server/commands/userProvisioner.test.ts b/server/commands/userProvisioner.test.ts index f4ac0920be..88bc6259f8 100644 --- a/server/commands/userProvisioner.test.ts +++ b/server/commands/userProvisioner.test.ts @@ -437,7 +437,7 @@ describe("userProvisioner", () => { } expect(error && error.toString()).toContain( - "The domain is not allowed for this team" + "The domain is not allowed for this workspace" ); }); }); diff --git a/server/commands/userProvisioner.ts b/server/commands/userProvisioner.ts index 905bddbef0..f6221358a7 100644 --- a/server/commands/userProvisioner.ts +++ b/server/commands/userProvisioner.ts @@ -1,6 +1,5 @@ import { InferCreationAttributes } from "sequelize"; import { UserRole } from "@shared/types"; -import { parseEmail } from "@shared/utils/email"; import InviteAcceptedEmail from "@server/emails/templates/InviteAcceptedEmail"; import { DomainNotAllowedError, @@ -227,8 +226,7 @@ export default async function userProvisioner({ // If the team settings do not allow this domain, // throw an error and fail user creation. - const { domain } = parseEmail(email); - if (team && !(await team.isDomainAllowed(domain))) { + if (team && !(await team.isDomainAllowed(email))) { throw DomainNotAllowedError(); } diff --git a/server/emails/templates/ConfirmUpdateEmail.tsx b/server/emails/templates/ConfirmUpdateEmail.tsx new file mode 100644 index 0000000000..05b903513b --- /dev/null +++ b/server/emails/templates/ConfirmUpdateEmail.tsx @@ -0,0 +1,73 @@ +import * as React from "react"; +import env from "@server/env"; +import BaseEmail, { EmailMessageCategory, EmailProps } from "./BaseEmail"; +import Body from "./components/Body"; +import Button from "./components/Button"; +import EmailTemplate from "./components/EmailLayout"; +import EmptySpace from "./components/EmptySpace"; +import Footer from "./components/Footer"; +import Header from "./components/Header"; +import Heading from "./components/Heading"; + +type Props = EmailProps & { + code: string; + previous: string | null; + teamUrl: string; +}; + +/** + * Email sent to a user when they request to change their email. + */ +export default class ConfirmUpdateEmail extends BaseEmail { + protected get category() { + return EmailMessageCategory.Authentication; + } + + protected subject() { + return `Your email update request`; + } + + protected preview() { + return `Here’s your email change confirmation.`; + } + + protected renderAsText({ teamUrl, code, previous, to }: Props): string { + return ` +You requested to update your ${env.APP_NAME} account email. Please +follow the link below to confirm the change ${ + previous ? `from ${previous} ` : "" + }to ${to}. + + ${this.updateLink(teamUrl, code)} + `; + } + + protected render({ teamUrl, code, previous, to }: Props) { + return ( + +
+ + + Your email update request +

+ You requested to update your {env.APP_NAME} account email. Please + click below to confirm the change{" "} + {previous ? `from ${previous} ` : ""}to {to}. +

+ +

+ +

+ + +