feat: revamp login page and support disabling media server login (#1286)

* feat: support disabling jellyfin login

* feat: revamp login screen

Update the login screen for better usability, especially with OpenID
Connect and Plex login, allowing one-click login and removing the
accordion layout. Additionally, ensures that media server login is
hidden when disabled in the settings.

* test: update cypress login command
This commit is contained in:
Michael Thomas
2025-02-22 11:40:38 -05:00
committed by GitHub
parent 9712f56054
commit 73d8efaa54
23 changed files with 921 additions and 639 deletions

View File

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

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.
## Enable Jellyfin/Emby/Plex Sign-In
When enabled, users will be able to sign in to Jellyseerr using their Jellyfin/Emby/Plex credentials, provided they have linked their media server accounts.
When disabled, users will only be able to sign in using their email address. Users without a password set will not be able to sign in to Jellyseerr.
This setting is **enabled** by default.
## Enable New Jellyfin/Emby/Plex Sign-In
When enabled, users with access to your media server will be able to sign in to Jellyseerr even if they have not yet been imported. Users will be automatically assigned the permissions configured in the [Default Permissions](#default-permissions) setting upon first sign-in.

View File

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

27
pnpm-lock.yaml generated
View File

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

View File

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

View File

@@ -123,6 +123,7 @@ export interface MainSettings {
};
hideAvailable: boolean;
localLogin: boolean;
mediaServerLogin: boolean;
newPlexLogin: boolean;
discoverRegion: string;
streamingRegion: string;
@@ -150,6 +151,7 @@ interface FullPublicSettings extends PublicSettings {
applicationUrl: string;
hideAvailable: boolean;
localLogin: boolean;
mediaServerLogin: boolean;
movie4kEnabled: boolean;
series4kEnabled: boolean;
discoverRegion: string;
@@ -343,6 +345,7 @@ class Settings {
},
hideAvailable: false,
localLogin: true,
mediaServerLogin: true,
newPlexLogin: true,
discoverRegion: '',
streamingRegion: '',
@@ -588,6 +591,8 @@ class Settings {
applicationUrl: this.data.main.applicationUrl,
hideAvailable: this.data.main.hideAvailable,
localLogin: this.data.main.localLogin,
mediaServerLogin: this.data.main.mediaServerLogin,
jellyfinExternalHost: this.data.jellyfin.externalHostname,
jellyfinForgotPasswordUrl: this.data.jellyfin.jellyfinForgotPasswordUrl,
movie4kEnabled: this.data.radarr.some(
(radarr) => radarr.is4k && radarr.isDefault

View File

@@ -56,8 +56,9 @@ authRoutes.post('/plex', async (req, res, next) => {
}
if (
settings.main.mediaServerType != MediaServerType.PLEX &&
settings.main.mediaServerType != MediaServerType.NOT_CONFIGURED
settings.main.mediaServerType != MediaServerType.NOT_CONFIGURED &&
(settings.main.mediaServerLogin === false ||
settings.main.mediaServerType != MediaServerType.PLEX)
) {
return res.status(500).json({ error: 'Plex login is disabled' });
}
@@ -231,10 +232,13 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
//Make sure jellyfin login is enabled, but only if jellyfin && Emby is not already configured
if (
settings.main.mediaServerType !== MediaServerType.JELLYFIN &&
settings.main.mediaServerType !== MediaServerType.EMBY &&
// media server not configured, allow login for setup
settings.main.mediaServerType != MediaServerType.NOT_CONFIGURED &&
settings.jellyfin.ip !== ''
(settings.main.mediaServerLogin === false ||
// media server is neither jellyfin or emby
(settings.main.mediaServerType !== MediaServerType.JELLYFIN &&
settings.main.mediaServerType !== MediaServerType.EMBY &&
settings.jellyfin.ip !== ''))
) {
return res.status(500).json({ error: 'Jellyfin login is disabled' });
}

View File

@@ -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<P extends ElementTypes = 'button'>(
if (as === 'a') {
return (
<a
className={buttonStyle.join(' ')}
className={twMerge(buttonStyle)}
{...(props as React.ComponentProps<'a'>)}
ref={ref as ForwardedRef<HTMLAnchorElement>}
>
@@ -107,7 +108,7 @@ function Button<P extends ElementTypes = 'button'>(
} else {
return (
<button
className={buttonStyle.join(' ')}
className={twMerge(buttonStyle)}
{...(props as React.ComponentProps<'button'>)}
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 Tooltip from '@app/components/Common/Tooltip';
import SensitiveInput from '@app/components/Common/SensitiveInput';
import useSettings from '@app/hooks/useSettings';
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 { MediaServerType, ServerType } from '@server/constants/server';
import { Field, Form, Formik } from 'formik';
import { FormattedMessage, useIntl } from 'react-intl';
import { useIntl } from 'react-intl';
import { useToasts } from 'react-toast-notifications';
import * as Yup from 'yup';
const messages = defineMessages('components.Login', {
loginwithapp: 'Login with {appName}',
username: 'Username',
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',
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.',
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…',
signingin: 'Signing In…',
signin: 'Sign In',
initialsigningin: 'Connecting…',
initialsignin: 'Connect',
forgotpassword: 'Forgot Password?',
servertype: 'Server Type',
back: 'Go back',
});
interface JellyfinLoginProps {
revalidate: () => void;
initial?: boolean;
serverType?: MediaServerType;
onCancel?: () => void;
}
const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
revalidate,
initial,
serverType,
onCancel,
}) => {
const toasts = useToasts();
const intl = useIntl();
@@ -72,56 +48,29 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
: '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(),
});
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 (
return (
<div>
<Formik
initialValues={{
username: '',
password: '',
hostname: '',
port: 8096,
useSsl: false,
urlBase: '',
email: '',
}}
validationSchema={LoginSchema}
validateOnBlur={false}
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: {
@@ -130,12 +79,7 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
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,
email: values.username,
}),
});
if (!res.ok) throw new Error(res.statusText, { cause: res });
@@ -165,7 +109,6 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
errorMessage = messages.loginerror;
break;
}
toasts.addToast(
intl.formatMessage(errorMessage, mediaServerFormatValues),
{
@@ -178,303 +121,51 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
}
}}
>
{({
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>
{({ errors, touched, isSubmitting, isValid }) => {
return (
<>
<Form>
<div>
<h2 className="mb-6 -mt-1 text-center text-lg font-bold text-neutral-200">
{intl.formatMessage(messages.loginwithapp, {
appName: mediaServerFormatValues.mediaServerName,
})}
</h2>
<div className="mt-1 mb-4">
<div className="form-input-field">
<Field
id="hostname"
name="hostname"
id="username"
name="username"
type="text"
className="rounded-r-only flex-1"
placeholder={intl.formatMessage(
messages.hostname,
mediaServerFormatValues
)}
placeholder={intl.formatMessage(messages.username)}
className="!bg-gray-700/80 placeholder:text-gray-400"
/>
</div>
{errors.hostname && touched.hostname && (
<div className="error">{errors.hostname}</div>
{errors.username && touched.username && (
<div className="error">{errors.username}</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({
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 (
<div>
<Formik
initialValues={{
username: '',
password: '',
}}
validationSchema={LoginSchema}
onSubmit={async (values) => {
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 (
<>
<Form>
<div className="sm:border-t sm:border-gray-800">
<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 max-w-lg 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 className="mt-1 mb-2">
<div className="form-input-field">
<SensitiveInput
as="field"
id="password"
name="password"
type="password"
autoComplete="current-password"
placeholder={intl.formatMessage(messages.password)}
className="!bg-gray-700/80 placeholder:text-gray-400"
/>
</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="flex max-w-lg rounded-md shadow-sm">
<Field
id="password"
name="password"
type="password"
placeholder={intl.formatMessage(messages.password)}
/>
</div>
<div className="flex">
{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 justify-between">
<span className="inline-flex rounded-md shadow-sm">
<Button
as="a"
buttonType="ghost"
<div className="flex-grow"></div>
{baseUrl && (
<a
href={
jellyfinForgotPasswordUrl
? `${jellyfinForgotPasswordUrl}`
@@ -485,31 +176,35 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
: ''
}forgotpassword.html`
}
className="pt-2 text-sm text-indigo-500 hover:text-indigo-400"
>
{intl.formatMessage(messages.forgotpassword)}
</Button>
</span>
<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>
</a>
)}
</div>
</div>
</Form>
</>
);
}}
</Formik>
</div>
);
}
</div>
<Button
buttonType="primary"
type="submit"
disabled={isSubmitting || !isValid}
className="mt-2 w-full shadow-sm"
>
<ArrowLeftOnRectangleIcon />
<span>
{isSubmitting
? intl.formatMessage(messages.signingin)
: intl.formatMessage(messages.signin)}
</span>
</Button>
</Form>
</>
);
}}
</Formik>
</div>
);
};
export default JellyfinLogin;

View File

@@ -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) => {
<>
<Form>
<div>
<label htmlFor="email" className="text-label">
{intl.formatMessage(messages.email) +
' / ' +
intl.formatMessage(messages.username)}
</label>
<div className="mt-1 mb-2 sm:col-span-2 sm:mt-0">
<h2 className="mb-6 -mt-1 text-center text-lg font-bold text-neutral-200">
{intl.formatMessage(messages.loginwithapp, {
appName: settings.currentSettings.applicationTitle,
})}
</h2>
<div className="mt-1 mb-4">
<div className="form-input-field">
<Field
id="email"
name="email"
placeholder={`${intl.formatMessage(
messages.email
)} / ${intl.formatMessage(messages.username)}`}
type="text"
inputMode="email"
data-testid="email"
className="!bg-gray-700/80 placeholder:text-gray-400"
/>
</div>
{errors.email &&
@@ -99,25 +103,35 @@ const LocalLogin = ({ revalidate }: LocalLoginProps) => {
<div className="error">{errors.email}</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="mt-1 mb-2">
<div className="form-input-field">
<SensitiveInput
as="field"
id="password"
name="password"
type="password"
placeholder={intl.formatMessage(messages.password)}
autoComplete="current-password"
data-testid="password"
className="!bg-gray-700/80 placeholder:text-gray-400"
/>
</div>
{errors.password &&
touched.password &&
typeof errors.password === 'string' && (
<div className="error">{errors.password}</div>
<div className="flex">
{errors.password &&
touched.password &&
typeof errors.password === 'string' && (
<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>
{loginError && (
<div className="mt-1 mb-2 sm:col-span-2 sm:mt-0">
@@ -125,37 +139,21 @@ const LocalLogin = ({ revalidate }: LocalLoginProps) => {
</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}
data-testid="local-signin-button"
>
<ArrowLeftOnRectangleIcon />
<span>
{isSubmitting
? intl.formatMessage(messages.signingin)
: intl.formatMessage(messages.signin)}
</span>
</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>
<Button
buttonType="primary"
type="submit"
disabled={isSubmitting || !isValid}
data-testid="local-signin-button"
className="mt-2 w-full shadow-sm"
>
<ArrowLeftOnRectangleIcon />
<span>
{isSubmitting
? intl.formatMessage(messages.signingin)
: intl.formatMessage(messages.signin)}
</span>
</Button>
</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 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<string | undefined>(undefined);
const { user, revalidate } = useUser();
const router = useRouter();
const settings = useSettings();
const [mediaServerLogin, setMediaServerLogin] = useState(
settings.currentSettings.mediaServerLogin
);
// Effect that is triggered when the `authToken` comes back from the Plex OAuth
// We take the token and attempt to sign in. If we get a success message, we will
@@ -86,14 +95,73 @@ const Login = () => {
revalidateOnFocus: false,
});
const mediaServerFormatValues = {
mediaServerName:
settings.currentSettings.mediaServerType === MediaServerType.JELLYFIN
? 'Jellyfin'
: settings.currentSettings.mediaServerType === MediaServerType.EMBY
? 'Emby'
: undefined,
};
const mediaServerName =
settings.currentSettings.mediaServerType === MediaServerType.PLEX
? 'Plex'
: settings.currentSettings.mediaServerType === MediaServerType.JELLYFIN
? 'Jellyfin'
: settings.currentSettings.mediaServerType === MediaServerType.EMBY
? 'Emby'
: undefined;
const MediaServerLogo =
settings.currentSettings.mediaServerType === MediaServerType.PLEX
? PlexLogo
: settings.currentSettings.mediaServerType === MediaServerType.JELLYFIN
? JellyfinLogo
: settings.currentSettings.mediaServerType === MediaServerType.EMBY
? EmbyLogo
: undefined;
const isJellyfin =
settings.currentSettings.mediaServerType === MediaServerType.JELLYFIN ||
settings.currentSettings.mediaServerType === MediaServerType.EMBY;
const mediaServerLoginRef = useRef<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 (
<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">
<Image src="/logo_stacked.svg" alt="Logo" fill />
</div>
<h2 className="mt-12 text-center text-3xl font-extrabold leading-9 text-gray-100">
{intl.formatMessage(messages.signinheader)}
</h2>
</div>
<div className="relative z-50 mt-8 sm:mx-auto sm:w-full sm:max-w-md">
<div
@@ -145,65 +210,71 @@ const Login = () => {
</div>
</div>
</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">
{settings.currentSettings.mediaServerType ==
MediaServerType.PLEX ? (
<PlexLoginButton
isProcessing={isProcessing}
onAuthToken={(authToken) => setAuthToken(authToken)}
/>
) : (
<JellyfinLogin revalidate={revalidate} />
)}
</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} />
</div>
</AccordionContent>
</div>
)}
</>
)}
</Accordion>
<div className="px-10 py-8">
<SwitchTransition mode="out-in">
<CSSTransition
key={mediaServerLogin ? 'ms' : 'local'}
nodeRef={loginRef}
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}
/>
) : (
settings.currentSettings.localLogin && (
<LocalLogin revalidate={revalidate} />
)
)}
</div>
</CSSTransition>
</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>

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

View File

@@ -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<LoginWithMediaServerProps> = ({
</div>
{serverType === MediaServerType.PLEX && (
<>
<div
className="px-10 py-8"
style={{ backgroundColor: 'rgba(0,0,0,0.3)' }}
>
<div className="flex justify-center bg-black/30 px-10 py-8">
<PlexLoginButton
large
onAuthToken={(authToken) => {
setMediaServerType(MediaServerType.PLEX);
setAuthToken(authToken);
@@ -102,16 +100,14 @@ const SetupLogin: React.FC<LoginWithMediaServerProps> = ({
</>
)}
{serverType === MediaServerType.JELLYFIN && (
<JellyfinLogin
initial={true}
<JellyfinSetup
revalidate={revalidate}
serverType={serverType}
onCancel={onCancel}
/>
)}
{serverType === MediaServerType.EMBY && (
<JellyfinLogin
initial={true}
<JellyfinSetup
revalidate={revalidate}
serverType={serverType}
onCancel={onCancel}

View File

@@ -14,6 +14,7 @@ const defaultSettings = {
applicationUrl: '',
hideAvailable: false,
localLogin: true,
mediaServerLogin: true,
movie4kEnabled: false,
series4kEnabled: false,
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.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} <quotaUnits>{movies} per {quotaDays} {days}</quotaUnits>",
"components.QuotaSelector.movies": "{count, plural, one {movie} other {movies}}",
@@ -963,10 +963,15 @@
"components.Settings.SettingsNetwork.trustProxy": "Enable Proxy Support",
"components.Settings.SettingsNetwork.trustProxyTip": "Allow Jellyseerr to correctly register client IP addresses behind a proxy",
"components.Settings.SettingsNetwork.validationProxyPort": "You must provide a valid port",
"components.Settings.SettingsUsers.atLeastOneAuth": "At least one authentication method must be selected.",
"components.Settings.SettingsUsers.defaultPermissions": "Default Permissions",
"components.Settings.SettingsUsers.defaultPermissionsTip": "Initial permissions assigned to new users",
"components.Settings.SettingsUsers.localLogin": "Enable Local Sign-In",
"components.Settings.SettingsUsers.localLoginTip": "Allow users to sign in using their email address and password, instead of {mediaServerName} OAuth",
"components.Settings.SettingsUsers.localLoginTip": "Allow users to sign in using their email address and password",
"components.Settings.SettingsUsers.loginMethods": "Login Methods",
"components.Settings.SettingsUsers.loginMethodsTip": "Configure login methods for users.",
"components.Settings.SettingsUsers.mediaServerLogin": "Enable {mediaServerName} Sign-In",
"components.Settings.SettingsUsers.mediaServerLoginTip": "Allow users to sign in using their {mediaServerName} account",
"components.Settings.SettingsUsers.movieRequestLimitLabel": "Global Movie Request Limit",
"components.Settings.SettingsUsers.newPlexLogin": "Enable New {mediaServerName} Sign-In",
"components.Settings.SettingsUsers.newPlexLoginTip": "Allow {mediaServerName} users to sign in without first being imported",

View File

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

View File

@@ -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;
}