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:
Michael Thomas
2025-02-22 11:16:25 -05:00
committed by GitHub
parent 80927b9705
commit 64f05bcad6
17 changed files with 1095 additions and 128 deletions

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