mirror of
https://github.com/sct/overseerr.git
synced 2025-12-27 00:34:56 +01:00
feat: add ability to unlink plex account
This commit is contained in:
@@ -3329,6 +3329,15 @@ paths:
|
||||
type: string
|
||||
required:
|
||||
- authToken
|
||||
/auth/plex/unlink:
|
||||
get:
|
||||
summary: Unlink Plex from currently logged in account
|
||||
description: Will remove connected Plex account information from the currently logged-in user.
|
||||
tags:
|
||||
- auth
|
||||
responses:
|
||||
'204':
|
||||
description: OK
|
||||
/auth/local:
|
||||
post:
|
||||
summary: Sign in using a local account
|
||||
|
||||
@@ -62,8 +62,8 @@ export class User {
|
||||
})
|
||||
public email: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
public plexUsername?: string;
|
||||
@Column({ type: 'varchar', nullable: true })
|
||||
public plexUsername?: string | null;
|
||||
|
||||
@Column({ nullable: true })
|
||||
public username?: string;
|
||||
@@ -77,11 +77,11 @@ export class User {
|
||||
@Column({ type: 'date', nullable: true })
|
||||
public recoveryLinkExpirationDate?: Date | null;
|
||||
|
||||
@Column({ nullable: true })
|
||||
public plexId?: number;
|
||||
@Column({ type: 'int', nullable: true })
|
||||
public plexId?: number | null;
|
||||
|
||||
@Column({ nullable: true, select: false })
|
||||
public plexToken?: string;
|
||||
@Column({ type: 'varchar', nullable: true, select: false })
|
||||
public plexToken?: string | null;
|
||||
|
||||
@Column({ type: 'integer', default: 0 })
|
||||
public permissions = 0;
|
||||
|
||||
@@ -70,7 +70,7 @@ app
|
||||
where: { id: 1 },
|
||||
});
|
||||
|
||||
if (admin) {
|
||||
if (admin?.plexToken) {
|
||||
logger.info('Migrating Plex libraries to include media type', {
|
||||
label: 'Settings',
|
||||
});
|
||||
|
||||
@@ -189,6 +189,36 @@ authRoutes.post('/plex', async (req, res, next) => {
|
||||
}
|
||||
});
|
||||
|
||||
authRoutes.get('/plex/unlink', isAuthenticated(), async (req, res, next) => {
|
||||
const userRepository = getRepository(User);
|
||||
try {
|
||||
if (!req.user) {
|
||||
throw new Error('User data is not present in request.');
|
||||
}
|
||||
|
||||
const user = await userRepository.findOneByOrFail({ id: req.user.id });
|
||||
|
||||
user.plexId = null;
|
||||
user.plexToken = null;
|
||||
user.avatar = gravatarUrl(user.email, { default: 'mm', size: 200 });
|
||||
user.plexUsername = null;
|
||||
|
||||
await userRepository.save(user);
|
||||
|
||||
return res.status(204).send();
|
||||
} catch (e) {
|
||||
logger.error('Something went wrong unlinking a Plex account', {
|
||||
label: 'API',
|
||||
errorMessage: e.message,
|
||||
userId: req.user?.id,
|
||||
});
|
||||
return next({
|
||||
status: 500,
|
||||
message: 'Unable to unlink plex account.',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
authRoutes.post('/local', async (req, res, next) => {
|
||||
const settings = getSettings();
|
||||
const userRepository = getRepository(User);
|
||||
|
||||
@@ -156,9 +156,12 @@ settingsRoutes.get('/plex/devices/servers', async (req, res, next) => {
|
||||
select: { id: true, plexToken: true },
|
||||
where: { id: 1 },
|
||||
});
|
||||
const plexTvClient = admin.plexToken
|
||||
? new PlexTvAPI(admin.plexToken)
|
||||
: null;
|
||||
|
||||
if (!admin.plexToken) {
|
||||
throw new Error('Plex must be configured to retrieve servers.');
|
||||
}
|
||||
|
||||
const plexTvClient = new PlexTvAPI(admin.plexToken);
|
||||
const devices = (await plexTvClient?.getDevices())?.filter((device) => {
|
||||
return device.provides.includes('server') && device.owned;
|
||||
});
|
||||
@@ -195,7 +198,7 @@ settingsRoutes.get('/plex/devices/servers', async (req, res, next) => {
|
||||
useSsl: connection.protocol === 'https',
|
||||
};
|
||||
const plexClient = new PlexAPI({
|
||||
plexToken: admin.plexToken,
|
||||
plexToken: admin.plexToken ?? '',
|
||||
plexSettings: plexDeviceSettings,
|
||||
timeout: 5000,
|
||||
});
|
||||
@@ -226,7 +229,7 @@ settingsRoutes.get('/plex/devices/servers', async (req, res, next) => {
|
||||
}
|
||||
});
|
||||
|
||||
settingsRoutes.get('/plex/library', async (req, res) => {
|
||||
settingsRoutes.get('/plex/library', async (req, res, next) => {
|
||||
const settings = getSettings();
|
||||
|
||||
if (req.query.sync) {
|
||||
@@ -235,6 +238,14 @@ settingsRoutes.get('/plex/library', async (req, res) => {
|
||||
select: { id: true, plexToken: true },
|
||||
where: { id: 1 },
|
||||
});
|
||||
|
||||
if (!admin.plexToken) {
|
||||
return next({
|
||||
status: '500',
|
||||
message: 'Plex must be configured to retrieve libraries.',
|
||||
});
|
||||
}
|
||||
|
||||
const plexapi = new PlexAPI({ plexToken: admin.plexToken });
|
||||
|
||||
await plexapi.syncLibraries();
|
||||
|
||||
@@ -7,7 +7,8 @@ export type ButtonType =
|
||||
| 'danger'
|
||||
| 'warning'
|
||||
| 'success'
|
||||
| 'ghost';
|
||||
| 'ghost'
|
||||
| 'plex';
|
||||
|
||||
// Helper type to override types (overrides onClick)
|
||||
type MergeElementProps<
|
||||
@@ -74,6 +75,9 @@ function Button<P extends ElementTypes = 'button'>(
|
||||
'text-white bg-transparent border-gray-600 hover:border-gray-200 focus:border-gray-100 active:border-gray-100'
|
||||
);
|
||||
break;
|
||||
case 'plex':
|
||||
buttonStyle.push('plex-button');
|
||||
break;
|
||||
default:
|
||||
buttonStyle.push(
|
||||
'text-gray-200 bg-gray-800 bg-opacity-80 border-gray-600 hover:text-white hover:bg-gray-700 hover:border-gray-600 group-hover:text-white group-hover:bg-gray-700 group-hover:border-gray-600 focus:border-blue-300 focus:ring-blue active:text-gray-200 active:bg-gray-700 active:border-gray-600'
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import Button from '@app/components/Common/Button';
|
||||
import globalMessages from '@app/i18n/globalMessages';
|
||||
import PlexOAuth from '@app/utils/plex';
|
||||
import { ArrowLeftOnRectangleIcon } from '@heroicons/react/24/outline';
|
||||
@@ -11,18 +12,25 @@ const messages = defineMessages({
|
||||
|
||||
const plexOAuth = new PlexOAuth();
|
||||
|
||||
interface PlexLoginButtonProps {
|
||||
type PlexLoginButtonProps = Pick<
|
||||
React.ComponentPropsWithoutRef<typeof Button>,
|
||||
'buttonSize' | 'buttonType'
|
||||
> & {
|
||||
onAuthToken: (authToken: string) => void;
|
||||
isProcessing?: boolean;
|
||||
onError?: (message: string) => void;
|
||||
textOverride?: string;
|
||||
}
|
||||
svgIcon?: React.ReactNode;
|
||||
};
|
||||
|
||||
const PlexLoginButton = ({
|
||||
onAuthToken,
|
||||
onError,
|
||||
isProcessing,
|
||||
textOverride,
|
||||
buttonType = 'plex',
|
||||
buttonSize,
|
||||
svgIcon,
|
||||
}: PlexLoginButtonProps) => {
|
||||
const intl = useIntl();
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -42,16 +50,17 @@ const PlexLoginButton = ({
|
||||
};
|
||||
return (
|
||||
<span className="block w-full rounded-md shadow-sm">
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
plexOAuth.preparePopup();
|
||||
setTimeout(() => getPlexLogin(), 1500);
|
||||
}}
|
||||
disabled={loading || isProcessing}
|
||||
className="plex-button"
|
||||
buttonType={buttonType}
|
||||
buttonSize={buttonSize}
|
||||
>
|
||||
<ArrowLeftOnRectangleIcon />
|
||||
{svgIcon ?? <LoginIcon />}
|
||||
<span>
|
||||
{loading
|
||||
? intl.formatMessage(globalMessages.loading)
|
||||
@@ -61,7 +70,7 @@ const PlexLoginButton = ({
|
||||
? textOverride
|
||||
: intl.formatMessage(messages.signinwithplex)}
|
||||
</span>
|
||||
</button>
|
||||
</Button>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -8,14 +8,20 @@ const messages = defineMessages({
|
||||
signinwithplex: 'Sign In with Plex',
|
||||
});
|
||||
|
||||
interface LoginWithPlexProps {
|
||||
type LoginWithPlexProps = Omit<
|
||||
React.ComponentPropsWithoutRef<typeof PlexLoginButton>,
|
||||
'onAuthToken'
|
||||
> & {
|
||||
onComplete: () => void;
|
||||
}
|
||||
};
|
||||
|
||||
const LoginWithPlex = ({ onComplete }: LoginWithPlexProps) => {
|
||||
const LoginWithPlex = ({
|
||||
onComplete,
|
||||
...plexLoginButtonProps
|
||||
}: LoginWithPlexProps) => {
|
||||
const intl = useIntl();
|
||||
const [authToken, setAuthToken] = useState<string | undefined>(undefined);
|
||||
const { user, revalidate } = useUser();
|
||||
const { revalidate } = useUser();
|
||||
|
||||
// Effect that is triggered when the `authToken` comes back from the Plex OAuth
|
||||
// We take the token and attempt to login. If we get a success message, we will
|
||||
@@ -26,21 +32,17 @@ const LoginWithPlex = ({ onComplete }: LoginWithPlexProps) => {
|
||||
const response = await axios.post('/api/v1/auth/plex', { authToken });
|
||||
|
||||
if (response.data?.id) {
|
||||
revalidate();
|
||||
const user = await revalidate();
|
||||
if (user) {
|
||||
setAuthToken(undefined);
|
||||
onComplete();
|
||||
}
|
||||
}
|
||||
};
|
||||
if (authToken) {
|
||||
login();
|
||||
}
|
||||
}, [authToken, revalidate]);
|
||||
|
||||
// Effect that is triggered whenever `useUser`'s user changes. If we get a new
|
||||
// valid user, we call onComplete which will take us to the next step in Setup.
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
onComplete();
|
||||
}
|
||||
}, [user, onComplete]);
|
||||
}, [authToken, revalidate, onComplete]);
|
||||
|
||||
return (
|
||||
<form>
|
||||
@@ -48,6 +50,7 @@ const LoginWithPlex = ({ onComplete }: LoginWithPlexProps) => {
|
||||
<PlexLoginButton
|
||||
onAuthToken={(authToken) => setAuthToken(authToken)}
|
||||
textOverride={intl.formatMessage(messages.signinwithplex)}
|
||||
{...plexLoginButtonProps}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import Badge from '@app/components/Common/Badge';
|
||||
import PlexLogo from '@app/assets/services/plex.svg';
|
||||
import Button from '@app/components/Common/Button';
|
||||
import LoadingSpinner from '@app/components/Common/LoadingSpinner';
|
||||
import PageTitle from '@app/components/Common/PageTitle';
|
||||
import LanguageSelector from '@app/components/LanguageSelector';
|
||||
import QuotaSelector from '@app/components/QuotaSelector';
|
||||
import RegionSelector from '@app/components/RegionSelector';
|
||||
import LoginWithPlex from '@app/components/Setup/LoginWithPlex';
|
||||
import type { AvailableLocale } from '@app/context/LanguageContext';
|
||||
import { availableLanguages } from '@app/context/LanguageContext';
|
||||
import useLocale from '@app/hooks/useLocale';
|
||||
@@ -12,7 +13,12 @@ import useSettings from '@app/hooks/useSettings';
|
||||
import { Permission, useUser } from '@app/hooks/useUser';
|
||||
import globalMessages from '@app/i18n/globalMessages';
|
||||
import Error from '@app/pages/_error';
|
||||
import { ArrowDownOnSquareIcon } from '@heroicons/react/24/outline';
|
||||
import { SaveIcon } from '@heroicons/react/outline';
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
RefreshIcon,
|
||||
XCircleIcon,
|
||||
} from '@heroicons/react/solid';
|
||||
import type { UserSettingsGeneralResponse } from '@server/interfaces/api/userSettingsInterfaces';
|
||||
import axios from 'axios';
|
||||
import { Field, Form, Formik } from 'formik';
|
||||
@@ -27,7 +33,7 @@ const messages = defineMessages({
|
||||
general: 'General',
|
||||
generalsettings: 'General Settings',
|
||||
displayName: 'Display Name',
|
||||
accounttype: 'Account Type',
|
||||
connectedaccounts: 'Connected Accounts',
|
||||
plexuser: 'Plex User',
|
||||
localuser: 'Local User',
|
||||
role: 'Role',
|
||||
@@ -96,6 +102,23 @@ const UserGeneralSettings = () => {
|
||||
);
|
||||
}, [data]);
|
||||
|
||||
const unlinkPlex = async () => {
|
||||
try {
|
||||
await axios.get('/api/v1/auth/plex/unlink');
|
||||
|
||||
addToast('Plex account is no longer connected.', {
|
||||
appearance: 'success',
|
||||
autoDismiss: true,
|
||||
});
|
||||
revalidateUser();
|
||||
} catch (e) {
|
||||
addToast('Failed to disconnect Plex account.', {
|
||||
appearance: 'error',
|
||||
autoDismiss: true,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (!data && !error) {
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
@@ -186,20 +209,51 @@ const UserGeneralSettings = () => {
|
||||
<Form className="section">
|
||||
<div className="form-row">
|
||||
<label className="text-label">
|
||||
{intl.formatMessage(messages.accounttype)}
|
||||
{intl.formatMessage(messages.connectedaccounts)}
|
||||
</label>
|
||||
<div className="mb-1 text-sm font-medium leading-5 text-gray-400 sm:mt-2">
|
||||
<div className="flex max-w-lg items-center">
|
||||
{user?.isPlexUser ? (
|
||||
<Badge badgeType="warning">
|
||||
{intl.formatMessage(messages.plexuser)}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge badgeType="default">
|
||||
{intl.formatMessage(messages.localuser)}
|
||||
</Badge>
|
||||
)}
|
||||
<div className="flex items-center rounded sm:col-span-2">
|
||||
<div className="mr-4 flex h-7 w-7 items-center justify-center rounded-full border border-gray-700 bg-gray-800">
|
||||
<CheckCircleIcon className="w-full text-green-500" />
|
||||
</div>
|
||||
<PlexLogo className="h-8 border-r border-gray-700 pr-4" />
|
||||
{!user?.isPlexUser ? (
|
||||
<>
|
||||
<div className="ml-4">
|
||||
<LoginWithPlex
|
||||
onComplete={() => {
|
||||
revalidateUser();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="ml-4">
|
||||
<LoginWithPlex
|
||||
onComplete={() => {
|
||||
addToast('Refreshed Plex token.', {
|
||||
appearance: 'success',
|
||||
autoDismiss: true,
|
||||
});
|
||||
revalidateUser();
|
||||
}}
|
||||
svgIcon={<RefreshIcon />}
|
||||
textOverride="Refresh Token"
|
||||
buttonSize="sm"
|
||||
buttonType="primary"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
className="ml-4"
|
||||
buttonSize="sm"
|
||||
onClick={() => unlinkPlex()}
|
||||
>
|
||||
<XCircleIcon />
|
||||
<span>Disconnect Plex</span>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
|
||||
@@ -24,7 +24,7 @@ export const UserContext = ({ initialUser, children }: UserContextProps) => {
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!router.pathname.match(/(setup|login|resetpassword)/) &&
|
||||
!router.pathname.match(/(setup|login|resetpassword|loading)/) &&
|
||||
(!user || error) &&
|
||||
!routing.current
|
||||
) {
|
||||
|
||||
@@ -275,7 +275,7 @@ CoreApp.getInitialProps = async (initialProps) => {
|
||||
// If there is no user, and ctx.res is set (to check if we are on the server side)
|
||||
// _AND_ we are not already on the login or setup route, redirect to /login with a 307
|
||||
// before anything actually renders
|
||||
if (!router.pathname.match(/(login|setup|resetpassword)/)) {
|
||||
if (!router.pathname.match(/(login|setup|resetpassword|loading)/)) {
|
||||
ctx.res.writeHead(307, {
|
||||
Location: '/login',
|
||||
});
|
||||
|
||||
@@ -198,7 +198,7 @@ class PlexOAuth {
|
||||
|
||||
//Set url to login/plex/loading so browser doesn't block popup
|
||||
const newWindow = window.open(
|
||||
'/login/plex/loading',
|
||||
'/loading',
|
||||
title,
|
||||
'scrollbars=yes, width=' +
|
||||
w +
|
||||
|
||||
Reference in New Issue
Block a user