diff --git a/package.json b/package.json index cba4d61cf..4c151b9d9 100644 --- a/package.json +++ b/package.json @@ -85,6 +85,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", @@ -94,6 +95,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 f99449432..50f47deee 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -167,6 +167,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) @@ -194,6 +197,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)) @@ -8835,6 +8841,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'} @@ -11281,7 +11290,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 @@ -11311,7 +11320,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 @@ -11339,7 +11348,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 @@ -14240,13 +14249,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 @@ -15336,7 +15345,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: @@ -19352,7 +19361,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 @@ -19479,7 +19488,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: @@ -20260,6 +20269,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/lib/settings/index.ts b/server/lib/settings/index.ts index d29f329ea..3fb9a2beb 100644 --- a/server/lib/settings/index.ts +++ b/server/lib/settings/index.ts @@ -586,6 +586,7 @@ class Settings { 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/src/assets/services/jellyfin-icon.svg b/src/assets/services/jellyfin-icon.svg new file mode 100644 index 000000000..d4d7f0172 --- /dev/null +++ b/src/assets/services/jellyfin-icon.svg @@ -0,0 +1,24 @@ + + + + + + + + + + icon-transparent + + + + + 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}
- )} + +
+
+
- -
-
- -
+
{errors.password && touched.password && (
{errors.password}
)} -
-
-
- - - ); - }} - -
- ); - } +
+ + + + + ); + }} + +
+ ); }; export default JellyfinLogin; diff --git a/src/components/Login/LocalLogin.tsx b/src/components/Login/LocalLogin.tsx index 2f2e00ed5..9d078b9b9 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: 'Jellyseerr', + })} +

