mirror of
https://github.com/fallenbagel/jellyseerr.git
synced 2026-01-01 12:18:35 -05:00
feat: add linked accounts page (#883)
* feat(linked-accounts): create page and display linked media server accounts * 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. * refactor(modal): add support for configuring button props * feat(linked-accounts): add support for linking/unlinking jellyfin accounts * feat(linked-accounts): support linking/unlinking plex accounts * 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. * feat(linked-accounts): support linking/unlinking emby accounts * style(dropdown): improve style class application * fix(server): improve error handling and API spec * style(usersettings): improve syntax & performance of user password checks * style(linkedaccounts): use applicationName in page description * fix(linkedaccounts): resolve typo * refactor(app): remove RequestError class
This commit is contained in:
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;
|
||||
|
||||
@@ -1275,6 +1275,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",
|
||||
@@ -1315,6 +1326,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",
|
||||
@@ -1377,6 +1396,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