From 73d8efaa54888b5282624e618c1461c23653f0b9 Mon Sep 17 00:00:00 2001 From: Michael Thomas Date: Sat, 22 Feb 2025 11:40:38 -0500 Subject: [PATCH] feat: revamp login page and support disabling media server login (#1286) * feat: support disabling jellyfin login * feat: revamp login screen Update the login screen for better usability, especially with OpenID Connect and Plex login, allowing one-click login and removing the accordion layout. Additionally, ensures that media server login is hidden when disabled in the settings. * test: update cypress login command --- cypress/support/commands.ts | 1 - docs/using-jellyseerr/settings/users.md | 8 + package.json | 2 + pnpm-lock.yaml | 27 +- server/interfaces/api/settingsInterfaces.ts | 1 + server/lib/settings/index.ts | 5 + server/routes/auth.ts | 14 +- src/components/Common/Button/index.tsx | 5 +- .../Common/LabeledCheckbox/index.tsx | 44 ++ src/components/Login/JellyfinLogin.tsx | 465 +++--------------- src/components/Login/LocalLogin.tsx | 96 ++-- src/components/Login/PlexLoginButton.tsx | 62 +++ src/components/Login/index.tsx | 225 ++++++--- src/components/PlexLoginButton/index.tsx | 66 --- .../Settings/SettingsUsers/index.tsx | 105 +++- src/components/Setup/JellyfinSetup.tsx | 352 +++++++++++++ src/components/Setup/LoginWithPlex.tsx | 2 +- src/components/Setup/SetupLogin.tsx | 16 +- src/context/SettingsContext.tsx | 1 + src/hooks/usePlexLogin.ts | 37 ++ src/i18n/locale/en.json | 11 +- src/pages/_app.tsx | 1 + src/styles/globals.css | 14 +- 23 files changed, 921 insertions(+), 639 deletions(-) create mode 100644 src/components/Common/LabeledCheckbox/index.tsx create mode 100644 src/components/Login/PlexLoginButton.tsx delete mode 100644 src/components/PlexLoginButton/index.tsx create mode 100644 src/components/Setup/JellyfinSetup.tsx create mode 100644 src/hooks/usePlexLogin.ts diff --git a/cypress/support/commands.ts b/cypress/support/commands.ts index 0eb9c869a..a23cb5e68 100644 --- a/cypress/support/commands.ts +++ b/cypress/support/commands.ts @@ -6,7 +6,6 @@ Cypress.Commands.add('login', (email, password) => { [email, password], () => { cy.visit('/login'); - cy.contains('Use your Overseerr account').click(); cy.get('[data-testid=email]').type(email); cy.get('[data-testid=password]').type(password); diff --git a/docs/using-jellyseerr/settings/users.md b/docs/using-jellyseerr/settings/users.md index ebe547efc..0fdeb7db3 100644 --- a/docs/using-jellyseerr/settings/users.md +++ b/docs/using-jellyseerr/settings/users.md @@ -14,6 +14,14 @@ When disabled, your mediaserver OAuth becomes the only sign-in option, and any " This setting is **enabled** by default. +## Enable Jellyfin/Emby/Plex Sign-In + +When enabled, users will be able to sign in to Jellyseerr using their Jellyfin/Emby/Plex credentials, provided they have linked their media server accounts. + +When disabled, users will only be able to sign in using their email address. Users without a password set will not be able to sign in to Jellyseerr. + +This setting is **enabled** by default. + ## Enable New Jellyfin/Emby/Plex Sign-In When enabled, users with access to your media server will be able to sign in to Jellyseerr even if they have not yet been imported. Users will be automatically assigned the permissions configured in the [Default Permissions](#default-permissions) setting upon first sign-in. diff --git a/package.json b/package.json index 6e6500ede..745120205 100644 --- a/package.json +++ b/package.json @@ -86,6 +86,7 @@ "react-spring": "9.7.1", "react-tailwindcss-datepicker-sct": "1.3.4", "react-toast-notifications": "2.5.1", + "react-transition-group": "^4.4.5", "react-truncate-markup": "5.1.2", "react-use-clipboard": "1.0.9", "reflect-metadata": "0.1.13", @@ -95,6 +96,7 @@ "sqlite3": "5.1.4", "swagger-ui-express": "4.6.2", "swr": "2.2.5", + "tailwind-merge": "^2.6.0", "typeorm": "0.3.11", "undici": "^6.20.1", "web-push": "3.5.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index de1247df5..07a8ac57b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -170,6 +170,9 @@ importers: react-toast-notifications: specifier: 2.5.1 version: 2.5.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react-transition-group: + specifier: ^4.4.5 + version: 4.4.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react-truncate-markup: specifier: 5.1.2 version: 5.1.2(react@18.3.1) @@ -197,6 +200,9 @@ importers: swr: specifier: 2.2.5 version: 2.2.5(react@18.3.1) + tailwind-merge: + specifier: ^2.6.0 + version: 2.6.0 typeorm: specifier: 0.3.11 version: 0.3.11(pg@8.11.0)(sqlite3@5.1.4(encoding@0.1.13))(ts-node@10.9.1(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@22.10.5)(typescript@4.9.5)) @@ -8844,6 +8850,9 @@ packages: peerDependencies: react: ^16.11.0 || ^17.0.0 || ^18.0.0 + tailwind-merge@2.6.0: + resolution: {integrity: sha512-P+Vu1qXfzediirmHOC3xKGAYeZtPcV9g76X+xg2FD4tYgR71ewMA35Y3sCz3zhiN/dwefRpJX0yBcgwi1fXNQA==} + tailwindcss@3.2.7: resolution: {integrity: sha512-B6DLqJzc21x7wntlH/GsZwEXTBttVSl1FtCzC8WP4oBc/NKef7kaax5jeihkkCEWc831/5NDJ9gRNDK6NEioQQ==} engines: {node: '>=12.13.0'} @@ -11293,7 +11302,7 @@ snapshots: '@emotion/babel-plugin@11.11.0': dependencies: '@babel/helper-module-imports': 7.24.7 - '@babel/runtime': 7.24.7 + '@babel/runtime': 7.26.0 '@emotion/hash': 0.9.1 '@emotion/memoize': 0.8.1 '@emotion/serialize': 1.1.4 @@ -11323,7 +11332,7 @@ snapshots: '@emotion/core@10.3.1(react@18.3.1)': dependencies: - '@babel/runtime': 7.24.7 + '@babel/runtime': 7.26.0 '@emotion/cache': 10.0.29 '@emotion/css': 10.0.27 '@emotion/serialize': 0.11.16 @@ -11351,7 +11360,7 @@ snapshots: '@emotion/react@11.11.4(@types/react@18.3.3)(react@18.3.1)': dependencies: - '@babel/runtime': 7.24.7 + '@babel/runtime': 7.26.0 '@emotion/babel-plugin': 11.11.0 '@emotion/cache': 11.11.0 '@emotion/serialize': 1.1.4 @@ -14254,13 +14263,13 @@ snapshots: babel-plugin-macros@2.8.0: dependencies: - '@babel/runtime': 7.24.7 + '@babel/runtime': 7.26.0 cosmiconfig: 6.0.0 resolve: 1.22.8 babel-plugin-macros@3.1.0: dependencies: - '@babel/runtime': 7.24.7 + '@babel/runtime': 7.26.0 cosmiconfig: 7.1.0 resolve: 1.22.8 @@ -15350,7 +15359,7 @@ snapshots: dom-helpers@5.2.1: dependencies: - '@babel/runtime': 7.24.7 + '@babel/runtime': 7.26.0 csstype: 3.1.3 dom-serializer@1.4.1: @@ -19366,7 +19375,7 @@ snapshots: react-transition-group@4.4.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: - '@babel/runtime': 7.24.7 + '@babel/runtime': 7.26.0 dom-helpers: 5.2.1 loose-envify: 1.4.0 prop-types: 15.8.1 @@ -19493,7 +19502,7 @@ snapshots: regenerator-transform@0.15.2: dependencies: - '@babel/runtime': 7.24.7 + '@babel/runtime': 7.26.0 regexp.prototype.flags@1.5.2: dependencies: @@ -20274,6 +20283,8 @@ snapshots: react: 18.3.1 use-sync-external-store: 1.2.2(react@18.3.1) + tailwind-merge@2.6.0: {} + tailwindcss@3.2.7(postcss@8.4.21)(ts-node@10.9.1(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@22.10.5)(typescript@4.9.5)): dependencies: arg: 5.0.2 diff --git a/server/interfaces/api/settingsInterfaces.ts b/server/interfaces/api/settingsInterfaces.ts index 017eef856..0e97c2bf4 100644 --- a/server/interfaces/api/settingsInterfaces.ts +++ b/server/interfaces/api/settingsInterfaces.ts @@ -30,6 +30,7 @@ export interface PublicSettingsResponse { applicationUrl: string; hideAvailable: boolean; localLogin: boolean; + mediaServerLogin: boolean; movie4kEnabled: boolean; series4kEnabled: boolean; discoverRegion: string; diff --git a/server/lib/settings/index.ts b/server/lib/settings/index.ts index 258dfe2f4..7fc09fb3f 100644 --- a/server/lib/settings/index.ts +++ b/server/lib/settings/index.ts @@ -123,6 +123,7 @@ export interface MainSettings { }; hideAvailable: boolean; localLogin: boolean; + mediaServerLogin: boolean; newPlexLogin: boolean; discoverRegion: string; streamingRegion: string; @@ -150,6 +151,7 @@ interface FullPublicSettings extends PublicSettings { applicationUrl: string; hideAvailable: boolean; localLogin: boolean; + mediaServerLogin: boolean; movie4kEnabled: boolean; series4kEnabled: boolean; discoverRegion: string; @@ -343,6 +345,7 @@ class Settings { }, hideAvailable: false, localLogin: true, + mediaServerLogin: true, newPlexLogin: true, discoverRegion: '', streamingRegion: '', @@ -588,6 +591,8 @@ class Settings { applicationUrl: this.data.main.applicationUrl, hideAvailable: this.data.main.hideAvailable, localLogin: this.data.main.localLogin, + mediaServerLogin: this.data.main.mediaServerLogin, + jellyfinExternalHost: this.data.jellyfin.externalHostname, jellyfinForgotPasswordUrl: this.data.jellyfin.jellyfinForgotPasswordUrl, movie4kEnabled: this.data.radarr.some( (radarr) => radarr.is4k && radarr.isDefault diff --git a/server/routes/auth.ts b/server/routes/auth.ts index cbfbc3f79..31c846adc 100644 --- a/server/routes/auth.ts +++ b/server/routes/auth.ts @@ -56,8 +56,9 @@ authRoutes.post('/plex', async (req, res, next) => { } if ( - settings.main.mediaServerType != MediaServerType.PLEX && - settings.main.mediaServerType != MediaServerType.NOT_CONFIGURED + settings.main.mediaServerType != MediaServerType.NOT_CONFIGURED && + (settings.main.mediaServerLogin === false || + settings.main.mediaServerType != MediaServerType.PLEX) ) { return res.status(500).json({ error: 'Plex login is disabled' }); } @@ -231,10 +232,13 @@ authRoutes.post('/jellyfin', async (req, res, next) => { //Make sure jellyfin login is enabled, but only if jellyfin && Emby is not already configured if ( - settings.main.mediaServerType !== MediaServerType.JELLYFIN && - settings.main.mediaServerType !== MediaServerType.EMBY && + // media server not configured, allow login for setup settings.main.mediaServerType != MediaServerType.NOT_CONFIGURED && - settings.jellyfin.ip !== '' + (settings.main.mediaServerLogin === false || + // media server is neither jellyfin or emby + (settings.main.mediaServerType !== MediaServerType.JELLYFIN && + settings.main.mediaServerType !== MediaServerType.EMBY && + settings.jellyfin.ip !== '')) ) { return res.status(500).json({ error: 'Jellyfin login is disabled' }); } diff --git a/src/components/Common/Button/index.tsx b/src/components/Common/Button/index.tsx index a4df31150..ac1c330c6 100644 --- a/src/components/Common/Button/index.tsx +++ b/src/components/Common/Button/index.tsx @@ -1,5 +1,6 @@ import type { ForwardedRef } from 'react'; import React from 'react'; +import { twMerge } from 'tailwind-merge'; export type ButtonType = | 'default' @@ -97,7 +98,7 @@ function Button

( if (as === 'a') { return ( )} ref={ref as ForwardedRef} > @@ -107,7 +108,7 @@ function Button

( } else { return ( - - {onCancel && ( - - - - )} - - - - )} - - ); - } else { - const LoginSchema = Yup.object().shape({ - username: Yup.string().required( - intl.formatMessage(messages.validationusernamerequired) - ), - password: Yup.string(), - }); - const baseUrl = settings.currentSettings.jellyfinExternalHost - ? settings.currentSettings.jellyfinExternalHost - : settings.currentSettings.jellyfinHost; - const jellyfinForgotPasswordUrl = - settings.currentSettings.jellyfinForgotPasswordUrl; - return ( -

- { - try { - const res = await fetch('/api/v1/auth/jellyfin', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - username: values.username, - password: values.password, - email: values.username, - }), - }); - if (!res.ok) throw new Error(res.statusText, { cause: res }); - } catch (e) { - let errorData; - try { - errorData = await e.cause?.text(); - errorData = JSON.parse(errorData); - } catch { - /* empty */ - } - let errorMessage = null; - switch (errorData?.message) { - case ApiErrorCode.InvalidUrl: - errorMessage = messages.invalidurlerror; - break; - case ApiErrorCode.InvalidCredentials: - errorMessage = messages.credentialerror; - break; - case ApiErrorCode.NotAdmin: - errorMessage = messages.adminerror; - break; - case ApiErrorCode.NoAdminUser: - errorMessage = messages.noadminerror; - break; - default: - errorMessage = messages.loginerror; - break; - } - toasts.addToast( - intl.formatMessage(errorMessage, mediaServerFormatValues), - { - autoDismiss: true, - appearance: 'error', - } - ); - } finally { - revalidate(); - } - }} - > - {({ errors, touched, isSubmitting, isValid }) => { - return ( - <> -
-
- -
-
- -
- {errors.username && touched.username && ( -
{errors.username}
- )} + +
- - - ); - }} - -
- ); - } +
+ + + + + ); + }} + +
+ ); }; export default JellyfinLogin; diff --git a/src/components/Login/LocalLogin.tsx b/src/components/Login/LocalLogin.tsx index 2f2e00ed5..2372bc7f0 100644 --- a/src/components/Login/LocalLogin.tsx +++ b/src/components/Login/LocalLogin.tsx @@ -2,10 +2,7 @@ import Button from '@app/components/Common/Button'; import SensitiveInput from '@app/components/Common/SensitiveInput'; import useSettings from '@app/hooks/useSettings'; import defineMessages from '@app/utils/defineMessages'; -import { - ArrowLeftOnRectangleIcon, - LifebuoyIcon, -} from '@heroicons/react/24/outline'; +import { ArrowLeftOnRectangleIcon } from '@heroicons/react/24/outline'; import { Field, Form, Formik } from 'formik'; import Link from 'next/link'; import { useState } from 'react'; @@ -13,6 +10,7 @@ import { useIntl } from 'react-intl'; import * as Yup from 'yup'; const messages = defineMessages('components.Login', { + loginwithapp: 'Login with {appName}', username: 'Username', email: 'Email Address', password: 'Password', @@ -53,6 +51,7 @@ const LocalLogin = ({ revalidate }: LocalLoginProps) => { password: '', }} validationSchema={LoginSchema} + validateOnBlur={false} onSubmit={async (values) => { try { const res = await fetch('/api/v1/auth/local', { @@ -78,19 +77,24 @@ const LocalLogin = ({ revalidate }: LocalLoginProps) => { <>
- -
+

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

+ +
{errors.email && @@ -99,25 +103,35 @@ const LocalLogin = ({ revalidate }: LocalLoginProps) => {
{errors.email}
)}
- -
+
- {errors.password && - touched.password && - typeof errors.password === 'string' && ( -
{errors.password}
+
+ {errors.password && + touched.password && + typeof errors.password === 'string' && ( +
{errors.password}
+ )} +
+ {passwordResetEnabled && ( + + {intl.formatMessage(messages.forgotpassword)} + )} +
{loginError && (
@@ -125,37 +139,21 @@ const LocalLogin = ({ revalidate }: LocalLoginProps) => {
)}
-
-
- - - - {passwordResetEnabled && ( - - - - - - )} -
-
+ + ); diff --git a/src/components/Login/PlexLoginButton.tsx b/src/components/Login/PlexLoginButton.tsx new file mode 100644 index 000000000..111b95d32 --- /dev/null +++ b/src/components/Login/PlexLoginButton.tsx @@ -0,0 +1,62 @@ +import PlexIcon from '@app/assets/services/plex.svg'; +import Button from '@app/components/Common/Button'; +import { SmallLoadingSpinner } from '@app/components/Common/LoadingSpinner'; +import usePlexLogin from '@app/hooks/usePlexLogin'; +import defineMessages from '@app/utils/defineMessages'; +import { FormattedMessage } from 'react-intl'; + +const messages = defineMessages('components.Login', { + loginwithapp: 'Login with {appName}', +}); + +interface PlexLoginButtonProps { + onAuthToken: (authToken: string) => void; + isProcessing?: boolean; + onError?: (message: string) => void; + large?: boolean; +} + +const PlexLoginButton = ({ + onAuthToken, + onError, + isProcessing, + large, +}: PlexLoginButtonProps) => { + const { loading, login } = usePlexLogin({ onAuthToken, onError }); + + return ( + + ); +}; + +export default PlexLoginButton; diff --git a/src/components/Login/index.tsx b/src/components/Login/index.tsx index 7b95b9fcd..0b51e86f1 100644 --- a/src/components/Login/index.tsx +++ b/src/components/Login/index.tsx @@ -1,9 +1,13 @@ -import Accordion from '@app/components/Common/Accordion'; +import EmbyLogo from '@app/assets/services/emby-icon-only.svg'; +import JellyfinLogo from '@app/assets/services/jellyfin-icon.svg'; +import PlexLogo from '@app/assets/services/plex.svg'; +import Button from '@app/components/Common/Button'; import ImageFader from '@app/components/Common/ImageFader'; import PageTitle from '@app/components/Common/PageTitle'; import LanguagePicker from '@app/components/Layout/LanguagePicker'; +import JellyfinLogin from '@app/components/Login/JellyfinLogin'; import LocalLogin from '@app/components/Login/LocalLogin'; -import PlexLoginButton from '@app/components/PlexLoginButton'; +import PlexLoginButton from '@app/components/Login/PlexLoginButton'; import useSettings from '@app/hooks/useSettings'; import { useUser } from '@app/hooks/useUser'; import defineMessages from '@app/utils/defineMessages'; @@ -12,10 +16,10 @@ import { XCircleIcon } from '@heroicons/react/24/solid'; import { MediaServerType } from '@server/constants/server'; import { useRouter } from 'next/dist/client/router'; import Image from 'next/image'; -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { useIntl } from 'react-intl'; +import { CSSTransition, SwitchTransition } from 'react-transition-group'; import useSWR from 'swr'; -import JellyfinLogin from './JellyfinLogin'; const messages = defineMessages('components.Login', { signin: 'Sign In', @@ -23,16 +27,21 @@ const messages = defineMessages('components.Login', { signinwithplex: 'Use your Plex account', signinwithjellyfin: 'Use your {mediaServerName} account', signinwithoverseerr: 'Use your {applicationTitle} account', + orsigninwith: 'Or sign in with', }); const Login = () => { const intl = useIntl(); + const router = useRouter(); + const settings = useSettings(); + const { user, revalidate } = useUser(); + const [error, setError] = useState(''); const [isProcessing, setProcessing] = useState(false); const [authToken, setAuthToken] = useState(undefined); - const { user, revalidate } = useUser(); - const router = useRouter(); - const settings = useSettings(); + const [mediaServerLogin, setMediaServerLogin] = useState( + settings.currentSettings.mediaServerLogin + ); // Effect that is triggered when the `authToken` comes back from the Plex OAuth // We take the token and attempt to sign in. If we get a success message, we will @@ -86,14 +95,73 @@ const Login = () => { revalidateOnFocus: false, }); - const mediaServerFormatValues = { - mediaServerName: - settings.currentSettings.mediaServerType === MediaServerType.JELLYFIN - ? 'Jellyfin' - : settings.currentSettings.mediaServerType === MediaServerType.EMBY - ? 'Emby' - : undefined, - }; + const mediaServerName = + settings.currentSettings.mediaServerType === MediaServerType.PLEX + ? 'Plex' + : settings.currentSettings.mediaServerType === MediaServerType.JELLYFIN + ? 'Jellyfin' + : settings.currentSettings.mediaServerType === MediaServerType.EMBY + ? 'Emby' + : undefined; + + const MediaServerLogo = + settings.currentSettings.mediaServerType === MediaServerType.PLEX + ? PlexLogo + : settings.currentSettings.mediaServerType === MediaServerType.JELLYFIN + ? JellyfinLogo + : settings.currentSettings.mediaServerType === MediaServerType.EMBY + ? EmbyLogo + : undefined; + + const isJellyfin = + settings.currentSettings.mediaServerType === MediaServerType.JELLYFIN || + settings.currentSettings.mediaServerType === MediaServerType.EMBY; + const mediaServerLoginRef = useRef(null); + const localLoginRef = useRef(null); + const loginRef = mediaServerLogin ? mediaServerLoginRef : localLoginRef; + + const loginFormVisible = + (isJellyfin && settings.currentSettings.mediaServerLogin) || + settings.currentSettings.localLogin; + const additionalLoginOptions = [ + settings.currentSettings.mediaServerLogin && + (settings.currentSettings.mediaServerType === MediaServerType.PLEX ? ( + setAuthToken(authToken)} + large={!isJellyfin && !settings.currentSettings.localLogin} + /> + ) : ( + settings.currentSettings.localLogin && + (mediaServerLogin ? ( + + ) : ( + + )) + )), + ].filter((o): o is JSX.Element => !!o); return (
@@ -112,9 +180,6 @@ const Login = () => {
Logo
-

- {intl.formatMessage(messages.signinheader)} -

{
- - {({ openIndexes, handleClick, AccordionContent }) => ( - <> - - -
- {settings.currentSettings.mediaServerType == - MediaServerType.PLEX ? ( - setAuthToken(authToken)} - /> - ) : ( - - )} -
-
- {settings.currentSettings.localLogin && ( -
- - -
- -
-
-
- )} - - )} -
+
+ + { + loginRef.current?.addEventListener( + 'transitionend', + done, + false + ); + }} + onEntered={() => { + document + .querySelector('#email, #username') + ?.focus(); + }} + classNames={{ + appear: 'opacity-0', + appearActive: 'transition-opacity duration-500 opacity-100', + enter: 'opacity-0', + enterActive: 'transition-opacity duration-500 opacity-100', + exitActive: 'transition-opacity duration-0 opacity-0', + }} + > +
+ {isJellyfin && + (mediaServerLogin || + !settings.currentSettings.localLogin) ? ( + + ) : ( + settings.currentSettings.localLogin && ( + + ) + )} +
+
+
+ + {additionalLoginOptions.length > 0 && + (loginFormVisible ? ( +
+
+ + {intl.formatMessage(messages.orsigninwith)} + +
+
+ ) : ( +

+ {intl.formatMessage(messages.signinheader)} +

+ ))} + +
+ {additionalLoginOptions} +
+
diff --git a/src/components/PlexLoginButton/index.tsx b/src/components/PlexLoginButton/index.tsx deleted file mode 100644 index 3cf1d3ee3..000000000 --- a/src/components/PlexLoginButton/index.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import globalMessages from '@app/i18n/globalMessages'; -import defineMessages from '@app/utils/defineMessages'; -import PlexOAuth from '@app/utils/plex'; -import { ArrowLeftOnRectangleIcon } from '@heroicons/react/24/outline'; -import { useState } from 'react'; -import { useIntl } from 'react-intl'; - -const messages = defineMessages('components.PlexLoginButton', { - signinwithplex: 'Sign In', - signingin: 'Signing In…', -}); - -const plexOAuth = new PlexOAuth(); - -interface PlexLoginButtonProps { - onAuthToken: (authToken: string) => void; - isProcessing?: boolean; - onError?: (message: string) => void; -} - -const PlexLoginButton = ({ - onAuthToken, - onError, - isProcessing, -}: PlexLoginButtonProps) => { - const intl = useIntl(); - const [loading, setLoading] = useState(false); - - const getPlexLogin = async () => { - setLoading(true); - try { - const authToken = await plexOAuth.login(); - setLoading(false); - onAuthToken(authToken); - } catch (e) { - if (onError) { - onError(e.message); - } - setLoading(false); - } - }; - return ( - - - - ); -}; - -export default PlexLoginButton; diff --git a/src/components/Settings/SettingsUsers/index.tsx b/src/components/Settings/SettingsUsers/index.tsx index 7f6fa1fcf..8203360bd 100644 --- a/src/components/Settings/SettingsUsers/index.tsx +++ b/src/components/Settings/SettingsUsers/index.tsx @@ -1,4 +1,5 @@ import Button from '@app/components/Common/Button'; +import LabeledCheckbox from '@app/components/Common/LabeledCheckbox'; import LoadingSpinner from '@app/components/Common/LoadingSpinner'; import PageTitle from '@app/components/Common/PageTitle'; import PermissionEdit from '@app/components/PermissionEdit'; @@ -13,6 +14,7 @@ import { Field, Form, Formik } from 'formik'; import { useIntl } from 'react-intl'; import { useToasts } from 'react-toast-notifications'; import useSWR, { mutate } from 'swr'; +import * as yup from 'yup'; const messages = defineMessages('components.Settings.SettingsUsers', { users: 'Users', @@ -20,9 +22,15 @@ const messages = defineMessages('components.Settings.SettingsUsers', { userSettingsDescription: 'Configure global and default user settings.', toastSettingsSuccess: 'User settings saved successfully!', toastSettingsFailure: 'Something went wrong while saving settings.', + loginMethods: 'Login Methods', + loginMethodsTip: 'Configure login methods for users.', localLogin: 'Enable Local Sign-In', localLoginTip: - 'Allow users to sign in using their email address and password, instead of {mediaServerName} OAuth', + 'Allow users to sign in using their email address and password', + mediaServerLogin: 'Enable {mediaServerName} Sign-In', + mediaServerLoginTip: + 'Allow users to sign in using their {mediaServerName} account', + atLeastOneAuth: 'At least one authentication method must be selected.', newPlexLogin: 'Enable New {mediaServerName} Sign-In', newPlexLoginTip: 'Allow {mediaServerName} users to sign in without first being imported', @@ -42,6 +50,27 @@ const SettingsUsers = () => { } = useSWR('/api/v1/settings/main'); const settings = useSettings(); + const schema = yup + .object() + .shape({ + localLogin: yup.boolean(), + mediaServerLogin: yup.boolean(), + }) + .test({ + name: 'atLeastOneAuth', + test: function (values) { + const isValid = ['localLogin', 'mediaServerLogin'].some( + (field) => !!values[field] + ); + + if (isValid) return true; + return this.createError({ + path: 'localLogin | mediaServerLogin', + message: intl.formatMessage(messages.atLeastOneAuth), + }); + }, + }); + if (!data && !error) { return ; } @@ -52,6 +81,8 @@ const SettingsUsers = () => { ? 'Jellyfin' : settings.currentSettings.mediaServerType === MediaServerType.EMBY ? 'Emby' + : settings.currentSettings.mediaServerType === MediaServerType.PLEX + ? 'Plex' : undefined, }; @@ -73,6 +104,7 @@ const SettingsUsers = () => { { tvQuotaDays: data?.defaultQuotas.tv.quotaDays ?? 7, defaultPermissions: data?.defaultPermissions ?? 0, }} + validationSchema={schema} enableReinitialize onSubmit={async (values) => { try { @@ -90,6 +123,7 @@ const SettingsUsers = () => { }, body: JSON.stringify({ localLogin: values.localLogin, + mediaServerLogin: values.mediaServerLogin, newPlexLogin: values.newPlexLogin, defaultQuotas: { movie: { @@ -121,30 +155,61 @@ const SettingsUsers = () => { } }} > - {({ isSubmitting, values, setFieldValue }) => { + {({ isSubmitting, isValid, values, errors, setFieldValue }) => { return (
-
-
{serverType === MediaServerType.PLEX && ( <> -
+
{ setMediaServerType(MediaServerType.PLEX); setAuthToken(authToken); @@ -102,16 +100,14 @@ const SetupLogin: React.FC = ({ )} {serverType === MediaServerType.JELLYFIN && ( - )} {serverType === MediaServerType.EMBY && ( - void; + onError?: (err: string) => void; +}) { + const [loading, setLoading] = useState(false); + + const getPlexLogin = async () => { + setLoading(true); + try { + const authToken = await plexOAuth.login(); + setLoading(false); + onAuthToken(authToken); + } catch (e) { + if (onError) { + onError(e.message); + } + setLoading(false); + } + }; + + const login = () => { + plexOAuth.preparePopup(); + setTimeout(() => getPlexLogin(), 1500); + }; + + return { loading, login }; +} + +export default usePlexLogin; diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json index 1a18a3a40..bd2ce8647 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -246,7 +246,9 @@ "components.Login.initialsigningin": "Connecting…", "components.Login.invalidurlerror": "Unable to connect to {mediaServerName} server.", "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.", + "components.Login.orsigninwith": "Or sign in with", "components.Login.password": "Password", "components.Login.port": "Port", "components.Login.save": "Add", @@ -441,8 +443,6 @@ "components.PersonDetails.birthdate": "Born {birthdate}", "components.PersonDetails.crewmember": "Crew", "components.PersonDetails.lifespan": "{birthdate} – {deathdate}", - "components.PlexLoginButton.signingin": "Signing In…", - "components.PlexLoginButton.signinwithplex": "Sign In", "components.QuotaSelector.days": "{count, plural, one {day} other {days}}", "components.QuotaSelector.movieRequests": "{quotaLimit} {movies} per {quotaDays} {days}", "components.QuotaSelector.movies": "{count, plural, one {movie} other {movies}}", @@ -963,10 +963,15 @@ "components.Settings.SettingsNetwork.trustProxy": "Enable Proxy Support", "components.Settings.SettingsNetwork.trustProxyTip": "Allow Jellyseerr to correctly register client IP addresses behind a proxy", "components.Settings.SettingsNetwork.validationProxyPort": "You must provide a valid port", + "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.localLogin": "Enable Local Sign-In", - "components.Settings.SettingsUsers.localLoginTip": "Allow users to sign in using their email address and password, instead of {mediaServerName} OAuth", + "components.Settings.SettingsUsers.localLoginTip": "Allow users to sign in using their email address and password", + "components.Settings.SettingsUsers.loginMethods": "Login Methods", + "components.Settings.SettingsUsers.loginMethodsTip": "Configure login methods for users.", + "components.Settings.SettingsUsers.mediaServerLogin": "Enable {mediaServerName} Sign-In", + "components.Settings.SettingsUsers.mediaServerLoginTip": "Allow users to sign in using their {mediaServerName} account", "components.Settings.SettingsUsers.movieRequestLimitLabel": "Global Movie Request Limit", "components.Settings.SettingsUsers.newPlexLogin": "Enable New {mediaServerName} Sign-In", "components.Settings.SettingsUsers.newPlexLoginTip": "Allow {mediaServerName} users to sign in without first being imported", diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index facb3a44e..3ab8ab137 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -194,6 +194,7 @@ CoreApp.getInitialProps = async (initialProps) => { movie4kEnabled: false, series4kEnabled: false, localLogin: true, + mediaServerLogin: true, discoverRegion: '', streamingRegion: '', originalLanguage: '', diff --git a/src/styles/globals.css b/src/styles/globals.css index 1e99d53df..287336585 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -74,15 +74,6 @@ top: env(safe-area-inset-top); } - .plex-button { - @apply flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-center text-sm font-medium text-white transition duration-150 ease-in-out disabled:opacity-50; - background-color: #cc7b19; - } - - .plex-button:hover { - background: #f19a30; - } - .server-type-button { @apply rounded-md border border-gray-500 bg-gray-700 px-4 py-2 text-white transition duration-150 ease-in-out hover:bg-gray-500; } @@ -354,9 +345,8 @@ @apply relative -ml-px inline-flex items-center border border-gray-500 bg-indigo-600 bg-opacity-80 px-3 py-2 text-sm font-medium leading-5 text-white transition duration-150 ease-in-out last:rounded-r-md hover:bg-opacity-100 active:bg-gray-100 active:text-gray-700 sm:px-3.5; } - .button-md svg, - button.input-action svg, - .plex-button svg { + .button-md :where(svg), + button.input-action svg { @apply ml-2 mr-2 h-5 w-5 first:ml-0 last:mr-0; }