From 4219a2b9b607d2ff0acca29af5d611d2c2c59ad0 Mon Sep 17 00:00:00 2001 From: Ryan Cohen Date: Tue, 13 Sep 2022 14:32:47 +0900 Subject: [PATCH] feat: add ability to unlink plex account --- overseerr-api.yml | 9 ++ server/entity/User.ts | 12 +-- server/index.ts | 2 +- server/routes/auth.ts | 30 +++++++ server/routes/settings/index.ts | 21 +++-- src/components/Common/Button/index.tsx | 6 +- src/components/PlexLoginButton/index.tsx | 21 +++-- src/components/Setup/LoginWithPlex.tsx | 31 +++---- .../UserGeneralSettings/index.tsx | 82 +++++++++++++++---- src/context/UserContext.tsx | 2 +- src/pages/_app.tsx | 4 +- src/pages/{login/plex => }/loading.tsx | 0 src/utils/plex.ts | 2 +- 13 files changed, 171 insertions(+), 51 deletions(-) rename src/pages/{login/plex => }/loading.tsx (100%) diff --git a/overseerr-api.yml b/overseerr-api.yml index 80d80c5e9..8a929da31 100644 --- a/overseerr-api.yml +++ b/overseerr-api.yml @@ -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 diff --git a/server/entity/User.ts b/server/entity/User.ts index 6abf413e0..56ecf20c8 100644 --- a/server/entity/User.ts +++ b/server/entity/User.ts @@ -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; diff --git a/server/index.ts b/server/index.ts index e78d5a577..e8859502d 100644 --- a/server/index.ts +++ b/server/index.ts @@ -67,7 +67,7 @@ app where: { id: 1 }, }); - if (admin) { + if (admin?.plexToken) { logger.info('Migrating Plex libraries to include media type', { label: 'Settings', }); diff --git a/server/routes/auth.ts b/server/routes/auth.ts index 402a4efc1..8ff11f965 100644 --- a/server/routes/auth.ts +++ b/server/routes/auth.ts @@ -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); diff --git a/server/routes/settings/index.ts b/server/routes/settings/index.ts index 29daee543..827fa01c5 100644 --- a/server/routes/settings/index.ts +++ b/server/routes/settings/index.ts @@ -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(); diff --git a/src/components/Common/Button/index.tsx b/src/components/Common/Button/index.tsx index d3f96ae98..be0f17725 100644 --- a/src/components/Common/Button/index.tsx +++ b/src/components/Common/Button/index.tsx @@ -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

( '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' diff --git a/src/components/PlexLoginButton/index.tsx b/src/components/PlexLoginButton/index.tsx index f28a1e23e..3c268cd46 100644 --- a/src/components/PlexLoginButton/index.tsx +++ b/src/components/PlexLoginButton/index.tsx @@ -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, + '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 ( - + ); }; diff --git a/src/components/Setup/LoginWithPlex.tsx b/src/components/Setup/LoginWithPlex.tsx index 34cadc039..ea1f4439a 100644 --- a/src/components/Setup/LoginWithPlex.tsx +++ b/src/components/Setup/LoginWithPlex.tsx @@ -8,14 +8,20 @@ const messages = defineMessages({ signinwithplex: 'Sign In with Plex', }); -interface LoginWithPlexProps { +type LoginWithPlexProps = Omit< + React.ComponentPropsWithoutRef, + 'onAuthToken' +> & { onComplete: () => void; -} +}; -const LoginWithPlex = ({ onComplete }: LoginWithPlexProps) => { +const LoginWithPlex = ({ + onComplete, + ...plexLoginButtonProps +}: LoginWithPlexProps) => { const intl = useIntl(); const [authToken, setAuthToken] = useState(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 (

@@ -48,6 +50,7 @@ const LoginWithPlex = ({ onComplete }: LoginWithPlexProps) => { setAuthToken(authToken)} textOverride={intl.formatMessage(messages.signinwithplex)} + {...plexLoginButtonProps} /> diff --git a/src/components/UserProfile/UserSettings/UserGeneralSettings/index.tsx b/src/components/UserProfile/UserSettings/UserGeneralSettings/index.tsx index 06c10d204..4ec06d60d 100644 --- a/src/components/UserProfile/UserSettings/UserGeneralSettings/index.tsx +++ b/src/components/UserProfile/UserSettings/UserGeneralSettings/index.tsx @@ -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 ; } @@ -186,20 +209,51 @@ const UserGeneralSettings = () => {
-
-
- {user?.isPlexUser ? ( - - {intl.formatMessage(messages.plexuser)} - - ) : ( - - {intl.formatMessage(messages.localuser)} - - )} +
+
+
+ + {!user?.isPlexUser ? ( + <> +
+ { + revalidateUser(); + }} + /> +
+ + ) : ( + <> +
+ { + addToast('Refreshed Plex token.', { + appearance: 'success', + autoDismiss: true, + }); + revalidateUser(); + }} + svgIcon={} + textOverride="Refresh Token" + buttonSize="sm" + buttonType="primary" + /> +
+ + + )}
diff --git a/src/context/UserContext.tsx b/src/context/UserContext.tsx index bd07989b5..1011270bd 100644 --- a/src/context/UserContext.tsx +++ b/src/context/UserContext.tsx @@ -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 ) { diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index d467e61b9..867cf8363 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -111,7 +111,7 @@ const CoreApp: Omit = ({ loadLocaleData(currentLocale).then(setMessages); }, [currentLocale]); - if (router.pathname.match(/(login|setup|resetpassword)/)) { + if (router.pathname.match(/(login|setup|resetpassword|loading)/)) { component = ; } 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', }); diff --git a/src/pages/login/plex/loading.tsx b/src/pages/loading.tsx similarity index 100% rename from src/pages/login/plex/loading.tsx rename to src/pages/loading.tsx diff --git a/src/utils/plex.ts b/src/utils/plex.ts index f773d8680..ef2cfccbd 100644 --- a/src/utils/plex.ts +++ b/src/utils/plex.ts @@ -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 +