fix: improve local login UX (#2849)

This commit is contained in:
fallenbagel
2026-04-15 16:24:22 +08:00
committed by GitHub
parent 15b31093c0
commit aef2481722
10 changed files with 215 additions and 111 deletions
-8
View File
@@ -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 = ({
);
})}
</nav>
<div className="px-2">
<UserWarnings onClick={() => setClosed()} />
</div>
{hasPermission(Permission.ADMIN) && (
<div className="px-2">
<VersionStatus onClick={() => setClosed()} />
@@ -322,9 +317,6 @@ const Sidebar = ({
);
})}
</nav>
<div className="px-2">
<UserWarnings />
</div>
{hasPermission(Permission.ADMIN) && (
<div className="px-2">
<VersionStatus />
+47 -38
View File
@@ -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<UserWarningsProps> = ({ 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;
}
}
res = (
<Link
href={link}
onClick={onClick}
onKeyDown={(e) => {
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"
if (!warningText) {
return null;
}
return (
<>
{showEmailModal && (
<AddEmailModal
onClose={() => setShowEmailModal(false)}
onSave={() => {
setShowEmailModal(false);
revalidate();
}}
/>
)}
<button
onClick={showSetEmail ? () => setShowEmailModal(true) : undefined}
className="service-error-banner mb-4 w-full cursor-pointer transition duration-300 hover:border-yellow-400 hover:bg-yellow-500/30"
>
<ExclamationTriangleIcon className="h-6 w-6" />
<div className="flex min-w-0 flex-1 flex-col truncate px-2 last:pr-0">
<span className="font-bold">{warningTitle}</span>
<span className="truncate">{warningText}</span>
</div>
</Link>
);
});
return res;
<ExclamationTriangleIcon className="h-5 w-5 flex-shrink-0" />
<span className="flex-1 text-left">
<span className="font-bold">
{intl.formatMessage(messages.profileIncomplete)}
</span>
{': '}
{warningText}
</span>
<ChevronRightIcon className="h-5 w-5 flex-shrink-0" />
</button>
</>
);
};
export default UserWarnings;
+5 -1
View File
@@ -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) => {
<main className="relative top-16 z-0 focus:outline-none" tabIndex={0}>
<div className="mb-6">
<div className="max-w-8xl mx-auto px-4">{children}</div>
<div className="max-w-8xl mx-auto px-4">
<UserWarnings />
{children}
</div>
</div>
</main>
</div>
+72 -58
View File
@@ -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<AddEmailModalProps> = ({
onClose,
username,
password,
onSave,
}) => {
const AddEmailModal: React.FC<AddEmailModalProps> = ({ 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<AddEmailModalProps> = ({
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"
>
<Formik
initialValues={{
email: '',
}}
validationSchema={EmailSettingsSchema}
initialValues={{ email: '' }}
validationSchema={EmailSchema}
onSubmit={async (values) => {
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 (
<Modal
onCancel={onClose}
okButtonType="primary"
okText={
isSubmitting
? intl.formatMessage(messages.saving)
: intl.formatMessage(messages.save)
}
okDisabled={isSubmitting || !isValid}
onOk={() => handleSubmit()}
title={intl.formatMessage(messages.title)}
>
{intl.formatMessage(messages.description, {
applicationName: settings.currentSettings.applicationTitle,
})}
<label htmlFor="email" className="text-label">
{intl.formatMessage(messages.email)}
</label>
<div className="mb-2 mt-1 sm:col-span-2 sm:mt-0">
<div className="flex rounded-md shadow-sm">
<Field
id="email"
name="email"
type="text"
placeholder={intl.formatMessage(messages.email)}
/>
</div>
{errors.email && touched.email && (
<div className="error">{errors.email}</div>
)}
{({ errors, touched, handleSubmit, isSubmitting, isValid }) => (
<Modal
onCancel={onClose}
okButtonType="primary"
okText={
isSubmitting
? intl.formatMessage(messages.saving)
: intl.formatMessage(messages.save)
}
okDisabled={isSubmitting || !isValid}
onOk={() => handleSubmit()}
title={intl.formatMessage(messages.title)}
>
{intl.formatMessage(messages.description)}
<label htmlFor="email" className="text-label">
{intl.formatMessage(messages.email)}
</label>
<div className="mb-2 mt-1 sm:col-span-2 sm:mt-0">
<div className="flex rounded-md shadow-sm">
<Field
id="email"
name="email"
type="text"
placeholder={intl.formatMessage(messages.email)}
/>
</div>
</Modal>
);
}}
{errors.email && touched.email && (
<div className="error">{errors.email}</div>
)}
</div>
</Modal>
)}
</Formik>
</Transition>
);
+27 -5
View File
@@ -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) => {
<Field
id="email"
name="email"
placeholder={`${intl.formatMessage(
messages.email
)} / ${intl.formatMessage(messages.username)}`}
placeholder={intl.formatMessage(messages.email)}
type="text"
inputMode="email"
data-testid="email"
@@ -107,6 +115,20 @@ const LocalLogin = ({ revalidate }: LocalLoginProps) => {
typeof errors.email === 'string' && (
<div className="error">{errors.email}</div>
)}
{(settings.currentSettings.mediaServerType ===
MediaServerType.JELLYFIN ||
settings.currentSettings.mediaServerType ===
MediaServerType.EMBY) && (
<div className="mt-1 text-xs text-gray-400">
{intl.formatMessage(messages.jellyfinLocalLoginHint, {
mediaServerName:
settings.currentSettings.mediaServerType ===
MediaServerType.JELLYFIN
? 'Jellyfin'
: 'Emby',
})}
</div>
)}
</div>
<div className="mb-2 mt-1">
<div className="form-input-field">
@@ -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 && (
<div className="mt-4">
<Alert
title={intl.formatMessage(
messages.disabledMediaServerLoginWarning,
{
applicationTitle:
settings.currentSettings.applicationTitle,
...mediaServerFormatValues,
}
)}
type="warning"
/>
</div>
)}
</div>
</div>
</div>
@@ -24,6 +24,8 @@ const messages = defineMessages('components.UserList', {
'Something went wrong while importing {mediaServerName} users.',
importedfromJellyfin:
'<strong>{userCount}</strong> {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<JellyfinImportProps> = ({
}
);
addToast(
intl.formatMessage(messages.importedUsersNoPassword, {
applicationTitle: settings.currentSettings.applicationTitle,
mediaServerName:
settings.currentSettings.mediaServerType === MediaServerType.EMBY
? 'Emby'
: 'Jellyfin',
}),
{
autoDismiss: false,
appearance: 'warning',
}
);
if (onComplete) {
onComplete();
}
@@ -20,6 +20,8 @@ const messages = defineMessages('components.UserList', {
importfromplexerror: 'Something went wrong while importing Plex users.',
importedfromplex:
'<strong>{userCount}</strong> 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();
}
@@ -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 = () => {
/>
<div className="mb-6">
<h3 className="heading">{intl.formatMessage(messages.password)}</h3>
<p className="mt-1 text-sm text-gray-400">
{intl.formatMessage(messages.localPasswordDescription, {
applicationTitle: settings.currentSettings.applicationTitle,
})}
</p>
</div>
<Formik
initialValues={{
+9 -1
View File
@@ -239,6 +239,7 @@
"components.Layout.UserWarnings.emailInvalid": "Email address is invalid.",
"components.Layout.UserWarnings.emailRequired": "An email address is required.",
"components.Layout.UserWarnings.passwordRequired": "A password is required.",
"components.Layout.UserWarnings.profileIncomplete": "Profile is incomplete",
"components.Layout.VersionStatus.commitsbehind": "{commitsBehind} {commitsBehind, plural, one {commit} other {commits}} behind",
"components.Layout.VersionStatus.outofdate": "Out of Date",
"components.Layout.VersionStatus.streamdevelop": "Seerr Develop",
@@ -246,8 +247,9 @@
"components.Login.adminerror": "You must use an admin account to sign in.",
"components.Login.back": "Go back",
"components.Login.credentialerror": "The username or password is incorrect.",
"components.Login.description": "Since this is your first time logging into {applicationName}, you are required to add a valid email address.",
"components.Login.description": "Add a valid email address to complete your profile. This will be used for notifications and local sign-in.",
"components.Login.email": "Email Address",
"components.Login.emailAlreadyTaken": "This email is already in use.",
"components.Login.emailtooltip": "Address does not need to be associated with your {mediaServerName} instance.",
"components.Login.enablessl": "Use SSL",
"components.Login.forgotpassword": "Forgot Password?",
@@ -255,6 +257,7 @@
"components.Login.initialsignin": "Connect",
"components.Login.initialsigningin": "Connecting…",
"components.Login.invalidurlerror": "Unable to connect to {mediaServerName} server.",
"components.Login.jellyfinLocalLoginHint": "If you haven't set an email address in your profile, use your {mediaServerName} username instead.",
"components.Login.loginerror": "Something went wrong while trying to sign in.",
"components.Login.loginwithapp": "Login with {appName}",
"components.Login.noadminerror": "No admin user found on the server.",
@@ -262,6 +265,7 @@
"components.Login.password": "Password",
"components.Login.port": "Port",
"components.Login.save": "Add",
"components.Login.saveFailed": "Something went wrong while saving.",
"components.Login.saving": "Adding…",
"components.Login.servertype": "Server Type",
"components.Login.signin": "Sign In",
@@ -1058,6 +1062,7 @@
"components.Settings.SettingsUsers.atLeastOneAuth": "At least one authentication method must be selected.",
"components.Settings.SettingsUsers.defaultPermissions": "Default Permissions",
"components.Settings.SettingsUsers.defaultPermissionsTip": "Initial permissions assigned to new users",
"components.Settings.SettingsUsers.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.",
"components.Settings.SettingsUsers.localLogin": "Enable Local Sign-In",
"components.Settings.SettingsUsers.localLoginTip": "Allow users to sign in using their email address and password",
"components.Settings.SettingsUsers.loginMethods": "Login Methods",
@@ -1378,6 +1383,8 @@
"components.UserList.descending": "descending",
"components.UserList.edituser": "Edit User Permissions",
"components.UserList.email": "Email Address",
"components.UserList.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.",
"components.UserList.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.",
"components.UserList.importedfromJellyfin": "<strong>{userCount}</strong> {mediaServerName} {userCount, plural, one {user} other {users}} imported successfully!",
"components.UserList.importedfromplex": "<strong>{userCount}</strong> Plex {userCount, plural, one {user} other {users}} imported successfully!",
"components.UserList.importfromJellyfin": "Import {mediaServerName} Users",
@@ -1545,6 +1552,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.",