Compare commits

...

3 Commits

Author SHA1 Message Date
Michael Thomas
321865f916 test: update cypress login command 2025-02-16 10:33:33 -05:00
Michael Thomas
1b84c89300 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.
2025-02-16 10:33:33 -05:00
Michael Thomas
3f6e5cc84a feat: support disabling jellyfin login 2025-02-16 10:33:33 -05:00
24 changed files with 945 additions and 639 deletions

View File

@@ -6,7 +6,6 @@ Cypress.Commands.add('login', (email, password) => {
[email, password], [email, password],
() => { () => {
cy.visit('/login'); cy.visit('/login');
cy.contains('Use your Overseerr account').click();
cy.get('[data-testid=email]').type(email); cy.get('[data-testid=email]').type(email);
cy.get('[data-testid=password]').type(password); cy.get('[data-testid=password]').type(password);

View File

@@ -14,6 +14,14 @@ When disabled, your mediaserver OAuth becomes the only sign-in option, and any "
This setting is **enabled** by default. 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 ## 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. 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.

View File

@@ -86,6 +86,7 @@
"react-spring": "9.7.1", "react-spring": "9.7.1",
"react-tailwindcss-datepicker-sct": "1.3.4", "react-tailwindcss-datepicker-sct": "1.3.4",
"react-toast-notifications": "2.5.1", "react-toast-notifications": "2.5.1",
"react-transition-group": "^4.4.5",
"react-truncate-markup": "5.1.2", "react-truncate-markup": "5.1.2",
"react-use-clipboard": "1.0.9", "react-use-clipboard": "1.0.9",
"reflect-metadata": "0.1.13", "reflect-metadata": "0.1.13",
@@ -95,6 +96,7 @@
"sqlite3": "5.1.4", "sqlite3": "5.1.4",
"swagger-ui-express": "4.6.2", "swagger-ui-express": "4.6.2",
"swr": "2.2.5", "swr": "2.2.5",
"tailwind-merge": "^2.6.0",
"typeorm": "0.3.11", "typeorm": "0.3.11",
"undici": "^6.20.1", "undici": "^6.20.1",
"web-push": "3.5.0", "web-push": "3.5.0",

27
pnpm-lock.yaml generated
View File

@@ -170,6 +170,9 @@ importers:
react-toast-notifications: react-toast-notifications:
specifier: 2.5.1 specifier: 2.5.1
version: 2.5.1(react-dom@18.3.1(react@18.3.1))(react@18.3.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: react-truncate-markup:
specifier: 5.1.2 specifier: 5.1.2
version: 5.1.2(react@18.3.1) version: 5.1.2(react@18.3.1)
@@ -197,6 +200,9 @@ importers:
swr: swr:
specifier: 2.2.5 specifier: 2.2.5
version: 2.2.5(react@18.3.1) version: 2.2.5(react@18.3.1)
tailwind-merge:
specifier: ^2.6.0
version: 2.6.0
typeorm: typeorm:
specifier: 0.3.11 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)) 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: peerDependencies:
react: ^16.11.0 || ^17.0.0 || ^18.0.0 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: tailwindcss@3.2.7:
resolution: {integrity: sha512-B6DLqJzc21x7wntlH/GsZwEXTBttVSl1FtCzC8WP4oBc/NKef7kaax5jeihkkCEWc831/5NDJ9gRNDK6NEioQQ==} resolution: {integrity: sha512-B6DLqJzc21x7wntlH/GsZwEXTBttVSl1FtCzC8WP4oBc/NKef7kaax5jeihkkCEWc831/5NDJ9gRNDK6NEioQQ==}
engines: {node: '>=12.13.0'} engines: {node: '>=12.13.0'}
@@ -11293,7 +11302,7 @@ snapshots:
'@emotion/babel-plugin@11.11.0': '@emotion/babel-plugin@11.11.0':
dependencies: dependencies:
'@babel/helper-module-imports': 7.24.7 '@babel/helper-module-imports': 7.24.7
'@babel/runtime': 7.24.7 '@babel/runtime': 7.26.0
'@emotion/hash': 0.9.1 '@emotion/hash': 0.9.1
'@emotion/memoize': 0.8.1 '@emotion/memoize': 0.8.1
'@emotion/serialize': 1.1.4 '@emotion/serialize': 1.1.4
@@ -11323,7 +11332,7 @@ snapshots:
'@emotion/core@10.3.1(react@18.3.1)': '@emotion/core@10.3.1(react@18.3.1)':
dependencies: dependencies:
'@babel/runtime': 7.24.7 '@babel/runtime': 7.26.0
'@emotion/cache': 10.0.29 '@emotion/cache': 10.0.29
'@emotion/css': 10.0.27 '@emotion/css': 10.0.27
'@emotion/serialize': 0.11.16 '@emotion/serialize': 0.11.16
@@ -11351,7 +11360,7 @@ snapshots:
'@emotion/react@11.11.4(@types/react@18.3.3)(react@18.3.1)': '@emotion/react@11.11.4(@types/react@18.3.3)(react@18.3.1)':
dependencies: dependencies:
'@babel/runtime': 7.24.7 '@babel/runtime': 7.26.0
'@emotion/babel-plugin': 11.11.0 '@emotion/babel-plugin': 11.11.0
'@emotion/cache': 11.11.0 '@emotion/cache': 11.11.0
'@emotion/serialize': 1.1.4 '@emotion/serialize': 1.1.4
@@ -14254,13 +14263,13 @@ snapshots:
babel-plugin-macros@2.8.0: babel-plugin-macros@2.8.0:
dependencies: dependencies:
'@babel/runtime': 7.24.7 '@babel/runtime': 7.26.0
cosmiconfig: 6.0.0 cosmiconfig: 6.0.0
resolve: 1.22.8 resolve: 1.22.8
babel-plugin-macros@3.1.0: babel-plugin-macros@3.1.0:
dependencies: dependencies:
'@babel/runtime': 7.24.7 '@babel/runtime': 7.26.0
cosmiconfig: 7.1.0 cosmiconfig: 7.1.0
resolve: 1.22.8 resolve: 1.22.8
@@ -15350,7 +15359,7 @@ snapshots:
dom-helpers@5.2.1: dom-helpers@5.2.1:
dependencies: dependencies:
'@babel/runtime': 7.24.7 '@babel/runtime': 7.26.0
csstype: 3.1.3 csstype: 3.1.3
dom-serializer@1.4.1: 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): react-transition-group@4.4.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies: dependencies:
'@babel/runtime': 7.24.7 '@babel/runtime': 7.26.0
dom-helpers: 5.2.1 dom-helpers: 5.2.1
loose-envify: 1.4.0 loose-envify: 1.4.0
prop-types: 15.8.1 prop-types: 15.8.1
@@ -19493,7 +19502,7 @@ snapshots:
regenerator-transform@0.15.2: regenerator-transform@0.15.2:
dependencies: dependencies:
'@babel/runtime': 7.24.7 '@babel/runtime': 7.26.0
regexp.prototype.flags@1.5.2: regexp.prototype.flags@1.5.2:
dependencies: dependencies:
@@ -20274,6 +20283,8 @@ snapshots:
react: 18.3.1 react: 18.3.1
use-sync-external-store: 1.2.2(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)): 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: dependencies:
arg: 5.0.2 arg: 5.0.2

View File

@@ -30,6 +30,7 @@ export interface PublicSettingsResponse {
applicationUrl: string; applicationUrl: string;
hideAvailable: boolean; hideAvailable: boolean;
localLogin: boolean; localLogin: boolean;
mediaServerLogin: boolean;
movie4kEnabled: boolean; movie4kEnabled: boolean;
series4kEnabled: boolean; series4kEnabled: boolean;
discoverRegion: string; discoverRegion: string;

View File

@@ -124,6 +124,7 @@ export interface MainSettings {
}; };
hideAvailable: boolean; hideAvailable: boolean;
localLogin: boolean; localLogin: boolean;
mediaServerLogin: boolean;
newPlexLogin: boolean; newPlexLogin: boolean;
discoverRegion: string; discoverRegion: string;
streamingRegion: string; streamingRegion: string;
@@ -147,6 +148,7 @@ interface FullPublicSettings extends PublicSettings {
applicationUrl: string; applicationUrl: string;
hideAvailable: boolean; hideAvailable: boolean;
localLogin: boolean; localLogin: boolean;
mediaServerLogin: boolean;
movie4kEnabled: boolean; movie4kEnabled: boolean;
series4kEnabled: boolean; series4kEnabled: boolean;
discoverRegion: string; discoverRegion: string;
@@ -340,6 +342,7 @@ class Settings {
}, },
hideAvailable: false, hideAvailable: false,
localLogin: true, localLogin: true,
mediaServerLogin: true,
newPlexLogin: true, newPlexLogin: true,
discoverRegion: '', discoverRegion: '',
streamingRegion: '', streamingRegion: '',
@@ -582,6 +585,8 @@ class Settings {
applicationUrl: this.data.main.applicationUrl, applicationUrl: this.data.main.applicationUrl,
hideAvailable: this.data.main.hideAvailable, hideAvailable: this.data.main.hideAvailable,
localLogin: this.data.main.localLogin, localLogin: this.data.main.localLogin,
mediaServerLogin: this.data.main.mediaServerLogin,
jellyfinExternalHost: this.data.jellyfin.externalHostname,
jellyfinForgotPasswordUrl: this.data.jellyfin.jellyfinForgotPasswordUrl, jellyfinForgotPasswordUrl: this.data.jellyfin.jellyfinForgotPasswordUrl,
movie4kEnabled: this.data.radarr.some( movie4kEnabled: this.data.radarr.some(
(radarr) => radarr.is4k && radarr.isDefault (radarr) => radarr.is4k && radarr.isDefault

View File

@@ -56,8 +56,9 @@ authRoutes.post('/plex', async (req, res, next) => {
} }
if ( 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' }); 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 //Make sure jellyfin login is enabled, but only if jellyfin && Emby is not already configured
if ( if (
settings.main.mediaServerType !== MediaServerType.JELLYFIN && // media server not configured, allow login for setup
settings.main.mediaServerType !== MediaServerType.EMBY &&
settings.main.mediaServerType != MediaServerType.NOT_CONFIGURED && 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' }); return res.status(500).json({ error: 'Jellyfin login is disabled' });
} }

View File

@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- ***** BEGIN LICENSE BLOCK *****
- Part of the Jellyfin project (https://jellyfin.media)
-
- All copyright belongs to the Jellyfin contributors; a full list can
- be found in the file CONTRIBUTORS.md
-
- This work is licensed under the Creative Commons Attribution-ShareAlike 4.0 International License.
- To view a copy of this license, visit http://creativecommons.org/licenses/by-sa/4.0/.
- ***** END LICENSE BLOCK ***** -->
<svg version="1.1" id="icon-transparent" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 512 512">
<defs>
<linearGradient id="linear-gradient" gradientUnits="userSpaceOnUse" x1="110.25" y1="213.3" x2="496.14" y2="436.09">
<stop offset="0" style="stop-color:#AA5CC3"/>
<stop offset="1" style="stop-color:#00A4DC"/>
</linearGradient>
</defs>
<title>icon-transparent</title>
<g id="icon-transparent">
<path id="inner-shape" d="M256,201.6c-20.4,0-86.2,119.3-76.2,139.4s142.5,19.9,152.4,0S276.5,201.6,256,201.6z" fill="url(#linear-gradient)"/>
<path id="outer-shape" d="M256,23.3c-61.6,0-259.8,359.4-229.6,420.1s429.3,60,459.2,0S317.6,23.3,256,23.3z
M406.5,390.8c-19.6,39.3-281.1,39.8-300.9,0s110.1-275.3,150.4-275.3S426.1,351.4,406.5,390.8z" fill="url(#linear-gradient)"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -1,5 +1,6 @@
import type { ForwardedRef } from 'react'; import type { ForwardedRef } from 'react';
import React from 'react'; import React from 'react';
import { twMerge } from 'tailwind-merge';
export type ButtonType = export type ButtonType =
| 'default' | 'default'
@@ -97,7 +98,7 @@ function Button<P extends ElementTypes = 'button'>(
if (as === 'a') { if (as === 'a') {
return ( return (
<a <a
className={buttonStyle.join(' ')} className={twMerge(buttonStyle)}
{...(props as React.ComponentProps<'a'>)} {...(props as React.ComponentProps<'a'>)}
ref={ref as ForwardedRef<HTMLAnchorElement>} ref={ref as ForwardedRef<HTMLAnchorElement>}
> >
@@ -107,7 +108,7 @@ function Button<P extends ElementTypes = 'button'>(
} else { } else {
return ( return (
<button <button
className={buttonStyle.join(' ')} className={twMerge(buttonStyle)}
{...(props as React.ComponentProps<'button'>)} {...(props as React.ComponentProps<'button'>)}
ref={ref as ForwardedRef<HTMLButtonElement>} ref={ref as ForwardedRef<HTMLButtonElement>}
> >

View File

@@ -0,0 +1,44 @@
import { Field } from 'formik';
import { twMerge } from 'tailwind-merge';
interface LabeledCheckboxProps {
id: string;
className?: string;
label: string;
description: string;
onChange: () => void;
children?: React.ReactNode;
}
const LabeledCheckbox: React.FC<LabeledCheckboxProps> = ({
id,
className,
label,
description,
onChange,
children,
}) => {
return (
<>
<div className={twMerge('relative flex items-start', className)}>
<div className="flex h-6 items-center">
<Field type="checkbox" id={id} name={id} onChange={onChange} />
</div>
<div className="ml-3 text-sm leading-6">
<label htmlFor="localLogin" className="block">
<div className="flex flex-col">
<span className="font-medium text-white">{label}</span>
<span className="font-normal text-gray-400">{description}</span>
</div>
</label>
</div>
</div>
{
/* can hold child checkboxes */
children && <div className="mt-4 pl-10">{children}</div>
}
</>
);
};
export default LabeledCheckbox;

View File

@@ -1,63 +1,39 @@
import Button from '@app/components/Common/Button'; import Button from '@app/components/Common/Button';
import Tooltip from '@app/components/Common/Tooltip'; import SensitiveInput from '@app/components/Common/SensitiveInput';
import useSettings from '@app/hooks/useSettings'; import useSettings from '@app/hooks/useSettings';
import defineMessages from '@app/utils/defineMessages'; import defineMessages from '@app/utils/defineMessages';
import { InformationCircleIcon } from '@heroicons/react/24/solid'; import { ArrowLeftOnRectangleIcon } from '@heroicons/react/24/outline';
import { ApiErrorCode } from '@server/constants/error'; import { ApiErrorCode } from '@server/constants/error';
import { MediaServerType, ServerType } from '@server/constants/server'; import { MediaServerType, ServerType } from '@server/constants/server';
import { Field, Form, Formik } from 'formik'; import { Field, Form, Formik } from 'formik';
import { FormattedMessage, useIntl } from 'react-intl'; import { useIntl } from 'react-intl';
import { useToasts } from 'react-toast-notifications'; import { useToasts } from 'react-toast-notifications';
import * as Yup from 'yup'; import * as Yup from 'yup';
const messages = defineMessages('components.Login', { const messages = defineMessages('components.Login', {
loginwithapp: 'Login with {appName}',
username: 'Username', username: 'Username',
password: 'Password', password: 'Password',
hostname: '{mediaServerName} URL',
port: 'Port',
enablessl: 'Use SSL',
urlBase: 'URL Base',
email: 'Email',
emailtooltip:
'Address does not need to be associated with your {mediaServerName} instance.',
validationhostrequired: '{mediaServerName} URL required',
validationhostformat: 'Valid URL required',
validationemailrequired: 'Email required',
validationemailformat: 'Valid email required',
validationusernamerequired: 'Username required', validationusernamerequired: 'Username required',
validationpasswordrequired: 'Password required', validationpasswordrequired: 'Password required',
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.', loginerror: 'Something went wrong while trying to sign in.',
adminerror: 'You must use an admin account to sign in.', adminerror: 'You must use an admin account to sign in.',
noadminerror: 'No admin user found on the server.', noadminerror: 'No admin user found on the server.',
credentialerror: 'The username or password is incorrect.', credentialerror: 'The username or password is incorrect.',
invalidurlerror: 'Unable to connect to {mediaServerName} server.', invalidurlerror: 'Unable to connect to {mediaServerName} server.',
signingin: 'Signing in…', signingin: 'Signing In…',
signin: 'Sign In', signin: 'Sign In',
initialsigningin: 'Connecting…',
initialsignin: 'Connect',
forgotpassword: 'Forgot Password?', forgotpassword: 'Forgot Password?',
servertype: 'Server Type',
back: 'Go back',
}); });
interface JellyfinLoginProps { interface JellyfinLoginProps {
revalidate: () => void; revalidate: () => void;
initial?: boolean;
serverType?: MediaServerType; serverType?: MediaServerType;
onCancel?: () => void;
} }
const JellyfinLogin: React.FC<JellyfinLoginProps> = ({ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
revalidate, revalidate,
initial,
serverType, serverType,
onCancel,
}) => { }) => {
const toasts = useToasts(); const toasts = useToasts();
const intl = useIntl(); const intl = useIntl();
@@ -72,294 +48,6 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
: 'Media Server', : 'Media Server',
}; };
if (initial) {
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 (
<Formik
initialValues={{
username: '',
password: '',
hostname: '',
port: 8096,
useSsl: false,
urlBase: '',
email: '',
}}
validationSchema={LoginSchema}
onSubmit={async (values) => {
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,
}) => (
<Form>
<div className="sm:border-t sm:border-gray-800">
<div className="flex flex-col sm:flex-row sm:gap-4">
<div className="w-full">
<label htmlFor="hostname" className="text-label">
{intl.formatMessage(
messages.hostname,
mediaServerFormatValues
)}
</label>
<div className="mt-1 mb-2 sm:col-span-2 sm:mb-0 sm:mt-0">
<div className="flex rounded-md shadow-sm">
<span className="inline-flex cursor-default items-center rounded-l-md border border-r-0 border-gray-500 bg-gray-800 px-3 text-gray-100 sm:text-sm">
{values.useSsl ? 'https://' : 'http://'}
</span>
<Field
id="hostname"
name="hostname"
type="text"
className="rounded-r-only flex-1"
placeholder={intl.formatMessage(
messages.hostname,
mediaServerFormatValues
)}
/>
</div>
{errors.hostname && touched.hostname && (
<div className="error">{errors.hostname}</div>
)}
</div>
</div>
<div className="flex-1">
<label htmlFor="port" className="text-label">
{intl.formatMessage(messages.port)}
</label>
<div className="mt-1 sm:mt-0">
<Field
id="port"
name="port"
inputMode="numeric"
type="text"
className="short flex-1"
placeholder={intl.formatMessage(messages.port)}
/>
{errors.port && touched.port && (
<div className="error">{errors.port}</div>
)}
</div>
</div>
</div>
<label htmlFor="useSsl" className="text-label mt-2">
{intl.formatMessage(messages.enablessl)}
</label>
<div className="mt-1 mb-2 sm:col-span-2">
<div className="flex rounded-md shadow-sm">
<Field
id="useSsl"
name="useSsl"
type="checkbox"
onChange={() => {
setFieldValue('useSsl', !values.useSsl);
setFieldValue('port', values.useSsl ? 8096 : 443);
}}
/>
</div>
</div>
<label htmlFor="urlBase" className="text-label mt-1">
{intl.formatMessage(messages.urlBase)}
</label>
<div className="mt-1 mb-2 sm:col-span-2 sm:mt-0">
<div className="flex rounded-md shadow-sm">
<Field
type="text"
inputMode="url"
id="urlBase"
name="urlBase"
placeholder={intl.formatMessage(messages.urlBase)}
/>
</div>
{errors.urlBase && touched.urlBase && (
<div className="error">{errors.urlBase}</div>
)}
</div>
<label
htmlFor="email"
className="text-label inline-flex gap-1 align-middle"
>
{intl.formatMessage(messages.email)}
<span className="label-tip">
<Tooltip
content={intl.formatMessage(
messages.emailtooltip,
mediaServerFormatValues
)}
>
<span className="tooltip-trigger">
<InformationCircleIcon className="h-4 w-4" />
</span>
</Tooltip>
</span>
</label>
<div className="mt-1 sm:col-span-2 sm:mb-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>
)}
</div>
<label htmlFor="username" className="text-label">
{intl.formatMessage(messages.username)}
</label>
<div className="mt-1 mb-2 sm:col-span-2 sm:mt-0">
<div className="flex rounded-md shadow-sm">
<Field
id="username"
name="username"
type="text"
placeholder={intl.formatMessage(messages.username)}
/>
</div>
{errors.username && touched.username && (
<div className="error">{errors.username}</div>
)}
</div>
<label htmlFor="password" className="text-label">
{intl.formatMessage(messages.password)}
</label>
<div className="mt-1 mb-2 sm:col-span-2 sm:mt-0">
<div className="flexrounded-md shadow-sm">
<Field
id="password"
name="password"
type="password"
placeholder={intl.formatMessage(messages.password)}
/>
</div>
{errors.password && touched.password && (
<div className="error">{errors.password}</div>
)}
</div>
</div>
<div className="mt-8 border-t border-gray-700 pt-5">
<div className="flex flex-row-reverse justify-between">
<span className="inline-flex rounded-md shadow-sm">
<Button
buttonType="primary"
type="submit"
disabled={isSubmitting || !isValid}
>
{isSubmitting
? intl.formatMessage(messages.signingin)
: intl.formatMessage(messages.signin)}
</Button>
</span>
{onCancel && (
<span className="inline-flex rounded-md shadow-sm">
<Button buttonType="default" onClick={() => onCancel()}>
<FormattedMessage {...messages.back} />
</Button>
</span>
)}
</div>
</div>
</Form>
)}
</Formik>
);
} else {
const LoginSchema = Yup.object().shape({ const LoginSchema = Yup.object().shape({
username: Yup.string().required( username: Yup.string().required(
intl.formatMessage(messages.validationusernamerequired) intl.formatMessage(messages.validationusernamerequired)
@@ -371,6 +59,7 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
: settings.currentSettings.jellyfinHost; : settings.currentSettings.jellyfinHost;
const jellyfinForgotPasswordUrl = const jellyfinForgotPasswordUrl =
settings.currentSettings.jellyfinForgotPasswordUrl; settings.currentSettings.jellyfinForgotPasswordUrl;
return ( return (
<div> <div>
<Formik <Formik
@@ -379,6 +68,7 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
password: '', password: '',
}} }}
validationSchema={LoginSchema} validationSchema={LoginSchema}
validateOnBlur={false}
onSubmit={async (values) => { onSubmit={async (values) => {
try { try {
const res = await fetch('/api/v1/auth/jellyfin', { const res = await fetch('/api/v1/auth/jellyfin', {
@@ -435,46 +125,47 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
return ( return (
<> <>
<Form> <Form>
<div className="sm:border-t sm:border-gray-800"> <div>
<label htmlFor="username" className="text-label"> <h2 className="mb-6 -mt-1 text-center text-lg font-bold text-neutral-200">
{intl.formatMessage(messages.username)} {intl.formatMessage(messages.loginwithapp, {
</label> appName: mediaServerFormatValues.mediaServerName,
<div className="mt-1 mb-2 sm:col-span-2 sm:mt-0"> })}
<div className="flex max-w-lg rounded-md shadow-sm"> </h2>
<div className="mt-1 mb-4">
<div className="form-input-field">
<Field <Field
id="username" id="username"
name="username" name="username"
type="text" type="text"
placeholder={intl.formatMessage(messages.username)} placeholder={intl.formatMessage(messages.username)}
className="!bg-gray-700/80 placeholder:text-gray-400"
/> />
</div> </div>
{errors.username && touched.username && ( {errors.username && touched.username && (
<div className="error">{errors.username}</div> <div className="error">{errors.username}</div>
)} )}
</div> </div>
<label htmlFor="password" className="text-label">
{intl.formatMessage(messages.password)} <div className="mt-1 mb-2">
</label> <div className="form-input-field">
<div className="mt-1 mb-2 sm:col-span-2 sm:mt-0"> <SensitiveInput
<div className="flex max-w-lg rounded-md shadow-sm"> as="field"
<Field
id="password" id="password"
name="password" name="password"
type="password" type="password"
autoComplete="current-password"
placeholder={intl.formatMessage(messages.password)} placeholder={intl.formatMessage(messages.password)}
className="!bg-gray-700/80 placeholder:text-gray-400"
/> />
</div> </div>
<div className="flex">
{errors.password && touched.password && ( {errors.password && touched.password && (
<div className="error">{errors.password}</div> <div className="error">{errors.password}</div>
)} )}
</div> <div className="flex-grow"></div>
</div> {baseUrl && (
<div className="mt-8 border-t border-gray-700 pt-5"> <a
<div className="flex justify-between">
<span className="inline-flex rounded-md shadow-sm">
<Button
as="a"
buttonType="ghost"
href={ href={
jellyfinForgotPasswordUrl jellyfinForgotPasswordUrl
? `${jellyfinForgotPasswordUrl}` ? `${jellyfinForgotPasswordUrl}`
@@ -485,23 +176,28 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
: '' : ''
}forgotpassword.html` }forgotpassword.html`
} }
className="pt-2 text-sm text-indigo-500 hover:text-indigo-400"
> >
{intl.formatMessage(messages.forgotpassword)} {intl.formatMessage(messages.forgotpassword)}
</Button> </a>
</span> )}
<span className="inline-flex rounded-md shadow-sm"> </div>
</div>
</div>
<Button <Button
buttonType="primary" buttonType="primary"
type="submit" type="submit"
disabled={isSubmitting || !isValid} disabled={isSubmitting || !isValid}
className="mt-2 w-full shadow-sm"
> >
<ArrowLeftOnRectangleIcon />
<span>
{isSubmitting {isSubmitting
? intl.formatMessage(messages.signingin) ? intl.formatMessage(messages.signingin)
: intl.formatMessage(messages.signin)} : intl.formatMessage(messages.signin)}
</Button>
</span> </span>
</div> </Button>
</div>
</Form> </Form>
</> </>
); );
@@ -509,7 +205,6 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
</Formik> </Formik>
</div> </div>
); );
}
}; };
export default JellyfinLogin; export default JellyfinLogin;

View File

@@ -2,10 +2,7 @@ import Button from '@app/components/Common/Button';
import SensitiveInput from '@app/components/Common/SensitiveInput'; import SensitiveInput from '@app/components/Common/SensitiveInput';
import useSettings from '@app/hooks/useSettings'; import useSettings from '@app/hooks/useSettings';
import defineMessages from '@app/utils/defineMessages'; import defineMessages from '@app/utils/defineMessages';
import { import { ArrowLeftOnRectangleIcon } from '@heroicons/react/24/outline';
ArrowLeftOnRectangleIcon,
LifebuoyIcon,
} from '@heroicons/react/24/outline';
import { Field, Form, Formik } from 'formik'; import { Field, Form, Formik } from 'formik';
import Link from 'next/link'; import Link from 'next/link';
import { useState } from 'react'; import { useState } from 'react';
@@ -13,6 +10,7 @@ import { useIntl } from 'react-intl';
import * as Yup from 'yup'; import * as Yup from 'yup';
const messages = defineMessages('components.Login', { const messages = defineMessages('components.Login', {
loginwithapp: 'Login with {appName}',
username: 'Username', username: 'Username',
email: 'Email Address', email: 'Email Address',
password: 'Password', password: 'Password',
@@ -53,6 +51,7 @@ const LocalLogin = ({ revalidate }: LocalLoginProps) => {
password: '', password: '',
}} }}
validationSchema={LoginSchema} validationSchema={LoginSchema}
validateOnBlur={false}
onSubmit={async (values) => { onSubmit={async (values) => {
try { try {
const res = await fetch('/api/v1/auth/local', { const res = await fetch('/api/v1/auth/local', {
@@ -78,19 +77,24 @@ const LocalLogin = ({ revalidate }: LocalLoginProps) => {
<> <>
<Form> <Form>
<div> <div>
<label htmlFor="email" className="text-label"> <h2 className="mb-6 -mt-1 text-center text-lg font-bold text-neutral-200">
{intl.formatMessage(messages.email) + {intl.formatMessage(messages.loginwithapp, {
' / ' + appName: settings.currentSettings.applicationTitle,
intl.formatMessage(messages.username)} })}
</label> </h2>
<div className="mt-1 mb-2 sm:col-span-2 sm:mt-0">
<div className="mt-1 mb-4">
<div className="form-input-field"> <div className="form-input-field">
<Field <Field
id="email" id="email"
name="email" name="email"
placeholder={`${intl.formatMessage(
messages.email
)} / ${intl.formatMessage(messages.username)}`}
type="text" type="text"
inputMode="email" inputMode="email"
data-testid="email" data-testid="email"
className="!bg-gray-700/80 placeholder:text-gray-400"
/> />
</div> </div>
{errors.email && {errors.email &&
@@ -99,25 +103,35 @@ const LocalLogin = ({ revalidate }: LocalLoginProps) => {
<div className="error">{errors.email}</div> <div className="error">{errors.email}</div>
)} )}
</div> </div>
<label htmlFor="password" className="text-label"> <div className="mt-1 mb-2">
{intl.formatMessage(messages.password)}
</label>
<div className="mt-1 mb-2 sm:col-span-2 sm:mt-0">
<div className="form-input-field"> <div className="form-input-field">
<SensitiveInput <SensitiveInput
as="field" as="field"
id="password" id="password"
name="password" name="password"
type="password" type="password"
placeholder={intl.formatMessage(messages.password)}
autoComplete="current-password" autoComplete="current-password"
data-testid="password" data-testid="password"
className="!bg-gray-700/80 placeholder:text-gray-400"
/> />
</div> </div>
<div className="flex">
{errors.password && {errors.password &&
touched.password && touched.password &&
typeof errors.password === 'string' && ( typeof errors.password === 'string' && (
<div className="error">{errors.password}</div> <div className="error">{errors.password}</div>
)} )}
<div className="flex-grow"></div>
{passwordResetEnabled && (
<Link
href="/resetpassword"
className="pt-2 text-sm text-indigo-500 hover:text-indigo-400"
>
{intl.formatMessage(messages.forgotpassword)}
</Link>
)}
</div>
</div> </div>
{loginError && ( {loginError && (
<div className="mt-1 mb-2 sm:col-span-2 sm:mt-0"> <div className="mt-1 mb-2 sm:col-span-2 sm:mt-0">
@@ -125,14 +139,13 @@ const LocalLogin = ({ revalidate }: LocalLoginProps) => {
</div> </div>
)} )}
</div> </div>
<div className="mt-8 border-t border-gray-700 pt-5">
<div className="flex flex-row-reverse justify-between">
<span className="inline-flex rounded-md shadow-sm">
<Button <Button
buttonType="primary" buttonType="primary"
type="submit" type="submit"
disabled={isSubmitting || !isValid} disabled={isSubmitting || !isValid}
data-testid="local-signin-button" data-testid="local-signin-button"
className="mt-2 w-full shadow-sm"
> >
<ArrowLeftOnRectangleIcon /> <ArrowLeftOnRectangleIcon />
<span> <span>
@@ -141,21 +154,6 @@ const LocalLogin = ({ revalidate }: LocalLoginProps) => {
: intl.formatMessage(messages.signin)} : intl.formatMessage(messages.signin)}
</span> </span>
</Button> </Button>
</span>
{passwordResetEnabled && (
<span className="inline-flex rounded-md shadow-sm">
<Link href="/resetpassword" passHref legacyBehavior>
<Button as="a" buttonType="ghost">
<LifebuoyIcon />
<span>
{intl.formatMessage(messages.forgotpassword)}
</span>
</Button>
</Link>
</span>
)}
</div>
</div>
</Form> </Form>
</> </>
); );

View File

@@ -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 (
<Button
className="relative flex-1 border-[#cc7b19] bg-[rgba(204,123,25,0.3)] hover:border-[#cc7b19] hover:bg-[rgba(204,123,25,0.7)] disabled:opacity-50"
onClick={login}
disabled={loading || isProcessing}
data-testid="plex-login-button"
>
{loading && (
<div className="absolute right-0 mr-4 h-4 w-4">
<SmallLoadingSpinner />
</div>
)}
{large ? (
<FormattedMessage
{...messages.loginwithapp}
values={{
appName: <PlexIcon className="mt-[2px] ml-[0.35em] w-8" />,
}}
>
{(chunks) => (
<>
{chunks.map((c) =>
typeof c === 'string' ? <span>{c}</span> : c
)}
</>
)}
</FormattedMessage>
) : (
<PlexIcon className="w-8" />
)}
</Button>
);
};
export default PlexLoginButton;

View File

@@ -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 ImageFader from '@app/components/Common/ImageFader';
import PageTitle from '@app/components/Common/PageTitle'; import PageTitle from '@app/components/Common/PageTitle';
import LanguagePicker from '@app/components/Layout/LanguagePicker'; import LanguagePicker from '@app/components/Layout/LanguagePicker';
import JellyfinLogin from '@app/components/Login/JellyfinLogin';
import LocalLogin from '@app/components/Login/LocalLogin'; 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 useSettings from '@app/hooks/useSettings';
import { useUser } from '@app/hooks/useUser'; import { useUser } from '@app/hooks/useUser';
import defineMessages from '@app/utils/defineMessages'; import defineMessages from '@app/utils/defineMessages';
@@ -12,10 +16,10 @@ import { XCircleIcon } from '@heroicons/react/24/solid';
import { MediaServerType } from '@server/constants/server'; import { MediaServerType } from '@server/constants/server';
import { useRouter } from 'next/dist/client/router'; import { useRouter } from 'next/dist/client/router';
import Image from 'next/image'; import Image from 'next/image';
import { useEffect, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { useIntl } from 'react-intl'; import { useIntl } from 'react-intl';
import { CSSTransition, SwitchTransition } from 'react-transition-group';
import useSWR from 'swr'; import useSWR from 'swr';
import JellyfinLogin from './JellyfinLogin';
const messages = defineMessages('components.Login', { const messages = defineMessages('components.Login', {
signin: 'Sign In', signin: 'Sign In',
@@ -23,16 +27,21 @@ const messages = defineMessages('components.Login', {
signinwithplex: 'Use your Plex account', signinwithplex: 'Use your Plex account',
signinwithjellyfin: 'Use your {mediaServerName} account', signinwithjellyfin: 'Use your {mediaServerName} account',
signinwithoverseerr: 'Use your {applicationTitle} account', signinwithoverseerr: 'Use your {applicationTitle} account',
orsigninwith: 'Or sign in with',
}); });
const Login = () => { const Login = () => {
const intl = useIntl(); const intl = useIntl();
const router = useRouter();
const settings = useSettings();
const { user, revalidate } = useUser();
const [error, setError] = useState(''); const [error, setError] = useState('');
const [isProcessing, setProcessing] = useState(false); const [isProcessing, setProcessing] = useState(false);
const [authToken, setAuthToken] = useState<string | undefined>(undefined); const [authToken, setAuthToken] = useState<string | undefined>(undefined);
const { user, revalidate } = useUser(); const [mediaServerLogin, setMediaServerLogin] = useState(
const router = useRouter(); settings.currentSettings.mediaServerLogin
const settings = useSettings(); );
// Effect that is triggered when the `authToken` comes back from the Plex OAuth // 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 // 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, revalidateOnFocus: false,
}); });
const mediaServerFormatValues = { const mediaServerName =
mediaServerName: settings.currentSettings.mediaServerType === MediaServerType.PLEX
settings.currentSettings.mediaServerType === MediaServerType.JELLYFIN ? 'Plex'
: settings.currentSettings.mediaServerType === MediaServerType.JELLYFIN
? 'Jellyfin' ? 'Jellyfin'
: settings.currentSettings.mediaServerType === MediaServerType.EMBY : settings.currentSettings.mediaServerType === MediaServerType.EMBY
? 'Emby' ? 'Emby'
: undefined, : 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<HTMLDivElement>(null);
const localLoginRef = useRef<HTMLDivElement>(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 ? (
<PlexLoginButton
key="plex"
isProcessing={isProcessing}
onAuthToken={(authToken) => setAuthToken(authToken)}
large={!isJellyfin && !settings.currentSettings.localLogin}
/>
) : (
settings.currentSettings.localLogin &&
(mediaServerLogin ? (
<Button
key="jellyseerr"
data-testid="jellyseerr-login-button"
className="flex-1 bg-transparent"
onClick={() => setMediaServerLogin(false)}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src="/os_icon.svg"
alt={settings.currentSettings.applicationTitle}
className="mr-2 h-5"
/>
<span>{settings.currentSettings.applicationTitle}</span>
</Button>
) : (
<Button
key="mediaserver"
data-testid="mediaserver-login-button"
className="flex-1 bg-transparent"
onClick={() => setMediaServerLogin(true)}
>
<MediaServerLogo />
<span>{mediaServerName}</span>
</Button>
))
)),
].filter((o): o is JSX.Element => !!o);
return ( return (
<div className="relative flex min-h-screen flex-col bg-gray-900 py-14"> <div className="relative flex min-h-screen flex-col bg-gray-900 py-14">
@@ -112,9 +180,6 @@ const Login = () => {
<div className="relative h-48 w-full max-w-full"> <div className="relative h-48 w-full max-w-full">
<Image src="/logo_stacked.svg" alt="Logo" fill /> <Image src="/logo_stacked.svg" alt="Logo" fill />
</div> </div>
<h2 className="mt-12 text-center text-3xl font-extrabold leading-9 text-gray-100">
{intl.formatMessage(messages.signinheader)}
</h2>
</div> </div>
<div className="relative z-50 mt-8 sm:mx-auto sm:w-full sm:max-w-md"> <div className="relative z-50 mt-8 sm:mx-auto sm:w-full sm:max-w-md">
<div <div
@@ -145,65 +210,71 @@ const Login = () => {
</div> </div>
</div> </div>
</Transition> </Transition>
<Accordion single atLeastOne>
{({ openIndexes, handleClick, AccordionContent }) => (
<>
<button
className={`w-full cursor-default bg-gray-800 bg-opacity-70 py-2 text-center text-sm font-bold text-gray-400 transition-colors duration-200 focus:outline-none sm:rounded-t-lg ${
openIndexes.includes(0) && 'text-indigo-500'
} ${
settings.currentSettings.localLogin &&
'hover:cursor-pointer hover:bg-gray-700'
}`}
onClick={() => handleClick(0)}
disabled={!settings.currentSettings.localLogin}
>
{settings.currentSettings.mediaServerType ==
MediaServerType.PLEX
? intl.formatMessage(messages.signinwithplex)
: intl.formatMessage(
messages.signinwithjellyfin,
mediaServerFormatValues
)}
</button>
<AccordionContent isOpen={openIndexes.includes(0)}>
<div className="px-10 py-8"> <div className="px-10 py-8">
{settings.currentSettings.mediaServerType == <SwitchTransition mode="out-in">
MediaServerType.PLEX ? ( <CSSTransition
<PlexLoginButton key={mediaServerLogin ? 'ms' : 'local'}
isProcessing={isProcessing} nodeRef={loginRef}
onAuthToken={(authToken) => setAuthToken(authToken)} addEndListener={(done) => {
loginRef.current?.addEventListener(
'transitionend',
done,
false
);
}}
onEntered={() => {
document
.querySelector<HTMLInputElement>('#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',
}}
>
<div ref={loginRef} className="button-container">
{isJellyfin &&
(mediaServerLogin ||
!settings.currentSettings.localLogin) ? (
<JellyfinLogin
serverType={settings.currentSettings.mediaServerType}
revalidate={revalidate}
/> />
) : ( ) : (
<JellyfinLogin revalidate={revalidate} /> settings.currentSettings.localLogin && (
)}
</div>
</AccordionContent>
{settings.currentSettings.localLogin && (
<div>
<button
className={`w-full cursor-default bg-gray-800 bg-opacity-70 py-2 text-center text-sm font-bold text-gray-400 transition-colors duration-200 hover:cursor-pointer hover:bg-gray-700 focus:outline-none ${
openIndexes.includes(1)
? 'text-indigo-500'
: 'sm:rounded-b-lg'
}`}
onClick={() => handleClick(1)}
>
{intl.formatMessage(messages.signinwithoverseerr, {
applicationTitle:
settings.currentSettings.applicationTitle,
})}
</button>
<AccordionContent isOpen={openIndexes.includes(1)}>
<div className="px-10 py-8">
<LocalLogin revalidate={revalidate} /> <LocalLogin revalidate={revalidate} />
</div> )
</AccordionContent>
</div>
)} )}
</> </div>
)} </CSSTransition>
</Accordion> </SwitchTransition>
{additionalLoginOptions.length > 0 &&
(loginFormVisible ? (
<div className="flex items-center py-5">
<div className="flex-grow border-t border-gray-600"></div>
<span className="mx-2 flex-shrink text-sm text-gray-400">
{intl.formatMessage(messages.orsigninwith)}
</span>
<div className="flex-grow border-t border-gray-600"></div>
</div>
) : (
<h2 className="mb-6 text-center text-lg font-bold text-neutral-200">
{intl.formatMessage(messages.signinheader)}
</h2>
))}
<div
className={`flex w-full flex-wrap gap-2 ${
!loginFormVisible ? 'flex-col' : ''
}`}
>
{additionalLoginOptions}
</div>
</div>
</> </>
</div> </div>
</div> </div>

View File

@@ -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 (
<span className="block w-full rounded-md shadow-sm">
<button
type="button"
onClick={() => {
plexOAuth.preparePopup();
setTimeout(() => getPlexLogin(), 1500);
}}
disabled={loading || isProcessing}
className="plex-button"
>
<ArrowLeftOnRectangleIcon />
<span>
{loading
? intl.formatMessage(globalMessages.loading)
: isProcessing
? intl.formatMessage(messages.signingin)
: intl.formatMessage(messages.signinwithplex)}
</span>
</button>
</span>
);
};
export default PlexLoginButton;

View File

@@ -1,4 +1,5 @@
import Button from '@app/components/Common/Button'; import Button from '@app/components/Common/Button';
import LabeledCheckbox from '@app/components/Common/LabeledCheckbox';
import LoadingSpinner from '@app/components/Common/LoadingSpinner'; import LoadingSpinner from '@app/components/Common/LoadingSpinner';
import PageTitle from '@app/components/Common/PageTitle'; import PageTitle from '@app/components/Common/PageTitle';
import PermissionEdit from '@app/components/PermissionEdit'; import PermissionEdit from '@app/components/PermissionEdit';
@@ -13,6 +14,7 @@ import { Field, Form, Formik } from 'formik';
import { useIntl } from 'react-intl'; import { useIntl } from 'react-intl';
import { useToasts } from 'react-toast-notifications'; import { useToasts } from 'react-toast-notifications';
import useSWR, { mutate } from 'swr'; import useSWR, { mutate } from 'swr';
import * as yup from 'yup';
const messages = defineMessages('components.Settings.SettingsUsers', { const messages = defineMessages('components.Settings.SettingsUsers', {
users: 'Users', users: 'Users',
@@ -20,9 +22,15 @@ const messages = defineMessages('components.Settings.SettingsUsers', {
userSettingsDescription: 'Configure global and default user settings.', userSettingsDescription: 'Configure global and default user settings.',
toastSettingsSuccess: 'User settings saved successfully!', toastSettingsSuccess: 'User settings saved successfully!',
toastSettingsFailure: 'Something went wrong while saving settings.', toastSettingsFailure: 'Something went wrong while saving settings.',
loginMethods: 'Login Methods',
loginMethodsTip: 'Configure login methods for users.',
localLogin: 'Enable Local Sign-In', localLogin: 'Enable Local Sign-In',
localLoginTip: 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', newPlexLogin: 'Enable New {mediaServerName} Sign-In',
newPlexLoginTip: newPlexLoginTip:
'Allow {mediaServerName} users to sign in without first being imported', 'Allow {mediaServerName} users to sign in without first being imported',
@@ -42,6 +50,27 @@ const SettingsUsers = () => {
} = useSWR<MainSettings>('/api/v1/settings/main'); } = useSWR<MainSettings>('/api/v1/settings/main');
const settings = useSettings(); 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) { if (!data && !error) {
return <LoadingSpinner />; return <LoadingSpinner />;
} }
@@ -52,6 +81,8 @@ const SettingsUsers = () => {
? 'Jellyfin' ? 'Jellyfin'
: settings.currentSettings.mediaServerType === MediaServerType.EMBY : settings.currentSettings.mediaServerType === MediaServerType.EMBY
? 'Emby' ? 'Emby'
: settings.currentSettings.mediaServerType === MediaServerType.PLEX
? 'Plex'
: undefined, : undefined,
}; };
@@ -73,6 +104,7 @@ const SettingsUsers = () => {
<Formik <Formik
initialValues={{ initialValues={{
localLogin: data?.localLogin, localLogin: data?.localLogin,
mediaServerLogin: data?.mediaServerLogin,
newPlexLogin: data?.newPlexLogin, newPlexLogin: data?.newPlexLogin,
movieQuotaLimit: data?.defaultQuotas.movie.quotaLimit ?? 0, movieQuotaLimit: data?.defaultQuotas.movie.quotaLimit ?? 0,
movieQuotaDays: data?.defaultQuotas.movie.quotaDays ?? 7, movieQuotaDays: data?.defaultQuotas.movie.quotaDays ?? 7,
@@ -80,6 +112,7 @@ const SettingsUsers = () => {
tvQuotaDays: data?.defaultQuotas.tv.quotaDays ?? 7, tvQuotaDays: data?.defaultQuotas.tv.quotaDays ?? 7,
defaultPermissions: data?.defaultPermissions ?? 0, defaultPermissions: data?.defaultPermissions ?? 0,
}} }}
validationSchema={schema}
enableReinitialize enableReinitialize
onSubmit={async (values) => { onSubmit={async (values) => {
try { try {
@@ -90,6 +123,7 @@ const SettingsUsers = () => {
}, },
body: JSON.stringify({ body: JSON.stringify({
localLogin: values.localLogin, localLogin: values.localLogin,
mediaServerLogin: values.mediaServerLogin,
newPlexLogin: values.newPlexLogin, newPlexLogin: values.newPlexLogin,
defaultQuotas: { defaultQuotas: {
movie: { movie: {
@@ -121,30 +155,61 @@ const SettingsUsers = () => {
} }
}} }}
> >
{({ isSubmitting, values, setFieldValue }) => { {({ isSubmitting, isValid, values, errors, setFieldValue }) => {
return ( return (
<Form className="section"> <Form className="section">
<div
role="group"
aria-labelledby="group-label"
className="form-group"
>
<div className="form-row"> <div className="form-row">
<label htmlFor="localLogin" className="checkbox-label"> <span id="group-label" className="group-label">
{intl.formatMessage(messages.localLogin)} {intl.formatMessage(messages.loginMethods)}
<span className="label-tip"> <span className="label-tip">
{intl.formatMessage( {intl.formatMessage(messages.loginMethodsTip)}
</span>
{'localLogin | mediaServerLogin' in errors && (
<span className="error">
{errors['localLogin | mediaServerLogin'] as string}
</span>
)}
</span>
<div className="form-input-area max-w-lg">
<LabeledCheckbox
id="localLogin"
label={intl.formatMessage(messages.localLogin)}
description={intl.formatMessage(
messages.localLoginTip, messages.localLoginTip,
mediaServerFormatValues mediaServerFormatValues
)} )}
</span> onChange={() =>
</label> setFieldValue('localLogin', !values.localLogin)
<div className="form-input-area"> }
<Field />
type="checkbox" <LabeledCheckbox
id="localLogin" id="mediaServerLogin"
name="localLogin" className="mt-4"
onChange={() => { label={intl.formatMessage(
setFieldValue('localLogin', !values.localLogin); messages.mediaServerLogin,
}} mediaServerFormatValues
)}
description={intl.formatMessage(
messages.mediaServerLoginTip,
mediaServerFormatValues
)}
onChange={() =>
setFieldValue(
'mediaServerLogin',
!values.mediaServerLogin
)
}
/> />
</div> </div>
</div> </div>
</div>
<div className="form-row"> <div className="form-row">
<label htmlFor="newPlexLogin" className="checkbox-label"> <label htmlFor="newPlexLogin" className="checkbox-label">
{intl.formatMessage( {intl.formatMessage(
@@ -229,7 +294,7 @@ const SettingsUsers = () => {
<Button <Button
buttonType="primary" buttonType="primary"
type="submit" type="submit"
disabled={isSubmitting} disabled={isSubmitting || !isValid}
> >
<ArrowDownOnSquareIcon /> <ArrowDownOnSquareIcon />
<span> <span>

View File

@@ -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 (
<Formik
initialValues={{
username: '',
password: '',
hostname: '',
port: 8096,
useSsl: false,
urlBase: '',
email: '',
}}
validationSchema={LoginSchema}
onSubmit={async (values) => {
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 }) => (
<Form>
<div className="sm:border-t sm:border-gray-800">
<div className="flex flex-col sm:flex-row sm:gap-4">
<div className="w-full">
<label htmlFor="hostname" className="text-label">
{intl.formatMessage(
messages.hostname,
mediaServerFormatValues
)}
</label>
<div className="mt-1 mb-2 sm:col-span-2 sm:mb-0 sm:mt-0">
<div className="flex rounded-md shadow-sm">
<span className="inline-flex cursor-default items-center rounded-l-md border border-r-0 border-gray-500 bg-gray-800 px-3 text-gray-100 sm:text-sm">
{values.useSsl ? 'https://' : 'http://'}
</span>
<Field
id="hostname"
name="hostname"
type="text"
className="rounded-r-only flex-1"
placeholder={intl.formatMessage(
messages.hostname,
mediaServerFormatValues
)}
/>
</div>
{errors.hostname && touched.hostname && (
<div className="error">{errors.hostname}</div>
)}
</div>
</div>
<div className="flex-1">
<label htmlFor="port" className="text-label">
{intl.formatMessage(messages.port)}
</label>
<div className="mt-1 sm:mt-0">
<Field
id="port"
name="port"
inputMode="numeric"
type="text"
className="short flex-1"
placeholder={intl.formatMessage(messages.port)}
/>
{errors.port && touched.port && (
<div className="error">{errors.port}</div>
)}
</div>
</div>
</div>
<label htmlFor="useSsl" className="text-label mt-2">
{intl.formatMessage(messages.enablessl)}
</label>
<div className="mt-1 mb-2 sm:col-span-2">
<div className="flex rounded-md shadow-sm">
<Field
id="useSsl"
name="useSsl"
type="checkbox"
onChange={() => {
setFieldValue('useSsl', !values.useSsl);
setFieldValue('port', values.useSsl ? 8096 : 443);
}}
/>
</div>
</div>
<label htmlFor="urlBase" className="text-label mt-1">
{intl.formatMessage(messages.urlBase)}
</label>
<div className="mt-1 mb-2 sm:col-span-2 sm:mt-0">
<div className="flex rounded-md shadow-sm">
<Field
type="text"
inputMode="url"
id="urlBase"
name="urlBase"
placeholder={intl.formatMessage(messages.urlBase)}
/>
</div>
{errors.urlBase && touched.urlBase && (
<div className="error">{errors.urlBase}</div>
)}
</div>
<label
htmlFor="email"
className="text-label inline-flex gap-1 align-middle"
>
{intl.formatMessage(messages.email)}
<span className="label-tip">
<Tooltip
content={intl.formatMessage(
messages.emailtooltip,
mediaServerFormatValues
)}
>
<span className="tooltip-trigger">
<InformationCircleIcon className="h-4 w-4" />
</span>
</Tooltip>
</span>
</label>
<div className="mt-1 sm:col-span-2 sm:mb-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>
)}
</div>
<label htmlFor="username" className="text-label">
{intl.formatMessage(messages.username)}
</label>
<div className="mt-1 mb-2 sm:col-span-2 sm:mt-0">
<div className="flex rounded-md shadow-sm">
<Field
id="username"
name="username"
type="text"
placeholder={intl.formatMessage(messages.username)}
/>
</div>
{errors.username && touched.username && (
<div className="error">{errors.username}</div>
)}
</div>
<label htmlFor="password" className="text-label">
{intl.formatMessage(messages.password)}
</label>
<div className="mt-1 mb-2 sm:col-span-2 sm:mt-0">
<div className="flexrounded-md shadow-sm">
<Field
id="password"
name="password"
type="password"
placeholder={intl.formatMessage(messages.password)}
/>
</div>
{errors.password && touched.password && (
<div className="error">{errors.password}</div>
)}
</div>
</div>
<div className="mt-8 border-t border-gray-700 pt-5">
<div className="flex flex-row-reverse justify-between">
<span className="inline-flex rounded-md shadow-sm">
<Button
buttonType="primary"
type="submit"
disabled={isSubmitting || !isValid}
>
{isSubmitting
? intl.formatMessage(messages.signingin)
: intl.formatMessage(messages.signin)}
</Button>
</span>
{onCancel && (
<span className="inline-flex rounded-md shadow-sm">
<Button buttonType="default" onClick={() => onCancel()}>
<FormattedMessage {...messages.back} />
</Button>
</span>
)}
</div>
</div>
</Form>
)}
</Formik>
);
}
export default JellyfinSetup;

View File

@@ -1,4 +1,4 @@
import PlexLoginButton from '@app/components/PlexLoginButton'; import PlexLoginButton from '@app/components/Login/PlexLoginButton';
import { useUser } from '@app/hooks/useUser'; import { useUser } from '@app/hooks/useUser';
import defineMessages from '@app/utils/defineMessages'; import defineMessages from '@app/utils/defineMessages';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';

View File

@@ -1,6 +1,6 @@
import Button from '@app/components/Common/Button'; import Button from '@app/components/Common/Button';
import JellyfinLogin from '@app/components/Login/JellyfinLogin'; import PlexLoginButton from '@app/components/Login/PlexLoginButton';
import PlexLoginButton from '@app/components/PlexLoginButton'; import JellyfinSetup from '@app/components/Setup/JellyfinSetup';
import { useUser } from '@app/hooks/useUser'; import { useUser } from '@app/hooks/useUser';
import defineMessages from '@app/utils/defineMessages'; import defineMessages from '@app/utils/defineMessages';
import { MediaServerType } from '@server/constants/server'; import { MediaServerType } from '@server/constants/server';
@@ -83,11 +83,9 @@ const SetupLogin: React.FC<LoginWithMediaServerProps> = ({
</div> </div>
{serverType === MediaServerType.PLEX && ( {serverType === MediaServerType.PLEX && (
<> <>
<div <div className="flex justify-center bg-black/30 px-10 py-8">
className="px-10 py-8"
style={{ backgroundColor: 'rgba(0,0,0,0.3)' }}
>
<PlexLoginButton <PlexLoginButton
large
onAuthToken={(authToken) => { onAuthToken={(authToken) => {
setMediaServerType(MediaServerType.PLEX); setMediaServerType(MediaServerType.PLEX);
setAuthToken(authToken); setAuthToken(authToken);
@@ -102,16 +100,14 @@ const SetupLogin: React.FC<LoginWithMediaServerProps> = ({
</> </>
)} )}
{serverType === MediaServerType.JELLYFIN && ( {serverType === MediaServerType.JELLYFIN && (
<JellyfinLogin <JellyfinSetup
initial={true}
revalidate={revalidate} revalidate={revalidate}
serverType={serverType} serverType={serverType}
onCancel={onCancel} onCancel={onCancel}
/> />
)} )}
{serverType === MediaServerType.EMBY && ( {serverType === MediaServerType.EMBY && (
<JellyfinLogin <JellyfinSetup
initial={true}
revalidate={revalidate} revalidate={revalidate}
serverType={serverType} serverType={serverType}
onCancel={onCancel} onCancel={onCancel}

View File

@@ -14,6 +14,7 @@ const defaultSettings = {
applicationUrl: '', applicationUrl: '',
hideAvailable: false, hideAvailable: false,
localLogin: true, localLogin: true,
mediaServerLogin: true,
movie4kEnabled: false, movie4kEnabled: false,
series4kEnabled: false, series4kEnabled: false,
discoverRegion: '', discoverRegion: '',

37
src/hooks/usePlexLogin.ts Normal file
View File

@@ -0,0 +1,37 @@
import PlexOAuth from '@app/utils/plex';
import { useState } from 'react';
const plexOAuth = new PlexOAuth();
function usePlexLogin({
onAuthToken,
onError,
}: {
onAuthToken: (authToken: string) => 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;

View File

@@ -246,7 +246,9 @@
"components.Login.initialsigningin": "Connecting…", "components.Login.initialsigningin": "Connecting…",
"components.Login.invalidurlerror": "Unable to connect to {mediaServerName} server.", "components.Login.invalidurlerror": "Unable to connect to {mediaServerName} server.",
"components.Login.loginerror": "Something went wrong while trying to sign in.", "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.noadminerror": "No admin user found on the server.",
"components.Login.orsigninwith": "Or sign in with",
"components.Login.password": "Password", "components.Login.password": "Password",
"components.Login.port": "Port", "components.Login.port": "Port",
"components.Login.save": "Add", "components.Login.save": "Add",
@@ -441,8 +443,6 @@
"components.PersonDetails.birthdate": "Born {birthdate}", "components.PersonDetails.birthdate": "Born {birthdate}",
"components.PersonDetails.crewmember": "Crew", "components.PersonDetails.crewmember": "Crew",
"components.PersonDetails.lifespan": "{birthdate} {deathdate}", "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.days": "{count, plural, one {day} other {days}}",
"components.QuotaSelector.movieRequests": "{quotaLimit} <quotaUnits>{movies} per {quotaDays} {days}</quotaUnits>", "components.QuotaSelector.movieRequests": "{quotaLimit} <quotaUnits>{movies} per {quotaDays} {days}</quotaUnits>",
"components.QuotaSelector.movies": "{count, plural, one {movie} other {movies}}", "components.QuotaSelector.movies": "{count, plural, one {movie} other {movies}}",
@@ -954,10 +954,15 @@
"components.Settings.SettingsMain.validationApplicationUrl": "You must provide a valid URL", "components.Settings.SettingsMain.validationApplicationUrl": "You must provide a valid URL",
"components.Settings.SettingsMain.validationApplicationUrlTrailingSlash": "URL must not end in a trailing slash", "components.Settings.SettingsMain.validationApplicationUrlTrailingSlash": "URL must not end in a trailing slash",
"components.Settings.SettingsMain.validationProxyPort": "You must provide a valid port", "components.Settings.SettingsMain.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.defaultPermissions": "Default Permissions",
"components.Settings.SettingsUsers.defaultPermissionsTip": "Initial permissions assigned to new users", "components.Settings.SettingsUsers.defaultPermissionsTip": "Initial permissions assigned to new users",
"components.Settings.SettingsUsers.localLogin": "Enable Local Sign-In", "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.movieRequestLimitLabel": "Global Movie Request Limit",
"components.Settings.SettingsUsers.newPlexLogin": "Enable New {mediaServerName} Sign-In", "components.Settings.SettingsUsers.newPlexLogin": "Enable New {mediaServerName} Sign-In",
"components.Settings.SettingsUsers.newPlexLoginTip": "Allow {mediaServerName} users to sign in without first being imported", "components.Settings.SettingsUsers.newPlexLoginTip": "Allow {mediaServerName} users to sign in without first being imported",

View File

@@ -194,6 +194,7 @@ CoreApp.getInitialProps = async (initialProps) => {
movie4kEnabled: false, movie4kEnabled: false,
series4kEnabled: false, series4kEnabled: false,
localLogin: true, localLogin: true,
mediaServerLogin: true,
discoverRegion: '', discoverRegion: '',
streamingRegion: '', streamingRegion: '',
originalLanguage: '', originalLanguage: '',

View File

@@ -74,15 +74,6 @@
top: env(safe-area-inset-top); 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 { .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; @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; @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-md :where(svg),
button.input-action svg, button.input-action svg {
.plex-button svg {
@apply ml-2 mr-2 h-5 w-5 first:ml-0 last:mr-0; @apply ml-2 mr-2 h-5 w-5 first:ml-0 last:mr-0;
} }