feat(auth): support Plex home profile login

This commit is contained in:
0xsysr3ll
2025-04-17 23:26:43 +02:00
parent 75a7279ea2
commit f5089502b9
12 changed files with 1389 additions and 98 deletions

View File

@@ -0,0 +1,142 @@
import Button from '@app/components/Common/Button';
import defineMessages from '@app/utils/defineMessages';
import { ApiErrorCode } from '@server/constants/error';
import { useEffect, useState } from 'react';
import { useIntl } from 'react-intl';
const messages = defineMessages('components.Login.PlexPinEntry', {
pinRequired: 'PIN Required',
pinDescription: 'Enter the PIN for this profile',
submit: 'Submit',
cancel: 'Cancel',
invalidPin: 'Invalid PIN. Please try again.',
pinCheck: 'Checking PIN...',
accessDenied: 'Access denied.',
});
interface PlexPinEntryProps {
profileId: string;
profileName: string;
onSubmit: (pin: string) => Promise<void>;
onCancel: () => void;
error?: string | null;
}
const PlexPinEntry = ({
profileName,
onSubmit,
onCancel,
error: externalError,
}: PlexPinEntryProps) => {
const intl = useIntl();
const [pin, setPin] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const [internalError, setInternalError] = useState<string | null>(null);
const displayError = externalError || internalError;
useEffect(() => {
if (externalError) {
setInternalError(null);
}
}, [externalError]);
const handleSubmit = async () => {
if (!pin || isSubmitting) return;
setIsSubmitting(true);
setInternalError(null);
try {
await onSubmit(pin);
} catch (err: any) {
const code = err?.response?.data?.error as string | undefined;
const httpStatus = err?.response?.status;
let msg: string;
switch (code) {
case ApiErrorCode.InvalidPin:
msg = intl.formatMessage(messages.invalidPin);
break;
case ApiErrorCode.NewPlexLoginDisabled:
msg = intl.formatMessage(messages.accessDenied);
break;
default:
if (httpStatus === 401) {
msg = intl.formatMessage(messages.invalidPin);
} else if (httpStatus === 403) {
msg = intl.formatMessage(messages.accessDenied);
} else {
msg =
err?.response?.data?.message ??
intl.formatMessage(messages.invalidPin);
}
}
setInternalError(msg);
} finally {
setIsSubmitting(false);
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && pin && !isSubmitting) {
handleSubmit();
}
};
return (
<div className="w-full max-w-md">
<h2 className="mb-6 text-center text-xl font-bold text-gray-100">
{intl.formatMessage(messages.pinRequired)}
</h2>
<p className="mb-6 text-center text-sm text-gray-300">
{intl.formatMessage(messages.pinDescription)}{' '}
<strong>{profileName}</strong>
</p>
{displayError && (
<div
className="mb-4 rounded-md bg-red-500/90 p-3 text-center text-sm font-medium text-white shadow-md transition-all duration-300"
role="alert"
aria-live="polite"
>
{displayError}
</div>
)}
<div className="mb-6">
<input
type="password"
className="w-full rounded-md bg-white/10 px-4 py-3 text-center font-mono text-3xl tracking-[0.3em] text-white placeholder-white/30 focus:outline-none focus:ring-2 focus:ring-indigo-500"
value={pin}
onChange={(e) => setPin(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="• • • •"
maxLength={4}
autoFocus
pattern="[0-9]{4}"
inputMode="numeric"
/>
</div>
<div className="flex justify-between">
<Button buttonType="default" onClick={onCancel} className="mr-2 flex-1">
{intl.formatMessage(messages.cancel)}
</Button>
<Button
buttonType="primary"
disabled={!pin || isSubmitting}
onClick={handleSubmit}
className="ml-2 flex-1"
>
{isSubmitting
? intl.formatMessage(messages.pinCheck)
: intl.formatMessage(messages.submit)}
</Button>
</div>
</div>
);
};
export default PlexPinEntry;

View File

@@ -0,0 +1,152 @@
import { SmallLoadingSpinner } from '@app/components/Common/LoadingSpinner';
import PlexPinEntry from '@app/components/Login/PlexPinEntry';
import defineMessages from '@app/utils/defineMessages';
import { LockClosedIcon } from '@heroicons/react/24/solid';
import { PlexProfile } from '@server/api/plextv';
import { useState } from 'react';
import { useIntl } from 'react-intl';
const messages = defineMessages('components.Login.PlexProfileSelector', {
profile: 'Profile',
selectProfile: 'Select Profile',
selectProfileDescription: 'Select which Plex profile you want to use',
selectProfileError: 'Failed to select profile',
});
interface PlexProfileSelectorProps {
profiles: PlexProfile[];
mainUserId: number;
authToken?: string;
onProfileSelected: (
profileId: string,
pin?: string,
onError?: (msg: string) => void
) => Promise<void>;
}
const PlexProfileSelector = ({
profiles,
mainUserId,
authToken,
onProfileSelected,
}: PlexProfileSelectorProps) => {
const intl = useIntl();
const [selectedProfileId, setSelectedProfileId] = useState<string | null>(
null
);
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [pinError, setPinError] = useState<string | null>(null);
const [showPinEntry, setShowPinEntry] = useState(false);
const [selectedProfile, setSelectedProfile] = useState<PlexProfile | null>(
null
);
const handleProfileClick = (profile: PlexProfile) => {
setSelectedProfileId(profile.id);
setSelectedProfile(profile);
if (profile.protected) {
setShowPinEntry(true);
} else {
onProfileSelected(profile.id, undefined, (msg) => {
setError(msg);
});
}
};
const handlePinSubmit = async (pin: string) => {
if (!selectedProfileId) return;
await onProfileSelected(selectedProfileId, pin, setPinError);
};
const handlePinCancel = () => {
setShowPinEntry(false);
setSelectedProfile(null);
setSelectedProfileId(null);
setPinError(null);
};
if (showPinEntry && selectedProfile) {
return (
<PlexPinEntry
profileId={selectedProfileId!}
profileName={
selectedProfile.title ||
selectedProfile.username ||
intl.formatMessage(messages.profile)
}
onSubmit={handlePinSubmit}
onCancel={handlePinCancel}
error={pinError}
/>
);
}
return (
<div className="w-full">
<h2 className="mb-6 text-center text-xl font-bold text-gray-100">
{intl.formatMessage(messages.selectProfile)}
</h2>
<p className="mb-6 text-center text-sm text-gray-300">
{intl.formatMessage(messages.selectProfileDescription)}
</p>
{error && (
<div className="mb-4 rounded-md bg-red-600 p-3 text-white">
{intl.formatMessage(messages.selectProfileError)}
</div>
)}
<div className="relative mb-6">
{isSubmitting && (
<div className="absolute inset-0 z-10 flex items-center justify-center rounded-lg bg-black/50">
<SmallLoadingSpinner />
</div>
)}
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3">
{profiles.map((profile) => (
<button
key={profile.id}
type="button"
onClick={() => handleProfileClick(profile)}
disabled={
isSubmitting ||
(selectedProfileId === profile.id && !profile.protected)
}
className={`relative flex transform flex-col items-center rounded-2xl p-5 transition-all hover:scale-105 hover:shadow-lg ${
selectedProfileId === profile.id
? 'bg-indigo-600 ring-2 ring-indigo-400'
: 'border border-white/20 bg-white/10 backdrop-blur-sm'
} ${isSubmitting ? 'cursor-not-allowed opacity-50' : ''}`}
>
<div className="mb-4 h-20 w-20 overflow-hidden rounded-full shadow-md ring-2 ring-white/30">
<img
src={profile.thumb}
alt={profile.title || profile.username || 'Profile'}
className="h-full w-full object-cover"
/>
</div>
<span
className="mb-1 w-full break-words text-center text-base font-semibold text-white"
title={profile.username || profile.title}
>
{profile.username || profile.title}
</span>
{profile.protected && (
<div className="mt-2 text-gray-400">
<LockClosedIcon className="h-4 w-4" />
</div>
)}
</button>
))}
</div>
</div>
</div>
);
};
export default PlexProfileSelector;

View File

@@ -8,11 +8,15 @@ import LanguagePicker from '@app/components/Layout/LanguagePicker';
import JellyfinLogin from '@app/components/Login/JellyfinLogin';
import LocalLogin from '@app/components/Login/LocalLogin';
import PlexLoginButton from '@app/components/Login/PlexLoginButton';
import PlexPinEntry from '@app/components/Login/PlexPinEntry';
import PlexProfileSelector from '@app/components/Login/PlexProfileSelector';
import useSettings from '@app/hooks/useSettings';
import { useUser } from '@app/hooks/useUser';
import defineMessages from '@app/utils/defineMessages';
import { Transition } from '@headlessui/react';
import { XCircleIcon } from '@heroicons/react/24/solid';
import { PlexProfile } from '@server/api/plex';
import { ApiErrorCode } from '@server/constants/error';
import { MediaServerType } from '@server/constants/server';
import axios from 'axios';
import { useRouter } from 'next/dist/client/router';
@@ -29,6 +33,9 @@ const messages = defineMessages('components.Login', {
signinwithjellyfin: 'Use your {mediaServerName} account',
signinwithoverseerr: 'Use your {applicationTitle} account',
orsigninwith: 'Or sign in with',
authFailed: 'Authentication failed',
invalidPin: 'Invalid PIN. Please try again.',
accessDenied: 'Access denied.',
});
const Login = () => {
@@ -39,36 +46,132 @@ const Login = () => {
const [error, setError] = useState('');
const [isProcessing, setProcessing] = useState(false);
const [authToken, setAuthToken] = useState<string | undefined>(undefined);
const [authToken, setAuthToken] = useState<string | undefined>();
const [mediaServerLogin, setMediaServerLogin] = useState(
settings.currentSettings.mediaServerLogin
);
const profilesRef = useRef<PlexProfile[]>([]);
const [profiles, setProfiles] = useState<PlexProfile[]>([]);
const [mainUserId, setMainUserId] = useState<number | null>(null);
const [showProfileSelector, setShowProfileSelector] = useState(false);
const [showPinEntry, setShowPinEntry] = useState(false);
const [pinProfileId, setPinProfileId] = useState<string | null>(null);
const [pinProfileName, setPinProfileName] = useState<string | null>(null);
const [pinError, setPinError] = useState<string | null>(null);
// Effect that is triggered when the `authToken` comes back from the Plex OAuth
// We take the token and attempt to sign in. If we get a success message, we will
// ask swr to revalidate the user which _should_ come back with a valid user.
useEffect(() => {
const login = async () => {
setProcessing(true);
try {
const response = await axios.post('/api/v1/auth/plex', { authToken });
if (response.data?.id) {
revalidate();
switch (response.data?.status) {
case 'REQUIRES_PIN':
setPinProfileId(response.data.profileId);
setPinProfileName(response.data.profileName);
setShowPinEntry(true);
break;
case 'REQUIRES_PROFILE':
setProfiles(response.data.profiles);
profilesRef.current = response.data.profiles;
const rawUserId = response.data.mainUserId;
let numericUserId = Number(rawUserId);
if (!numericUserId || isNaN(numericUserId) || numericUserId <= 0) {
numericUserId = 1;
}
setMainUserId(numericUserId);
setShowProfileSelector(true);
break;
default:
if (response.data?.id) {
revalidate();
}
break;
}
} catch (e) {
setError(e.response?.data?.message);
} catch (e: any) {
const httpStatus = e?.response?.status;
const msg =
httpStatus === 403
? intl.formatMessage(messages.accessDenied)
: e?.response?.data?.message ??
intl.formatMessage(messages.authFailed);
setError(msg);
setAuthToken(undefined);
} finally {
setProcessing(false);
}
};
if (authToken) {
login();
}
}, [authToken, revalidate]);
// Effect that is triggered whenever `useUser`'s user changes. If we get a new
// valid user, we redirect the user to the home page as the login was successful.
const handleSubmitProfile = async (
profileId: string,
pin?: string,
onError?: (message: string) => void
) => {
setProcessing(true);
setError(null);
try {
const payload = {
profileId,
mainUserId,
...(pin && { pin }),
...(authToken && { authToken }),
};
const response = await axios.post(
'/api/v1/auth/plex/profile/select',
payload
);
if (response.data?.status === 'REQUIRES_PIN') {
setShowPinEntry(true);
setPinError(intl.formatMessage(messages.invalidPin));
return;
} else {
setShowProfileSelector(false);
setShowPinEntry(false);
setPinError(null);
revalidate();
}
} catch (e: any) {
const code = e?.response?.data?.error as string | undefined;
const httpStatus = e?.response?.status;
let msg: string;
switch (code) {
case ApiErrorCode.NewPlexLoginDisabled:
msg = intl.formatMessage(messages.accessDenied);
break;
case ApiErrorCode.InvalidPin:
msg = intl.formatMessage(messages.invalidPin);
break;
default:
if (httpStatus === 401) {
msg = intl.formatMessage(messages.invalidPin);
} else if (httpStatus === 403) {
msg = intl.formatMessage(messages.accessDenied);
} else {
msg =
e?.response?.data?.message ??
intl.formatMessage(messages.authFailed);
}
}
setError(msg);
}
};
useEffect(() => {
if (user) {
router.push('/');
@@ -197,48 +300,77 @@ const Login = () => {
</div>
</Transition>
<div className="px-10 py-8">
<SwitchTransition mode="out-in">
<CSSTransition
key={mediaServerLogin ? 'ms' : 'local'}
nodeRef={loginRef}
addEndListener={(done) => {
loginRef.current?.addEventListener(
'transitionend',
done,
false
);
{showPinEntry && pinProfileId && pinProfileName ? (
<PlexPinEntry
profileId={pinProfileId}
profileName={pinProfileName}
onSubmit={handleSubmitProfile}
onCancel={() => {
setShowPinEntry(false);
setPinProfileId(null);
setPinProfileName(null);
setPinError(null);
setShowProfileSelector(true);
}}
onEntered={() => {
document
.querySelector<HTMLInputElement>('#email, #username')
?.focus();
}}
classNames={{
appear: 'opacity-0',
appearActive: 'transition-opacity duration-500 opacity-100',
enter: 'opacity-0',
enterActive: 'transition-opacity duration-500 opacity-100',
exitActive: 'transition-opacity duration-0 opacity-0',
}}
>
<div ref={loginRef} className="button-container">
{isJellyfin &&
(mediaServerLogin ||
!settings.currentSettings.localLogin) ? (
<JellyfinLogin
serverType={settings.currentSettings.mediaServerType}
revalidate={revalidate}
/>
) : (
settings.currentSettings.localLogin && (
<LocalLogin revalidate={revalidate} />
)
)}
</div>
</CSSTransition>
</SwitchTransition>
error={pinError}
/>
) : showProfileSelector ? (
<PlexProfileSelector
profiles={profiles}
mainUserId={mainUserId || 1}
authToken={authToken}
onProfileSelected={(profileId, pin, onError) =>
handleSubmitProfile(profileId, pin, onError)
}
/>
) : (
<SwitchTransition mode="out-in">
<CSSTransition
key={mediaServerLogin ? 'ms' : 'local'}
nodeRef={loginRef}
addEndListener={(done) => {
loginRef.current?.addEventListener(
'transitionend',
done,
false
);
}}
onEntered={() => {
document
.querySelector<HTMLInputElement>('#email, #username')
?.focus();
}}
classNames={{
appear: 'opacity-0',
appearActive:
'transition-opacity duration-500 opacity-100',
enter: 'opacity-0',
enterActive:
'transition-opacity duration-500 opacity-100',
exitActive: 'transition-opacity duration-0 opacity-0',
}}
>
<div ref={loginRef} className="button-container">
{isJellyfin &&
(mediaServerLogin ||
!settings.currentSettings.localLogin) ? (
<JellyfinLogin
serverType={settings.currentSettings.mediaServerType}
revalidate={revalidate}
/>
) : (
settings.currentSettings.localLogin && (
<LocalLogin revalidate={revalidate} />
)
)}
</div>
</CSSTransition>
</SwitchTransition>
)}
{additionalLoginOptions.length > 0 &&
{!showProfileSelector &&
!showPinEntry &&
additionalLoginOptions.length > 0 &&
(loginFormVisible ? (
<div className="flex items-center py-5">
<div className="flex-grow border-t border-gray-600"></div>
@@ -253,13 +385,15 @@ const Login = () => {
</h2>
))}
<div
className={`flex w-full flex-wrap gap-2 ${
!loginFormVisible ? 'flex-col' : ''
}`}
>
{additionalLoginOptions}
</div>
{!showProfileSelector && !showPinEntry && (
<div
className={`flex w-full flex-wrap gap-2 ${
!loginFormVisible ? 'flex-col' : ''
}`}
>
{additionalLoginOptions}
</div>
)}
</div>
</>
</div>

View File

@@ -34,22 +34,37 @@ const SetupLogin: React.FC<LoginWithMediaServerProps> = ({
MediaServerType.NOT_CONFIGURED
);
const { user, revalidate } = useUser();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// 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
// ask swr to revalidate the user which _shouid_ come back with a valid user.
useEffect(() => {
const login = async () => {
const response = await axios.post('/api/v1/auth/plex', {
authToken: authToken,
});
if (!authToken) return;
if (response.data?.email) {
revalidate();
setIsLoading(true);
setError(null);
try {
const response = await axios.post('/api/v1/auth/plex', {
authToken,
isSetup: true,
});
if (response.status >= 200 && response.status < 300) {
revalidate();
}
} catch (err) {
setError(
err.response?.data?.message ||
'Failed to connect to Plex. Please try again.'
);
} finally {
setIsLoading(false);
}
};
if (authToken && mediaServerType == MediaServerType.PLEX) {
if (authToken && mediaServerType === MediaServerType.PLEX) {
login();
}
}, [authToken, mediaServerType, revalidate]);
@@ -58,7 +73,7 @@ const SetupLogin: React.FC<LoginWithMediaServerProps> = ({
if (user) {
onComplete();
}
}, [user, mediaServerType, onComplete]);
}, [user, onComplete]);
return (
<div className="p-4">
@@ -74,14 +89,20 @@ const SetupLogin: React.FC<LoginWithMediaServerProps> = ({
<FormattedMessage {...messages.signinWithPlex} />
)}
</div>
{error && (
<div className="mb-4 rounded bg-red-600 p-3 text-white">{error}</div>
)}
{serverType === MediaServerType.PLEX && (
<>
<div className="flex justify-center bg-black/30 px-10 py-8">
<PlexLoginButton
isProcessing={isLoading}
large
onAuthToken={(authToken) => {
onAuthToken={(token) => {
setMediaServerType(MediaServerType.PLEX);
setAuthToken(authToken);
setAuthToken(token);
}}
/>
</div>

View File

@@ -237,7 +237,19 @@
"components.Layout.VersionStatus.outofdate": "Out of Date",
"components.Layout.VersionStatus.streamdevelop": "Jellyseerr Develop",
"components.Layout.VersionStatus.streamstable": "Jellyseerr Stable",
"components.Login.PlexPinEntry.cancel": "Cancel",
"components.Login.PlexPinEntry.invalidPin": "Invalid PIN. Please try again.",
"components.Login.PlexPinEntry.pinCheck": "Checking PIN...",
"components.Login.PlexPinEntry.pinDescription": "Enter the PIN for this profile",
"components.Login.PlexPinEntry.pinRequired": "PIN Required",
"components.Login.PlexPinEntry.submit": "Submit",
"components.Login.PlexProfileSelector.profile": "Profile",
"components.Login.PlexProfileSelector.selectProfile": "Select Profile",
"components.Login.PlexProfileSelector.selectProfileDescription": "Select which Plex profile you want to use",
"components.Login.PlexProfileSelector.selectProfileError": "Failed to select profile",
"components.Login.accessDenied": "Access denied.",
"components.Login.adminerror": "You must use an admin account to sign in.",
"components.Login.authFailed": "Authentication failed",
"components.Login.back": "Go back",
"components.Login.credentialerror": "The username or password is incorrect.",
"components.Login.description": "Since this is your first time logging into {applicationName}, you are required to add a valid email address.",
@@ -248,6 +260,7 @@
"components.Login.hostname": "{mediaServerName} URL",
"components.Login.initialsignin": "Connect",
"components.Login.initialsigningin": "Connecting…",
"components.Login.invalidPin": "Invalid PIN. Please try again.",
"components.Login.invalidurlerror": "Unable to connect to {mediaServerName} server.",
"components.Login.loginerror": "Something went wrong while trying to sign in.",
"components.Login.loginwithapp": "Login with {appName}",