feat(linked-accounts): add support for linking/unlinking jellyfin accounts

This commit is contained in:
Michael Thomas
2024-07-21 10:17:49 -04:00
parent 2597657fee
commit 557b584dcd
10 changed files with 529 additions and 21 deletions

View File

@@ -4383,6 +4383,54 @@ paths:
responses:
'204':
description: User password updated
/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
'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

@@ -59,8 +59,8 @@ export class User {
@Column({ nullable: true })
public plexUsername?: string;
@Column({ nullable: true })
public jellyfinUsername?: string;
@Column({ type: 'varchar', nullable: true })
public jellyfinUsername?: string | null;
@Column({ nullable: true })
public username?: string;
@@ -80,14 +80,14 @@ export class User {
@Column({ nullable: true, select: true })
public plexId?: number;
@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;

View File

@@ -1,4 +1,6 @@
import JellyfinAPI from '@server/api/jellyfin';
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 +14,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 (
@@ -290,6 +306,127 @@ userSettingsRoutes.post<
}
});
userSettingsRoutes.post<{ username: string; password: string }>(
'/linked-accounts/jellyfin',
isOwnProfile(),
async (req, res, next) => {
const settings = getSettings();
const userRepository = getRepository(User);
if (!req.user) {
return next({ status: 401, message: 'Unauthorized' });
}
// Make sure jellyfin login is enabled
if (settings.main.mediaServerType !== MediaServerType.JELLYFIN) {
return res.status(500).json({ error: 'Jellyfin 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({
error:
'The specified Jellyfin 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;
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({
error:
'The specified Jellyfin account is already linked to a Jellyseerr user',
});
}
const user = req.user;
// valid jellyfin user found, link to current user
user.userType = 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 Jellyfin account to user.', {
label: 'API',
ip: req.ip,
error: e,
});
if (
e instanceof ApiError &&
(e.errorCode == ApiErrorCode.InvalidCredentials ||
e.errorCode == ApiErrorCode.NotAdmin)
)
return next({ status: 401, message: 'Unauthorized' });
return next({ status: 500 });
}
}
);
userSettingsRoutes.delete<{ id: string }>(
'/linked-accounts/jellyfin',
isOwnProfileOrAdmin(),
async (req, res, next) => {
const userRepository = getRepository(User);
try {
const user = await userRepository.findOne({
where: { id: Number(req.params.id) },
});
if (!user) {
return next({ status: 404, message: 'User not found.' });
}
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) {
next({ status: 500, message: e.message });
}
}
);
userSettingsRoutes.get<{ id: string }, UserSettingsNotificationsResponse>(
'/notifications',
isOwnProfileOrAdmin(),

View File

@@ -0,0 +1,172 @@
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 { RequestError } from '@app/types/error';
import defineMessages from '@app/utils/defineMessages';
import { Transition } from '@headlessui/react';
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 Jellyfin Account',
description:
'Enter your Jellyfin credentials to link your account with Jellyseerr.',
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 Jellyfin using your credentials',
errorExists: 'This account is already linked to a Jellyseerr 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)
),
});
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) throw new RequestError(res);
onSave();
} catch (e) {
if (e instanceof RequestError && e.status == 401) {
setError(intl.formatMessage(messages.errorUnauthorized));
} else if (e instanceof RequestError && e.status == 422) {
setError(intl.formatMessage(messages.errorExists));
} else {
setError(intl.formatMessage(messages.errorServer));
}
}
}}
>
{({ 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)}
dialogClass="sm:max-w-lg"
>
<Form id="link-jellyfin-account">
{intl.formatMessage(messages.description, {
applicationName: settings.currentSettings.applicationTitle,
})}
{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

@@ -1,10 +1,19 @@
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 { useUser } from '@app/hooks/useUser';
import useSettings from '@app/hooks/useSettings';
import { Permission, useUser } from '@app/hooks/useUser';
import globalMessages from '@app/i18n/globalMessages';
import defineMessages from '@app/utils/defineMessages';
import { TrashIcon } from '@heroicons/react/24/solid';
import { MediaServerType } from '@server/constants/server';
import { useRouter } from 'next/router';
import { useState } from 'react';
import { useIntl } from 'react-intl';
import LinkJellyfinModal from './LinkJellyfinModal';
const messages = defineMessages(
'components.UserProfile.UserSettings.UserLinkedAccountsSettings',
@@ -14,6 +23,9 @@ const messages = defineMessages(
'These external accounts are linked to your Jellyseerr 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.",
deleteFailed: 'Unable to delete linked account.',
}
);
@@ -29,7 +41,16 @@ type LinkedAccount = {
const UserLinkedAccountsSettings = () => {
const intl = useIntl();
const { user } = useUser();
const settings = useSettings();
const router = useRouter();
const { user: currentUser } = useUser();
const {
user,
hasPermission,
revalidate: revalidateUser,
} = useUser({ id: Number(router.query.userId) });
const [showJellyfinModal, setShowJellyfinModal] = useState(false);
const [error, setError] = useState<string | null>(null);
const accounts: LinkedAccount[] = [
...(user?.plexUsername
@@ -40,6 +61,50 @@ const UserLinkedAccountsSettings = () => {
: []),
];
const linkable = [
{
name: 'Jellyfin',
action: () => setShowJellyfinModal(true),
hide:
settings.currentSettings.mediaServerType != MediaServerType.JELLYFIN ||
accounts.find((a) => a.type == LinkedAccountType.Jellyfin),
},
].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));
}
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"
/>
</>
);
}
return (
<>
<PageTitle
@@ -49,18 +114,39 @@ const UserLinkedAccountsSettings = () => {
user?.displayName,
]}
/>
<div className="mb-6">
<h3 className="heading">
{intl.formatMessage(messages.linkedAccounts)}
</h3>
<h6 className="description">
{intl.formatMessage(messages.linkedAccountsHint)}
</h6>
<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)}
</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={intl.formatMessage(globalMessages.failed)} type="error">
{error}
</Alert>
)}
{accounts.length ? (
<ul className="space-y-4">
{accounts.map((acct) => (
<li 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">
{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">
@@ -78,6 +164,18 @@ const UserLinkedAccountsSettings = () => {
{acct.username}
</div>
</div>
<div className="flex-grow" />
<ConfirmButton
onClick={() => {
deleteRequest(
acct.type == LinkedAccountType.Plex ? 'plex' : 'jellyfin'
);
}}
confirmText={intl.formatMessage(globalMessages.areyousure)}
>
<TrashIcon />
<span>{intl.formatMessage(globalMessages.delete)}</span>
</ConfirmButton>
</li>
))}
</ul>
@@ -88,6 +186,15 @@ const UserLinkedAccountsSettings = () => {
</h3>
</div>
)}
<LinkJellyfinModal
show={showJellyfinModal}
onClose={() => setShowJellyfinModal(false)}
onSave={() => {
setShowJellyfinModal(false);
revalidateUser();
}}
/>
</>
);
};

View File

@@ -12,7 +12,7 @@ export interface User {
id: number;
warnings: string[];
plexUsername?: string;
jellyfinUsername?: string;
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 Jellyfin credentials to link your account with Jellyseerr.",
"components.UserProfile.UserSettings.LinkJellyfinModal.errorExists": "This account is already linked to a Jellyseerr user",
"components.UserProfile.UserSettings.LinkJellyfinModal.errorUnauthorized": "Unable to connect to Jellyfin 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 Jellyfin 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,9 +1312,11 @@
"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.linkedAccounts": "Linked Accounts",
"components.UserProfile.UserSettings.UserLinkedAccountsSettings.linkedAccountsHint": "These external accounts are linked to your Jellyseerr 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.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",

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;

11
src/types/error.ts Normal file
View File

@@ -0,0 +1,11 @@
export class RequestError extends Error {
status: number;
res: Response;
constructor(res: Response) {
const status = res.status;
super(`Request failed with status code ${status}`);
this.status = status;
this.res = res;
}
}