Merge branch 'develop' of github.com:fallenbagel/jellyseerr into feature-emby-connect-login
This commit is contained in:
@@ -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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
<a href="http://translate.jellyseerr.dev/engage/jellyseerr/"><img src="http://translate.jellyseerr.dev/widget/jellyseerr/jellyseerr-frontend/svg-badge.svg" alt="Translation status" /></a>
|
||||
<a href="https://github.com/fallenbagel/jellyseerr/blob/develop/LICENSE"><img alt="GitHub" src="https://img.shields.io/github/license/fallenbagel/jellyseerr"></a>
|
||||
<!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section -->
|
||||
<a href="#contributors-"><img alt="All Contributors" src="https://img.shields.io/badge/all_contributors-66-orange.svg"/></a>
|
||||
<a href="#contributors-"><img alt="All Contributors" src="https://img.shields.io/badge/all_contributors-67-orange.svg"/></a>
|
||||
<!-- ALL-CONTRIBUTORS-BADGE:END -->
|
||||
|
||||
**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
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/methbkts"><img src="https://avatars.githubusercontent.com/u/30674934?v=4?s=100" width="100px;" alt="Metin Bektas"/><br /><sub><b>Metin Bektas</b></sub></a><br /><a href="#infra-methbkts" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/andrewkolda"><img src="https://avatars.githubusercontent.com/u/158614532?v=4?s=100" width="100px;" alt="andrewkolda"/><br /><sub><b>andrewkolda</b></sub></a><br /><a href="#design-andrewkolda" title="Design">🎨</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://ishanjain.me"><img src="https://avatars.githubusercontent.com/u/7921368?v=4?s=100" width="100px;" alt="Ishan Jain"/><br /><sub><b>Ishan Jain</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=ishanjain28" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="http://michaelt.xyz"><img src="https://avatars.githubusercontent.com/u/18223295?v=4?s=100" width="100px;" alt="Michael Thomas"/><br /><sub><b>Michael Thomas</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=michaelhthomas" title="Code">💻</a></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
27
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
@@ -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}"`;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -30,6 +30,7 @@ export interface PublicSettingsResponse {
|
||||
applicationUrl: string;
|
||||
hideAvailable: boolean;
|
||||
localLogin: boolean;
|
||||
mediaServerLogin: boolean;
|
||||
movie4kEnabled: boolean;
|
||||
series4kEnabled: boolean;
|
||||
discoverRegion: string;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' });
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
24
src/assets/services/jellyfin-icon.svg
Normal file
24
src/assets/services/jellyfin-icon.svg
Normal file
@@ -0,0 +1,24 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- ***** BEGIN LICENSE BLOCK *****
|
||||
- Part of the Jellyfin project (https://jellyfin.media)
|
||||
-
|
||||
- All copyright belongs to the Jellyfin contributors; a full list can
|
||||
- be found in the file CONTRIBUTORS.md
|
||||
-
|
||||
- This work is licensed under the Creative Commons Attribution-ShareAlike 4.0 International License.
|
||||
- To view a copy of this license, visit http://creativecommons.org/licenses/by-sa/4.0/.
|
||||
- ***** END LICENSE BLOCK ***** -->
|
||||
<svg version="1.1" id="icon-transparent" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 512 512">
|
||||
<defs>
|
||||
<linearGradient id="linear-gradient" gradientUnits="userSpaceOnUse" x1="110.25" y1="213.3" x2="496.14" y2="436.09">
|
||||
<stop offset="0" style="stop-color:#AA5CC3"/>
|
||||
<stop offset="1" style="stop-color:#00A4DC"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<title>icon-transparent</title>
|
||||
<g id="icon-transparent">
|
||||
<path id="inner-shape" d="M256,201.6c-20.4,0-86.2,119.3-76.2,139.4s142.5,19.9,152.4,0S276.5,201.6,256,201.6z" fill="url(#linear-gradient)"/>
|
||||
<path id="outer-shape" d="M256,23.3c-61.6,0-259.8,359.4-229.6,420.1s429.3,60,459.2,0S317.6,23.3,256,23.3z
|
||||
M406.5,390.8c-19.6,39.3-281.1,39.8-300.9,0s110.1-275.3,150.4-275.3S426.1,351.4,406.5,390.8z" fill="url(#linear-gradient)"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -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>}
|
||||
>
|
||||
|
||||
@@ -1,77 +1,29 @@
|
||||
import useClickOutside from '@app/hooks/useClickOutside';
|
||||
import Dropdown from '@app/components/Common/Dropdown';
|
||||
import { withProperties } from '@app/utils/typeHelpers';
|
||||
import { Transition } from '@headlessui/react';
|
||||
import { Menu } from '@headlessui/react';
|
||||
import { ChevronDownIcon } from '@heroicons/react/24/solid';
|
||||
import type {
|
||||
AnchorHTMLAttributes,
|
||||
ButtonHTMLAttributes,
|
||||
RefObject,
|
||||
} from 'react';
|
||||
import { Fragment, useRef, useState } from 'react';
|
||||
import type { AnchorHTMLAttributes, ButtonHTMLAttributes } from 'react';
|
||||
|
||||
interface DropdownItemProps extends AnchorHTMLAttributes<HTMLAnchorElement> {
|
||||
buttonType?: 'primary' | 'ghost';
|
||||
}
|
||||
|
||||
const DropdownItem = ({
|
||||
children,
|
||||
buttonType = 'primary',
|
||||
...props
|
||||
}: DropdownItemProps) => {
|
||||
let styleClass = 'button-md text-white';
|
||||
|
||||
switch (buttonType) {
|
||||
case 'ghost':
|
||||
styleClass +=
|
||||
' bg-transparent rounded hover:bg-gradient-to-br from-indigo-600 to-purple-600 text-white focus:border-gray-500 focus:text-white';
|
||||
break;
|
||||
default:
|
||||
styleClass +=
|
||||
' bg-indigo-600 rounded hover:bg-indigo-500 focus:border-indigo-700 focus:text-white';
|
||||
}
|
||||
return (
|
||||
<a
|
||||
className={`flex cursor-pointer items-center px-4 py-2 text-sm leading-5 focus:outline-none ${styleClass}`}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
||||
interface ButtonWithDropdownProps {
|
||||
type ButtonWithDropdownProps = {
|
||||
text: React.ReactNode;
|
||||
dropdownIcon?: React.ReactNode;
|
||||
buttonType?: 'primary' | 'ghost';
|
||||
}
|
||||
interface ButtonProps
|
||||
extends ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
ButtonWithDropdownProps {
|
||||
as?: 'button';
|
||||
}
|
||||
interface AnchorProps
|
||||
extends AnchorHTMLAttributes<HTMLAnchorElement>,
|
||||
ButtonWithDropdownProps {
|
||||
as: 'a';
|
||||
}
|
||||
} & (
|
||||
| ({ as?: 'button' } & ButtonHTMLAttributes<HTMLButtonElement>)
|
||||
| ({ as: 'a' } & AnchorHTMLAttributes<HTMLAnchorElement>)
|
||||
);
|
||||
|
||||
const ButtonWithDropdown = ({
|
||||
as,
|
||||
text,
|
||||
children,
|
||||
dropdownIcon,
|
||||
className,
|
||||
buttonType = 'primary',
|
||||
...props
|
||||
}: ButtonProps | AnchorProps) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const buttonRef = useRef<HTMLButtonElement | HTMLAnchorElement>(null);
|
||||
useClickOutside(buttonRef, () => setIsOpen(false));
|
||||
|
||||
}: ButtonWithDropdownProps) => {
|
||||
const styleClasses = {
|
||||
mainButtonClasses: 'button-md text-white border',
|
||||
dropdownSideButtonClasses: 'button-md border',
|
||||
dropdownClasses: 'button-md',
|
||||
};
|
||||
|
||||
switch (buttonType) {
|
||||
@@ -79,72 +31,40 @@ const ButtonWithDropdown = ({
|
||||
styleClasses.mainButtonClasses +=
|
||||
' bg-transparent border-gray-600 hover:border-gray-200 focus:border-gray-100 active:border-gray-100';
|
||||
styleClasses.dropdownSideButtonClasses = styleClasses.mainButtonClasses;
|
||||
styleClasses.dropdownClasses +=
|
||||
' bg-gray-800 border border-gray-700 bg-opacity-80 p-1 backdrop-blur';
|
||||
break;
|
||||
default:
|
||||
styleClasses.mainButtonClasses +=
|
||||
' bg-indigo-600 border-indigo-500 bg-opacity-80 hover:bg-opacity-100 hover:border-indigo-500 active:bg-indigo-700 active:border-indigo-700 focus:ring-blue';
|
||||
styleClasses.dropdownSideButtonClasses +=
|
||||
' bg-indigo-600 bg-opacity-80 border-indigo-500 hover:bg-opacity-100 active:bg-opacity-100 focus:ring-blue';
|
||||
styleClasses.dropdownClasses += ' bg-indigo-600 p-1';
|
||||
}
|
||||
|
||||
const TriggerElement = props.as ?? 'button';
|
||||
|
||||
return (
|
||||
<span className="relative inline-flex h-full rounded-md shadow-sm">
|
||||
{as === 'a' ? (
|
||||
<a
|
||||
className={`relative z-10 inline-flex h-full items-center px-4 py-2 text-sm font-medium leading-5 transition duration-150 ease-in-out hover:z-20 focus:z-20 focus:outline-none ${
|
||||
styleClasses.mainButtonClasses
|
||||
} ${children ? 'rounded-l-md' : 'rounded-md'} ${className}`}
|
||||
ref={buttonRef as RefObject<HTMLAnchorElement>}
|
||||
{...(props as AnchorHTMLAttributes<HTMLAnchorElement>)}
|
||||
>
|
||||
{text}
|
||||
</a>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className={`relative z-10 inline-flex h-full items-center px-4 py-2 text-sm font-medium leading-5 transition duration-150 ease-in-out hover:z-20 focus:z-20 focus:outline-none ${
|
||||
styleClasses.mainButtonClasses
|
||||
} ${children ? 'rounded-l-md' : 'rounded-md'} ${className}`}
|
||||
ref={buttonRef as RefObject<HTMLButtonElement>}
|
||||
{...(props as ButtonHTMLAttributes<HTMLButtonElement>)}
|
||||
>
|
||||
{text}
|
||||
</button>
|
||||
)}
|
||||
<Menu as="div" className="relative z-10 inline-flex">
|
||||
<TriggerElement
|
||||
type="button"
|
||||
className={`relative z-10 inline-flex h-full items-center px-4 py-2 text-sm font-medium leading-5 transition duration-150 ease-in-out hover:z-20 focus:z-20 focus:outline-none ${
|
||||
styleClasses.mainButtonClasses
|
||||
} ${children ? 'rounded-l-md' : 'rounded-md'} ${className}`}
|
||||
{...(props as Record<string, string>)}
|
||||
>
|
||||
{text}
|
||||
</TriggerElement>
|
||||
{children && (
|
||||
<span className="relative -ml-px block">
|
||||
<button
|
||||
<Menu.Button
|
||||
type="button"
|
||||
className={`relative z-10 inline-flex h-full items-center rounded-r-md px-2 py-2 text-sm font-medium leading-5 text-white transition duration-150 ease-in-out hover:z-20 focus:z-20 ${styleClasses.dropdownSideButtonClasses}`}
|
||||
aria-label="Expand"
|
||||
onClick={() => setIsOpen((state) => !state)}
|
||||
>
|
||||
{dropdownIcon ? dropdownIcon : <ChevronDownIcon />}
|
||||
</button>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
show={isOpen}
|
||||
enter="transition ease-out duration-100"
|
||||
enterFrom="opacity-0 scale-95"
|
||||
enterTo="opacity-100 scale-100"
|
||||
leave="transition ease-in duration-75"
|
||||
leaveFrom="opacity-100 scale-100"
|
||||
leaveTo="opacity-0 scale-95"
|
||||
>
|
||||
<div className="absolute right-0 z-40 mt-2 -mr-1 w-56 origin-top-right rounded-md shadow-lg">
|
||||
<div
|
||||
className={`rounded-md ring-1 ring-black ring-opacity-5 ${styleClasses.dropdownClasses}`}
|
||||
>
|
||||
<div className="py-1">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Menu.Button>
|
||||
<Dropdown.Items dropdownType={buttonType}>{children}</Dropdown.Items>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
export default withProperties(ButtonWithDropdown, { Item: DropdownItem });
|
||||
export default withProperties(ButtonWithDropdown, { Item: Dropdown.Item });
|
||||
|
||||
117
src/components/Common/Dropdown/index.tsx
Normal file
117
src/components/Common/Dropdown/index.tsx
Normal file
@@ -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<HTMLAnchorElement> {
|
||||
buttonType?: 'primary' | 'ghost';
|
||||
}
|
||||
|
||||
const DropdownItem = ({
|
||||
children,
|
||||
buttonType = 'primary',
|
||||
...props
|
||||
}: DropdownItemProps) => {
|
||||
return (
|
||||
<Menu.Item>
|
||||
<a
|
||||
className={[
|
||||
'button-md flex cursor-pointer items-center rounded px-4 py-2 text-sm leading-5 text-white focus:text-white focus:outline-none',
|
||||
buttonType === 'ghost'
|
||||
? 'bg-transparent from-indigo-600 to-purple-600 hover:bg-gradient-to-br focus:border-gray-500'
|
||||
: 'bg-indigo-600 hover:bg-indigo-500 focus:border-indigo-700',
|
||||
].join(' ')}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
</Menu.Item>
|
||||
);
|
||||
};
|
||||
|
||||
type DropdownItemsProps = HTMLAttributes<HTMLDivElement> & {
|
||||
dropdownType: 'primary' | 'ghost';
|
||||
};
|
||||
|
||||
const DropdownItems = ({
|
||||
children,
|
||||
className,
|
||||
dropdownType,
|
||||
...props
|
||||
}: DropdownItemsProps) => {
|
||||
return (
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-100"
|
||||
enterFrom="opacity-0 scale-95"
|
||||
enterTo="opacity-100 scale-100"
|
||||
leave="transition ease-in duration-75"
|
||||
leaveFrom="opacity-100 scale-100"
|
||||
leaveTo="opacity-0 scale-95"
|
||||
>
|
||||
<Menu.Items
|
||||
className={[
|
||||
'absolute right-0 z-40 mt-2 -mr-1 w-56 origin-top-right rounded-md p-1 shadow-lg',
|
||||
dropdownType === 'ghost'
|
||||
? 'border border-gray-700 bg-gray-800 bg-opacity-80 backdrop-blur'
|
||||
: 'bg-indigo-600',
|
||||
className,
|
||||
].join(' ')}
|
||||
{...props}
|
||||
>
|
||||
<div className="py-1">{children}</div>
|
||||
</Menu.Items>
|
||||
</Transition>
|
||||
);
|
||||
};
|
||||
|
||||
interface DropdownProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
text: React.ReactNode;
|
||||
dropdownIcon?: React.ReactNode;
|
||||
buttonType?: 'primary' | 'ghost';
|
||||
}
|
||||
|
||||
const Dropdown = ({
|
||||
text,
|
||||
children,
|
||||
dropdownIcon,
|
||||
className,
|
||||
buttonType = 'primary',
|
||||
...props
|
||||
}: DropdownProps) => {
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
return (
|
||||
<Menu as="div" className="relative z-10">
|
||||
<Menu.Button
|
||||
type="button"
|
||||
className={[
|
||||
'button-md inline-flex h-full items-center space-x-2 rounded-md border px-4 py-2 text-sm font-medium leading-5 text-white transition duration-150 ease-in-out hover:z-20 focus:z-20 focus:outline-none',
|
||||
buttonType === 'ghost'
|
||||
? 'border-gray-600 bg-transparent hover:border-gray-200 focus:border-gray-100 active:border-gray-100'
|
||||
: 'focus:ring-blue border-indigo-500 bg-indigo-600 bg-opacity-80 hover:border-indigo-500 hover:bg-opacity-100 active:border-indigo-700 active:bg-indigo-700',
|
||||
className,
|
||||
].join(' ')}
|
||||
ref={buttonRef}
|
||||
disabled={!children}
|
||||
{...props}
|
||||
>
|
||||
<span>{text}</span>
|
||||
{children && (dropdownIcon ? dropdownIcon : <ChevronDownIcon />)}
|
||||
</Menu.Button>
|
||||
{children && (
|
||||
<DropdownItems dropdownType={buttonType}>{children}</DropdownItems>
|
||||
)}
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
export default withProperties(Dropdown, {
|
||||
Item: DropdownItem,
|
||||
Items: DropdownItems,
|
||||
});
|
||||
44
src/components/Common/LabeledCheckbox/index.tsx
Normal file
44
src/components/Common/LabeledCheckbox/index.tsx
Normal 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;
|
||||
@@ -29,11 +29,16 @@ interface ModalProps {
|
||||
secondaryDisabled?: boolean;
|
||||
tertiaryDisabled?: boolean;
|
||||
tertiaryButtonType?: ButtonType;
|
||||
okButtonProps?: React.ButtonHTMLAttributes<HTMLButtonElement>;
|
||||
cancelButtonProps?: React.ButtonHTMLAttributes<HTMLButtonElement>;
|
||||
secondaryButtonProps?: React.ButtonHTMLAttributes<HTMLButtonElement>;
|
||||
tertiaryButtonProps?: React.ButtonHTMLAttributes<HTMLButtonElement>;
|
||||
disableScrollLock?: boolean;
|
||||
backgroundClickable?: boolean;
|
||||
loading?: boolean;
|
||||
backdrop?: string;
|
||||
children?: React.ReactNode;
|
||||
dialogClass?: string;
|
||||
}
|
||||
|
||||
const Modal = React.forwardRef<HTMLDivElement, ModalProps>(
|
||||
@@ -61,6 +66,11 @@ const Modal = React.forwardRef<HTMLDivElement, ModalProps>(
|
||||
loading = false,
|
||||
onTertiary,
|
||||
backdrop,
|
||||
dialogClass,
|
||||
okButtonProps,
|
||||
cancelButtonProps,
|
||||
secondaryButtonProps,
|
||||
tertiaryButtonProps,
|
||||
},
|
||||
parentRef
|
||||
) => {
|
||||
@@ -106,7 +116,7 @@ const Modal = React.forwardRef<HTMLDivElement, ModalProps>(
|
||||
</div>
|
||||
</Transition>
|
||||
<Transition
|
||||
className="hide-scrollbar relative inline-block w-full overflow-auto bg-gray-800 px-4 pt-4 pb-4 text-left align-bottom shadow-xl ring-1 ring-gray-700 transition-all sm:my-8 sm:max-w-3xl sm:rounded-lg sm:align-middle"
|
||||
className={`hide-scrollbar relative inline-block w-full overflow-auto bg-gray-800 px-4 pt-4 pb-4 text-left align-bottom shadow-xl ring-1 ring-gray-700 transition-all sm:my-8 sm:max-w-3xl sm:rounded-lg sm:align-middle ${dialogClass}`}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="modal-headline"
|
||||
@@ -189,6 +199,7 @@ const Modal = React.forwardRef<HTMLDivElement, ModalProps>(
|
||||
className="ml-3"
|
||||
disabled={okDisabled}
|
||||
data-testid="modal-ok-button"
|
||||
{...okButtonProps}
|
||||
>
|
||||
{okText ? okText : 'Ok'}
|
||||
</Button>
|
||||
@@ -200,6 +211,7 @@ const Modal = React.forwardRef<HTMLDivElement, ModalProps>(
|
||||
className="ml-3"
|
||||
disabled={secondaryDisabled}
|
||||
data-testid="modal-secondary-button"
|
||||
{...secondaryButtonProps}
|
||||
>
|
||||
{secondaryText}
|
||||
</Button>
|
||||
@@ -210,6 +222,7 @@ const Modal = React.forwardRef<HTMLDivElement, ModalProps>(
|
||||
onClick={onTertiary}
|
||||
className="ml-3"
|
||||
disabled={tertiaryDisabled}
|
||||
{...tertiaryButtonProps}
|
||||
>
|
||||
{tertiaryText}
|
||||
</Button>
|
||||
@@ -220,6 +233,7 @@ const Modal = React.forwardRef<HTMLDivElement, ModalProps>(
|
||||
onClick={onCancel}
|
||||
className="ml-3 sm:ml-0"
|
||||
data-testid="modal-cancel-button"
|
||||
{...cancelButtonProps}
|
||||
>
|
||||
{cancelText
|
||||
? cancelText
|
||||
|
||||
@@ -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<Language[]>('/api/v1/languages');
|
||||
@@ -96,6 +98,7 @@ const LanguageSelector = ({
|
||||
<Select<OptionType, true>
|
||||
options={options}
|
||||
isMulti
|
||||
isDisabled={isDisabled}
|
||||
className="react-select-container"
|
||||
classNamePrefix="react-select"
|
||||
value={
|
||||
|
||||
@@ -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,313 +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">
|
||||
{serverType === MediaServerType.EMBY
|
||||
? `Emby Connect ${intl.formatMessage(
|
||||
messages.email
|
||||
)} / ${intl.formatMessage(messages.username)}`
|
||||
: 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={
|
||||
serverType === MediaServerType.EMBY
|
||||
? `Emby Connect ${intl.formatMessage(
|
||||
messages.email
|
||||
)} / ${intl.formatMessage(messages.username)}`
|
||||
: 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}`
|
||||
@@ -495,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;
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
62
src/components/Login/PlexLoginButton.tsx
Normal file
62
src/components/Login/PlexLoginButton.tsx
Normal 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;
|
||||
@@ -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,68 +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}
|
||||
serverType={settings.currentSettings.mediaServerType}
|
||||
/>
|
||||
)}
|
||||
</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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -56,6 +56,7 @@ const MediaSlider = ({
|
||||
},
|
||||
{
|
||||
initialSize: 2,
|
||||
revalidateFirstPage: false,
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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',
|
||||
});
|
||||
|
||||
@@ -52,18 +52,21 @@ type SingleVal = {
|
||||
type BaseSelectorMultiProps = {
|
||||
defaultValue?: string;
|
||||
isMulti: true;
|
||||
isDisabled?: boolean;
|
||||
onChange: (value: MultiValue<SingleVal> | null) => void;
|
||||
};
|
||||
|
||||
type BaseSelectorSingleProps = {
|
||||
defaultValue?: string;
|
||||
isMulti?: false;
|
||||
isDisabled?: boolean;
|
||||
onChange: (value: SingleValue<SingleVal> | 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) => {
|
||||
|
||||
@@ -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<DVRTestResponse>({
|
||||
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 (
|
||||
<Transition
|
||||
@@ -79,6 +155,8 @@ const OverrideRuleModal = ({
|
||||
>
|
||||
<Formik
|
||||
initialValues={{
|
||||
radarrServiceId: rule?.radarrServiceId,
|
||||
sonarrServiceId: rule?.sonarrServiceId,
|
||||
users: rule?.users,
|
||||
genre: rule?.genre,
|
||||
language: rule?.language,
|
||||
@@ -97,8 +175,8 @@ const OverrideRuleModal = ({
|
||||
profileId: Number(values.profileId) || null,
|
||||
rootFolder: values.rootFolder || null,
|
||||
tags: values.tags || null,
|
||||
radarrServiceId: radarrId,
|
||||
sonarrServiceId: sonarrId,
|
||||
radarrServiceId: values.radarrServiceId,
|
||||
sonarrServiceId: values.sonarrServiceId,
|
||||
};
|
||||
if (!rule) {
|
||||
const res = await fetch('/api/v1/overrideRule', {
|
||||
@@ -170,6 +248,75 @@ const OverrideRuleModal = ({
|
||||
}
|
||||
>
|
||||
<div className="mb-6">
|
||||
<h3 className="text-lg font-bold leading-8 text-gray-100">
|
||||
{intl.formatMessage(messages.service)}
|
||||
</h3>
|
||||
<p className="description">
|
||||
{intl.formatMessage(messages.serviceDescription)}
|
||||
</p>
|
||||
<div className="form-row">
|
||||
<label htmlFor="service" className="text-label">
|
||||
{intl.formatMessage(messages.service)}
|
||||
</label>
|
||||
<div className="form-input-area">
|
||||
<div className="form-input-field">
|
||||
<select
|
||||
id="service"
|
||||
name="service"
|
||||
defaultValue={
|
||||
values.radarrServiceId !== null
|
||||
? `radarr-${values.radarrServiceId}`
|
||||
: `sonarr-${values.sonarrServiceId}`
|
||||
}
|
||||
onChange={(e) => {
|
||||
const id = Number(e.target.value.split('-')[1]);
|
||||
if (e.target.value.startsWith('radarr-')) {
|
||||
setFieldValue('radarrServiceId', id);
|
||||
setFieldValue('sonarrServiceId', null);
|
||||
if (radarrServices[id]) {
|
||||
getServiceInfos(radarrServices[id]);
|
||||
}
|
||||
} else if (e.target.value.startsWith('sonarr-')) {
|
||||
setFieldValue('radarrServiceId', null);
|
||||
setFieldValue('sonarrServiceId', id);
|
||||
if (sonarrServices[id]) {
|
||||
getServiceInfos(sonarrServices[id]);
|
||||
}
|
||||
} else {
|
||||
setFieldValue('radarrServiceId', null);
|
||||
setFieldValue('sonarrServiceId', null);
|
||||
setIsValidated(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<option value="">
|
||||
{intl.formatMessage(messages.selectService)}
|
||||
</option>
|
||||
{radarrServices.map((radarr) => (
|
||||
<option
|
||||
key={`radarr-${radarr.id}`}
|
||||
value={`radarr-${radarr.id}`}
|
||||
>
|
||||
{radarr.name}
|
||||
</option>
|
||||
))}
|
||||
{sonarrServices.map((sonarr) => (
|
||||
<option
|
||||
key={`sonarr-${sonarr.id}`}
|
||||
value={`sonarr-${sonarr.id}`}
|
||||
>
|
||||
{sonarr.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{errors.rootFolder &&
|
||||
touched.rootFolder &&
|
||||
typeof errors.rootFolder === 'string' && (
|
||||
<div className="error">{errors.rootFolder}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="text-lg font-bold leading-8 text-gray-100">
|
||||
{intl.formatMessage(messages.conditions)}
|
||||
</h3>
|
||||
@@ -184,6 +331,7 @@ const OverrideRuleModal = ({
|
||||
<div className="form-input-field">
|
||||
<UserSelector
|
||||
defaultValue={values.users}
|
||||
isDisabled={!isValidated || isTesting}
|
||||
isMulti
|
||||
onChange={(users) => {
|
||||
setFieldValue(
|
||||
@@ -207,9 +355,10 @@ const OverrideRuleModal = ({
|
||||
<div className="form-input-area">
|
||||
<div className="form-input-field">
|
||||
<GenreSelector
|
||||
type={radarrId ? 'movie' : 'tv'}
|
||||
type={values.radarrServiceId ? 'movie' : 'tv'}
|
||||
defaultValue={values.genre}
|
||||
isMulti
|
||||
isDisabled={!isValidated || isTesting}
|
||||
onChange={(genres) => {
|
||||
setFieldValue(
|
||||
'genre',
|
||||
@@ -237,6 +386,7 @@ const OverrideRuleModal = ({
|
||||
setFieldValue={(_key, value) => {
|
||||
setFieldValue('language', value);
|
||||
}}
|
||||
isDisabled={!isValidated || isTesting}
|
||||
/>
|
||||
</div>
|
||||
{errors.language &&
|
||||
@@ -255,6 +405,7 @@ const OverrideRuleModal = ({
|
||||
<KeywordSelector
|
||||
defaultValue={values.keywords}
|
||||
isMulti
|
||||
isDisabled={!isValidated || isTesting}
|
||||
onChange={(value) => {
|
||||
setFieldValue(
|
||||
'keywords',
|
||||
@@ -282,7 +433,12 @@ const OverrideRuleModal = ({
|
||||
</label>
|
||||
<div className="form-input-area">
|
||||
<div className="form-input-field">
|
||||
<Field as="select" id="rootFolderRule" name="rootFolder">
|
||||
<Field
|
||||
as="select"
|
||||
id="rootFolderRule"
|
||||
name="rootFolder"
|
||||
disabled={!isValidated || isTesting}
|
||||
>
|
||||
<option value="">
|
||||
{intl.formatMessage(messages.selectRootFolder)}
|
||||
</option>
|
||||
@@ -310,7 +466,12 @@ const OverrideRuleModal = ({
|
||||
</label>
|
||||
<div className="form-input-area">
|
||||
<div className="form-input-field">
|
||||
<Field as="select" id="profileIdRule" name="profileId">
|
||||
<Field
|
||||
as="select"
|
||||
id="profileIdRule"
|
||||
name="profileId"
|
||||
disabled={!isValidated || isTesting}
|
||||
>
|
||||
<option value="">
|
||||
{intl.formatMessage(messages.selectQualityProfile)}
|
||||
</option>
|
||||
@@ -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"
|
||||
|
||||
@@ -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<User[] | null>(null);
|
||||
const [keywords, setKeywords] = useState<Keyword[] | null>(null);
|
||||
const { data: languages } = useSWR<Language[]>('/api/v1/languages');
|
||||
const { data: genres } = useSWR<TmdbGenre[]>('/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) => (
|
||||
<li className="flex h-full flex-col rounded-lg bg-gray-800 text-left shadow ring-1 ring-gray-500">
|
||||
<div className="flex w-full flex-1 items-center justify-between space-x-6 p-6">
|
||||
<div className="flex-1 truncate">
|
||||
<span className="text-lg">
|
||||
{intl.formatMessage(messages.conditions)}
|
||||
</span>
|
||||
{rule.users && (
|
||||
<p className="truncate text-sm leading-5 text-gray-300">
|
||||
<span className="mr-2 font-bold">
|
||||
{intl.formatMessage(messages.users)}
|
||||
</span>
|
||||
<div className="inline-flex gap-2">
|
||||
{rule.users.split(',').map((userId) => {
|
||||
return (
|
||||
<span>
|
||||
{
|
||||
users?.find((user) => user.id === Number(userId))
|
||||
?.displayName
|
||||
}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</p>
|
||||
)}
|
||||
{rule.genre && (
|
||||
<p className="truncate text-sm leading-5 text-gray-300">
|
||||
<span className="mr-2 font-bold">
|
||||
{intl.formatMessage(messages.genre)}
|
||||
</span>
|
||||
<div className="inline-flex gap-2">
|
||||
{rule.genre.split(',').map((genreId) => (
|
||||
<span>
|
||||
{genres?.find((g) => g.id === Number(genreId))?.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</p>
|
||||
)}
|
||||
{rule.language && (
|
||||
<p className="truncate text-sm leading-5 text-gray-300">
|
||||
<span className="mr-2 font-bold">
|
||||
{intl.formatMessage(messages.language)}
|
||||
</span>
|
||||
<div className="inline-flex gap-2">
|
||||
{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 <span>{languageName}</span>;
|
||||
})}
|
||||
</div>
|
||||
</p>
|
||||
)}
|
||||
{rule.keywords && (
|
||||
<p className="truncate text-sm leading-5 text-gray-300">
|
||||
<span className="mr-2 font-bold">
|
||||
{intl.formatMessage(messages.keywords)}
|
||||
</span>
|
||||
<div className="inline-flex gap-2">
|
||||
{rule.keywords.split(',').map((keywordId) => {
|
||||
return (
|
||||
<span>
|
||||
{
|
||||
keywords?.find(
|
||||
(keyword) => keyword.id === Number(keywordId)
|
||||
)?.name
|
||||
}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</p>
|
||||
)}
|
||||
<span className="text-lg">
|
||||
{intl.formatMessage(messages.settings)}
|
||||
</span>
|
||||
{rule.profileId && (
|
||||
<p className="runcate text-sm leading-5 text-gray-300">
|
||||
<span className="mr-2 font-bold">
|
||||
{intl.formatMessage(messages.qualityprofile)}
|
||||
</span>
|
||||
{
|
||||
testResponse.profiles.find(
|
||||
(profile) => rule.profileId === profile.id
|
||||
)?.name
|
||||
}
|
||||
</p>
|
||||
)}
|
||||
{rule.rootFolder && (
|
||||
<p className="truncate text-sm leading-5 text-gray-300">
|
||||
<span className="mr-2 font-bold">
|
||||
{intl.formatMessage(messages.rootfolder)}
|
||||
</span>
|
||||
{rule.rootFolder}
|
||||
</p>
|
||||
)}
|
||||
{rule.tags && rule.tags.length > 0 && (
|
||||
<p className="truncate text-sm leading-5 text-gray-300">
|
||||
<span className="mr-2 font-bold">
|
||||
{intl.formatMessage(messages.tags)}
|
||||
</span>
|
||||
<div className="inline-flex gap-2">
|
||||
{rule.tags.split(',').map((tag) => (
|
||||
<span>
|
||||
{
|
||||
testResponse.tags?.find((t) => t.id === Number(tag))
|
||||
?.label
|
||||
}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t border-gray-500">
|
||||
<div className="-mt-px flex">
|
||||
<div className="flex w-0 flex-1 border-r border-gray-500">
|
||||
<button
|
||||
onClick={() =>
|
||||
setOverrideRuleModal({ open: true, rule, testResponse })
|
||||
}
|
||||
className="focus:ring-blue relative -mr-px inline-flex w-0 flex-1 items-center justify-center rounded-bl-lg border border-transparent py-4 text-sm font-medium leading-5 text-gray-200 transition duration-150 ease-in-out hover:text-white focus:z-10 focus:border-gray-500 focus:outline-none"
|
||||
>
|
||||
<PencilIcon className="mr-2 h-5 w-5" />
|
||||
<span>{intl.formatMessage(globalMessages.edit)}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="-ml-px flex w-0 flex-1">
|
||||
<button
|
||||
onClick={async () => {
|
||||
const res = await fetch(
|
||||
`/api/v1/overrideRule/${rule.id}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
}
|
||||
);
|
||||
if (!res.ok) throw new Error();
|
||||
revalidate();
|
||||
}}
|
||||
className="focus:ring-blue relative inline-flex w-0 flex-1 items-center justify-center rounded-br-lg border border-transparent py-4 text-sm font-medium leading-5 text-gray-200 transition duration-150 ease-in-out hover:text-white focus:z-10 focus:border-gray-500 focus:outline-none"
|
||||
>
|
||||
<TrashIcon className="mr-2 h-5 w-5" />
|
||||
<span>{intl.formatMessage(globalMessages.delete)}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default OverrideRuleTile;
|
||||
318
src/components/Settings/OverrideRule/OverrideRuleTiles.tsx
Normal file
318
src/components/Settings/OverrideRule/OverrideRuleTiles.tsx
Normal file
@@ -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<User[] | null>(null);
|
||||
const [keywords, setKeywords] = useState<Keyword[] | null>(null);
|
||||
const { data: languages } = useSWR<Language[]>('/api/v1/languages');
|
||||
const { data: genres } = useSWR<TmdbGenre[]>('/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) => (
|
||||
<li className="flex h-full flex-col rounded-lg bg-gray-800 text-left shadow ring-1 ring-gray-500">
|
||||
<div className="flex w-full flex-1 items-center justify-between space-x-6 p-6">
|
||||
<div className="flex-1 truncate">
|
||||
<span className="text-lg">
|
||||
{intl.formatMessage(messages.conditions)}
|
||||
</span>
|
||||
{rule.users && (
|
||||
<p className="truncate text-sm leading-5 text-gray-300">
|
||||
<span className="mr-2 font-bold">
|
||||
{intl.formatMessage(messages.users)}
|
||||
</span>
|
||||
<div className="inline-flex gap-2">
|
||||
{rule.users.split(',').map((userId) => {
|
||||
return (
|
||||
<span>
|
||||
{
|
||||
users?.find((user) => user.id === Number(userId))
|
||||
?.displayName
|
||||
}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</p>
|
||||
)}
|
||||
{rule.genre && (
|
||||
<p className="truncate text-sm leading-5 text-gray-300">
|
||||
<span className="mr-2 font-bold">
|
||||
{intl.formatMessage(messages.genre)}
|
||||
</span>
|
||||
<div className="inline-flex gap-2">
|
||||
{rule.genre.split(',').map((genreId) => (
|
||||
<span>
|
||||
{genres?.find((g) => g.id === Number(genreId))?.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</p>
|
||||
)}
|
||||
{rule.language && (
|
||||
<p className="truncate text-sm leading-5 text-gray-300">
|
||||
<span className="mr-2 font-bold">
|
||||
{intl.formatMessage(messages.language)}
|
||||
</span>
|
||||
<div className="inline-flex gap-2">
|
||||
{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 <span>{languageName}</span>;
|
||||
})}
|
||||
</div>
|
||||
</p>
|
||||
)}
|
||||
{rule.keywords && (
|
||||
<p className="truncate text-sm leading-5 text-gray-300">
|
||||
<span className="mr-2 font-bold">
|
||||
{intl.formatMessage(messages.keywords)}
|
||||
</span>
|
||||
<div className="inline-flex gap-2">
|
||||
{rule.keywords.split(',').map((keywordId) => {
|
||||
return (
|
||||
<span>
|
||||
{
|
||||
keywords?.find(
|
||||
(keyword) => keyword.id === Number(keywordId)
|
||||
)?.name
|
||||
}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</p>
|
||||
)}
|
||||
<span className="text-lg">
|
||||
{intl.formatMessage(messages.settings)}
|
||||
</span>
|
||||
{rule.profileId && (
|
||||
<p className="runcate text-sm leading-5 text-gray-300">
|
||||
<span className="mr-2 font-bold">
|
||||
{intl.formatMessage(messages.qualityprofile)}
|
||||
</span>
|
||||
{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}
|
||||
</p>
|
||||
)}
|
||||
{rule.rootFolder && (
|
||||
<p className="truncate text-sm leading-5 text-gray-300">
|
||||
<span className="mr-2 font-bold">
|
||||
{intl.formatMessage(messages.rootfolder)}
|
||||
</span>
|
||||
{rule.rootFolder}
|
||||
</p>
|
||||
)}
|
||||
{rule.tags && rule.tags.length > 0 && (
|
||||
<p className="truncate text-sm leading-5 text-gray-300">
|
||||
<span className="mr-2 font-bold">
|
||||
{intl.formatMessage(messages.tags)}
|
||||
</span>
|
||||
<div className="inline-flex gap-2">
|
||||
{rule.tags.split(',').map((tag) => (
|
||||
<span>
|
||||
{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}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t border-gray-500">
|
||||
<div className="-mt-px flex">
|
||||
<div className="flex w-0 flex-1 border-r border-gray-500">
|
||||
<button
|
||||
onClick={() => setOverrideRuleModal({ open: true, rule })}
|
||||
className="focus:ring-blue relative -mr-px inline-flex w-0 flex-1 items-center justify-center rounded-bl-lg border border-transparent py-4 text-sm font-medium leading-5 text-gray-200 transition duration-150 ease-in-out hover:text-white focus:z-10 focus:border-gray-500 focus:outline-none"
|
||||
>
|
||||
<PencilIcon className="mr-2 h-5 w-5" />
|
||||
<span>{intl.formatMessage(globalMessages.edit)}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="-ml-px flex w-0 flex-1">
|
||||
<button
|
||||
onClick={async () => {
|
||||
const res = await fetch(`/api/v1/overrideRule/${rule.id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (!res.ok) throw new Error();
|
||||
revalidate();
|
||||
}}
|
||||
className="focus:ring-blue relative inline-flex w-0 flex-1 items-center justify-center rounded-br-lg border border-transparent py-4 text-sm font-medium leading-5 text-gray-200 transition duration-150 ease-in-out hover:text-white focus:z-10 focus:border-gray-500 focus:outline-none"
|
||||
>
|
||||
<TrashIcon className="mr-2 h-5 w-5" />
|
||||
<span>{intl.formatMessage(globalMessages.delete)}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default OverrideRuleTiles;
|
||||
@@ -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<OverrideRuleResultsResponse>('/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 (
|
||||
<Transition
|
||||
as="div"
|
||||
@@ -382,7 +349,6 @@ const RadarrModal = ({
|
||||
values.is4k ? messages.edit4kradarr : messages.editradarr
|
||||
)
|
||||
}
|
||||
backgroundClickable={!overrideRuleModal.open}
|
||||
>
|
||||
<div className="mb-6">
|
||||
<div className="form-row">
|
||||
@@ -773,42 +739,6 @@ const RadarrModal = ({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{radarr && (
|
||||
<>
|
||||
<h3 className="mb-4 text-xl font-bold leading-8 text-gray-100">
|
||||
{intl.formatMessage(messages.overrideRules)}
|
||||
</h3>
|
||||
<ul className="grid gap-x-4 gap-y-8 sm:grid-cols-3 sm:gap-x-6 sm:gap-y-6 lg:grid-cols-2">
|
||||
{rules && (
|
||||
<OverrideRuleTile
|
||||
rules={rules}
|
||||
setOverrideRuleModal={setOverrideRuleModal}
|
||||
testResponse={testResponse}
|
||||
radarr={radarr}
|
||||
revalidate={revalidate}
|
||||
/>
|
||||
)}
|
||||
<li className="min-h-[8rem] rounded-lg border-2 border-dashed border-gray-400 shadow sm:min-h-[11rem]">
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<Button
|
||||
buttonType="ghost"
|
||||
onClick={() =>
|
||||
setOverrideRuleModal({
|
||||
open: true,
|
||||
rule: null,
|
||||
testResponse,
|
||||
})
|
||||
}
|
||||
disabled={!isValidated}
|
||||
>
|
||||
<PlusIcon />
|
||||
<span>{intl.formatMessage(messages.addrule)}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}}
|
||||
|
||||
@@ -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 = () => {
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label htmlFor="forceIpv4First" className="checkbox-label">
|
||||
<span className="mr-2">
|
||||
{intl.formatMessage(messages.forceIpv4First)}
|
||||
</span>
|
||||
<SettingsBadge badgeType="advanced" className="mr-2" />
|
||||
<SettingsBadge badgeType="restartRequired" />
|
||||
<span className="label-tip">
|
||||
{intl.formatMessage(messages.forceIpv4FirstTip)}
|
||||
</span>
|
||||
</label>
|
||||
<div className="form-input-area">
|
||||
<Field
|
||||
type="checkbox"
|
||||
id="forceIpv4First"
|
||||
name="forceIpv4First"
|
||||
onChange={() => {
|
||||
setFieldValue('forceIpv4First', !values.forceIpv4First);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label htmlFor="dnsServers" className="checkbox-label">
|
||||
<span className="mr-2">
|
||||
{intl.formatMessage(messages.dnsServers)}
|
||||
</span>
|
||||
<SettingsBadge badgeType="advanced" className="mr-2" />
|
||||
<SettingsBadge badgeType="restartRequired" />
|
||||
<span className="label-tip">
|
||||
{intl.formatMessage(messages.dnsServersTip)}
|
||||
</span>
|
||||
</label>
|
||||
<div className="form-input-area">
|
||||
<div className="form-input-field">
|
||||
<Field
|
||||
id="dnsServers"
|
||||
name="dnsServers"
|
||||
type="text"
|
||||
inputMode="url"
|
||||
/>
|
||||
</div>
|
||||
{errors.dnsServers &&
|
||||
touched.dnsServers &&
|
||||
typeof errors.dnsServers === 'string' && (
|
||||
<div className="error">{errors.dnsServers}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label htmlFor="proxyEnabled" className="checkbox-label">
|
||||
<span className="mr-2">
|
||||
@@ -431,6 +386,74 @@ const SettingsMain = () => {
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<h3 className="heading mt-10">
|
||||
{intl.formatMessage(messages.advancedNetworkSettings)}
|
||||
</h3>
|
||||
<p className="description">
|
||||
{intl.formatMessage(messages.networkDisclaimer, {
|
||||
docs: (
|
||||
<a
|
||||
href="https://docs.jellyseerr.dev/troubleshooting"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-white"
|
||||
>
|
||||
{intl.formatMessage(messages.docs)}
|
||||
</a>
|
||||
),
|
||||
})}
|
||||
</p>
|
||||
<div className="form-row">
|
||||
<label htmlFor="forceIpv4First" className="checkbox-label">
|
||||
<span className="mr-2">
|
||||
{intl.formatMessage(messages.forceIpv4First)}
|
||||
</span>
|
||||
<SettingsBadge badgeType="advanced" className="mr-2" />
|
||||
<SettingsBadge badgeType="restartRequired" />
|
||||
<SettingsBadge badgeType="experimental" />
|
||||
<span className="label-tip">
|
||||
{intl.formatMessage(messages.forceIpv4FirstTip)}
|
||||
</span>
|
||||
</label>
|
||||
<div className="form-input-area">
|
||||
<Field
|
||||
type="checkbox"
|
||||
id="forceIpv4First"
|
||||
name="forceIpv4First"
|
||||
onChange={() => {
|
||||
setFieldValue('forceIpv4First', !values.forceIpv4First);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label htmlFor="dnsServers" className="checkbox-label">
|
||||
<span className="mr-2">
|
||||
{intl.formatMessage(messages.dnsServers)}
|
||||
</span>
|
||||
<SettingsBadge badgeType="advanced" className="mr-2" />
|
||||
<SettingsBadge badgeType="restartRequired" />
|
||||
<SettingsBadge badgeType="experimental" />
|
||||
<span className="label-tip">
|
||||
{intl.formatMessage(messages.dnsServersTip)}
|
||||
</span>
|
||||
</label>
|
||||
<div className="form-input-area">
|
||||
<div className="form-input-field">
|
||||
<Field
|
||||
id="dnsServers"
|
||||
name="dnsServers"
|
||||
type="text"
|
||||
inputMode="url"
|
||||
/>
|
||||
</div>
|
||||
{errors.dnsServers &&
|
||||
touched.dnsServers &&
|
||||
typeof errors.dnsServers === 'string' && (
|
||||
<div className="error">{errors.dnsServers}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="actions">
|
||||
<div className="flex justify-end">
|
||||
<span className="ml-3 inline-flex rounded-md shadow-sm">
|
||||
@@ -458,4 +481,4 @@ const SettingsMain = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingsMain;
|
||||
export default SettingsNetwork;
|
||||
|
||||
@@ -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<SonarrSettings[]>('/api/v1/settings/sonarr');
|
||||
const { data: rules, mutate: revalidate } =
|
||||
useSWR<OverrideRuleResultsResponse>('/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 = () => {
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
{overrideRuleModal.open && overrideRuleModal.testResponse && (
|
||||
<OverrideRuleModal
|
||||
rule={overrideRuleModal.rule}
|
||||
onClose={() =>
|
||||
setOverrideRuleModal({
|
||||
open: false,
|
||||
rule: null,
|
||||
testResponse: null,
|
||||
})
|
||||
}
|
||||
testResponse={overrideRuleModal.testResponse}
|
||||
radarrId={editRadarrModal.radarr?.id}
|
||||
sonarrId={editSonarrModal.sonarr?.id}
|
||||
/>
|
||||
)}
|
||||
{editRadarrModal.open && (
|
||||
<RadarrModal
|
||||
radarr={editRadarrModal.radarr}
|
||||
@@ -292,8 +283,6 @@ const SettingsServices = () => {
|
||||
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}
|
||||
/>
|
||||
)}
|
||||
<Transition
|
||||
@@ -507,6 +494,59 @@ const SettingsServices = () => {
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-10 mb-6">
|
||||
<h3 className="heading">
|
||||
{intl.formatMessage(messages.overrideRules)}
|
||||
</h3>
|
||||
<p className="description">
|
||||
{intl.formatMessage(messages.overrideRulesDescription, {
|
||||
serverType: 'Sonarr',
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<div className="section">
|
||||
<ul className="grid max-w-6xl grid-cols-1 gap-6 lg:grid-cols-2 xl:grid-cols-3">
|
||||
{rules && radarrData && sonarrData && (
|
||||
<OverrideRuleTiles
|
||||
rules={rules}
|
||||
radarrServices={radarrData}
|
||||
sonarrServices={sonarrData}
|
||||
setOverrideRuleModal={setOverrideRuleModal}
|
||||
revalidate={revalidate}
|
||||
/>
|
||||
)}
|
||||
<li className="min-h-[8rem] rounded-lg border-2 border-dashed border-gray-400 shadow sm:min-h-[11rem]">
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<Button
|
||||
buttonType="ghost"
|
||||
onClick={() =>
|
||||
setOverrideRuleModal({
|
||||
open: true,
|
||||
rule: null,
|
||||
})
|
||||
}
|
||||
>
|
||||
<PlusIcon />
|
||||
<span>{intl.formatMessage(messages.addrule)}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
{overrideRuleModal.open && radarrData && sonarrData && (
|
||||
<OverrideRuleModal
|
||||
rule={overrideRuleModal.rule}
|
||||
onClose={() => {
|
||||
setOverrideRuleModal({
|
||||
open: false,
|
||||
rule: null,
|
||||
});
|
||||
revalidate();
|
||||
}}
|
||||
radarrServices={radarrData}
|
||||
sonarrServices={sonarrData}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,17 +1,9 @@
|
||||
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,
|
||||
SonarrTestResponse,
|
||||
} from '@app/components/Settings/SettingsServices';
|
||||
import type { SonarrTestResponse } 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 { SonarrSettings } from '@server/lib/settings';
|
||||
import { Field, Formik } from 'formik';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
@@ -19,7 +11,6 @@ import { useIntl } from 'react-intl';
|
||||
import type { OnChangeValue } from 'react-select';
|
||||
import Select from 'react-select';
|
||||
import { useToasts } from 'react-toast-notifications';
|
||||
import useSWR from 'swr';
|
||||
import * as Yup from 'yup';
|
||||
|
||||
type OptionType = {
|
||||
@@ -85,36 +76,16 @@ const messages = defineMessages('components.Settings.SonarrModal', {
|
||||
animeTags: 'Anime Tags',
|
||||
notagoptions: 'No tags.',
|
||||
selecttags: 'Select tags',
|
||||
overrideRules: 'Override Rules',
|
||||
addrule: 'New Override Rule',
|
||||
});
|
||||
|
||||
interface SonarrModalProps {
|
||||
sonarr: SonarrSettings | null;
|
||||
onClose: () => void;
|
||||
onSave: () => void;
|
||||
overrideRuleModal: { open: boolean; rule: OverrideRule | null };
|
||||
setOverrideRuleModal: ({
|
||||
open,
|
||||
rule,
|
||||
testResponse,
|
||||
}: {
|
||||
open: boolean;
|
||||
rule: OverrideRule | null;
|
||||
testResponse: DVRTestResponse;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
const SonarrModal = ({
|
||||
onClose,
|
||||
sonarr,
|
||||
onSave,
|
||||
overrideRuleModal,
|
||||
setOverrideRuleModal,
|
||||
}: SonarrModalProps) => {
|
||||
const SonarrModal = ({ onClose, sonarr, onSave }: SonarrModalProps) => {
|
||||
const intl = useIntl();
|
||||
const { data: rules, mutate: revalidate } =
|
||||
useSWR<OverrideRuleResultsResponse>('/api/v1/overrideRule');
|
||||
const initialLoad = useRef(false);
|
||||
const { addToast } = useToasts();
|
||||
const [isValidated, setIsValidated] = useState(sonarr ? true : false);
|
||||
@@ -244,10 +215,6 @@ const SonarrModal = ({
|
||||
}
|
||||
}, [sonarr, testConnection]);
|
||||
|
||||
useEffect(() => {
|
||||
revalidate();
|
||||
}, [overrideRuleModal, revalidate]);
|
||||
|
||||
return (
|
||||
<Transition
|
||||
as="div"
|
||||
@@ -415,7 +382,6 @@ const SonarrModal = ({
|
||||
values.is4k ? messages.edit4ksonarr : messages.editsonarr
|
||||
)
|
||||
}
|
||||
backgroundClickable={!overrideRuleModal.open}
|
||||
>
|
||||
<div className="mb-6">
|
||||
<div className="form-row">
|
||||
@@ -1070,42 +1036,6 @@ const SonarrModal = ({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{sonarr && (
|
||||
<>
|
||||
<h3 className="mb-4 text-xl font-bold leading-8 text-gray-100">
|
||||
{intl.formatMessage(messages.overrideRules)}
|
||||
</h3>
|
||||
<ul className="grid gap-x-4 gap-y-8 sm:grid-cols-3 sm:gap-x-6 sm:gap-y-6 lg:grid-cols-2">
|
||||
{rules && (
|
||||
<OverrideRuleTile
|
||||
rules={rules}
|
||||
setOverrideRuleModal={setOverrideRuleModal}
|
||||
testResponse={testResponse}
|
||||
sonarr={sonarr}
|
||||
revalidate={revalidate}
|
||||
/>
|
||||
)}
|
||||
<li className="min-h-[8rem] rounded-lg border-2 border-dashed border-gray-400 shadow sm:min-h-[11rem]">
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<Button
|
||||
buttonType="ghost"
|
||||
onClick={() =>
|
||||
setOverrideRuleModal({
|
||||
open: true,
|
||||
rule: null,
|
||||
testResponse,
|
||||
})
|
||||
}
|
||||
disabled={!isValidated}
|
||||
>
|
||||
<PlusIcon />
|
||||
<span>{intl.formatMessage(messages.addrule)}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}}
|
||||
|
||||
352
src/components/Setup/JellyfinSetup.tsx
Normal file
352
src/components/Setup/JellyfinSetup.tsx
Normal 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;
|
||||
@@ -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';
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
import Alert from '@app/components/Common/Alert';
|
||||
import Modal from '@app/components/Common/Modal';
|
||||
import useSettings from '@app/hooks/useSettings';
|
||||
import { useUser } from '@app/hooks/useUser';
|
||||
import defineMessages from '@app/utils/defineMessages';
|
||||
import { Transition } from '@headlessui/react';
|
||||
import { MediaServerType } from '@server/constants/server';
|
||||
import { Field, Form, Formik } from 'formik';
|
||||
import { useState } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import * as Yup from 'yup';
|
||||
|
||||
const messages = defineMessages(
|
||||
'components.UserProfile.UserSettings.LinkJellyfinModal',
|
||||
{
|
||||
title: 'Link {mediaServerName} Account',
|
||||
description:
|
||||
'Enter your {mediaServerName} credentials to link your account with {applicationName}.',
|
||||
username: 'Username',
|
||||
password: 'Password',
|
||||
usernameRequired: 'You must provide a username',
|
||||
passwordRequired: 'You must provide a password',
|
||||
saving: 'Adding…',
|
||||
save: 'Link',
|
||||
errorUnauthorized:
|
||||
'Unable to connect to {mediaServerName} using your credentials',
|
||||
errorExists: 'This account is already linked to a {applicationName} user',
|
||||
errorUnknown: 'An unknown error occurred',
|
||||
}
|
||||
);
|
||||
|
||||
interface LinkJellyfinModalProps {
|
||||
show: boolean;
|
||||
onClose: () => void;
|
||||
onSave: () => void;
|
||||
}
|
||||
|
||||
const LinkJellyfinModal: React.FC<LinkJellyfinModalProps> = ({
|
||||
show,
|
||||
onClose,
|
||||
onSave,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const settings = useSettings();
|
||||
const { user } = useUser();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const JellyfinLoginSchema = Yup.object().shape({
|
||||
username: Yup.string().required(
|
||||
intl.formatMessage(messages.usernameRequired)
|
||||
),
|
||||
password: Yup.string().required(
|
||||
intl.formatMessage(messages.passwordRequired)
|
||||
),
|
||||
});
|
||||
|
||||
const applicationName = settings.currentSettings.applicationTitle;
|
||||
const mediaServerName =
|
||||
settings.currentSettings.mediaServerType === MediaServerType.EMBY
|
||||
? 'Emby'
|
||||
: 'Jellyfin';
|
||||
|
||||
return (
|
||||
<Transition
|
||||
appear
|
||||
show={show}
|
||||
enter="transition ease-in-out duration-300 transform opacity-0"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacuty-100"
|
||||
leave="transition ease-in-out duration-300 transform opacity-100"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<Formik
|
||||
initialValues={{
|
||||
username: '',
|
||||
password: '',
|
||||
}}
|
||||
validationSchema={JellyfinLoginSchema}
|
||||
onSubmit={async ({ username, password }) => {
|
||||
try {
|
||||
setError(null);
|
||||
const res = await fetch(
|
||||
`/api/v1/user/${user?.id}/settings/linked-accounts/jellyfin`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
username,
|
||||
password,
|
||||
}),
|
||||
}
|
||||
);
|
||||
if (!res.ok) {
|
||||
if (res.status === 401) {
|
||||
setError(
|
||||
intl.formatMessage(messages.errorUnauthorized, {
|
||||
mediaServerName,
|
||||
})
|
||||
);
|
||||
} else if (res.status === 422) {
|
||||
setError(
|
||||
intl.formatMessage(messages.errorExists, { applicationName })
|
||||
);
|
||||
} else {
|
||||
setError(intl.formatMessage(messages.errorUnknown));
|
||||
}
|
||||
} else {
|
||||
onSave();
|
||||
}
|
||||
} catch (e) {
|
||||
setError(intl.formatMessage(messages.errorUnknown));
|
||||
}
|
||||
}}
|
||||
>
|
||||
{({ errors, touched, handleSubmit, isSubmitting, isValid }) => {
|
||||
return (
|
||||
<Modal
|
||||
onCancel={() => {
|
||||
setError(null);
|
||||
onClose();
|
||||
}}
|
||||
okButtonType="primary"
|
||||
okButtonProps={{ type: 'submit', form: 'link-jellyfin-account' }}
|
||||
okText={
|
||||
isSubmitting
|
||||
? intl.formatMessage(messages.saving)
|
||||
: intl.formatMessage(messages.save)
|
||||
}
|
||||
okDisabled={isSubmitting || !isValid}
|
||||
onOk={() => handleSubmit()}
|
||||
title={intl.formatMessage(messages.title, { mediaServerName })}
|
||||
dialogClass="sm:max-w-lg"
|
||||
>
|
||||
<Form id="link-jellyfin-account">
|
||||
{intl.formatMessage(messages.description, {
|
||||
mediaServerName,
|
||||
applicationName,
|
||||
})}
|
||||
{error && (
|
||||
<div className="mt-2">
|
||||
<Alert type="error">{error}</Alert>
|
||||
</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="flex rounded-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>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
}}
|
||||
</Formik>
|
||||
</Transition>
|
||||
);
|
||||
};
|
||||
|
||||
export default LinkJellyfinModal;
|
||||
@@ -0,0 +1,276 @@
|
||||
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 Alert from '@app/components/Common/Alert';
|
||||
import ConfirmButton from '@app/components/Common/ConfirmButton';
|
||||
import Dropdown from '@app/components/Common/Dropdown';
|
||||
import PageTitle from '@app/components/Common/PageTitle';
|
||||
import useSettings from '@app/hooks/useSettings';
|
||||
import { Permission, UserType, useUser } from '@app/hooks/useUser';
|
||||
import globalMessages from '@app/i18n/globalMessages';
|
||||
import defineMessages from '@app/utils/defineMessages';
|
||||
import PlexOAuth from '@app/utils/plex';
|
||||
import { TrashIcon } from '@heroicons/react/24/solid';
|
||||
import { MediaServerType } from '@server/constants/server';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import useSWR from 'swr';
|
||||
import LinkJellyfinModal from './LinkJellyfinModal';
|
||||
|
||||
const messages = defineMessages(
|
||||
'components.UserProfile.UserSettings.UserLinkedAccountsSettings',
|
||||
{
|
||||
linkedAccounts: 'Linked Accounts',
|
||||
linkedAccountsHint:
|
||||
'These external accounts are linked to your {applicationName} account.',
|
||||
noLinkedAccounts:
|
||||
'You do not have any external accounts linked to your account.',
|
||||
noPermissionDescription:
|
||||
"You do not have permission to modify this user's linked accounts.",
|
||||
plexErrorUnauthorized: 'Unable to connect to Plex using your credentials',
|
||||
plexErrorExists: 'This account is already linked to a Plex user',
|
||||
errorUnknown: 'An unknown error occurred',
|
||||
deleteFailed: 'Unable to delete linked account.',
|
||||
}
|
||||
);
|
||||
|
||||
const plexOAuth = new PlexOAuth();
|
||||
|
||||
enum LinkedAccountType {
|
||||
Plex = 'Plex',
|
||||
Jellyfin = 'Jellyfin',
|
||||
Emby = 'Emby',
|
||||
}
|
||||
|
||||
type LinkedAccount = {
|
||||
type: LinkedAccountType;
|
||||
username: string;
|
||||
};
|
||||
|
||||
const UserLinkedAccountsSettings = () => {
|
||||
const intl = useIntl();
|
||||
const settings = useSettings();
|
||||
const router = useRouter();
|
||||
const { user: currentUser } = useUser();
|
||||
const {
|
||||
user,
|
||||
hasPermission,
|
||||
revalidate: revalidateUser,
|
||||
} = useUser({ id: Number(router.query.userId) });
|
||||
const { data: passwordInfo } = useSWR<{ hasPassword: boolean }>(
|
||||
user ? `/api/v1/user/${user?.id}/settings/password` : null
|
||||
);
|
||||
const [showJellyfinModal, setShowJellyfinModal] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const applicationName = settings.currentSettings.applicationTitle;
|
||||
|
||||
const accounts: LinkedAccount[] = useMemo(() => {
|
||||
const accounts: LinkedAccount[] = [];
|
||||
if (!user) return accounts;
|
||||
if (user.userType === UserType.PLEX && user.plexUsername)
|
||||
accounts.push({
|
||||
type: LinkedAccountType.Plex,
|
||||
username: user.plexUsername,
|
||||
});
|
||||
if (user.userType === UserType.EMBY && user.jellyfinUsername)
|
||||
accounts.push({
|
||||
type: LinkedAccountType.Emby,
|
||||
username: user.jellyfinUsername,
|
||||
});
|
||||
if (user.userType === UserType.JELLYFIN && user.jellyfinUsername)
|
||||
accounts.push({
|
||||
type: LinkedAccountType.Jellyfin,
|
||||
username: user.jellyfinUsername,
|
||||
});
|
||||
return accounts;
|
||||
}, [user]);
|
||||
|
||||
const linkPlexAccount = async () => {
|
||||
setError(null);
|
||||
try {
|
||||
const authToken = await plexOAuth.login();
|
||||
const res = await fetch(
|
||||
`/api/v1/user/${user?.id}/settings/linked-accounts/plex`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ authToken }),
|
||||
}
|
||||
);
|
||||
if (!res.ok) {
|
||||
if (res.status === 401) {
|
||||
setError(intl.formatMessage(messages.plexErrorUnauthorized));
|
||||
} else if (res.status === 422) {
|
||||
setError(intl.formatMessage(messages.plexErrorExists));
|
||||
} else {
|
||||
setError(intl.formatMessage(messages.errorUnknown));
|
||||
}
|
||||
} else {
|
||||
await revalidateUser();
|
||||
}
|
||||
} catch (e) {
|
||||
setError(intl.formatMessage(messages.errorUnknown));
|
||||
}
|
||||
};
|
||||
|
||||
const linkable = [
|
||||
{
|
||||
name: 'Plex',
|
||||
action: () => {
|
||||
plexOAuth.preparePopup();
|
||||
setTimeout(() => linkPlexAccount(), 1500);
|
||||
},
|
||||
hide:
|
||||
settings.currentSettings.mediaServerType !== MediaServerType.PLEX ||
|
||||
accounts.some((a) => a.type === LinkedAccountType.Plex),
|
||||
},
|
||||
{
|
||||
name: 'Jellyfin',
|
||||
action: () => setShowJellyfinModal(true),
|
||||
hide:
|
||||
settings.currentSettings.mediaServerType !== MediaServerType.JELLYFIN ||
|
||||
accounts.some((a) => a.type === LinkedAccountType.Jellyfin),
|
||||
},
|
||||
{
|
||||
name: 'Emby',
|
||||
action: () => setShowJellyfinModal(true),
|
||||
hide:
|
||||
settings.currentSettings.mediaServerType !== MediaServerType.EMBY ||
|
||||
accounts.some((a) => a.type === LinkedAccountType.Emby),
|
||||
},
|
||||
].filter((l) => !l.hide);
|
||||
|
||||
const deleteRequest = async (account: string) => {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/v1/user/${user?.id}/settings/linked-accounts/${account}`,
|
||||
{ method: 'DELETE' }
|
||||
);
|
||||
if (!res.ok) throw new Error();
|
||||
} catch {
|
||||
setError(intl.formatMessage(messages.deleteFailed));
|
||||
}
|
||||
|
||||
await revalidateUser();
|
||||
};
|
||||
|
||||
if (
|
||||
currentUser?.id !== user?.id &&
|
||||
hasPermission(Permission.ADMIN) &&
|
||||
currentUser?.id !== 1
|
||||
) {
|
||||
return (
|
||||
<>
|
||||
<div className="mb-6">
|
||||
<h3 className="heading">
|
||||
{intl.formatMessage(messages.linkedAccounts)}
|
||||
</h3>
|
||||
</div>
|
||||
<Alert
|
||||
title={intl.formatMessage(messages.noPermissionDescription)}
|
||||
type="error"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const enableMediaServerUnlink = user?.id !== 1 && passwordInfo?.hasPassword;
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle
|
||||
title={[
|
||||
intl.formatMessage(messages.linkedAccounts),
|
||||
intl.formatMessage(globalMessages.usersettings),
|
||||
user?.displayName,
|
||||
]}
|
||||
/>
|
||||
<div className="mb-6 flex items-end justify-between">
|
||||
<div>
|
||||
<h3 className="heading">
|
||||
{intl.formatMessage(messages.linkedAccounts)}
|
||||
</h3>
|
||||
<h6 className="description">
|
||||
{intl.formatMessage(messages.linkedAccountsHint, {
|
||||
applicationName,
|
||||
})}
|
||||
</h6>
|
||||
</div>
|
||||
{currentUser?.id === user?.id && !!linkable.length && (
|
||||
<div>
|
||||
<Dropdown text="Link Account" buttonType="ghost">
|
||||
{linkable.map(({ name, action }) => (
|
||||
<Dropdown.Item key={name} onClick={action}>
|
||||
{name}
|
||||
</Dropdown.Item>
|
||||
))}
|
||||
</Dropdown>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{error && <Alert title={error} type="error" />}
|
||||
{accounts.length ? (
|
||||
<ul className="space-y-4">
|
||||
{accounts.map((acct, i) => (
|
||||
<li
|
||||
key={i}
|
||||
className="flex items-center gap-4 overflow-hidden rounded-lg bg-gray-800 bg-opacity-50 px-4 py-5 shadow ring-1 ring-gray-700 sm:p-6"
|
||||
>
|
||||
<div className="w-12">
|
||||
{acct.type === LinkedAccountType.Plex ? (
|
||||
<div className="flex aspect-square h-full items-center justify-center rounded-full bg-neutral-800">
|
||||
<PlexLogo className="w-9" />
|
||||
</div>
|
||||
) : acct.type === LinkedAccountType.Emby ? (
|
||||
<EmbyLogo />
|
||||
) : (
|
||||
<JellyfinLogo />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="truncate text-sm font-bold text-gray-300">
|
||||
{acct.type}
|
||||
</div>
|
||||
<div className="text-xl font-semibold text-white">
|
||||
{acct.username}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-grow" />
|
||||
{enableMediaServerUnlink && (
|
||||
<ConfirmButton
|
||||
onClick={() => {
|
||||
deleteRequest(
|
||||
acct.type === LinkedAccountType.Plex ? 'plex' : 'jellyfin'
|
||||
);
|
||||
}}
|
||||
confirmText={intl.formatMessage(globalMessages.areyousure)}
|
||||
>
|
||||
<TrashIcon />
|
||||
<span>{intl.formatMessage(globalMessages.delete)}</span>
|
||||
</ConfirmButton>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<div className="mt-4 text-center md:py-12">
|
||||
<h3 className="text-lg font-semibold text-gray-400">
|
||||
{intl.formatMessage(messages.noLinkedAccounts)}
|
||||
</h3>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<LinkJellyfinModal
|
||||
show={showJellyfinModal}
|
||||
onClose={() => setShowJellyfinModal(false)}
|
||||
onSave={() => {
|
||||
setShowJellyfinModal(false);
|
||||
revalidateUser();
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserLinkedAccountsSettings;
|
||||
@@ -18,6 +18,7 @@ import useSWR from 'swr';
|
||||
const messages = defineMessages('components.UserProfile.UserSettings', {
|
||||
menuGeneralSettings: 'General',
|
||||
menuChangePass: 'Password',
|
||||
menuLinkedAccounts: 'Linked Accounts',
|
||||
menuNotifications: 'Notifications',
|
||||
menuPermissions: 'Permissions',
|
||||
unauthorizedDescription:
|
||||
@@ -63,6 +64,11 @@ const UserSettings = ({ children }: UserSettingsProps) => {
|
||||
currentUser?.id !== user?.id &&
|
||||
hasPermission(Permission.ADMIN, user?.permissions ?? 0)),
|
||||
},
|
||||
{
|
||||
text: intl.formatMessage(messages.menuLinkedAccounts),
|
||||
route: '/settings/linked-accounts',
|
||||
regex: /\/settings\/linked-accounts/,
|
||||
},
|
||||
{
|
||||
text: intl.formatMessage(messages.menuNotifications),
|
||||
route: data?.emailEnabled
|
||||
|
||||
@@ -14,6 +14,7 @@ const defaultSettings = {
|
||||
applicationUrl: '',
|
||||
hideAvailable: false,
|
||||
localLogin: true,
|
||||
mediaServerLogin: true,
|
||||
movie4kEnabled: false,
|
||||
series4kEnabled: false,
|
||||
discoverRegion: '',
|
||||
|
||||
@@ -80,6 +80,7 @@ const useDiscover = <
|
||||
},
|
||||
{
|
||||
initialSize: 3,
|
||||
revalidateFirstPage: false,
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
37
src/hooks/usePlexLogin.ts
Normal file
37
src/hooks/usePlexLogin.ts
Normal 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;
|
||||
@@ -11,8 +11,8 @@ export type { PermissionCheckOptions };
|
||||
export interface User {
|
||||
id: number;
|
||||
warnings: string[];
|
||||
plexUsername?: string;
|
||||
jellyfinUsername?: string;
|
||||
plexUsername?: string | null;
|
||||
jellyfinUsername?: string | null;
|
||||
username?: string;
|
||||
displayName: string;
|
||||
email: string;
|
||||
|
||||
@@ -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}}",
|
||||
@@ -753,7 +753,10 @@
|
||||
"components.Settings.OverrideRuleModal.ruleUpdated": "Override rule updated successfully!",
|
||||
"components.Settings.OverrideRuleModal.selectQualityProfile": "Select quality profile",
|
||||
"components.Settings.OverrideRuleModal.selectRootFolder": "Select root folder",
|
||||
"components.Settings.OverrideRuleModal.selectService": "Select service",
|
||||
"components.Settings.OverrideRuleModal.selecttags": "Select tags",
|
||||
"components.Settings.OverrideRuleModal.service": "Service",
|
||||
"components.Settings.OverrideRuleModal.serviceDescription": "Apply this rule to the selected service.",
|
||||
"components.Settings.OverrideRuleModal.settings": "Settings",
|
||||
"components.Settings.OverrideRuleModal.settingsDescription": "Specifies which settings will be changed when the above conditions are met.",
|
||||
"components.Settings.OverrideRuleModal.tags": "Tags",
|
||||
@@ -768,7 +771,6 @@
|
||||
"components.Settings.OverrideRuleTile.tags": "Tags",
|
||||
"components.Settings.OverrideRuleTile.users": "Users",
|
||||
"components.Settings.RadarrModal.add": "Add Server",
|
||||
"components.Settings.RadarrModal.addrule": "New Override Rule",
|
||||
"components.Settings.RadarrModal.announced": "Announced",
|
||||
"components.Settings.RadarrModal.apiKey": "API Key",
|
||||
"components.Settings.RadarrModal.baseUrl": "URL Base",
|
||||
@@ -787,7 +789,6 @@
|
||||
"components.Settings.RadarrModal.loadingrootfolders": "Loading root folders…",
|
||||
"components.Settings.RadarrModal.minimumAvailability": "Minimum Availability",
|
||||
"components.Settings.RadarrModal.notagoptions": "No tags.",
|
||||
"components.Settings.RadarrModal.overrideRules": "Override Rules",
|
||||
"components.Settings.RadarrModal.port": "Port",
|
||||
"components.Settings.RadarrModal.qualityprofile": "Quality Profile",
|
||||
"components.Settings.RadarrModal.released": "Released",
|
||||
@@ -935,14 +936,17 @@
|
||||
"components.Settings.SettingsMain.validationApplicationTitle": "You must provide an application title",
|
||||
"components.Settings.SettingsMain.validationApplicationUrl": "You must provide a valid URL",
|
||||
"components.Settings.SettingsMain.validationApplicationUrlTrailingSlash": "URL must not end in a trailing slash",
|
||||
"components.Settings.SettingsNetwork.advancedNetworkSettings": "Advanced Network Settings",
|
||||
"components.Settings.SettingsNetwork.csrfProtection": "Enable CSRF Protection",
|
||||
"components.Settings.SettingsNetwork.csrfProtectionHoverTip": "Do NOT enable this setting unless you understand what you are doing!",
|
||||
"components.Settings.SettingsNetwork.csrfProtectionTip": "Set external API access to read-only (requires HTTPS)",
|
||||
"components.Settings.SettingsNetwork.dnsServers": "Custom DNS Servers",
|
||||
"components.Settings.SettingsNetwork.dnsServersTip": "Comma-separated list of custom DNS servers, e.g. \"1.1.1.1,[2606:4700:4700::1111]\"",
|
||||
"components.Settings.SettingsNetwork.forceIpv4First": "IPv4 Resolution First",
|
||||
"components.Settings.SettingsNetwork.docs": "documentation",
|
||||
"components.Settings.SettingsNetwork.forceIpv4First": "Force IPv4 Resolution First",
|
||||
"components.Settings.SettingsNetwork.forceIpv4FirstTip": "Force Jellyseerr to resolve IPv4 addresses first instead of IPv6",
|
||||
"components.Settings.SettingsNetwork.network": "Network",
|
||||
"components.Settings.SettingsNetwork.networkDisclaimer": "Network parameters from your container/system should be used instead of these settings. See the {docs} for more information.",
|
||||
"components.Settings.SettingsNetwork.networksettings": "Network Settings",
|
||||
"components.Settings.SettingsNetwork.networksettingsDescription": "Configure network settings for your Jellyseerr instance.",
|
||||
"components.Settings.SettingsNetwork.proxyBypassFilter": "Proxy Ignored Addresses",
|
||||
@@ -959,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",
|
||||
@@ -973,7 +982,6 @@
|
||||
"components.Settings.SettingsUsers.userSettingsDescription": "Configure global and default user settings.",
|
||||
"components.Settings.SettingsUsers.users": "Users",
|
||||
"components.Settings.SonarrModal.add": "Add Server",
|
||||
"components.Settings.SonarrModal.addrule": "New Override Rule",
|
||||
"components.Settings.SonarrModal.animeSeriesType": "Anime Series Type",
|
||||
"components.Settings.SonarrModal.animeTags": "Anime Tags",
|
||||
"components.Settings.SonarrModal.animelanguageprofile": "Anime Language Profile",
|
||||
@@ -996,7 +1004,6 @@
|
||||
"components.Settings.SonarrModal.loadingprofiles": "Loading quality profiles…",
|
||||
"components.Settings.SonarrModal.loadingrootfolders": "Loading root folders…",
|
||||
"components.Settings.SonarrModal.notagoptions": "No tags.",
|
||||
"components.Settings.SonarrModal.overrideRules": "Override Rules",
|
||||
"components.Settings.SonarrModal.port": "Port",
|
||||
"components.Settings.SonarrModal.qualityprofile": "Quality Profile",
|
||||
"components.Settings.SonarrModal.rootfolder": "Root Folder",
|
||||
@@ -1033,6 +1040,7 @@
|
||||
"components.Settings.activeProfile": "Active Profile",
|
||||
"components.Settings.addradarr": "Add Radarr Server",
|
||||
"components.Settings.address": "Address",
|
||||
"components.Settings.addrule": "New Override Rule",
|
||||
"components.Settings.addsonarr": "Add Sonarr Server",
|
||||
"components.Settings.advancedTooltip": "Incorrectly configuring this setting may result in broken functionality",
|
||||
"components.Settings.apiKey": "API key",
|
||||
@@ -1086,6 +1094,8 @@
|
||||
"components.Settings.notifications": "Notifications",
|
||||
"components.Settings.notificationsettings": "Notification Settings",
|
||||
"components.Settings.notrunning": "Not Running",
|
||||
"components.Settings.overrideRules": "Override Rules",
|
||||
"components.Settings.overrideRulesDescription": "Override rules allow you to specify properties that will be replaced if a request matches the rule.",
|
||||
"components.Settings.plex": "Plex",
|
||||
"components.Settings.plexlibraries": "Plex Libraries",
|
||||
"components.Settings.plexlibrariesDescription": "The libraries Jellyseerr scans for titles. Set up and save your Plex connection settings, then click the button below if no libraries are listed.",
|
||||
@@ -1272,6 +1282,17 @@
|
||||
"components.UserProfile.ProfileHeader.profile": "View Profile",
|
||||
"components.UserProfile.ProfileHeader.settings": "Edit Settings",
|
||||
"components.UserProfile.ProfileHeader.userid": "User ID: {userid}",
|
||||
"components.UserProfile.UserSettings.LinkJellyfinModal.description": "Enter your {mediaServerName} credentials to link your account with {applicationName}.",
|
||||
"components.UserProfile.UserSettings.LinkJellyfinModal.errorExists": "This account is already linked to a {applicationName} user",
|
||||
"components.UserProfile.UserSettings.LinkJellyfinModal.errorUnauthorized": "Unable to connect to {mediaServerName} using your credentials",
|
||||
"components.UserProfile.UserSettings.LinkJellyfinModal.errorUnknown": "An unknown error occurred",
|
||||
"components.UserProfile.UserSettings.LinkJellyfinModal.password": "Password",
|
||||
"components.UserProfile.UserSettings.LinkJellyfinModal.passwordRequired": "You must provide a password",
|
||||
"components.UserProfile.UserSettings.LinkJellyfinModal.save": "Link",
|
||||
"components.UserProfile.UserSettings.LinkJellyfinModal.saving": "Adding…",
|
||||
"components.UserProfile.UserSettings.LinkJellyfinModal.title": "Link {mediaServerName} Account",
|
||||
"components.UserProfile.UserSettings.LinkJellyfinModal.username": "Username",
|
||||
"components.UserProfile.UserSettings.LinkJellyfinModal.usernameRequired": "You must provide a username",
|
||||
"components.UserProfile.UserSettings.UserGeneralSettings.accounttype": "Account Type",
|
||||
"components.UserProfile.UserSettings.UserGeneralSettings.admin": "Admin",
|
||||
"components.UserProfile.UserSettings.UserGeneralSettings.applanguage": "Display Language",
|
||||
@@ -1312,6 +1333,14 @@
|
||||
"components.UserProfile.UserSettings.UserGeneralSettings.validationDiscordId": "You must provide a valid Discord user ID",
|
||||
"components.UserProfile.UserSettings.UserGeneralSettings.validationemailformat": "Valid email required",
|
||||
"components.UserProfile.UserSettings.UserGeneralSettings.validationemailrequired": "Email required",
|
||||
"components.UserProfile.UserSettings.UserLinkedAccountsSettings.deleteFailed": "Unable to delete linked account.",
|
||||
"components.UserProfile.UserSettings.UserLinkedAccountsSettings.errorUnknown": "An unknown error occurred",
|
||||
"components.UserProfile.UserSettings.UserLinkedAccountsSettings.linkedAccounts": "Linked Accounts",
|
||||
"components.UserProfile.UserSettings.UserLinkedAccountsSettings.linkedAccountsHint": "These external accounts are linked to your {applicationName} account.",
|
||||
"components.UserProfile.UserSettings.UserLinkedAccountsSettings.noLinkedAccounts": "You do not have any external accounts linked to your account.",
|
||||
"components.UserProfile.UserSettings.UserLinkedAccountsSettings.noPermissionDescription": "You do not have permission to modify this user's linked accounts.",
|
||||
"components.UserProfile.UserSettings.UserLinkedAccountsSettings.plexErrorExists": "This account is already linked to a Plex user",
|
||||
"components.UserProfile.UserSettings.UserLinkedAccountsSettings.plexErrorUnauthorized": "Unable to connect to Plex using your credentials",
|
||||
"components.UserProfile.UserSettings.UserNotificationSettings.deviceDefault": "Device Default",
|
||||
"components.UserProfile.UserSettings.UserNotificationSettings.discordId": "User ID",
|
||||
"components.UserProfile.UserSettings.UserNotificationSettings.discordIdTip": "The <FindDiscordIdLink>multi-digit ID number</FindDiscordIdLink> associated with your user account",
|
||||
@@ -1374,6 +1403,7 @@
|
||||
"components.UserProfile.UserSettings.UserPermissions.unauthorizedDescription": "You cannot modify your own permissions.",
|
||||
"components.UserProfile.UserSettings.menuChangePass": "Password",
|
||||
"components.UserProfile.UserSettings.menuGeneralSettings": "General",
|
||||
"components.UserProfile.UserSettings.menuLinkedAccounts": "Linked Accounts",
|
||||
"components.UserProfile.UserSettings.menuNotifications": "Notifications",
|
||||
"components.UserProfile.UserSettings.menuPermissions": "Permissions",
|
||||
"components.UserProfile.UserSettings.unauthorizedDescription": "You do not have permission to modify this user's settings.",
|
||||
|
||||
@@ -194,6 +194,7 @@ CoreApp.getInitialProps = async (initialProps) => {
|
||||
movie4kEnabled: false,
|
||||
series4kEnabled: false,
|
||||
localLogin: true,
|
||||
mediaServerLogin: true,
|
||||
discoverRegion: '',
|
||||
streamingRegion: '',
|
||||
originalLanguage: '',
|
||||
|
||||
13
src/pages/profile/settings/linked-accounts.tsx
Normal file
13
src/pages/profile/settings/linked-accounts.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import UserSettings from '@app/components/UserProfile/UserSettings';
|
||||
import UserLinkedAccountsSettings from '@app/components/UserProfile/UserSettings/UserLinkedAccountsSettings';
|
||||
import type { NextPage } from 'next';
|
||||
|
||||
const UserSettingsLinkedAccountsPage: NextPage = () => {
|
||||
return (
|
||||
<UserSettings>
|
||||
<UserLinkedAccountsSettings />
|
||||
</UserSettings>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserSettingsLinkedAccountsPage;
|
||||
16
src/pages/users/[userId]/settings/linked-accounts.tsx
Normal file
16
src/pages/users/[userId]/settings/linked-accounts.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import UserSettings from '@app/components/UserProfile/UserSettings';
|
||||
import UserLinkedAccountsSettings from '@app/components/UserProfile/UserSettings/UserLinkedAccountsSettings';
|
||||
import useRouteGuard from '@app/hooks/useRouteGuard';
|
||||
import { Permission } from '@app/hooks/useUser';
|
||||
import type { NextPage } from 'next';
|
||||
|
||||
const UserLinkedAccountsPage: NextPage = () => {
|
||||
useRouteGuard(Permission.MANAGE_USERS);
|
||||
return (
|
||||
<UserSettings>
|
||||
<UserLinkedAccountsSettings />
|
||||
</UserSettings>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserLinkedAccountsPage;
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user