mirror of
https://github.com/fallenbagel/jellyseerr.git
synced 2025-12-24 02:39:18 -05:00
feat(linked-accounts): support linking/unlinking plex accounts
This commit is contained in:
@@ -4383,6 +4383,52 @@ 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
|
||||
'404':
|
||||
description: User does not exist
|
||||
/user/{userId}/settings/linked-accounts/jellyfin:
|
||||
post:
|
||||
summary: Link the provided Jellyfin account to the current user
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -56,8 +56,8 @@ export class User {
|
||||
})
|
||||
public email: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
public plexUsername?: string;
|
||||
@Column({ type: 'varchar', nullable: true })
|
||||
public plexUsername?: string | null;
|
||||
|
||||
@Column({ type: 'varchar', nullable: true })
|
||||
public jellyfinUsername?: string | null;
|
||||
@@ -77,8 +77,8 @@ 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({ type: 'varchar', nullable: true })
|
||||
public jellyfinUserId?: string | null;
|
||||
@@ -89,8 +89,8 @@ export class User {
|
||||
@Column({ type: 'varchar', nullable: true })
|
||||
public jellyfinAuthToken?: string | null;
|
||||
|
||||
@Column({ nullable: true })
|
||||
public plexToken?: string;
|
||||
@Column({ type: 'varchar', nullable: true })
|
||||
public plexToken?: string | null;
|
||||
|
||||
@Column({ type: 'integer', default: 0 })
|
||||
public permissions = 0;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
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';
|
||||
@@ -306,6 +307,81 @@ userSettingsRoutes.post<
|
||||
}
|
||||
});
|
||||
|
||||
userSettingsRoutes.post<{ authToken: string }>(
|
||||
'/linked-accounts/plex',
|
||||
isOwnProfile(),
|
||||
async (req, res, next) => {
|
||||
const settings = getSettings();
|
||||
const userRepository = getRepository(User);
|
||||
|
||||
if (!req.user) {
|
||||
return next({ status: 404, message: 'Unauthorized' });
|
||||
}
|
||||
// Make sure Plex login is enabled
|
||||
if (settings.main.mediaServerType !== MediaServerType.PLEX) {
|
||||
return res.status(500).json({ error: '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({
|
||||
error: '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({
|
||||
error:
|
||||
'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, 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.plexId = null;
|
||||
user.plexUsername = null;
|
||||
user.plexToken = null;
|
||||
await userRepository.save(user);
|
||||
|
||||
return res.status(204).send();
|
||||
} catch (e) {
|
||||
next({ status: 500, message: e.message });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
userSettingsRoutes.post<{ username: string; password: string }>(
|
||||
'/linked-accounts/jellyfin',
|
||||
isOwnProfile(),
|
||||
|
||||
@@ -7,7 +7,9 @@ import PageTitle from '@app/components/Common/PageTitle';
|
||||
import useSettings from '@app/hooks/useSettings';
|
||||
import { Permission, useUser } from '@app/hooks/useUser';
|
||||
import globalMessages from '@app/i18n/globalMessages';
|
||||
import { RequestError } from '@app/types/error';
|
||||
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';
|
||||
@@ -25,10 +27,15 @@ const messages = defineMessages(
|
||||
'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();
|
||||
|
||||
const enum LinkedAccountType {
|
||||
Plex,
|
||||
Jellyfin,
|
||||
@@ -61,13 +68,50 @@ const UserLinkedAccountsSettings = () => {
|
||||
: []),
|
||||
];
|
||||
|
||||
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) {
|
||||
throw new RequestError(res);
|
||||
}
|
||||
|
||||
await revalidateUser();
|
||||
} catch (e) {
|
||||
if (e instanceof RequestError && e.status == 401) {
|
||||
setError(intl.formatMessage(messages.plexErrorUnauthorized));
|
||||
} else if (e instanceof RequestError && e.status == 422) {
|
||||
setError(intl.formatMessage(messages.plexErrorExists));
|
||||
} else {
|
||||
setError(intl.formatMessage(messages.errorServer));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
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.find((a) => a.type == LinkedAccountType.Jellyfin),
|
||||
accounts.some((a) => a.type == LinkedAccountType.Jellyfin),
|
||||
},
|
||||
].filter((l) => !l.hide);
|
||||
|
||||
@@ -82,7 +126,7 @@ const UserLinkedAccountsSettings = () => {
|
||||
setError(intl.formatMessage(messages.deleteFailed));
|
||||
}
|
||||
|
||||
revalidateUser();
|
||||
await revalidateUser();
|
||||
};
|
||||
|
||||
if (
|
||||
|
||||
@@ -11,7 +11,7 @@ export type { PermissionCheckOptions };
|
||||
export interface User {
|
||||
id: number;
|
||||
warnings: string[];
|
||||
plexUsername?: string;
|
||||
plexUsername?: string | null;
|
||||
jellyfinUsername?: string | null;
|
||||
username?: string;
|
||||
displayName: string;
|
||||
|
||||
@@ -1313,10 +1313,13 @@
|
||||
"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 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.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",
|
||||
|
||||
Reference in New Issue
Block a user