From 9891df2757fe0656be9b4cf32b1a396bffd3c7bf Mon Sep 17 00:00:00 2001 From: sct Date: Fri, 28 Mar 2025 11:08:34 +0900 Subject: [PATCH] feat: add email editing to settings --- overseerr-api.yml | 29 ++ server/routes/user/usersettings.ts | 71 +++++ src/components/Login/index.tsx | 6 +- .../UserSettings/UserEmailChange/index.tsx | 254 ++++++++++++++++++ .../UserProfile/UserSettings/index.tsx | 6 + src/pages/profile/settings/email.tsx | 13 + src/pages/users/[userId]/settings/email.tsx | 16 ++ 7 files changed, 391 insertions(+), 4 deletions(-) create mode 100644 src/components/UserProfile/UserSettings/UserEmailChange/index.tsx create mode 100644 src/pages/profile/settings/email.tsx create mode 100644 src/pages/users/[userId]/settings/email.tsx diff --git a/overseerr-api.yml b/overseerr-api.yml index b7f03682..7ebacf28 100644 --- a/overseerr-api.yml +++ b/overseerr-api.yml @@ -3946,6 +3946,35 @@ paths: username: type: string example: 'Mr User' + /user/{userId}/settings/email: + post: + summary: Update email for a user + description: Updates a user's email. Requires `MANAGE_USERS` permission if editing other users. + tags: + - users + parameters: + - in: path + name: userId + required: true + schema: + type: number + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + currentPassword: + type: string + nullable: true + newEmail: + type: string + required: + - newEmail + responses: + '204': + description: User email updated /user/{userId}/settings/password: get: summary: Get password page informatiom diff --git a/server/routes/user/usersettings.ts b/server/routes/user/usersettings.ts index c8b3f50b..1e3d2af0 100644 --- a/server/routes/user/usersettings.ts +++ b/server/routes/user/usersettings.ts @@ -167,6 +167,77 @@ userSettingsRoutes.get<{ id: string }, { hasPassword: boolean }>( } ); +userSettingsRoutes.post< + { id: string }, + null, + { currentPassword?: string; newEmail: string } +>('/email', isOwnProfileOrAdmin(), async (req, res, next) => { + const userRepository = getRepository(User); + + try { + const user = await userRepository.findOne({ + where: { id: Number(req.params.id) }, + }); + + const userWithPassword = await userRepository.findOne({ + select: ['id', 'password'], + where: { id: Number(req.params.id) }, + }); + + if (!user || !userWithPassword) { + return next({ status: 404, message: 'User not found.' }); + } + + if ( + (user.id === 1 && req.user?.id !== 1) || + (user.hasPermission(Permission.ADMIN) && + user.id !== req.user?.id && + req.user?.id !== 1) + ) { + return next({ + status: 403, + message: "You do not have permission to modify this user's email.", + }); + } + + // If the user has the permission to manage users and they are not + // editing themselves, we will just set the new password + if ( + req.user?.hasPermission(Permission.MANAGE_USERS) && + req.user?.id !== user.id + ) { + user.email = req.body.newEmail; + await userRepository.save(user); + logger.debug('Email overriden by user.', { + label: 'User Settings', + userEmail: user.email, + changingUser: req.user.email, + }); + return res.status(204).send(); + } + + // If the user has a password, we need to check the currentPassword is correct + if ( + user.password && + (!req.body.currentPassword || + !(await userWithPassword.passwordMatch(req.body.currentPassword))) + ) { + logger.debug( + 'Attempt to change email for user failed. Invalid current password provided.', + { label: 'User Settings', userEmail: user.email } + ); + return next({ status: 403, message: 'Current password is invalid.' }); + } + + user.email = req.body.newEmail; + await userRepository.save(user); + + return res.status(204).send(); + } catch (e) { + next({ status: 500, message: e.message }); + } +}); + userSettingsRoutes.post< { id: string }, null, diff --git a/src/components/Login/index.tsx b/src/components/Login/index.tsx index f8ce4875..a57000f5 100644 --- a/src/components/Login/index.tsx +++ b/src/components/Login/index.tsx @@ -5,10 +5,8 @@ import LocalLogin from '@app/components/Login/LocalLogin'; import PlexLogin from '@app/components/Login/PlexLogin'; import useSettings from '@app/hooks/useSettings'; import { Transition } from '@headlessui/react'; -import { XCircleIcon } from '@heroicons/react/solid'; -import { useEffect, useState } from 'react'; -import axios from 'axios'; -import { useRouter } from 'next/dist/client/router'; +import { XCircleIcon } from '@heroicons/react/24/solid'; +import { useState } from 'react'; import { defineMessages, useIntl } from 'react-intl'; import useSWR from 'swr'; diff --git a/src/components/UserProfile/UserSettings/UserEmailChange/index.tsx b/src/components/UserProfile/UserSettings/UserEmailChange/index.tsx new file mode 100644 index 00000000..e2b72b6f --- /dev/null +++ b/src/components/UserProfile/UserSettings/UserEmailChange/index.tsx @@ -0,0 +1,254 @@ +import Alert from '@app/components/Common/Alert'; +import Button from '@app/components/Common/Button'; +import LoadingSpinner from '@app/components/Common/LoadingSpinner'; +import PageTitle from '@app/components/Common/PageTitle'; +import SensitiveInput from '@app/components/Common/SensitiveInput'; +import { Permission, useUser } from '@app/hooks/useUser'; +import globalMessages from '@app/i18n/globalMessages'; +import Error from '@app/pages/_error'; +import { ArrowDownOnSquareIcon } from '@heroicons/react/24/outline'; +import axios from 'axios'; +import { Field, Form, Formik } from 'formik'; +import { useRouter } from 'next/router'; +import { defineMessages, useIntl } from 'react-intl'; +import { useToasts } from 'react-toast-notifications'; +import useSWR from 'swr'; +import * as Yup from 'yup'; + +const messages = defineMessages({ + email: 'Email', + currentpassword: 'Current Password', + newemail: 'New Email', + confirmemail: 'Confirm Email', + toastSettingsSuccess: 'Email changed successfully!', + toastSettingsFailure: 'Something went wrong while changing the email.', + toastSettingsFailureVerifyCurrent: + 'Something went wrong while changing the email. Was your current password entered correctly?', + validationCurrentPassword: 'You must provide your current password', + validationEmail: 'You must provide a valid email address', + validationConfirmEmail: 'You must confirm the new email', + validationConfirmEmailSame: 'Emails must match', + noPasswordSet: + 'This user account currently does not have a password set. Configure a password for this account first before trying to change the email address.', + noPasswordSetOwnAccount: + 'Your account currently does not have a password set. Configure a password first before trying to change the email.', + nopermissionDescription: + "You do not have permission to modify this user's email.", +}); + +const UserEmailChange = () => { + const intl = useIntl(); + const { addToast } = useToasts(); + const router = useRouter(); + const { user: currentUser, revalidate: revalidateUser } = useUser(); + const { user, hasPermission } = useUser({ id: Number(router.query.userId) }); + const { + data, + error, + mutate: revalidate, + } = useSWR<{ hasPassword: boolean }>( + user ? `/api/v1/user/${user?.id}/settings/password` : null + ); + + const EmailChangeSchema = Yup.object().shape({ + currentPassword: Yup.lazy(() => + currentUser?.id === user?.id + ? Yup.string().required( + intl.formatMessage(messages.validationCurrentPassword) + ) + : Yup.mixed().optional() + ), + newEmail: Yup.string() + .required(intl.formatMessage(messages.validationEmail)) + .email(intl.formatMessage(messages.validationEmail)), + confirmEmail: Yup.string() + .required(intl.formatMessage(messages.validationConfirmEmail)) + .oneOf( + [Yup.ref('newEmail'), null], + intl.formatMessage(messages.validationConfirmEmailSame) + ), + }); + + if (!data && !error) { + return ; + } + + if (!data) { + return ; + } + + if ( + currentUser?.id !== user?.id && + hasPermission(Permission.ADMIN) && + currentUser?.id !== 1 + ) { + return ( + <> +
+

