refactor(quickconnect): implement useQuickConnect hook for managing quick connect flow

This commit is contained in:
fallenbagel
2025-12-13 09:34:58 +08:00
parent 43553cb2d5
commit 87b51b809b
3 changed files with 245 additions and 251 deletions

View File

@@ -1,10 +1,10 @@
import LoadingSpinner from '@app/components/Common/LoadingSpinner';
import Modal from '@app/components/Common/Modal';
import { useQuickConnect } from '@app/hooks/useQuickConnect';
import defineMessages from '@app/utils/defineMessages';
import { Transition } from '@headlessui/react';
import { ApiErrorCode } from '@server/constants/error';
import axios from 'axios';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useCallback } from 'react';
import { useIntl } from 'react-intl';
const messages = defineMessages('components.Login.JellyfinQuickConnectModal', {
@@ -15,8 +15,6 @@ const messages = defineMessages('components.Login.JellyfinQuickConnectModal', {
expired: 'Code Expired',
expiredMessage: 'This Quick Connect code has expired. Please try again.',
error: 'Error',
errorMessage: 'Failed to initiate Quick Connect. Please try again.',
authorizationFailed: 'Quick Connect authorization failed.',
cancel: 'Cancel',
tryAgain: 'Try Again',
});
@@ -35,130 +33,39 @@ const JellyfinQuickConnectModal = ({
mediaServerName,
}: JellyfinQuickConnectModalProps) => {
const intl = useIntl();
const [code, setCode] = useState<string>('');
const [isLoading, setIsLoading] = useState(true);
const [hasError, setHasError] = useState(false);
const [isExpired, setIsExpired] = useState(false);
const pollingInterval = useRef<NodeJS.Timeout>();
const isMounted = useRef(true);
const hasInitiated = useRef(false);
useEffect(() => {
isMounted.current = true;
return () => {
isMounted.current = false;
if (pollingInterval.current) {
clearInterval(pollingInterval.current);
}
};
}, []);
const authenticateWithQuickConnect = useCallback(
const authenticate = useCallback(
async (secret: string) => {
try {
await axios.post('/api/v1/auth/jellyfin/quickconnect/authenticate', {
secret,
});
if (!isMounted.current) return;
onAuthenticated();
onClose();
} catch (error) {
if (!isMounted.current) return;
let errorMessage = intl.formatMessage(messages.errorMessage);
switch (error?.response?.data?.message) {
case ApiErrorCode.InvalidCredentials:
errorMessage = intl.formatMessage(messages.authorizationFailed);
break;
}
onError(errorMessage);
onClose();
}
await axios.post('/api/v1/auth/jellyfin/quickconnect', {
secret,
});
onAuthenticated();
onClose();
},
[onAuthenticated, onClose, onError, intl]
[onAuthenticated, onClose]
);
const startPolling = useCallback(
(secret: string) => {
pollingInterval.current = setInterval(async () => {
try {
const response = await axios.get(
'/api/v1/auth/jellyfin/quickconnect/check',
{
params: { secret },
}
);
if (!isMounted.current) {
if (pollingInterval.current) {
clearInterval(pollingInterval.current);
}
return;
}
if (response.data.authenticated) {
if (pollingInterval.current) {
clearInterval(pollingInterval.current);
}
await authenticateWithQuickConnect(secret);
}
} catch (error) {
if (!isMounted.current) return;
if (error?.response?.status === 404) {
if (pollingInterval.current) {
clearInterval(pollingInterval.current);
}
setIsExpired(true);
}
}
}, 2000);
const {
code,
isLoading,
hasError,
isExpired,
errorMessage,
initiateQuickConnect,
cleanup,
} = useQuickConnect({
show: true,
onSuccess: () => {
onAuthenticated();
onClose();
},
[authenticateWithQuickConnect]
);
onError,
authenticate,
});
const initiateQuickConnect = useCallback(async () => {
if (pollingInterval.current) {
clearInterval(pollingInterval.current);
}
setIsLoading(true);
setHasError(false);
setIsExpired(false);
try {
const response = await axios.post(
'/api/v1/auth/jellyfin/quickconnect/initiate'
);
if (!isMounted.current) return;
setCode(response.data.code);
setIsLoading(false);
startPolling(response.data.secret);
} catch (error) {
if (!isMounted.current) return;
setHasError(true);
setIsLoading(false);
onError(intl.formatMessage(messages.errorMessage));
}
}, [startPolling, onError, intl]);
useEffect(() => {
if (!hasInitiated.current) {
hasInitiated.current = true;
initiateQuickConnect();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleTryAgain = () => {
initiateQuickConnect();
const handleClose = () => {
cleanup();
onClose();
};
return (
@@ -174,14 +81,14 @@ const JellyfinQuickConnectModal = ({
leaveTo="opacity-0"
>
<Modal
onCancel={onClose}
onCancel={handleClose}
title={intl.formatMessage(messages.title)}
subTitle={intl.formatMessage(messages.subtitle)}
cancelText={intl.formatMessage(messages.cancel)}
{...(hasError || isExpired
? {
okText: intl.formatMessage(messages.tryAgain),
onOk: handleTryAgain,
onOk: initiateQuickConnect,
}
: {})}
>
@@ -222,9 +129,7 @@ const JellyfinQuickConnectModal = ({
<h3 className="text-lg font-semibold text-red-500">
{intl.formatMessage(messages.error)}
</h3>
<p className="mt-2 text-gray-300">
{intl.formatMessage(messages.errorMessage)}
</p>
<p className="mt-2 text-gray-300">{errorMessage}</p>
</div>
</div>
)}

View File

@@ -1,13 +1,14 @@
import Alert from '@app/components/Common/Alert';
import LoadingSpinner from '@app/components/Common/LoadingSpinner';
import Modal from '@app/components/Common/Modal';
import { useQuickConnect } from '@app/hooks/useQuickConnect';
import useSettings from '@app/hooks/useSettings';
import { useUser } from '@app/hooks/useUser';
import defineMessages from '@app/utils/defineMessages';
import { Transition } from '@headlessui/react';
import { MediaServerType } from '@server/constants/server';
import axios from 'axios';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useCallback } from 'react';
import { useIntl } from 'react-intl';
const messages = defineMessages(
@@ -20,7 +21,6 @@ const messages = defineMessages(
expired: 'Code Expired',
expiredMessage: 'This Quick Connect code has expired. Please try again.',
error: 'Error',
errorMessage: 'Failed to initiate Quick Connect. Please try again.',
usePassword: 'Use Password Instead',
tryAgain: 'Try Again',
errorExists: 'This account is already linked',
@@ -43,137 +43,43 @@ const LinkJellyfinQuickConnectModal = ({
const intl = useIntl();
const settings = useSettings();
const { user } = useUser();
const [code, setCode] = useState<string>('');
const [isLoading, setIsLoading] = useState(true);
const [hasError, setHasError] = useState(false);
const [isExpired, setIsExpired] = useState(false);
const [error, setError] = useState<string | null>(null);
const pollingInterval = useRef<NodeJS.Timeout>();
const isMounted = useRef(true);
const hasInitiated = useRef(false);
const mediaServerName =
settings.currentSettings.mediaServerType === MediaServerType.JELLYFIN
? 'Jellyfin'
: 'Emby';
useEffect(() => {
isMounted.current = true;
return () => {
isMounted.current = false;
if (pollingInterval.current) {
clearInterval(pollingInterval.current);
}
};
}, []);
const linkWithQuickConnect = useCallback(
const authenticate = useCallback(
async (secret: string) => {
try {
await axios.post(
`/api/v1/user/${user?.id}/settings/linked-accounts/jellyfin/quickconnect`,
{ secret }
);
if (!isMounted.current) return;
onSave();
onClose();
} catch (error) {
if (!isMounted.current) return;
let errorMessage = intl.formatMessage(messages.errorMessage);
if (error?.response?.status === 422) {
errorMessage = intl.formatMessage(messages.errorExists);
}
setError(errorMessage);
setHasError(true);
if (pollingInterval.current) {
clearInterval(pollingInterval.current);
}
}
},
[user, onSave, onClose, intl]
);
const startPolling = useCallback(
(secret: string) => {
pollingInterval.current = setInterval(async () => {
try {
const response = await axios.get(
`/api/v1/auth/jellyfin/quickconnect/check`,
{
params: { secret },
}
);
if (!isMounted.current) {
if (pollingInterval.current) {
clearInterval(pollingInterval.current);
}
return;
}
if (response.data.authenticated) {
if (pollingInterval.current) {
clearInterval(pollingInterval.current);
}
await linkWithQuickConnect(secret);
}
} catch (error) {
if (!isMounted.current) return;
if (error?.response?.status === 404) {
if (pollingInterval.current) {
clearInterval(pollingInterval.current);
}
setIsExpired(true);
}
}
}, 2000);
},
[linkWithQuickConnect]
);
const initiateQuickConnect = useCallback(async () => {
if (pollingInterval.current) {
clearInterval(pollingInterval.current);
}
setIsLoading(true);
setHasError(false);
setIsExpired(false);
setError(null);
try {
const response = await axios.post(
`/api/v1/auth/jellyfin/quickconnect/initiate`
await axios.post(
`/api/v1/user/${user?.id}/settings/linked-accounts/jellyfin/quickconnect`,
{ secret }
);
if (!isMounted.current) return;
onSave();
onClose();
},
[user, onSave, onClose]
);
setCode(response.data.code);
setIsLoading(false);
startPolling(response.data.secret);
} catch (error) {
if (!isMounted.current) return;
setHasError(true);
setIsLoading(false);
setError(intl.formatMessage(messages.errorMessage));
}
}, [startPolling, intl]);
useEffect(() => {
if (show && !hasInitiated.current) {
hasInitiated.current = true;
initiateQuickConnect();
}
}, [show, initiateQuickConnect]);
const {
code,
isLoading,
hasError,
isExpired,
errorMessage,
initiateQuickConnect,
cleanup,
} = useQuickConnect({
show: true,
onSuccess: () => {
onSave();
onClose();
},
authenticate,
});
const handleSwitchToPassword = () => {
if (pollingInterval.current) {
clearInterval(pollingInterval.current);
}
cleanup();
onClose();
onSwitchToPassword();
};
@@ -203,9 +109,9 @@ const LinkJellyfinQuickConnectModal = ({
: {})}
dialogClass="sm:max-w-lg"
>
{error && (
{errorMessage && (
<div className="mb-4">
<Alert type="error">{error}</Alert>
<Alert type="error">{errorMessage}</Alert>
</div>
)}
@@ -244,7 +150,7 @@ const LinkJellyfinQuickConnectModal = ({
<h3 className="text-lg font-semibold text-red-500">
{intl.formatMessage(messages.error)}
</h3>
<p className="mt-2 text-gray-300">{error}</p>
<p className="mt-2 text-gray-300">{errorMessage}</p>
</div>
</div>
)}

View File

@@ -0,0 +1,183 @@
import defineMessages from '@app/utils/defineMessages';
import axios from 'axios';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useIntl } from 'react-intl';
const messages = defineMessages('hooks.useQuickConnect', {
errorMessage: 'Failed to initiate Quick Connect. Please try again.',
});
interface UseQuickConnectOptions {
show: boolean;
onSuccess: () => void;
onError?: (error: string) => void;
authenticate: (secret: string) => Promise<void>;
}
export const useQuickConnect = ({
show,
onSuccess,
onError,
authenticate,
}: UseQuickConnectOptions) => {
const intl = useIntl();
const [code, setCode] = useState<string>('');
const [isLoading, setIsLoading] = useState(true);
const [hasError, setHasError] = useState(false);
const [isExpired, setIsExpired] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const pollingInterval = useRef<NodeJS.Timeout>();
const isMounted = useRef(true);
const hasInitiated = useRef(false);
const errorCount = useRef(0);
useEffect(() => {
isMounted.current = true;
const currentPollingInterval = pollingInterval.current;
return () => {
isMounted.current = false;
if (currentPollingInterval) {
clearInterval(currentPollingInterval);
}
};
}, []);
useEffect(() => {
if (!show) {
hasInitiated.current = false;
}
}, [show]);
const authenticateWithQuickConnect = useCallback(
async (secret: string) => {
try {
await authenticate(secret);
if (!isMounted.current) return;
onSuccess();
} catch (error) {
if (!isMounted.current) return;
const errMsg =
error?.response?.data?.errorMessage ||
intl.formatMessage(messages.errorMessage);
setErrorMessage(errMsg);
setHasError(true);
onError?.(errMsg);
if (pollingInterval.current) {
clearInterval(pollingInterval.current);
}
}
},
[authenticate, intl, onError, onSuccess]
);
const startPolling = useCallback(
(secret: string) => {
pollingInterval.current = setInterval(async () => {
try {
const response = await axios.get(
'/api/v1/auth/jellyfin/quickconnect/check',
{
params: { secret },
}
);
errorCount.current = 0;
if (!isMounted.current) {
if (pollingInterval.current) {
clearInterval(pollingInterval.current);
}
return;
}
if (response.data.authenticated) {
if (pollingInterval.current) {
clearInterval(pollingInterval.current);
}
await authenticateWithQuickConnect(secret);
}
} catch (error) {
if (!isMounted.current) return;
if (error?.response?.status === 404) {
if (pollingInterval.current) {
clearInterval(pollingInterval.current);
}
setIsExpired(true);
} else {
errorCount.current++;
if (errorCount.current >= 5) {
if (pollingInterval.current) {
clearInterval(pollingInterval.current);
}
setHasError(true);
const errorMessage = intl.formatMessage(messages.errorMessage);
setErrorMessage(errorMessage);
onError?.(errorMessage);
}
}
}
}, 2000);
},
[authenticateWithQuickConnect, intl, onError]
);
const initiateQuickConnect = useCallback(async () => {
if (pollingInterval.current) {
clearInterval(pollingInterval.current);
}
setIsLoading(true);
setHasError(false);
setIsExpired(false);
setErrorMessage(null);
try {
const response = await axios.post(
'/api/v1/auth/jellyfin/quickconnect/initiate'
);
if (!isMounted.current) return;
setCode(response.data.code);
setIsLoading(false);
startPolling(response.data.secret);
} catch (error) {
if (!isMounted.current) return;
setHasError(true);
setIsLoading(false);
const errMessage = intl.formatMessage(messages.errorMessage);
setErrorMessage(errMessage);
onError?.(errMessage);
}
}, [startPolling, onError, intl]);
useEffect(() => {
if (show && !hasInitiated.current) {
hasInitiated.current = true;
initiateQuickConnect();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [show]);
const cleanup = useCallback(() => {
if (pollingInterval.current) {
clearInterval(pollingInterval.current);
}
}, []);
return {
code,
isLoading,
hasError,
isExpired,
errorMessage,
initiateQuickConnect,
cleanup,
};
};