feat(auth): support Plex home profile login
This commit is contained in:
142
src/components/Login/PlexPinEntry.tsx
Normal file
142
src/components/Login/PlexPinEntry.tsx
Normal 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;
|
||||
152
src/components/Login/PlexProfileSelector.tsx
Normal file
152
src/components/Login/PlexProfileSelector.tsx
Normal 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;
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}",
|
||||
|
||||
Reference in New Issue
Block a user