Skip to content

Commit

Permalink
feat(linked-accounts): support linking/unlinking plex accounts
Browse files Browse the repository at this point in the history
  • Loading branch information
michaelhthomas committed Jul 30, 2024
1 parent b1b3f83 commit 9da4fbb
Show file tree
Hide file tree
Showing 7 changed files with 180 additions and 11 deletions.
46 changes: 46 additions & 0 deletions overseerr-api.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4229,6 +4229,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
Expand Down
4 changes: 2 additions & 2 deletions server/api/plexapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ class PlexAPI {
plexSettings,
timeout,
}: {
plexToken?: string;
plexToken?: string | null;
plexSettings?: PlexSettings;
timeout?: number;
}) {
Expand All @@ -107,7 +107,7 @@ class PlexAPI {
port: settingsPlex.port,
https: settingsPlex.useSsl,
timeout: timeout,
token: plexToken,
token: plexToken ?? undefined,
authenticator: {
authenticate: (
_plexApi,
Expand Down
12 changes: 6 additions & 6 deletions server/entity/User.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand Down
76 changes: 76 additions & 0 deletions server/routes/user/usersettings.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -267,6 +268,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(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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,
Expand Down Expand Up @@ -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);

Expand All @@ -82,7 +126,7 @@ const UserLinkedAccountsSettings = () => {
setError(intl.formatMessage(messages.deleteFailed));
}

revalidateUser();
await revalidateUser();
};

if (
Expand Down
2 changes: 1 addition & 1 deletion src/hooks/useUser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
3 changes: 3 additions & 0 deletions src/i18n/locale/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1199,10 +1199,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",
Expand Down

0 comments on commit 9da4fbb

Please sign in to comment.