feat: add ability to unlink plex account

pull/3105/head
Ryan Cohen 2 years ago
parent 6a4d884754
commit 4219a2b9b6

@ -3100,6 +3100,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;

@ -67,7 +67,7 @@ app
where: { id: 1 },
});
if (admin) {
if (admin?.plexToken) {
logger.info('Migrating Plex libraries to include media type', {
label: 'Settings',
});

@ -175,6 +175,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);

@ -153,9 +153,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;
});
@ -192,7 +195,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,
});
@ -223,7 +226,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) {
@ -232,6 +235,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-transaprent 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 { LoginIcon } from '@heroicons/react/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}
>
<LoginIcon />
{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';
@ -13,6 +14,11 @@ import { Permission, useUser } from '@app/hooks/useUser';
import globalMessages from '@app/i18n/globalMessages';
import Error from '@app/pages/_error';
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
) {

@ -111,7 +111,7 @@ const CoreApp: Omit<NextAppComponentType, 'origGetInitialProps'> = ({
loadLocaleData(currentLocale).then(setMessages);
}, [currentLocale]);
if (router.pathname.match(/(login|setup|resetpassword)/)) {
if (router.pathname.match(/(login|setup|resetpassword|loading)/)) {
component = <Component {...pageProps} />;
} else {
component = (
@ -225,7 +225,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 +

Loading…
Cancel
Save