diff --git a/.all-contributorsrc b/.all-contributorsrc index 8779bc16..5468b39c 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -610,6 +610,15 @@ "contributions": [ "code" ] + }, + { + "login": "michaelhthomas", + "name": "Michael Thomas", + "avatar_url": "https://avatars.githubusercontent.com/u/18223295?v=4", + "profile": "http://michaelt.xyz", + "contributions": [ + "code" + ] } ] } diff --git a/README.md b/README.md index 70463b60..6ed6bff7 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Translation status GitHub -All Contributors +All Contributors **Jellyseerr** is a free and open source software application for managing requests for your media library. It integrates with the media server of your choice: [Jellyfin](https://jellyfin.org), [Plex](https://plex.tv), and [Emby](https://emby.media/). In addition, it integrates with your existing services, such as **[Sonarr](https://sonarr.tv/)**, **[Radarr](https://radarr.video/)**. @@ -170,6 +170,7 @@ Thanks goes to these wonderful people from Overseerr ([emoji key](https://allcon Metin Bektas
Metin Bektas

🚇 andrewkolda
andrewkolda

🎨 Ishan Jain
Ishan Jain

đź’» + Michael Thomas
Michael Thomas

đź’» diff --git a/cypress/support/commands.ts b/cypress/support/commands.ts index 0eb9c869..a23cb5e6 100644 --- a/cypress/support/commands.ts +++ b/cypress/support/commands.ts @@ -6,7 +6,6 @@ Cypress.Commands.add('login', (email, password) => { [email, password], () => { cy.visit('/login'); - cy.contains('Use your Overseerr account').click(); cy.get('[data-testid=email]').type(email); cy.get('[data-testid=password]').type(password); diff --git a/docs/using-jellyseerr/settings/users.md b/docs/using-jellyseerr/settings/users.md index ebe547ef..0fdeb7db 100644 --- a/docs/using-jellyseerr/settings/users.md +++ b/docs/using-jellyseerr/settings/users.md @@ -14,6 +14,14 @@ When disabled, your mediaserver OAuth becomes the only sign-in option, and any " This setting is **enabled** by default. +## Enable Jellyfin/Emby/Plex Sign-In + +When enabled, users will be able to sign in to Jellyseerr using their Jellyfin/Emby/Plex credentials, provided they have linked their media server accounts. + +When disabled, users will only be able to sign in using their email address. Users without a password set will not be able to sign in to Jellyseerr. + +This setting is **enabled** by default. + ## Enable New Jellyfin/Emby/Plex Sign-In When enabled, users with access to your media server will be able to sign in to Jellyseerr even if they have not yet been imported. Users will be automatically assigned the permissions configured in the [Default Permissions](#default-permissions) setting upon first sign-in. diff --git a/overseerr-api.yml b/overseerr-api.yml index 641ce5d7..a713a5a1 100644 --- a/overseerr-api.yml +++ b/overseerr-api.yml @@ -4423,6 +4423,104 @@ paths: responses: '204': description: User password updated + /user/{userId}/settings/linked-accounts/plex: + post: + summary: Link the provided Plex account to the current user + description: Logs in to Plex with the provided auth token, then links the associated Plex account with the user's account. Users can only link external accounts to their own account. + tags: + - users + parameters: + - in: path + name: userId + required: true + schema: + type: number + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + authToken: + type: string + required: + - authToken + responses: + '204': + description: Linking account succeeded + '403': + description: Invalid credentials + '422': + description: Account already linked to a user + delete: + summary: Remove the linked Plex account for a user + description: Removes the linked Plex account for a specific user. Requires `MANAGE_USERS` permission if editing other users. + tags: + - users + parameters: + - in: path + name: userId + required: true + schema: + type: number + responses: + '204': + description: Unlinking account succeeded + '400': + description: Unlink request invalid + '404': + description: User does not exist + /user/{userId}/settings/linked-accounts/jellyfin: + post: + summary: Link the provided Jellyfin account to the current user + description: Logs in to Jellyfin with the provided credentials, then links the associated Jellyfin account with the user's account. Users can only link external accounts to their own account. + tags: + - users + parameters: + - in: path + name: userId + required: true + schema: + type: number + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + username: + type: string + example: 'Mr User' + password: + type: string + example: 'supersecret' + responses: + '204': + description: Linking account succeeded + '403': + description: Invalid credentials + '422': + description: Account already linked to a user + delete: + summary: Remove the linked Jellyfin account for a user + description: Removes the linked Jellyfin account for a specific user. Requires `MANAGE_USERS` permission if editing other users. + tags: + - users + parameters: + - in: path + name: userId + required: true + schema: + type: number + responses: + '204': + description: Unlinking account succeeded + '400': + description: Unlink request invalid + '404': + description: User does not exist /user/{userId}/settings/notifications: get: summary: Get notification settings for a user diff --git a/package.json b/package.json index 6e6500ed..74512020 100644 --- a/package.json +++ b/package.json @@ -86,6 +86,7 @@ "react-spring": "9.7.1", "react-tailwindcss-datepicker-sct": "1.3.4", "react-toast-notifications": "2.5.1", + "react-transition-group": "^4.4.5", "react-truncate-markup": "5.1.2", "react-use-clipboard": "1.0.9", "reflect-metadata": "0.1.13", @@ -95,6 +96,7 @@ "sqlite3": "5.1.4", "swagger-ui-express": "4.6.2", "swr": "2.2.5", + "tailwind-merge": "^2.6.0", "typeorm": "0.3.11", "undici": "^6.20.1", "web-push": "3.5.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8aa777b7..d68087d9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -170,6 +170,9 @@ importers: react-toast-notifications: specifier: 2.5.1 version: 2.5.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react-transition-group: + specifier: ^4.4.5 + version: 4.4.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react-truncate-markup: specifier: 5.1.2 version: 5.1.2(react@18.3.1) @@ -197,6 +200,9 @@ importers: swr: specifier: 2.2.5 version: 2.2.5(react@18.3.1) + tailwind-merge: + specifier: ^2.6.0 + version: 2.6.0 typeorm: specifier: 0.3.11 version: 0.3.11(pg@8.11.0)(sqlite3@5.1.4(encoding@0.1.13))(ts-node@10.9.1(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@22.10.5)(typescript@4.9.5)) @@ -8451,6 +8457,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'} @@ -10432,7 +10441,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 @@ -10462,7 +10471,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 @@ -10490,7 +10499,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 @@ -13374,13 +13383,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 @@ -14431,7 +14440,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: @@ -18393,7 +18402,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 @@ -18516,7 +18525,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: @@ -19247,6 +19256,8 @@ snapshots: react: 18.3.1 use-sync-external-store: 1.2.2(react@18.3.1) + tailwind-merge@2.6.0: {} + tailwindcss@3.2.7(postcss@8.4.21)(ts-node@10.9.1(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@22.10.5)(typescript@4.9.5)): dependencies: arg: 5.0.2 diff --git a/server/api/jellyfin.ts b/server/api/jellyfin.ts index 8bbfa887..a97fb85e 100644 --- a/server/api/jellyfin.ts +++ b/server/api/jellyfin.ts @@ -101,7 +101,11 @@ class JellyfinAPI extends ExternalAPI { private userId?: string; private deviceId?: string; - constructor(jellyfinHost: string, authToken?: string, deviceId?: string) { + constructor( + jellyfinHost: string, + authToken?: string | null, + deviceId?: string | null + ) { let authHeaderVal: string; if (authToken) { authHeaderVal = `MediaBrowser Client="Jellyseerr", Device="Jellyseerr", DeviceId="${deviceId}", Version="${getAppVersion()}", Token="${authToken}"`; diff --git a/server/api/plexapi.ts b/server/api/plexapi.ts index 10d5d1d2..977d367b 100644 --- a/server/api/plexapi.ts +++ b/server/api/plexapi.ts @@ -92,7 +92,7 @@ class PlexAPI { plexSettings, timeout, }: { - plexToken?: string; + plexToken?: string | null; plexSettings?: PlexSettings; timeout?: number; }) { @@ -107,7 +107,7 @@ class PlexAPI { port: settingsPlex.port, https: settingsPlex.useSsl, timeout: timeout, - token: plexToken, + token: plexToken ?? undefined, authenticator: { authenticate: ( _plexApi, diff --git a/server/constants/error.ts b/server/constants/error.ts index 664f02c9..daa02f1a 100644 --- a/server/constants/error.ts +++ b/server/constants/error.ts @@ -7,5 +7,6 @@ export enum ApiErrorCode { NoAdminUser = 'NO_ADMIN_USER', SyncErrorGroupedFolders = 'SYNC_ERROR_GROUPED_FOLDERS', SyncErrorNoLibraries = 'SYNC_ERROR_NO_LIBRARIES', + Unauthorized = 'UNAUTHORIZED', Unknown = 'UNKNOWN', } diff --git a/server/entity/MediaRequest.ts b/server/entity/MediaRequest.ts index b758da97..8ef614b1 100644 --- a/server/entity/MediaRequest.ts +++ b/server/entity/MediaRequest.ts @@ -734,8 +734,11 @@ export class MediaRequest { media.mediaType === MediaType.MOVIE && this.status === MediaRequestStatus.DECLINED ) { - media[this.is4k ? 'status4k' : 'status'] = MediaStatus.UNKNOWN; - mediaRepository.save(media); + const statusField = this.is4k ? 'status4k' : 'status'; + await mediaRepository.update( + { id: this.media.id }, + { [statusField]: MediaStatus.UNKNOWN } + ); } /** @@ -752,8 +755,11 @@ export class MediaRequest { ).length === 0 && media[this.is4k ? 'status4k' : 'status'] === MediaStatus.PENDING ) { - media[this.is4k ? 'status4k' : 'status'] = MediaStatus.UNKNOWN; - mediaRepository.save(media); + const statusField = this.is4k ? 'status4k' : 'status'; + mediaRepository.update( + { id: this.media.id }, + { [statusField]: MediaStatus.UNKNOWN } + ); } // Approve child seasons if parent is approved @@ -955,8 +961,10 @@ export class MediaRequest { }); const requestRepository = getRepository(MediaRequest); - this.status = MediaRequestStatus.APPROVED; - await requestRepository.save(this); + + await requestRepository.update(this.id, { + status: MediaRequestStatus.APPROVED, + }); return; } @@ -986,18 +994,22 @@ export class MediaRequest { throw new Error('Media data not found'); } - media[this.is4k ? 'externalServiceId4k' : 'externalServiceId'] = - radarrMovie.id; - media[this.is4k ? 'externalServiceSlug4k' : 'externalServiceSlug'] = - radarrMovie.titleSlug; - media[this.is4k ? 'serviceId4k' : 'serviceId'] = radarrSettings?.id; - await mediaRepository.save(media); + const updateFields = { + [this.is4k ? 'externalServiceId4k' : 'externalServiceId']: + radarrMovie.id, + [this.is4k ? 'externalServiceSlug4k' : 'externalServiceSlug']: + radarrMovie.titleSlug, + [this.is4k ? 'serviceId4k' : 'serviceId']: radarrMovie?.id, + }; + + await mediaRepository.update({ id: this.media.id }, updateFields); }) .catch(async () => { const requestRepository = getRepository(MediaRequest); - this.status = MediaRequestStatus.FAILED; - await requestRepository.save(this); + await requestRepository.update(this.id, { + status: MediaRequestStatus.FAILED, + }); logger.warn( 'Something went wrong sending movie request to Radarr, marking status as FAILED', @@ -1113,8 +1125,9 @@ export class MediaRequest { }); const requestRepository = getRepository(MediaRequest); - this.status = MediaRequestStatus.APPROVED; - await requestRepository.save(this); + await requestRepository.update(this.id, { + status: MediaRequestStatus.APPROVED, + }); return; } diff --git a/server/entity/User.ts b/server/entity/User.ts index c8753bfe..91b66740 100644 --- a/server/entity/User.ts +++ b/server/entity/User.ts @@ -56,11 +56,11 @@ export class User { }) public email: string; - @Column({ nullable: true }) - public plexUsername?: string; + @Column({ type: 'varchar', nullable: true }) + public plexUsername?: string | null; - @Column({ nullable: true }) - public jellyfinUsername?: string; + @Column({ type: 'varchar', nullable: true }) + public jellyfinUsername?: string | null; @Column({ nullable: true }) public username?: string; @@ -77,20 +77,20 @@ export class User { @Column({ type: 'integer', default: UserType.PLEX }) public userType: UserType; - @Column({ nullable: true, select: true }) - public plexId?: number; + @Column({ type: 'integer', nullable: true, select: true }) + public plexId?: number | null; - @Column({ nullable: true }) - public jellyfinUserId?: string; + @Column({ type: 'varchar', nullable: true }) + public jellyfinUserId?: string | null; - @Column({ nullable: true, select: false }) - public jellyfinDeviceId?: string; + @Column({ type: 'varchar', nullable: true, select: false }) + public jellyfinDeviceId?: string | null; - @Column({ nullable: true, select: false }) - public jellyfinAuthToken?: string; + @Column({ type: 'varchar', nullable: true, select: false }) + public jellyfinAuthToken?: string | null; - @Column({ nullable: true, select: false }) - public plexToken?: string; + @Column({ type: 'varchar', nullable: true, select: false }) + public plexToken?: string | null; @Column({ type: 'integer', default: 0 }) public permissions = 0; diff --git a/server/interfaces/api/settingsInterfaces.ts b/server/interfaces/api/settingsInterfaces.ts index 017eef85..0e97c2bf 100644 --- a/server/interfaces/api/settingsInterfaces.ts +++ b/server/interfaces/api/settingsInterfaces.ts @@ -30,6 +30,7 @@ export interface PublicSettingsResponse { applicationUrl: string; hideAvailable: boolean; localLogin: boolean; + mediaServerLogin: boolean; movie4kEnabled: boolean; series4kEnabled: boolean; discoverRegion: string; diff --git a/server/lib/settings/index.ts b/server/lib/settings/index.ts index 258dfe2f..7fc09fb3 100644 --- a/server/lib/settings/index.ts +++ b/server/lib/settings/index.ts @@ -123,6 +123,7 @@ export interface MainSettings { }; hideAvailable: boolean; localLogin: boolean; + mediaServerLogin: boolean; newPlexLogin: boolean; discoverRegion: string; streamingRegion: string; @@ -150,6 +151,7 @@ interface FullPublicSettings extends PublicSettings { applicationUrl: string; hideAvailable: boolean; localLogin: boolean; + mediaServerLogin: boolean; movie4kEnabled: boolean; series4kEnabled: boolean; discoverRegion: string; @@ -343,6 +345,7 @@ class Settings { }, hideAvailable: false, localLogin: true, + mediaServerLogin: true, newPlexLogin: true, discoverRegion: '', streamingRegion: '', @@ -588,6 +591,8 @@ class Settings { applicationUrl: this.data.main.applicationUrl, hideAvailable: this.data.main.hideAvailable, localLogin: this.data.main.localLogin, + mediaServerLogin: this.data.main.mediaServerLogin, + jellyfinExternalHost: this.data.jellyfin.externalHostname, jellyfinForgotPasswordUrl: this.data.jellyfin.jellyfinForgotPasswordUrl, movie4kEnabled: this.data.radarr.some( (radarr) => radarr.is4k && radarr.isDefault diff --git a/server/routes/auth.ts b/server/routes/auth.ts index 729df3d7..d7d9ac3c 100644 --- a/server/routes/auth.ts +++ b/server/routes/auth.ts @@ -55,8 +55,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' }); } @@ -230,10 +231,13 @@ authRoutes.post('/jellyfin', async (req, res, next) => { //Make sure jellyfin login is enabled, but only if jellyfin && Emby is not already configured if ( - settings.main.mediaServerType !== MediaServerType.JELLYFIN && - settings.main.mediaServerType !== MediaServerType.EMBY && + // media server not configured, allow login for setup settings.main.mediaServerType != MediaServerType.NOT_CONFIGURED && - settings.jellyfin.ip !== '' + (settings.main.mediaServerLogin === false || + // media server is neither jellyfin or emby + (settings.main.mediaServerType !== MediaServerType.JELLYFIN && + settings.main.mediaServerType !== MediaServerType.EMBY && + settings.jellyfin.ip !== '')) ) { return res.status(500).json({ error: 'Jellyfin login is disabled' }); } diff --git a/server/routes/user/usersettings.ts b/server/routes/user/usersettings.ts index 24ca976b..6ee0f893 100644 --- a/server/routes/user/usersettings.ts +++ b/server/routes/user/usersettings.ts @@ -1,4 +1,7 @@ +import JellyfinAPI from '@server/api/jellyfin'; +import PlexTvAPI from '@server/api/plextv'; import { ApiErrorCode } from '@server/constants/error'; +import { MediaServerType } from '@server/constants/server'; import { UserType } from '@server/constants/user'; import { getRepository } from '@server/datasource'; import { User } from '@server/entity/User'; @@ -12,9 +15,23 @@ import { getSettings } from '@server/lib/settings'; import logger from '@server/logger'; import { isAuthenticated } from '@server/middleware/auth'; import { ApiError } from '@server/types/error'; +import { getHostname } from '@server/utils/getHostname'; import { Router } from 'express'; +import net from 'net'; import { canMakePermissionsChange } from '.'; +const isOwnProfile = (): Middleware => { + return (req, res, next) => { + if (req.user?.id !== Number(req.params.id)) { + return next({ + status: 403, + message: "You do not have permission to view this user's settings.", + }); + } + next(); + }; +}; + const isOwnProfileOrAdmin = (): Middleware => { const authMiddleware: Middleware = (req, res, next) => { if ( @@ -183,9 +200,8 @@ userSettingsRoutes.post< status: e.statusCode, message: e.errorCode, }); - } else { - return next({ status: 500, message: e.message }); } + return next({ status: 500, message: e.message }); } }); @@ -290,6 +306,260 @@ userSettingsRoutes.post< } }); +userSettingsRoutes.post<{ authToken: string }>( + '/linked-accounts/plex', + isOwnProfile(), + async (req, res) => { + const settings = getSettings(); + const userRepository = getRepository(User); + + if (!req.user) { + return res.status(404).json({ code: ApiErrorCode.Unauthorized }); + } + // Make sure Plex login is enabled + if (settings.main.mediaServerType !== MediaServerType.PLEX) { + return res.status(500).json({ message: 'Plex login is disabled' }); + } + + // First we need to use this auth token to get the user's email from plex.tv + const plextv = new PlexTvAPI(req.body.authToken); + const account = await plextv.getUser(); + + // Do not allow linking of an already linked account + if (await userRepository.exist({ where: { plexId: account.id } })) { + return res.status(422).json({ + message: 'This Plex account is already linked to a Jellyseerr user', + }); + } + + const user = req.user; + + // Emails do not match + if (user.email !== account.email) { + return res.status(422).json({ + message: + 'This Plex account is registered under a different email address.', + }); + } + + // valid plex user found, link to current user + user.userType = UserType.PLEX; + user.plexId = account.id; + user.plexUsername = account.username; + user.plexToken = account.authToken; + await userRepository.save(user); + + return res.status(204).send(); + } +); + +userSettingsRoutes.delete<{ id: string }>( + '/linked-accounts/plex', + isOwnProfileOrAdmin(), + async (req, res) => { + const settings = getSettings(); + const userRepository = getRepository(User); + + // Make sure Plex login is enabled + if (settings.main.mediaServerType !== MediaServerType.PLEX) { + return res.status(500).json({ message: 'Plex login is disabled' }); + } + + try { + const user = await userRepository + .createQueryBuilder('user') + .addSelect('user.password') + .where({ + id: Number(req.params.id), + }) + .getOne(); + + if (!user) { + return res.status(404).json({ message: 'User not found.' }); + } + + if (user.id === 1) { + return res.status(400).json({ + message: + 'Cannot unlink media server accounts for the primary administrator.', + }); + } + + if (!user.email || !user.password) { + return res.status(400).json({ + message: 'User does not have a local email or password set.', + }); + } + + user.userType = UserType.LOCAL; + user.plexId = null; + user.plexUsername = null; + user.plexToken = null; + await userRepository.save(user); + + return res.status(204).send(); + } catch (e) { + return res.status(500).json({ message: e.message }); + } + } +); + +userSettingsRoutes.post<{ username: string; password: string }>( + '/linked-accounts/jellyfin', + isOwnProfile(), + async (req, res) => { + const settings = getSettings(); + const userRepository = getRepository(User); + + if (!req.user) { + return res.status(401).json({ code: ApiErrorCode.Unauthorized }); + } + // Make sure jellyfin login is enabled + if ( + settings.main.mediaServerType !== MediaServerType.JELLYFIN && + settings.main.mediaServerType !== MediaServerType.EMBY + ) { + return res + .status(500) + .json({ message: 'Jellyfin/Emby login is disabled' }); + } + + // Do not allow linking of an already linked account + if ( + await userRepository.exist({ + where: { jellyfinUsername: req.body.username }, + }) + ) { + return res.status(422).json({ + message: 'The specified account is already linked to a Jellyseerr user', + }); + } + + const hostname = getHostname(); + const deviceId = Buffer.from( + `BOT_overseerr_${req.user.username ?? ''}` + ).toString('base64'); + + const jellyfinserver = new JellyfinAPI(hostname, undefined, deviceId); + + const ip = req.ip; + let clientIp: string | undefined; + if (ip) { + if (net.isIPv4(ip)) { + clientIp = ip; + } else if (net.isIPv6(ip)) { + clientIp = ip.startsWith('::ffff:') ? ip.substring(7) : ip; + } + } + + try { + const account = await jellyfinserver.login( + req.body.username, + req.body.password, + clientIp + ); + + // Do not allow linking of an already linked account + if ( + await userRepository.exist({ + where: { jellyfinUserId: account.User.Id }, + }) + ) { + return res.status(422).json({ + message: + 'The specified account is already linked to a Jellyseerr user', + }); + } + + const user = req.user; + + // valid jellyfin user found, link to current user + user.userType = + settings.main.mediaServerType === MediaServerType.EMBY + ? UserType.EMBY + : UserType.JELLYFIN; + user.jellyfinUserId = account.User.Id; + user.jellyfinUsername = account.User.Name; + user.jellyfinAuthToken = account.AccessToken; + user.jellyfinDeviceId = deviceId; + await userRepository.save(user); + + return res.status(204).send(); + } catch (e) { + logger.error('Failed to link account to user.', { + label: 'API', + ip: req.ip, + error: e, + }); + if ( + e instanceof ApiError && + e.errorCode === ApiErrorCode.InvalidCredentials + ) { + return res.status(401).json({ code: e.errorCode }); + } + + return res.status(500).send(); + } + } +); + +userSettingsRoutes.delete<{ id: string }>( + '/linked-accounts/jellyfin', + isOwnProfileOrAdmin(), + async (req, res) => { + const settings = getSettings(); + const userRepository = getRepository(User); + + // Make sure jellyfin login is enabled + if ( + settings.main.mediaServerType !== MediaServerType.JELLYFIN && + settings.main.mediaServerType !== MediaServerType.EMBY + ) { + return res + .status(500) + .json({ message: 'Jellyfin/Emby login is disabled' }); + } + + try { + const user = await userRepository + .createQueryBuilder('user') + .addSelect('user.password') + .where({ + id: Number(req.params.id), + }) + .getOne(); + + if (!user) { + return res.status(404).json({ message: 'User not found.' }); + } + + if (user.id === 1) { + return res.status(400).json({ + message: + 'Cannot unlink media server accounts for the primary administrator.', + }); + } + + if (!user.email || !user.password) { + return res.status(400).json({ + message: 'User does not have a local email or password set.', + }); + } + + user.userType = UserType.LOCAL; + user.jellyfinUserId = null; + user.jellyfinUsername = null; + user.jellyfinAuthToken = null; + user.jellyfinDeviceId = null; + await userRepository.save(user); + + return res.status(204).send(); + } catch (e) { + return res.status(500).json({ message: e.message }); + } + } +); + userSettingsRoutes.get<{ id: string }, UserSettingsNotificationsResponse>( '/notifications', isOwnProfileOrAdmin(), diff --git a/src/assets/services/jellyfin-icon.svg b/src/assets/services/jellyfin-icon.svg new file mode 100644 index 00000000..d4d7f017 --- /dev/null +++ b/src/assets/services/jellyfin-icon.svg @@ -0,0 +1,24 @@ + + + + + + + + + + icon-transparent + + + + + diff --git a/src/components/Common/Button/index.tsx b/src/components/Common/Button/index.tsx index a4df3115..ac1c330c 100644 --- a/src/components/Common/Button/index.tsx +++ b/src/components/Common/Button/index.tsx @@ -1,5 +1,6 @@ import type { ForwardedRef } from 'react'; import React from 'react'; +import { twMerge } from 'tailwind-merge'; export type ButtonType = | 'default' @@ -97,7 +98,7 @@ function Button

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

( } else { return ( - )} +

+ )} + > + {text} + {children && ( - - -
-
-
{children}
-
-
-
+ + {children}
)} - +
); }; -export default withProperties(ButtonWithDropdown, { Item: DropdownItem }); +export default withProperties(ButtonWithDropdown, { Item: Dropdown.Item }); diff --git a/src/components/Common/Dropdown/index.tsx b/src/components/Common/Dropdown/index.tsx new file mode 100644 index 00000000..74ce79f2 --- /dev/null +++ b/src/components/Common/Dropdown/index.tsx @@ -0,0 +1,117 @@ +import { withProperties } from '@app/utils/typeHelpers'; +import { Menu, Transition } from '@headlessui/react'; +import { ChevronDownIcon } from '@heroicons/react/24/solid'; +import { + Fragment, + useRef, + type AnchorHTMLAttributes, + type ButtonHTMLAttributes, + type HTMLAttributes, +} from 'react'; + +interface DropdownItemProps extends AnchorHTMLAttributes { + buttonType?: 'primary' | 'ghost'; +} + +const DropdownItem = ({ + children, + buttonType = 'primary', + ...props +}: DropdownItemProps) => { + return ( + + + {children} + + + ); +}; + +type DropdownItemsProps = HTMLAttributes & { + dropdownType: 'primary' | 'ghost'; +}; + +const DropdownItems = ({ + children, + className, + dropdownType, + ...props +}: DropdownItemsProps) => { + return ( + + +
{children}
+
+
+ ); +}; + +interface DropdownProps extends ButtonHTMLAttributes { + text: React.ReactNode; + dropdownIcon?: React.ReactNode; + buttonType?: 'primary' | 'ghost'; +} + +const Dropdown = ({ + text, + children, + dropdownIcon, + className, + buttonType = 'primary', + ...props +}: DropdownProps) => { + const buttonRef = useRef(null); + + return ( + + + {text} + {children && (dropdownIcon ? dropdownIcon : )} + + {children && ( + {children} + )} + + ); +}; +export default withProperties(Dropdown, { + Item: DropdownItem, + Items: DropdownItems, +}); diff --git a/src/components/Common/LabeledCheckbox/index.tsx b/src/components/Common/LabeledCheckbox/index.tsx new file mode 100644 index 00000000..ff7e1f56 --- /dev/null +++ b/src/components/Common/LabeledCheckbox/index.tsx @@ -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 = ({ + id, + className, + label, + description, + onChange, + children, +}) => { + return ( + <> +
+
+ +
+
+ +
+
+ { + /* can hold child checkboxes */ + children &&
{children}
+ } + + ); +}; + +export default LabeledCheckbox; diff --git a/src/components/Common/Modal/index.tsx b/src/components/Common/Modal/index.tsx index 8cebf06f..ca7be654 100644 --- a/src/components/Common/Modal/index.tsx +++ b/src/components/Common/Modal/index.tsx @@ -29,11 +29,16 @@ interface ModalProps { secondaryDisabled?: boolean; tertiaryDisabled?: boolean; tertiaryButtonType?: ButtonType; + okButtonProps?: React.ButtonHTMLAttributes; + cancelButtonProps?: React.ButtonHTMLAttributes; + secondaryButtonProps?: React.ButtonHTMLAttributes; + tertiaryButtonProps?: React.ButtonHTMLAttributes; disableScrollLock?: boolean; backgroundClickable?: boolean; loading?: boolean; backdrop?: string; children?: React.ReactNode; + dialogClass?: string; } const Modal = React.forwardRef( @@ -61,6 +66,11 @@ const Modal = React.forwardRef( loading = false, onTertiary, backdrop, + dialogClass, + okButtonProps, + cancelButtonProps, + secondaryButtonProps, + tertiaryButtonProps, }, parentRef ) => { @@ -106,7 +116,7 @@ const Modal = React.forwardRef( ( className="ml-3" disabled={okDisabled} data-testid="modal-ok-button" + {...okButtonProps} > {okText ? okText : 'Ok'} @@ -200,6 +211,7 @@ const Modal = React.forwardRef( className="ml-3" disabled={secondaryDisabled} data-testid="modal-secondary-button" + {...secondaryButtonProps} > {secondaryText} @@ -210,6 +222,7 @@ const Modal = React.forwardRef( onClick={onTertiary} className="ml-3" disabled={tertiaryDisabled} + {...tertiaryButtonProps} > {tertiaryText} @@ -220,6 +233,7 @@ const Modal = React.forwardRef( onClick={onCancel} className="ml-3 sm:ml-0" data-testid="modal-cancel-button" + {...cancelButtonProps} > {cancelText ? cancelText diff --git a/src/components/LanguageSelector/index.tsx b/src/components/LanguageSelector/index.tsx index d7b9853c..083ecbc7 100644 --- a/src/components/LanguageSelector/index.tsx +++ b/src/components/LanguageSelector/index.tsx @@ -33,6 +33,7 @@ interface LanguageSelectorProps { setFieldValue: (property: string, value: string) => void; serverValue?: string; isUserSettings?: boolean; + isDisabled?: boolean; } const LanguageSelector = ({ @@ -40,6 +41,7 @@ const LanguageSelector = ({ setFieldValue, serverValue, isUserSettings = false, + isDisabled, }: LanguageSelectorProps) => { const intl = useIntl(); const { data: languages } = useSWR('/api/v1/languages'); @@ -96,6 +98,7 @@ const LanguageSelector = ({ options={options} isMulti + isDisabled={isDisabled} className="react-select-container" classNamePrefix="react-select" value={ diff --git a/src/components/Login/JellyfinLogin.tsx b/src/components/Login/JellyfinLogin.tsx index 740e50f8..e0e4b9cf 100644 --- a/src/components/Login/JellyfinLogin.tsx +++ b/src/components/Login/JellyfinLogin.tsx @@ -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 = ({ revalidate, - initial, serverType, - onCancel, }) => { const toasts = useToasts(); const intl = useIntl(); @@ -72,56 +48,29 @@ const JellyfinLogin: React.FC = ({ : '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 ( +
{ 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 = ({ 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 = ({ errorMessage = messages.loginerror; break; } - toasts.addToast( intl.formatMessage(errorMessage, mediaServerFormatValues), { @@ -178,313 +121,51 @@ const JellyfinLogin: React.FC = ({ } }} > - {({ - errors, - touched, - values, - setFieldValue, - isSubmitting, - isValid, - }) => ( -
-
-
-
- -
-
- - {values.useSsl ? 'https://' : 'http://'} - + {({ errors, touched, isSubmitting, isValid }) => { + return ( + <> + +
+

+ {intl.formatMessage(messages.loginwithapp, { + appName: mediaServerFormatValues.mediaServerName, + })} +

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

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

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

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

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

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

+ ))} + +
+ {additionalLoginOptions} +
+
diff --git a/src/components/ManageSlideOver/index.tsx b/src/components/ManageSlideOver/index.tsx index 205c9456..5c36e787 100644 --- a/src/components/ManageSlideOver/index.tsx +++ b/src/components/ManageSlideOver/index.tsx @@ -122,15 +122,13 @@ const ManageSlideOver = ({ const deleteMediaFile = async () => { if (data.mediaInfo) { - const res1 = await fetch(`/api/v1/media/${data.mediaInfo.id}/file`, { + // we don't check if the response is ok here because there may be no file to delete + await fetch(`/api/v1/media/${data.mediaInfo.id}/file`, { method: 'DELETE', }); - if (!res1.ok) throw new Error(); - - const res2 = await fetch(`/api/v1/media/${data.mediaInfo.id}`, { + await fetch(`/api/v1/media/${data.mediaInfo.id}`, { method: 'DELETE', }); - if (!res2.ok) throw new Error(); revalidate(); onClose(); diff --git a/src/components/MediaSlider/index.tsx b/src/components/MediaSlider/index.tsx index 006f0df9..b330798f 100644 --- a/src/components/MediaSlider/index.tsx +++ b/src/components/MediaSlider/index.tsx @@ -56,6 +56,7 @@ const MediaSlider = ({ }, { initialSize: 2, + revalidateFirstPage: false, } ); diff --git a/src/components/PlexLoginButton/index.tsx b/src/components/PlexLoginButton/index.tsx deleted file mode 100644 index 3cf1d3ee..00000000 --- a/src/components/PlexLoginButton/index.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import globalMessages from '@app/i18n/globalMessages'; -import defineMessages from '@app/utils/defineMessages'; -import PlexOAuth from '@app/utils/plex'; -import { ArrowLeftOnRectangleIcon } from '@heroicons/react/24/outline'; -import { useState } from 'react'; -import { useIntl } from 'react-intl'; - -const messages = defineMessages('components.PlexLoginButton', { - signinwithplex: 'Sign In', - signingin: 'Signing In…', -}); - -const plexOAuth = new PlexOAuth(); - -interface PlexLoginButtonProps { - onAuthToken: (authToken: string) => void; - isProcessing?: boolean; - onError?: (message: string) => void; -} - -const PlexLoginButton = ({ - onAuthToken, - onError, - isProcessing, -}: PlexLoginButtonProps) => { - const intl = useIntl(); - const [loading, setLoading] = useState(false); - - const getPlexLogin = async () => { - setLoading(true); - try { - const authToken = await plexOAuth.login(); - setLoading(false); - onAuthToken(authToken); - } catch (e) { - if (onError) { - onError(e.message); - } - setLoading(false); - } - }; - return ( - - - - ); -}; - -export default PlexLoginButton; diff --git a/src/components/RequestList/RequestItem/index.tsx b/src/components/RequestList/RequestItem/index.tsx index 0f8a5a24..018fa915 100644 --- a/src/components/RequestList/RequestItem/index.tsx +++ b/src/components/RequestList/RequestItem/index.tsx @@ -348,6 +348,7 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => { const deleteMediaFile = async () => { if (request.media) { + // we don't check if the response is ok here because there may be no file to delete await fetch(`/api/v1/media/${request.media.id}/file`, { method: 'DELETE', }); diff --git a/src/components/Selector/index.tsx b/src/components/Selector/index.tsx index 6c831909..2098c935 100644 --- a/src/components/Selector/index.tsx +++ b/src/components/Selector/index.tsx @@ -52,18 +52,21 @@ type SingleVal = { type BaseSelectorMultiProps = { defaultValue?: string; isMulti: true; + isDisabled?: boolean; onChange: (value: MultiValue | null) => void; }; type BaseSelectorSingleProps = { defaultValue?: string; isMulti?: false; + isDisabled?: boolean; onChange: (value: SingleValue | null) => void; }; export const CompanySelector = ({ defaultValue, isMulti, + isDisabled, onChange, }: BaseSelectorSingleProps | BaseSelectorMultiProps) => { const intl = useIntl(); @@ -117,6 +120,7 @@ export const CompanySelector = ({ className="react-select-container" classNamePrefix="react-select" isMulti={isMulti} + isDisabled={isDisabled} defaultValue={defaultDataValue} defaultOptions cacheOptions @@ -143,6 +147,7 @@ type GenreSelectorProps = (BaseSelectorMultiProps | BaseSelectorSingleProps) & { export const GenreSelector = ({ isMulti, defaultValue, + isDisabled, onChange, type, }: GenreSelectorProps) => { @@ -203,6 +208,7 @@ export const GenreSelector = ({ defaultOptions cacheOptions isMulti={isMulti} + isDisabled={isDisabled} loadOptions={loadGenreOptions} placeholder={intl.formatMessage(messages.searchGenres)} onChange={(value) => { @@ -215,6 +221,7 @@ export const GenreSelector = ({ export const StatusSelector = ({ isMulti, + isDisabled, defaultValue, onChange, }: BaseSelectorMultiProps | BaseSelectorSingleProps) => { @@ -272,6 +279,7 @@ export const StatusSelector = ({ defaultValue={isMulti ? defaultDataValue : defaultDataValue?.[0]} defaultOptions isMulti={isMulti} + isDisabled={isDisabled} loadOptions={loadStatusOptions} placeholder={intl.formatMessage(messages.searchStatus)} onChange={(value) => { @@ -284,6 +292,7 @@ export const StatusSelector = ({ export const KeywordSelector = ({ isMulti, + isDisabled, defaultValue, onChange, }: BaseSelectorMultiProps | BaseSelectorSingleProps) => { @@ -341,6 +350,7 @@ export const KeywordSelector = ({ key={`keyword-select-${defaultDataValue}`} inputId="data" isMulti={isMulti} + isDisabled={isDisabled} className="react-select-container" classNamePrefix="react-select" noOptionsMessage={({ inputValue }) => @@ -551,6 +561,7 @@ export const WatchProviderSelector = ({ export const UserSelector = ({ isMulti, + isDisabled, defaultValue, onChange, }: BaseSelectorMultiProps | BaseSelectorSingleProps) => { @@ -613,6 +624,7 @@ export const UserSelector = ({ defaultOptions cacheOptions isMulti={isMulti} + isDisabled={isDisabled} loadOptions={loadUserOptions} placeholder={intl.formatMessage(messages.searchUsers)} onChange={(value) => { diff --git a/src/components/Settings/OverrideRule/OverrideRuleModal.tsx b/src/components/Settings/OverrideRule/OverrideRuleModal.tsx index becb1ee9..a2776bba 100644 --- a/src/components/Settings/OverrideRule/OverrideRuleModal.tsx +++ b/src/components/Settings/OverrideRule/OverrideRuleModal.tsx @@ -11,7 +11,13 @@ import globalMessages from '@app/i18n/globalMessages'; import defineMessages from '@app/utils/defineMessages'; import { Transition } from '@headlessui/react'; import type OverrideRule from '@server/entity/OverrideRule'; +import type { + DVRSettings, + RadarrSettings, + SonarrSettings, +} from '@server/lib/settings'; import { Field, Formik } from 'formik'; +import { useCallback, useEffect, useState } from 'react'; import { useIntl } from 'react-intl'; import Select from 'react-select'; import { useToasts } from 'react-toast-notifications'; @@ -20,6 +26,9 @@ const messages = defineMessages('components.Settings.OverrideRuleModal', { createrule: 'New Override Rule', editrule: 'Edit Override Rule', create: 'Create rule', + service: 'Service', + serviceDescription: 'Apply this rule to the selected service.', + selectService: 'Select service', conditions: 'Conditions', conditionsDescription: 'Specifies conditions before applying parameter changes. Each field must be validated for the rules to be applied (AND operation). A field is considered verified if any of its properties match (OR operation).', @@ -49,21 +58,88 @@ type OptionType = { interface OverrideRuleModalProps { rule: OverrideRule | null; onClose: () => void; - testResponse: DVRTestResponse; - radarrId?: number; - sonarrId?: number; + radarrServices: RadarrSettings[]; + sonarrServices: SonarrSettings[]; } const OverrideRuleModal = ({ onClose, rule, - testResponse, - radarrId, - sonarrId, + radarrServices, + sonarrServices, }: OverrideRuleModalProps) => { const intl = useIntl(); const { addToast } = useToasts(); const { currentSettings } = useSettings(); + const [isValidated, setIsValidated] = useState(rule ? true : false); + const [isTesting, setIsTesting] = useState(false); + const [testResponse, setTestResponse] = useState({ + profiles: [], + rootFolders: [], + tags: [], + }); + + const getServiceInfos = useCallback( + async ({ + hostname, + port, + apiKey, + baseUrl, + useSsl = false, + }: { + hostname: string; + port: number; + apiKey: string; + baseUrl?: string; + useSsl?: boolean; + }) => { + setIsTesting(true); + try { + const res = await fetch('/api/v1/settings/sonarr/test', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + hostname, + apiKey, + port: Number(port), + baseUrl, + useSsl, + }), + }); + if (!res.ok) throw new Error(); + const data: DVRTestResponse = await res.json(); + + setIsValidated(true); + setTestResponse(data); + } catch (e) { + setIsValidated(false); + } finally { + setIsTesting(false); + } + }, + [] + ); + + useEffect(() => { + let service: DVRSettings | null = null; + if (rule?.radarrServiceId !== null && rule?.radarrServiceId !== undefined) { + service = radarrServices[rule?.radarrServiceId] || null; + } + if (rule?.sonarrServiceId !== null && rule?.sonarrServiceId !== undefined) { + service = sonarrServices[rule?.sonarrServiceId] || null; + } + if (service) { + getServiceInfos(service); + } + }, [ + getServiceInfos, + radarrServices, + rule?.radarrServiceId, + rule?.sonarrServiceId, + sonarrServices, + ]); return (
+

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

+

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

+
+ +
+
+ +
+ {errors.rootFolder && + touched.rootFolder && + typeof errors.rootFolder === 'string' && ( +
{errors.rootFolder}
+ )} +
+

{intl.formatMessage(messages.conditions)}

@@ -184,6 +331,7 @@ const OverrideRuleModal = ({
{ setFieldValue( @@ -207,9 +355,10 @@ const OverrideRuleModal = ({
{ setFieldValue( 'genre', @@ -237,6 +386,7 @@ const OverrideRuleModal = ({ setFieldValue={(_key, value) => { setFieldValue('language', value); }} + isDisabled={!isValidated || isTesting} />
{errors.language && @@ -255,6 +405,7 @@ const OverrideRuleModal = ({ { setFieldValue( 'keywords', @@ -282,7 +433,12 @@ const OverrideRuleModal = ({
- + @@ -310,7 +466,12 @@ const OverrideRuleModal = ({
- + @@ -343,6 +504,7 @@ const OverrideRuleModal = ({ value: tag.id, }))} isMulti + isDisabled={!isValidated || isTesting} placeholder={intl.formatMessage(messages.selecttags)} className="react-select-container" classNamePrefix="react-select" diff --git a/src/components/Settings/OverrideRule/OverrideRuleTile.tsx b/src/components/Settings/OverrideRule/OverrideRuleTile.tsx deleted file mode 100644 index c5c0451a..00000000 --- a/src/components/Settings/OverrideRule/OverrideRuleTile.tsx +++ /dev/null @@ -1,267 +0,0 @@ -import type { DVRTestResponse } from '@app/components/Settings/SettingsServices'; -import globalMessages from '@app/i18n/globalMessages'; -import defineMessages from '@app/utils/defineMessages'; -import { PencilIcon, TrashIcon } from '@heroicons/react/24/solid'; -import type { TmdbGenre } from '@server/api/themoviedb/interfaces'; -import type OverrideRule from '@server/entity/OverrideRule'; -import type { User } from '@server/entity/User'; -import type { - Language, - RadarrSettings, - SonarrSettings, -} from '@server/lib/settings'; -import type { Keyword } from '@server/models/common'; -import { useEffect, useState } from 'react'; -import { useIntl } from 'react-intl'; -import useSWR from 'swr'; - -const messages = defineMessages('components.Settings.OverrideRuleTile', { - qualityprofile: 'Quality Profile', - rootfolder: 'Root Folder', - tags: 'Tags', - users: 'Users', - genre: 'Genre', - language: 'Language', - keywords: 'Keywords', - conditions: 'Conditions', - settings: 'Settings', -}); - -interface OverrideRuleTileProps { - rules: OverrideRule[]; - setOverrideRuleModal: ({ - open, - rule, - testResponse, - }: { - open: boolean; - rule: OverrideRule | null; - testResponse: DVRTestResponse; - }) => void; - testResponse: DVRTestResponse; - radarr?: RadarrSettings | null; - sonarr?: SonarrSettings | null; - revalidate: () => void; -} - -const OverrideRuleTile = ({ - rules, - setOverrideRuleModal, - testResponse, - radarr, - sonarr, - revalidate, -}: OverrideRuleTileProps) => { - const intl = useIntl(); - const [users, setUsers] = useState(null); - const [keywords, setKeywords] = useState(null); - const { data: languages } = useSWR('/api/v1/languages'); - const { data: genres } = useSWR('/api/v1/genres/movie'); - - useEffect(() => { - (async () => { - const keywords = await Promise.all( - rules - .map((rule) => rule.keywords?.split(',')) - .flat() - .filter((keywordId) => keywordId) - .map(async (keywordId) => { - const res = await fetch(`/api/v1/keyword/${keywordId}`); - if (!res.ok) throw new Error(); - const keyword: Keyword = await res.json(); - return keyword; - }) - ); - setKeywords(keywords); - const users = await Promise.all( - rules - .map((rule) => rule.users?.split(',')) - .flat() - .filter((userId) => userId) - .map(async (userId) => { - const res = await fetch(`/api/v1/user/${userId}`); - if (!res.ok) throw new Error(); - const user: User = await res.json(); - return user; - }) - ); - setUsers(users); - })(); - }, [rules]); - - return ( - <> - {rules - .filter( - (rule) => - (rule.radarrServiceId !== null && - rule.radarrServiceId === radarr?.id) || - (rule.sonarrServiceId !== null && - rule.sonarrServiceId === sonarr?.id) - ) - .map((rule) => ( -
  • -
    -
    - - {intl.formatMessage(messages.conditions)} - - {rule.users && ( -

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

    - {rule.users.split(',').map((userId) => { - return ( - - { - users?.find((user) => user.id === Number(userId)) - ?.displayName - } - - ); - })} -
    -

    - )} - {rule.genre && ( -

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

    - {rule.genre.split(',').map((genreId) => ( - - {genres?.find((g) => g.id === Number(genreId))?.name} - - ))} -
    -

    - )} - {rule.language && ( -

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

    - {rule.language - .split('|') - .filter((languageId) => languageId !== 'server') - .map((languageId) => { - const language = languages?.find( - (language) => language.iso_639_1 === languageId - ); - if (!language) return null; - const languageName = - intl.formatDisplayName(language.iso_639_1, { - type: 'language', - fallback: 'none', - }) ?? language.english_name; - return {languageName}; - })} -
    -

    - )} - {rule.keywords && ( -

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

    - {rule.keywords.split(',').map((keywordId) => { - return ( - - { - keywords?.find( - (keyword) => keyword.id === Number(keywordId) - )?.name - } - - ); - })} -
    -

    - )} - - {intl.formatMessage(messages.settings)} - - {rule.profileId && ( -

    - - {intl.formatMessage(messages.qualityprofile)} - - { - testResponse.profiles.find( - (profile) => rule.profileId === profile.id - )?.name - } -

    - )} - {rule.rootFolder && ( -

    - - {intl.formatMessage(messages.rootfolder)} - - {rule.rootFolder} -

    - )} - {rule.tags && rule.tags.length > 0 && ( -

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

    - {rule.tags.split(',').map((tag) => ( - - { - testResponse.tags?.find((t) => t.id === Number(tag)) - ?.label - } - - ))} -
    -

    - )} -
    -
    -
    -
    -
    - -
    -
    - -
    -
    -
    -
  • - ))} - - ); -}; - -export default OverrideRuleTile; diff --git a/src/components/Settings/OverrideRule/OverrideRuleTiles.tsx b/src/components/Settings/OverrideRule/OverrideRuleTiles.tsx new file mode 100644 index 00000000..a3b9aa37 --- /dev/null +++ b/src/components/Settings/OverrideRule/OverrideRuleTiles.tsx @@ -0,0 +1,318 @@ +import type { DVRTestResponse } from '@app/components/Settings/SettingsServices'; +import globalMessages from '@app/i18n/globalMessages'; +import defineMessages from '@app/utils/defineMessages'; +import { PencilIcon, TrashIcon } from '@heroicons/react/24/solid'; +import type { TmdbGenre } from '@server/api/themoviedb/interfaces'; +import type OverrideRule from '@server/entity/OverrideRule'; +import type { User } from '@server/entity/User'; +import type { + DVRSettings, + Language, + RadarrSettings, + SonarrSettings, +} from '@server/lib/settings'; +import type { Keyword } from '@server/models/common'; +import { useCallback, useEffect, useState } from 'react'; +import { useIntl } from 'react-intl'; +import useSWR from 'swr'; + +const messages = defineMessages('components.Settings.OverrideRuleTile', { + qualityprofile: 'Quality Profile', + rootfolder: 'Root Folder', + tags: 'Tags', + users: 'Users', + genre: 'Genre', + language: 'Language', + keywords: 'Keywords', + conditions: 'Conditions', + settings: 'Settings', +}); + +interface OverrideRuleTilesProps { + rules: OverrideRule[]; + setOverrideRuleModal: ({ + open, + rule, + }: { + open: boolean; + rule: OverrideRule | null; + }) => void; + revalidate: () => void; + radarrServices: RadarrSettings[]; + sonarrServices: SonarrSettings[]; +} + +const OverrideRuleTiles = ({ + rules, + setOverrideRuleModal, + revalidate, + radarrServices, + sonarrServices, +}: OverrideRuleTilesProps) => { + const intl = useIntl(); + const [users, setUsers] = useState(null); + const [keywords, setKeywords] = useState(null); + const { data: languages } = useSWR('/api/v1/languages'); + const { data: genres } = useSWR('/api/v1/genres/movie'); + const [testResponses, setTestResponses] = useState< + (DVRTestResponse & { type: string; id: number })[] + >([]); + + const getServiceInfos = useCallback(async () => { + const results: (DVRTestResponse & { type: string; id: number })[] = []; + const services: DVRSettings[] = [...radarrServices, ...sonarrServices]; + for (const service of services) { + const { hostname, port, apiKey, baseUrl, useSsl = false } = service; + try { + const res = await fetch( + `/api/v1/settings/${ + radarrServices.includes(service as RadarrSettings) + ? 'radarr' + : 'sonarr' + }/test`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + hostname, + apiKey, + port: Number(port), + baseUrl, + useSsl, + }), + } + ); + if (!res.ok) throw new Error(); + const data: DVRTestResponse = await res.json(); + results.push({ + type: radarrServices.includes(service as RadarrSettings) + ? 'radarr' + : 'sonarr', + id: service.id, + ...data, + }); + } catch { + results.push({ + type: radarrServices.includes(service as RadarrSettings) + ? 'radarr' + : 'sonarr', + id: service.id, + profiles: [], + rootFolders: [], + tags: [], + }); + } + } + setTestResponses(results); + }, [radarrServices, sonarrServices]); + + useEffect(() => { + getServiceInfos(); + }, [getServiceInfos]); + + useEffect(() => { + (async () => { + const keywords = await Promise.all( + rules + .map((rule) => rule.keywords?.split(',')) + .flat() + .filter((keywordId) => keywordId) + .map(async (keywordId) => { + const res = await fetch(`/api/v1/keyword/${keywordId}`); + if (!res.ok) throw new Error(); + const keyword: Keyword = await res.json(); + return keyword; + }) + ); + setKeywords(keywords); + const users = await Promise.all( + rules + .map((rule) => rule.users?.split(',')) + .flat() + .filter((userId) => userId) + .map(async (userId) => { + const res = await fetch(`/api/v1/user/${userId}`); + if (!res.ok) throw new Error(); + const user: User = await res.json(); + return user; + }) + ); + setUsers(users); + })(); + }, [rules]); + + return ( + <> + {rules.map((rule) => ( +
  • +
    +
    + + {intl.formatMessage(messages.conditions)} + + {rule.users && ( +

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

    + {rule.users.split(',').map((userId) => { + return ( + + { + users?.find((user) => user.id === Number(userId)) + ?.displayName + } + + ); + })} +
    +

    + )} + {rule.genre && ( +

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

    + {rule.genre.split(',').map((genreId) => ( + + {genres?.find((g) => g.id === Number(genreId))?.name} + + ))} +
    +

    + )} + {rule.language && ( +

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

    + {rule.language + .split('|') + .filter((languageId) => languageId !== 'server') + .map((languageId) => { + const language = languages?.find( + (language) => language.iso_639_1 === languageId + ); + if (!language) return null; + const languageName = + intl.formatDisplayName(language.iso_639_1, { + type: 'language', + fallback: 'none', + }) ?? language.english_name; + return {languageName}; + })} +
    +

    + )} + {rule.keywords && ( +

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

    + {rule.keywords.split(',').map((keywordId) => { + return ( + + { + keywords?.find( + (keyword) => keyword.id === Number(keywordId) + )?.name + } + + ); + })} +
    +

    + )} + + {intl.formatMessage(messages.settings)} + + {rule.profileId && ( +

    + + {intl.formatMessage(messages.qualityprofile)} + + {testResponses + .find( + (r) => + (r.id === rule.radarrServiceId && + r.type === 'radarr') || + (r.id === rule.sonarrServiceId && r.type === 'sonarr') + ) + ?.profiles.find((profile) => rule.profileId === profile.id) + ?.name || rule.profileId} +

    + )} + {rule.rootFolder && ( +

    + + {intl.formatMessage(messages.rootfolder)} + + {rule.rootFolder} +

    + )} + {rule.tags && rule.tags.length > 0 && ( +

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

    + {rule.tags.split(',').map((tag) => ( + + {testResponses + .find( + (r) => + (r.id === rule.radarrServiceId && + r.type === 'radarr') || + (r.id === rule.sonarrServiceId && + r.type === 'sonarr') + ) + ?.tags?.find((t) => t.id === Number(tag))?.label || + tag} + + ))} +
    +

    + )} +
    +
    +
    +
    +
    + +
    +
    + +
    +
    +
    +
  • + ))} + + ); +}; + +export default OverrideRuleTiles; diff --git a/src/components/Settings/RadarrModal/index.tsx b/src/components/Settings/RadarrModal/index.tsx index 5067c71b..fbeb2dec 100644 --- a/src/components/Settings/RadarrModal/index.tsx +++ b/src/components/Settings/RadarrModal/index.tsx @@ -1,24 +1,15 @@ -import Button from '@app/components/Common/Button'; import Modal from '@app/components/Common/Modal'; import SensitiveInput from '@app/components/Common/SensitiveInput'; -import OverrideRuleTile from '@app/components/Settings/OverrideRule/OverrideRuleTile'; -import type { - DVRTestResponse, - RadarrTestResponse, -} from '@app/components/Settings/SettingsServices'; +import type { RadarrTestResponse } from '@app/components/Settings/SettingsServices'; import globalMessages from '@app/i18n/globalMessages'; import defineMessages from '@app/utils/defineMessages'; import { Transition } from '@headlessui/react'; -import { PlusIcon } from '@heroicons/react/24/solid'; -import type OverrideRule from '@server/entity/OverrideRule'; -import type { OverrideRuleResultsResponse } from '@server/interfaces/api/overrideRuleInterfaces'; import type { RadarrSettings } from '@server/lib/settings'; import { Field, Formik } from 'formik'; import { useCallback, useEffect, useRef, useState } from 'react'; import { useIntl } from 'react-intl'; import Select from 'react-select'; import { useToasts } from 'react-toast-notifications'; -import useSWR from 'swr'; import * as Yup from 'yup'; type OptionType = { @@ -79,36 +70,16 @@ const messages = defineMessages('components.Settings.RadarrModal', { announced: 'Announced', inCinemas: 'In Cinemas', released: 'Released', - overrideRules: 'Override Rules', - addrule: 'New Override Rule', }); interface RadarrModalProps { radarr: RadarrSettings | null; onClose: () => void; onSave: () => void; - overrideRuleModal: { open: boolean; rule: OverrideRule | null }; - setOverrideRuleModal: ({ - open, - rule, - testResponse, - }: { - open: boolean; - rule: OverrideRule | null; - testResponse: DVRTestResponse; - }) => void; } -const RadarrModal = ({ - onClose, - radarr, - onSave, - overrideRuleModal, - setOverrideRuleModal, -}: RadarrModalProps) => { +const RadarrModal = ({ onClose, radarr, onSave }: RadarrModalProps) => { const intl = useIntl(); - const { data: rules, mutate: revalidate } = - useSWR('/api/v1/overrideRule'); const initialLoad = useRef(false); const { addToast } = useToasts(); const [isValidated, setIsValidated] = useState(radarr ? true : false); @@ -235,10 +206,6 @@ const RadarrModal = ({ } }, [radarr, testConnection]); - useEffect(() => { - revalidate(); - }, [overrideRuleModal, revalidate]); - return (
    @@ -773,42 +739,6 @@ const RadarrModal = ({
    - {radarr && ( - <> -

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

    -
      - {rules && ( - - )} -
    • -
      - -
      -
    • -
    - - )} ); }} diff --git a/src/components/Settings/SettingsNetwork/index.tsx b/src/components/Settings/SettingsNetwork/index.tsx index 82701cda..befed1fb 100644 --- a/src/components/Settings/SettingsNetwork/index.tsx +++ b/src/components/Settings/SettingsNetwork/index.tsx @@ -27,12 +27,6 @@ const messages = defineMessages('components.Settings.SettingsNetwork', { trustProxy: 'Enable Proxy Support', trustProxyTip: 'Allow Jellyseerr to correctly register client IP addresses behind a proxy', - forceIpv4First: 'IPv4 Resolution First', - forceIpv4FirstTip: - 'Force Jellyseerr to resolve IPv4 addresses first instead of IPv6', - dnsServers: 'Custom DNS Servers', - dnsServersTip: - 'Comma-separated list of custom DNS servers, e.g. "1.1.1.1,[2606:4700:4700::1111]"', proxyEnabled: 'HTTP(S) Proxy', proxyHostname: 'Proxy Hostname', proxyPort: 'Proxy Port', @@ -44,9 +38,19 @@ const messages = defineMessages('components.Settings.SettingsNetwork', { "Use ',' as a separator, and '*.' as a wildcard for subdomains", proxyBypassLocalAddresses: 'Bypass Proxy for Local Addresses', validationProxyPort: 'You must provide a valid port', + advancedNetworkSettings: 'Advanced Network Settings', + networkDisclaimer: + 'Network parameters from your container/system should be used instead of these settings. See the {docs} for more information.', + docs: 'documentation', + forceIpv4First: 'Force IPv4 Resolution First', + forceIpv4FirstTip: + 'Force Jellyseerr to resolve IPv4 addresses first instead of IPv6', + dnsServers: 'Custom DNS Servers', + dnsServersTip: + 'Comma-separated list of custom DNS servers, e.g. "1.1.1.1,[2606:4700:4700::1111]"', }); -const SettingsMain = () => { +const SettingsNetwork = () => { const { addToast } = useToasts(); const intl = useIntl(); const { @@ -206,55 +210,6 @@ const SettingsMain = () => {
    -
    - -
    - { - setFieldValue('forceIpv4First', !values.forceIpv4First); - }} - /> -
    -
    -
    - -
    -
    - -
    - {errors.dnsServers && - touched.dnsServers && - typeof errors.dnsServers === 'string' && ( -
    {errors.dnsServers}
    - )} -
    -
    )} +

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

    +

    + {intl.formatMessage(messages.networkDisclaimer, { + docs: ( + + {intl.formatMessage(messages.docs)} + + ), + })} +

    +
    + +
    + { + setFieldValue('forceIpv4First', !values.forceIpv4First); + }} + /> +
    +
    +
    + +
    +
    + +
    + {errors.dnsServers && + touched.dnsServers && + typeof errors.dnsServers === 'string' && ( +
    {errors.dnsServers}
    + )} +
    +
    @@ -458,4 +481,4 @@ const SettingsMain = () => { ); }; -export default SettingsMain; +export default SettingsNetwork; diff --git a/src/components/Settings/SettingsServices.tsx b/src/components/Settings/SettingsServices.tsx index 5e3871fc..fc058c0b 100644 --- a/src/components/Settings/SettingsServices.tsx +++ b/src/components/Settings/SettingsServices.tsx @@ -7,6 +7,7 @@ import LoadingSpinner from '@app/components/Common/LoadingSpinner'; import Modal from '@app/components/Common/Modal'; import PageTitle from '@app/components/Common/PageTitle'; import OverrideRuleModal from '@app/components/Settings/OverrideRule/OverrideRuleModal'; +import OverrideRuleTiles from '@app/components/Settings/OverrideRule/OverrideRuleTiles'; import RadarrModal from '@app/components/Settings/RadarrModal'; import SonarrModal from '@app/components/Settings/SonarrModal'; import globalMessages from '@app/i18n/globalMessages'; @@ -14,6 +15,7 @@ import defineMessages from '@app/utils/defineMessages'; import { Transition } from '@headlessui/react'; import { PencilIcon, PlusIcon, TrashIcon } from '@heroicons/react/24/solid'; import type OverrideRule from '@server/entity/OverrideRule'; +import type { OverrideRuleResultsResponse } from '@server/interfaces/api/overrideRuleInterfaces'; import type { RadarrSettings, SonarrSettings } from '@server/lib/settings'; import { Fragment, useState } from 'react'; import { useIntl } from 'react-intl'; @@ -43,6 +45,10 @@ const messages = defineMessages('components.Settings', { mediaTypeMovie: 'movie', mediaTypeSeries: 'series', deleteServer: 'Delete {serverType} Server', + overrideRules: 'Override Rules', + overrideRulesDescription: + 'Override rules allow you to specify properties that will be replaced if a request matches the rule.', + addrule: 'New Override Rule', }); interface ServerInstanceProps { @@ -199,6 +205,8 @@ const SettingsServices = () => { error: sonarrError, mutate: revalidateSonarr, } = useSWR('/api/v1/settings/sonarr'); + const { data: rules, mutate: revalidate } = + useSWR('/api/v1/overrideRule'); const [editRadarrModal, setEditRadarrModal] = useState<{ open: boolean; radarr: RadarrSettings | null; @@ -225,11 +233,9 @@ const SettingsServices = () => { const [overrideRuleModal, setOverrideRuleModal] = useState<{ open: boolean; rule: OverrideRule | null; - testResponse: DVRTestResponse | null; }>({ open: false, rule: null, - testResponse: null, }); const deleteServer = async () => { @@ -265,21 +271,6 @@ const SettingsServices = () => { })}

    - {overrideRuleModal.open && overrideRuleModal.testResponse && ( - - setOverrideRuleModal({ - open: false, - rule: null, - testResponse: null, - }) - } - testResponse={overrideRuleModal.testResponse} - radarrId={editRadarrModal.radarr?.id} - sonarrId={editSonarrModal.sonarr?.id} - /> - )} {editRadarrModal.open && ( { mutate('/api/v1/settings/public'); setEditRadarrModal({ open: false, radarr: null }); }} - overrideRuleModal={overrideRuleModal} - setOverrideRuleModal={setOverrideRuleModal} /> )} {editSonarrModal.open && ( @@ -308,8 +297,6 @@ const SettingsServices = () => { mutate('/api/v1/settings/public'); setEditSonarrModal({ open: false, sonarr: null }); }} - overrideRuleModal={overrideRuleModal} - setOverrideRuleModal={setOverrideRuleModal} /> )} { )}
    +
    +

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

    +

    + {intl.formatMessage(messages.overrideRulesDescription, { + serverType: 'Sonarr', + })} +

    +
    +
    +
      + {rules && radarrData && sonarrData && ( + + )} +
    • +
      + +
      +
    • +
    +
    + {overrideRuleModal.open && radarrData && sonarrData && ( + { + setOverrideRuleModal({ + open: false, + rule: null, + }); + revalidate(); + }} + radarrServices={radarrData} + sonarrServices={sonarrData} + /> + )} ); }; diff --git a/src/components/Settings/SettingsUsers/index.tsx b/src/components/Settings/SettingsUsers/index.tsx index 7f6fa1fc..8203360b 100644 --- a/src/components/Settings/SettingsUsers/index.tsx +++ b/src/components/Settings/SettingsUsers/index.tsx @@ -1,4 +1,5 @@ import Button from '@app/components/Common/Button'; +import LabeledCheckbox from '@app/components/Common/LabeledCheckbox'; import LoadingSpinner from '@app/components/Common/LoadingSpinner'; import PageTitle from '@app/components/Common/PageTitle'; import PermissionEdit from '@app/components/PermissionEdit'; @@ -13,6 +14,7 @@ import { Field, Form, Formik } from 'formik'; import { useIntl } from 'react-intl'; import { useToasts } from 'react-toast-notifications'; import useSWR, { mutate } from 'swr'; +import * as yup from 'yup'; const messages = defineMessages('components.Settings.SettingsUsers', { users: 'Users', @@ -20,9 +22,15 @@ const messages = defineMessages('components.Settings.SettingsUsers', { userSettingsDescription: 'Configure global and default user settings.', toastSettingsSuccess: 'User settings saved successfully!', toastSettingsFailure: 'Something went wrong while saving settings.', + loginMethods: 'Login Methods', + loginMethodsTip: 'Configure login methods for users.', localLogin: 'Enable Local Sign-In', localLoginTip: - 'Allow users to sign in using their email address and password, instead of {mediaServerName} OAuth', + 'Allow users to sign in using their email address and password', + mediaServerLogin: 'Enable {mediaServerName} Sign-In', + mediaServerLoginTip: + 'Allow users to sign in using their {mediaServerName} account', + atLeastOneAuth: 'At least one authentication method must be selected.', newPlexLogin: 'Enable New {mediaServerName} Sign-In', newPlexLoginTip: 'Allow {mediaServerName} users to sign in without first being imported', @@ -42,6 +50,27 @@ const SettingsUsers = () => { } = useSWR('/api/v1/settings/main'); const settings = useSettings(); + const schema = yup + .object() + .shape({ + localLogin: yup.boolean(), + mediaServerLogin: yup.boolean(), + }) + .test({ + name: 'atLeastOneAuth', + test: function (values) { + const isValid = ['localLogin', 'mediaServerLogin'].some( + (field) => !!values[field] + ); + + if (isValid) return true; + return this.createError({ + path: 'localLogin | mediaServerLogin', + message: intl.formatMessage(messages.atLeastOneAuth), + }); + }, + }); + if (!data && !error) { return ; } @@ -52,6 +81,8 @@ const SettingsUsers = () => { ? 'Jellyfin' : settings.currentSettings.mediaServerType === MediaServerType.EMBY ? 'Emby' + : settings.currentSettings.mediaServerType === MediaServerType.PLEX + ? 'Plex' : undefined, }; @@ -73,6 +104,7 @@ const SettingsUsers = () => { { tvQuotaDays: data?.defaultQuotas.tv.quotaDays ?? 7, defaultPermissions: data?.defaultPermissions ?? 0, }} + validationSchema={schema} enableReinitialize onSubmit={async (values) => { try { @@ -90,6 +123,7 @@ const SettingsUsers = () => { }, body: JSON.stringify({ localLogin: values.localLogin, + mediaServerLogin: values.mediaServerLogin, newPlexLogin: values.newPlexLogin, defaultQuotas: { movie: { @@ -121,30 +155,61 @@ const SettingsUsers = () => { } }} > - {({ isSubmitting, values, setFieldValue }) => { + {({ isSubmitting, isValid, values, errors, setFieldValue }) => { return (
    -
    -