{intl.formatMessage(messages.email)}

+
+ + + ); + } + + if (!data.hasPassword) { + return ( + + ); + } + + return ( + <> + +
+

{intl.formatMessage(messages.email)}

+
+ { + try { + await axios.post(`/api/v1/user/${user?.id}/settings/email`, { + currentPassword: values.currentPassword, + newEmail: values.newEmail, + confirmEmail: values.confirmEmail, + }); + + addToast(intl.formatMessage(messages.toastSettingsSuccess), { + autoDismiss: true, + appearance: 'success', + }); + revalidateUser(); + } catch (e) { + addToast( + intl.formatMessage( + data.hasPassword && user?.id === currentUser?.id + ? messages.toastSettingsFailureVerifyCurrent + : messages.toastSettingsFailure + ), + { + autoDismiss: true, + appearance: 'error', + } + ); + } finally { + revalidate(); + resetForm(); + } + }} + > + {({ errors, touched, isSubmitting, isValid }) => { + return ( +
+ {!data.hasPassword && ( + + )} + {data.hasPassword && user?.id === currentUser?.id && ( +
+ +
+
+ +
+ {errors.currentPassword && + touched.currentPassword && + typeof errors.currentPassword === 'string' && ( +
{errors.currentPassword}
+ )} +
+
+ )} +
+ +
+
+ +
+ {errors.newEmail && + touched.newEmail && + typeof errors.newEmail === 'string' && ( +
{errors.newEmail}
+ )} +
+
+
+ +
+
+ +
+ {errors.confirmEmail && + touched.confirmEmail && + typeof errors.confirmEmail === 'string' && ( +
{errors.confirmEmail}
+ )} +
+
+
+
+ + + +
+
+ + ); + }} +
+ + ); +}; + +export default UserEmailChange; diff --git a/src/components/UserProfile/UserSettings/index.tsx b/src/components/UserProfile/UserSettings/index.tsx index eb162807..b2d23c14 100644 --- a/src/components/UserProfile/UserSettings/index.tsx +++ b/src/components/UserProfile/UserSettings/index.tsx @@ -16,6 +16,7 @@ import useSWR from 'swr'; const messages = defineMessages({ menuGeneralSettings: 'General', + menuChangeEmail: 'Email', menuChangePass: 'Password', menuNotifications: 'Notifications', menuPermissions: 'Permissions', @@ -51,6 +52,11 @@ const UserSettings = ({ children }: UserSettingsProps) => { route: '/settings/main', regex: /\/settings(\/main)?$/, }, + { + text: intl.formatMessage(messages.menuChangeEmail), + route: '/settings/email', + regex: /\/settings\/email/, + }, { text: intl.formatMessage(messages.menuChangePass), route: '/settings/password', diff --git a/src/pages/profile/settings/email.tsx b/src/pages/profile/settings/email.tsx new file mode 100644 index 00000000..d72cf023 --- /dev/null +++ b/src/pages/profile/settings/email.tsx @@ -0,0 +1,13 @@ +import UserSettings from '@app/components/UserProfile/UserSettings'; +import UserEmailChange from '@app/components/UserProfile/UserSettings/UserEmailChange'; +import type { NextPage } from 'next'; + +const UserEmailPage: NextPage = () => { + return ( + + + + ); +}; + +export default UserEmailPage; diff --git a/src/pages/users/[userId]/settings/email.tsx b/src/pages/users/[userId]/settings/email.tsx new file mode 100644 index 00000000..3db5a319 --- /dev/null +++ b/src/pages/users/[userId]/settings/email.tsx @@ -0,0 +1,16 @@ +import UserSettings from '@app/components/UserProfile/UserSettings'; +import UserEmailChange from '@app/components/UserProfile/UserSettings/UserEmailChange'; +import useRouteGuard from '@app/hooks/useRouteGuard'; +import { Permission } from '@app/hooks/useUser'; +import type { NextPage } from 'next'; + +const UserEmailPage: NextPage = () => { + useRouteGuard(Permission.MANAGE_USERS); + return ( + + + + ); +}; + +export default UserEmailPage;