diff --git a/src/components/Layout/Sidebar/index.tsx b/src/components/Layout/Sidebar/index.tsx index 1df32ac9b8..0d3a547819 100644 --- a/src/components/Layout/Sidebar/index.tsx +++ b/src/components/Layout/Sidebar/index.tsx @@ -1,5 +1,4 @@ import Badge from '@app/components/Common/Badge'; -import UserWarnings from '@app/components/Layout/UserWarnings'; import VersionStatus from '@app/components/Layout/VersionStatus'; import useClickOutside from '@app/hooks/useClickOutside'; import { Permission, useUser } from '@app/hooks/useUser'; @@ -233,10 +232,6 @@ const Sidebar = ({ ); })} -
- setClosed()} /> -
- {hasPermission(Permission.ADMIN) && (
setClosed()} /> @@ -322,9 +317,6 @@ const Sidebar = ({ ); })} -
- -
{hasPermission(Permission.ADMIN) && (
diff --git a/src/components/Layout/UserWarnings/index.tsx b/src/components/Layout/UserWarnings/index.tsx index 6128040bb6..545c4048ff 100644 --- a/src/components/Layout/UserWarnings/index.tsx +++ b/src/components/Layout/UserWarnings/index.tsx @@ -1,63 +1,72 @@ +import AddEmailModal from '@app/components/Login/AddEmailModal'; import { useUser } from '@app/hooks/useUser'; import defineMessages from '@app/utils/defineMessages'; -import { ExclamationTriangleIcon } from '@heroicons/react/24/outline'; -import Link from 'next/link'; +import { + ChevronRightIcon, + ExclamationTriangleIcon, +} from '@heroicons/react/24/outline'; +import { useState } from 'react'; import { useIntl } from 'react-intl'; const messages = defineMessages('components.Layout.UserWarnings', { emailRequired: 'An email address is required.', emailInvalid: 'Email address is invalid.', passwordRequired: 'A password is required.', + profileIncomplete: 'Profile is incomplete', }); -interface UserWarningsProps { - onClick?: () => void; -} - -const UserWarnings: React.FC = ({ onClick }) => { +const UserWarnings: React.FC = () => { const intl = useIntl(); - const { user } = useUser(); - //check if a user has warnings + const { user, revalidate } = useUser(); + const [showEmailModal, setShowEmailModal] = useState(false); + if (!user || !user.warnings || user.warnings.length === 0) { return null; } - let res = null; + let warningText = ''; + let showSetEmail = false; - user.warnings.forEach((warning) => { - let link = ''; - let warningText = ''; - let warningTitle = ''; + for (const warning of user.warnings) { switch (warning) { case 'userEmailRequired': - link = '/profile/settings/'; - warningTitle = 'Profile is incomplete'; warningText = intl.formatMessage(messages.emailRequired); + showSetEmail = true; + break; } + } + + if (!warningText) { + return null; + } - res = ( - { - if (e.key === 'Enter' && onClick) { - onClick(); - } - }} - role="button" - tabIndex={0} - className="mx-2 mb-2 flex items-center rounded-lg bg-yellow-500 p-2 text-xs text-white ring-1 ring-gray-700 transition duration-300 hover:bg-yellow-400" + return ( + <> + {showEmailModal && ( + setShowEmailModal(false)} + onSave={() => { + setShowEmailModal(false); + revalidate(); + }} + /> + )} + + + ); }; export default UserWarnings; diff --git a/src/components/Layout/index.tsx b/src/components/Layout/index.tsx index 5b1f98e46a..40c6df798b 100644 --- a/src/components/Layout/index.tsx +++ b/src/components/Layout/index.tsx @@ -3,6 +3,7 @@ import PullToRefresh from '@app/components/Layout/PullToRefresh'; import SearchInput from '@app/components/Layout/SearchInput'; import Sidebar from '@app/components/Layout/Sidebar'; import UserDropdown from '@app/components/Layout/UserDropdown'; +import UserWarnings from '@app/components/Layout/UserWarnings'; import useLocale from '@app/hooks/useLocale'; import useSettings from '@app/hooks/useSettings'; import { useUser } from '@app/hooks/useUser'; @@ -124,7 +125,10 @@ const Layout = ({ children }: LayoutProps) => {
-
{children}
+
+ + {children} +
diff --git a/src/components/Login/AddEmailModal.tsx b/src/components/Login/AddEmailModal.tsx index fe5c1281c9..3799acc830 100644 --- a/src/components/Login/AddEmailModal.tsx +++ b/src/components/Login/AddEmailModal.tsx @@ -1,41 +1,40 @@ import Modal from '@app/components/Common/Modal'; -import useSettings from '@app/hooks/useSettings'; +import { useUser } from '@app/hooks/useUser'; import defineMessages from '@app/utils/defineMessages'; import { Transition } from '@headlessui/react'; +import { ApiErrorCode } from '@server/constants/error'; import axios from 'axios'; import { Field, Formik } from 'formik'; import { useIntl } from 'react-intl'; +import { useToasts } from 'react-toast-notifications'; +import { mutate } from 'swr'; import validator from 'validator'; import * as Yup from 'yup'; const messages = defineMessages('components.Login', { title: 'Add Email', description: - 'Since this is your first time logging into {applicationName}, you are required to add a valid email address.', + 'Add a valid email address to complete your profile. This will be used for notifications and local sign-in.', email: 'Email address', + emailAlreadyTaken: 'This email is already in use.', validationEmailRequired: 'You must provide an email', validationEmailFormat: 'Invalid email', saving: 'Adding…', save: 'Add', + saveFailed: 'Something went wrong while saving.', }); interface AddEmailModalProps { - username: string; - password: string; onClose: () => void; onSave: () => void; } -const AddEmailModal: React.FC = ({ - onClose, - username, - password, - onSave, -}) => { +const AddEmailModal: React.FC = ({ onClose, onSave }) => { const intl = useIntl(); - const settings = useSettings(); + const { addToast } = useToasts(); + const { user } = useUser(); - const EmailSettingsSchema = Yup.object().shape({ + const EmailSchema = Yup.object().shape({ email: Yup.string() .test( 'email', @@ -51,66 +50,81 @@ const AddEmailModal: React.FC = ({ show enter="transition ease-in-out duration-300 transform opacity-0" enterFrom="opacity-0" - enterTo="opacuty-100" + enterTo="opacity-100" leave="transition ease-in-out duration-300 transform opacity-100" leaveFrom="opacity-100" leaveTo="opacity-0" > { + if (!user?.id) { + addToast(intl.formatMessage(messages.saveFailed), { + autoDismiss: true, + appearance: 'error', + }); + return; + } + try { - await axios.post('/api/v1/auth/jellyfin', { - username: username, - password: password, + const { data: current } = await axios.get( + `/api/v1/user/${user.id}/settings/main` + ); + await axios.post(`/api/v1/user/${user.id}/settings/main`, { + ...current, email: values.email, }); - + await mutate(`/api/v1/user/${user.id}/settings/main`); onSave(); - } catch { - // set error here + } catch (e) { + addToast( + intl.formatMessage( + axios.isAxiosError(e) && + e.response?.data?.message === ApiErrorCode.InvalidEmail + ? messages.emailAlreadyTaken + : messages.saveFailed + ), + { + autoDismiss: true, + appearance: 'error', + } + ); } }} > - {({ errors, touched, handleSubmit, isSubmitting, isValid }) => { - return ( - handleSubmit()} - title={intl.formatMessage(messages.title)} - > - {intl.formatMessage(messages.description, { - applicationName: settings.currentSettings.applicationTitle, - })} - -
-
- -
- {errors.email && touched.email && ( -
{errors.email}
- )} + {({ errors, touched, handleSubmit, isSubmitting, isValid }) => ( + handleSubmit()} + title={intl.formatMessage(messages.title)} + > + {intl.formatMessage(messages.description)} + +
+
+
- - ); - }} + {errors.email && touched.email && ( +
{errors.email}
+ )} +
+
+ )} ); diff --git a/src/components/Login/LocalLogin.tsx b/src/components/Login/LocalLogin.tsx index 9050c9536d..7fdd529a9c 100644 --- a/src/components/Login/LocalLogin.tsx +++ b/src/components/Login/LocalLogin.tsx @@ -4,6 +4,7 @@ import useSettings from '@app/hooks/useSettings'; import defineMessages from '@app/utils/defineMessages'; import { ArrowLeftOnRectangleIcon } from '@heroicons/react/24/outline'; import { ExclamationTriangleIcon } from '@heroicons/react/24/solid'; +import { MediaServerType } from '@server/constants/server'; import axios from 'axios'; import { Field, Form, Formik } from 'formik'; import Link from 'next/link'; @@ -18,7 +19,10 @@ const messages = defineMessages('components.Login', { password: 'Password', validationemailrequired: 'You must provide a valid email address', validationpasswordrequired: 'You must provide a password', + jellyfinLocalLoginHint: + "If you haven't set an email address in your profile, use your {mediaServerName} username instead.", loginerror: 'Something went wrong while trying to sign in.', + credentialerror: 'The email address or password is incorrect.', tipEmailHasTrailingWhitespace: 'The email ends with whitespace', signingin: 'Signing In…', signin: 'Sign In', @@ -61,8 +65,14 @@ const LocalLogin = ({ revalidate }: LocalLoginProps) => { email: values.email, password: values.password, }); - } catch { - setLoginError(intl.formatMessage(messages.loginerror)); + } catch (e) { + setLoginError( + intl.formatMessage( + axios.isAxiosError(e) && e.response?.status === 403 + ? messages.credentialerror + : messages.loginerror + ) + ); } finally { revalidate(); } @@ -84,9 +94,7 @@ const LocalLogin = ({ revalidate }: LocalLoginProps) => { { typeof errors.email === 'string' && (
{errors.email}
)} + {(settings.currentSettings.mediaServerType === + MediaServerType.JELLYFIN || + settings.currentSettings.mediaServerType === + MediaServerType.EMBY) && ( +
+ {intl.formatMessage(messages.jellyfinLocalLoginHint, { + mediaServerName: + settings.currentSettings.mediaServerType === + MediaServerType.JELLYFIN + ? 'Jellyfin' + : 'Emby', + })} +
+ )}
diff --git a/src/components/Settings/SettingsUsers/index.tsx b/src/components/Settings/SettingsUsers/index.tsx index 76f41a5a00..f0aad2aebd 100644 --- a/src/components/Settings/SettingsUsers/index.tsx +++ b/src/components/Settings/SettingsUsers/index.tsx @@ -1,3 +1,4 @@ +import Alert from '@app/components/Common/Alert'; import Button from '@app/components/Common/Button'; import LabeledCheckbox from '@app/components/Common/LabeledCheckbox'; import LoadingSpinner from '@app/components/Common/LoadingSpinner'; @@ -39,6 +40,8 @@ const messages = defineMessages('components.Settings.SettingsUsers', { tvRequestLimitLabel: 'Global Series Request Limit', defaultPermissions: 'Default Permissions', defaultPermissionsTip: 'Initial permissions assigned to new users', + disabledMediaServerLoginWarning: + 'Some users may not have a {applicationTitle} password set. Disabling {mediaServerName} sign-in could lock them out. Affected users will need to set a password from their profile or via a password reset link.', }); const SettingsUsers = () => { @@ -200,6 +203,21 @@ const SettingsUsers = () => { ) } /> + {!values.mediaServerLogin && values.localLogin && ( +
+ +
+ )}
diff --git a/src/components/UserList/JellyfinImportModal.tsx b/src/components/UserList/JellyfinImportModal.tsx index 39a963d484..4b303de2a3 100644 --- a/src/components/UserList/JellyfinImportModal.tsx +++ b/src/components/UserList/JellyfinImportModal.tsx @@ -24,6 +24,8 @@ const messages = defineMessages('components.UserList', { 'Something went wrong while importing {mediaServerName} users.', importedfromJellyfin: '{userCount} {mediaServerName} {userCount, plural, one {user} other {users}} imported successfully!', + importedUsersNoPassword: + 'Imported users do not have a {applicationTitle} password set. If you disable {mediaServerName} sign-in, they will need to set a password from their profile or via a password reset link.', user: 'User', noJellyfinuserstoimport: 'There are no {mediaServerName} users to import.', newJellyfinsigninenabled: @@ -84,6 +86,20 @@ const JellyfinImportModal: React.FC = ({ } ); + addToast( + intl.formatMessage(messages.importedUsersNoPassword, { + applicationTitle: settings.currentSettings.applicationTitle, + mediaServerName: + settings.currentSettings.mediaServerType === MediaServerType.EMBY + ? 'Emby' + : 'Jellyfin', + }), + { + autoDismiss: false, + appearance: 'warning', + } + ); + if (onComplete) { onComplete(); } diff --git a/src/components/UserList/PlexImportModal.tsx b/src/components/UserList/PlexImportModal.tsx index f93b698cef..c744219cb0 100644 --- a/src/components/UserList/PlexImportModal.tsx +++ b/src/components/UserList/PlexImportModal.tsx @@ -20,6 +20,8 @@ const messages = defineMessages('components.UserList', { importfromplexerror: 'Something went wrong while importing Plex users.', importedfromplex: '{userCount} Plex {userCount, plural, one {user} other {users}} imported successfully!', + importedPlexUsersNoPassword: + 'Imported users do not have a {applicationTitle} password set. If you disable Plex sign-in, they will need to set a password from their profile or via a password reset link.', user: 'User', nouserstoimport: 'There are no Plex users to import.', newplexsigninenabled: @@ -68,6 +70,16 @@ const PlexImportModal = ({ onCancel, onComplete }: PlexImportProps) => { } ); + addToast( + intl.formatMessage(messages.importedPlexUsersNoPassword, { + applicationTitle: settings.currentSettings.applicationTitle, + }), + { + autoDismiss: false, + appearance: 'warning', + } + ); + if (onComplete) { onComplete(); } diff --git a/src/components/UserProfile/UserSettings/UserPasswordChange/index.tsx b/src/components/UserProfile/UserSettings/UserPasswordChange/index.tsx index d5d6ea6266..869a1e2091 100644 --- a/src/components/UserProfile/UserSettings/UserPasswordChange/index.tsx +++ b/src/components/UserProfile/UserSettings/UserPasswordChange/index.tsx @@ -3,6 +3,7 @@ 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 useSettings from '@app/hooks/useSettings'; import { Permission, useUser } from '@app/hooks/useUser'; import globalMessages from '@app/i18n/globalMessages'; import ErrorPage from '@app/pages/_error'; @@ -19,6 +20,8 @@ import * as Yup from 'yup'; const messages = defineMessages( 'components.UserProfile.UserSettings.UserPasswordChange', { + localPasswordDescription: + 'This password is used for signing in with the {applicationTitle} local login form. It is separate from your media server password.', password: 'Password', currentpassword: 'Current Password', newpassword: 'New Password', @@ -44,6 +47,7 @@ const messages = defineMessages( const UserPasswordChange = () => { const intl = useIntl(); + const settings = useSettings(); const { addToast } = useToasts(); const router = useRouter(); const { user: currentUser } = useUser(); @@ -112,6 +116,11 @@ const UserPasswordChange = () => { />

{intl.formatMessage(messages.password)}

+

+ {intl.formatMessage(messages.localPasswordDescription, { + applicationTitle: settings.currentSettings.applicationTitle, + })} +

{userCount} {mediaServerName} {userCount, plural, one {user} other {users}} imported successfully!", "components.UserList.importedfromplex": "{userCount} Plex {userCount, plural, one {user} other {users}} imported successfully!", "components.UserList.importfromJellyfin": "Import {mediaServerName} Users", @@ -1531,6 +1538,7 @@ "components.UserProfile.UserSettings.UserNotificationSettings.webpush": "Web Push", "components.UserProfile.UserSettings.UserPasswordChange.confirmpassword": "Confirm Password", "components.UserProfile.UserSettings.UserPasswordChange.currentpassword": "Current Password", + "components.UserProfile.UserSettings.UserPasswordChange.localPasswordDescription": "This password is used for signing in with the {applicationTitle} local login form. It is separate from your media server password.", "components.UserProfile.UserSettings.UserPasswordChange.newpassword": "New Password", "components.UserProfile.UserSettings.UserPasswordChange.noPasswordSet": "This user account currently does not have a password set. Configure a password below to enable this account to sign in as a \"local user.\"", "components.UserProfile.UserSettings.UserPasswordChange.noPasswordSetOwnAccount": "Your account currently does not have a password set. Configure a password below to enable sign-in as a \"local user\" using your email address.",