diff --git a/cypress/support/commands.ts b/cypress/support/commands.ts index 0eb9c869..a23cb5e6 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 ebe547ef..0fdeb7db 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 6e6500ed..74512020 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 de1247df..07a8ac57 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 017eef85..0e97c2bf 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 258dfe2f..7fc09fb3 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 cbfbc3f7..31c846ad 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 a4df3115..ac1c330c 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}
- )} + +
+
+
- -
-
- -
+
{errors.password && touched.password && (
{errors.password}
)} -
-
-
- - - ); - }} - -
- ); - } +
+ + + + + ); + }} + +
+ ); }; export default JellyfinLogin; diff --git a/src/components/Login/LocalLogin.tsx b/src/components/Login/LocalLogin.tsx index 2f2e00ed..2372bc7f 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 00000000..111b95d3 --- /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 7b95b9fc..0b51e86f 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 3cf1d3ee..00000000 --- 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 7f6fa1fc..8203360b 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 1a18a3a4..bd2ce864 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 facb3a44..3ab8ab13 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 1e99d53d..28733658 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; }