mirror of
https://github.com/fallenbagel/jellyseerr.git
synced 2025-12-24 02:39:18 -05:00
Compare commits
13 Commits
ci-arm64-s
...
preview-li
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
033ccac813 | ||
|
|
4db1d75e22 | ||
|
|
56004b056f | ||
|
|
dbc1cb0a77 | ||
|
|
70524a19e9 | ||
|
|
445041e89d | ||
|
|
538c46872d | ||
|
|
be07f5cf49 | ||
|
|
56a876f80d | ||
|
|
557b584dcd | ||
|
|
2597657fee | ||
|
|
b6c6245da1 | ||
|
|
189dfb76f2 |
@@ -4383,6 +4383,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
|
||||
|
||||
@@ -95,7 +95,11 @@ export interface JellyfinLibraryItemExtended extends JellyfinLibraryItem {
|
||||
class JellyfinAPI extends ExternalAPI {
|
||||
private userId?: 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',
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
public jellyfinDeviceId?: string;
|
||||
@Column({ type: 'varchar', nullable: true })
|
||||
public jellyfinDeviceId?: string | null;
|
||||
|
||||
@Column({ nullable: true })
|
||||
public jellyfinAuthToken?: string;
|
||||
@Column({ type: 'varchar', nullable: true })
|
||||
public jellyfinAuthToken?: string | null;
|
||||
|
||||
@Column({ nullable: true })
|
||||
public plexToken?: string;
|
||||
@Column({ type: 'varchar', nullable: true })
|
||||
public plexToken?: string | null;
|
||||
|
||||
@Column({ type: 'integer', default: 0 })
|
||||
public permissions = 0;
|
||||
|
||||
@@ -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,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,
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1261,6 +1261,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",
|
||||
@@ -1301,6 +1312,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",
|
||||
@@ -1363,6 +1382,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.",
|
||||
|
||||
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;
|
||||
Reference in New Issue
Block a user