+ +
{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..e6c5d97fa --- /dev/null +++ b/src/components/Login/PlexLoginButton.tsx @@ -0,0 +1,61 @@ +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..eb6caf767 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,67 @@ 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 +174,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/Setup/JellyfinSetup.tsx b/src/components/Setup/JellyfinSetup.tsx new file mode 100644 index 000000000..0a19bffa0 --- /dev/null +++ b/src/components/Setup/JellyfinSetup.tsx @@ -0,0 +1,352 @@ +import Button from '@app/components/Common/Button'; +import Tooltip from '@app/components/Common/Tooltip'; +import defineMessages from '@app/utils/defineMessages'; +import { InformationCircleIcon } from '@heroicons/react/24/solid'; +import { ApiErrorCode } from '@server/constants/error'; +import { MediaServerType, ServerType } from '@server/constants/server'; +import { Field, Form, Formik } from 'formik'; +import { FormattedMessage, useIntl } from 'react-intl'; +import { useToasts } from 'react-toast-notifications'; +import * as Yup from 'yup'; + +const messages = defineMessages('components.Login', { + username: 'Username', + password: 'Password', + hostname: '{mediaServerName} URL', + port: 'Port', + enablessl: 'Use SSL', + urlBase: 'URL Base', + email: 'Email Address', + emailtooltip: + 'Address does not need to be associated with your {mediaServerName} instance.', + validationhostrequired: '{mediaServerName} URL required', + validationhostformat: 'Valid URL required', + validationemailrequired: 'You must provide a valid email address', + validationemailformat: 'Valid email required', + validationusernamerequired: 'Username required', + validationpasswordrequired: 'You must provide a password', + validationservertyperequired: 'Please select a server type', + validationHostnameRequired: 'You must provide a valid hostname or IP address', + validationPortRequired: 'You must provide a valid port number', + validationUrlTrailingSlash: 'URL must not end in a trailing slash', + validationUrlBaseLeadingSlash: 'URL base must have a leading slash', + validationUrlBaseTrailingSlash: 'URL base must not end in a trailing slash', + loginerror: 'Something went wrong while trying to sign in.', + adminerror: 'You must use an admin account to sign in.', + noadminerror: 'No admin user found on the server.', + credentialerror: 'The username or password is incorrect.', + invalidurlerror: 'Unable to connect to {mediaServerName} server.', + signingin: 'Signing In…', + signin: 'Sign In', + initialsigningin: 'Connecting…', + initialsignin: 'Connect', + forgotpassword: 'Forgot Password?', + servertype: 'Server Type', + back: 'Go back', +}); + +interface JellyfinSetupProps { + revalidate: () => void; + serverType?: MediaServerType; + onCancel?: () => void; +} + +function JellyfinSetup({ + revalidate, + serverType, + onCancel, +}: JellyfinSetupProps) { + const toasts = useToasts(); + const intl = useIntl(); + + const mediaServerFormatValues = { + mediaServerName: + serverType === MediaServerType.JELLYFIN + ? ServerType.JELLYFIN + : serverType === MediaServerType.EMBY + ? ServerType.EMBY + : 'Media Server', + }; + + const LoginSchema = Yup.object().shape({ + hostname: Yup.string().required( + intl.formatMessage( + messages.validationhostrequired, + mediaServerFormatValues + ) + ), + port: Yup.number().required( + intl.formatMessage(messages.validationPortRequired) + ), + urlBase: Yup.string() + .test( + 'leading-slash', + intl.formatMessage(messages.validationUrlBaseLeadingSlash), + (value) => !value || value.startsWith('/') + ) + .test( + 'trailing-slash', + intl.formatMessage(messages.validationUrlBaseTrailingSlash), + (value) => !value || !value.endsWith('/') + ), + email: Yup.string() + .email(intl.formatMessage(messages.validationemailformat)) + .required(intl.formatMessage(messages.validationemailrequired)), + username: Yup.string().required( + intl.formatMessage(messages.validationusernamerequired) + ), + password: Yup.string(), + }); + + return ( + { + try { + // Check if serverType is either 'Jellyfin' or 'Emby' + // if (serverType !== 'Jellyfin' && serverType !== 'Emby') { + // throw new Error('Invalid serverType'); // You can customize the error message + // } + + const res = await fetch('/api/v1/auth/jellyfin', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + username: values.username, + password: values.password, + hostname: values.hostname, + port: values.port, + useSsl: values.useSsl, + urlBase: values.urlBase, + email: values.email, + serverType: serverType, + }), + }); + 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, values, setFieldValue, isSubmitting, isValid }) => ( +
+
+
+
+ +
+
+ + {values.useSsl ? 'https://' : 'http://'} + + +
+ {errors.hostname && touched.hostname && ( +
{errors.hostname}
+ )} +
+
+
+ +
+ + {errors.port && touched.port && ( +
{errors.port}
+ )} +
+
+
+ +
+
+ { + setFieldValue('useSsl', !values.useSsl); + setFieldValue('port', values.useSsl ? 8096 : 443); + }} + /> +
+
+ +
+
+ +
+ {errors.urlBase && touched.urlBase && ( +
{errors.urlBase}
+ )} +
+ +
+
+ +
+ {errors.email && touched.email && ( +
{errors.email}
+ )} +
+ +
+
+ +
+ {errors.username && touched.username && ( +
{errors.username}
+ )} +
+ +
+
+ +
+ {errors.password && touched.password && ( +
{errors.password}
+ )} +
+
+
+
+ + + + {onCancel && ( + + + + )} +
+
+
+ )} +
+ ); +} + +export default JellyfinSetup; diff --git a/src/components/Setup/LoginWithPlex.tsx b/src/components/Setup/LoginWithPlex.tsx index 15ffe9562..c69212ee8 100644 --- a/src/components/Setup/LoginWithPlex.tsx +++ b/src/components/Setup/LoginWithPlex.tsx @@ -1,4 +1,4 @@ -import PlexLoginButton from '@app/components/PlexLoginButton'; +import PlexLoginButton from '@app/components/Login/PlexLoginButton'; import { useUser } from '@app/hooks/useUser'; import defineMessages from '@app/utils/defineMessages'; import { useEffect, useState } from 'react'; diff --git a/src/components/Setup/SetupLogin.tsx b/src/components/Setup/SetupLogin.tsx index 9af4de9d9..16a18770c 100644 --- a/src/components/Setup/SetupLogin.tsx +++ b/src/components/Setup/SetupLogin.tsx @@ -1,6 +1,6 @@ import Button from '@app/components/Common/Button'; -import JellyfinLogin from '@app/components/Login/JellyfinLogin'; -import PlexLoginButton from '@app/components/PlexLoginButton'; +import PlexLoginButton from '@app/components/Login/PlexLoginButton'; +import JellyfinSetup from '@app/components/Setup/JellyfinSetup'; import { useUser } from '@app/hooks/useUser'; import defineMessages from '@app/utils/defineMessages'; import { MediaServerType } from '@server/constants/server'; @@ -83,11 +83,9 @@ const SetupLogin: React.FC = ({ {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 a5ce5053b..671770332 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}}", 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; }