Compare commits

...

13 Commits

Author SHA1 Message Date
Michael Thomas
033ccac813 refactor(app): remove RequestError class 2025-01-10 20:52:24 -05:00
Michael Thomas
4db1d75e22 fix(linkedaccounts): resolve typo 2025-01-10 20:40:32 -05:00
Michael Thomas
56004b056f style(linkedaccounts): use applicationName in page description 2025-01-10 20:40:30 -05:00
Michael Thomas
dbc1cb0a77 style(usersettings): improve syntax & performance of user password checks 2025-01-10 20:40:29 -05:00
Michael Thomas
70524a19e9 fix(server): improve error handling and API spec 2025-01-10 20:40:27 -05:00
Michael Thomas
445041e89d style(dropdown): improve style class application 2025-01-10 20:40:26 -05:00
Michael Thomas
538c46872d feat(linked-accounts): support linking/unlinking emby accounts 2025-01-10 20:40:23 -05:00
Michael Thomas
be07f5cf49 fix(linked-accounts): probibit unlinking accounts in certain cases
Prevents the primary administrator from unlinking their media server account (which would break
sync). Additionally, prevents users without a configured local email and password from unlinking
their accounts, which would render them unable to log in.
2025-01-10 20:40:21 -05:00
Michael Thomas
56a876f80d feat(linked-accounts): support linking/unlinking plex accounts 2025-01-10 20:40:17 -05:00
Michael Thomas
557b584dcd feat(linked-accounts): add support for linking/unlinking jellyfin accounts 2025-01-10 20:40:10 -05:00
Michael Thomas
2597657fee refactor(modal): add support for configuring button props 2025-01-10 20:39:35 -05:00
Michael Thomas
b6c6245da1 feat(dropdown): add new shared Dropdown component
Adds a shared component for plain dropdown menus, based on the headlessui Menu component. Updates
the `ButtonWithDropdown` component to use the same inner components, ensuring that the only
difference between the two components is the trigger button, and both use the same components for
the actual dropdown menu.
2025-01-10 20:39:26 -05:00
Michael Thomas
189dfb76f2 feat(linked-accounts): create page and display linked media server accounts 2025-01-10 20:20:15 -05:00
17 changed files with 1095 additions and 128 deletions

View File

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

View File

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

View File

@@ -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,

View File

@@ -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',
}

View File

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

View File

@@ -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(),

View File

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

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -1,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 });

View 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,
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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.",

View 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;

View 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;