From 3c77c4b989b3ec677098009152a067aa820d5103 Mon Sep 17 00:00:00 2001 From: Mark McDowall Date: Mon, 22 Dec 2025 14:20:31 -0800 Subject: [PATCH] Use hook for OAuth input --- frontend/src/App/State/AppState.ts | 2 - frontend/src/App/State/OAuthAppState.ts | 9 - frontend/src/Components/Form/OAuthInput.tsx | 43 ++-- frontend/src/OAuth/useOAuth.ts | 232 ++++++++++++++++++++ frontend/src/Store/Actions/index.js | 2 - frontend/src/Store/Actions/oAuthActions.js | 231 ------------------- 6 files changed, 255 insertions(+), 264 deletions(-) delete mode 100644 frontend/src/App/State/OAuthAppState.ts create mode 100644 frontend/src/OAuth/useOAuth.ts delete mode 100644 frontend/src/Store/Actions/oAuthActions.js diff --git a/frontend/src/App/State/AppState.ts b/frontend/src/App/State/AppState.ts index 78bd2afa5..c49d1770f 100644 --- a/frontend/src/App/State/AppState.ts +++ b/frontend/src/App/State/AppState.ts @@ -1,7 +1,6 @@ import BlocklistAppState from './BlocklistAppState'; import CaptchaAppState from './CaptchaAppState'; import ImportSeriesAppState from './ImportSeriesAppState'; -import OAuthAppState from './OAuthAppState'; import ProviderOptionsAppState from './ProviderOptionsAppState'; import SettingsAppState from './SettingsAppState'; @@ -9,7 +8,6 @@ interface AppState { blocklist: BlocklistAppState; captcha: CaptchaAppState; importSeries: ImportSeriesAppState; - oAuth: OAuthAppState; providerOptions: ProviderOptionsAppState; settings: SettingsAppState; } diff --git a/frontend/src/App/State/OAuthAppState.ts b/frontend/src/App/State/OAuthAppState.ts deleted file mode 100644 index 619767929..000000000 --- a/frontend/src/App/State/OAuthAppState.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Error } from './AppSectionState'; - -interface OAuthAppState { - authorizing: boolean; - result: Record | null; - error: Error; -} - -export default OAuthAppState; diff --git a/frontend/src/Components/Form/OAuthInput.tsx b/frontend/src/Components/Form/OAuthInput.tsx index 19bc9ba23..7b0b9c489 100644 --- a/frontend/src/Components/Form/OAuthInput.tsx +++ b/frontend/src/Components/Form/OAuthInput.tsx @@ -1,17 +1,17 @@ import React, { useCallback, useEffect } from 'react'; -import { useDispatch, useSelector } from 'react-redux'; -import AppState from 'App/State/AppState'; import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton'; import { kinds } from 'Helpers/Props'; -import { resetOAuth, startOAuth } from 'Store/Actions/oAuthActions'; +import useOAuth from 'OAuth/useOAuth'; +import { getValidationFailures } from 'Store/Selectors/selectSettings'; import { InputOnChange } from 'typings/inputs'; +import { useFormInputGroup } from './FormInputGroupContext'; export interface OAuthInputProps { label?: string; name: string; provider: string; - providerData: object; - section: string; + providerData: Record; + section?: string; onChange: InputOnChange; } @@ -23,21 +23,17 @@ function OAuthInput({ section, onChange, }: OAuthInputProps) { - const dispatch = useDispatch(); - const { authorizing, error, result } = useSelector( - (state: AppState) => state.oAuth - ); + const formInputActions = useFormInputGroup(); + const { authorizing, error, result, startOAuth, resetOAuth } = useOAuth(); const handlePress = useCallback(() => { - dispatch( - startOAuth({ - name, - provider, - providerData, - section, - }) - ); - }, [name, provider, providerData, section, dispatch]); + startOAuth({ + name, + provider, + providerData, + section, + }); + }, [name, provider, providerData, section, startOAuth]); useEffect(() => { if (!result) { @@ -51,9 +47,16 @@ function OAuthInput({ useEffect(() => { return () => { - dispatch(resetOAuth()); + resetOAuth(); }; - }, [dispatch]); + }, [resetOAuth]); + + useEffect(() => { + const validationFailures = getValidationFailures(error); + + formInputActions?.setClientErrors(validationFailures?.errors ?? []); + formInputActions?.setClientWarnings(validationFailures?.warnings ?? []); + }, [name, error, formInputActions]); return (
diff --git a/frontend/src/OAuth/useOAuth.ts b/frontend/src/OAuth/useOAuth.ts new file mode 100644 index 000000000..f2ecd72bc --- /dev/null +++ b/frontend/src/OAuth/useOAuth.ts @@ -0,0 +1,232 @@ +import { useCallback, useState } from 'react'; +import { Error } from 'App/State/AppSectionState'; +import createAjaxRequest from 'Utilities/createAjaxRequest'; +import requestAction from 'Utilities/requestAction'; + +const callbackUrl = `${window.location.origin}${window.Sonarr.urlBase}/oauth.html`; + +interface OAuthResult { + [key: string]: string | number | boolean; +} + +interface OAuthState { + authorizing: boolean; + result: OAuthResult | null; + error: Error | null; +} + +interface StartOAuthParams { + name: string; + provider?: string; + providerData?: Record; + [key: string]: unknown; +} + +interface OAuthResponse { + oauthUrl?: string; + poll?: boolean; + success?: boolean; + [key: string]: unknown; +} + +interface QueryParams { + [key: string]: string; +} + +interface WindowWithOAuth extends Window { + onCompleteOauth?: (query: string, onComplete: () => void) => void; +} + +function showOAuthWindow( + url: string, + payload: StartOAuthParams, + poll = false, + ajaxOptions?: Record +): Promise { + return new Promise((resolve, reject) => { + const selfWindow = window as WindowWithOAuth; + const newWindow = window.open(url); + + if ( + !newWindow || + newWindow.closed || + typeof newWindow.closed === 'undefined' + ) { + // A fake validation error to mimic a 400 response from the API. + const error = Object.assign( + new Error('Pop-ups are being blocked by your browser'), + { + status: 400, + responseJSON: [ + { + propertyName: payload.name, + errorMessage: 'Pop-ups are being blocked by your browser', + }, + ], + } + ); + + return reject(error); + } + + if (poll) { + const pollAction = () => { + requestAction({ + action: 'pollOAuth', + queryParams: ajaxOptions, + ...payload, + }).then((response: OAuthResponse) => { + if (response.success) { + resolve({}); + } else { + setTimeout(() => { + pollAction(); + }, 5000); + } + }); + }; + + setTimeout(() => { + pollAction(); + }, 5000); + } else { + selfWindow.onCompleteOauth = function ( + query: string, + onComplete: () => void + ) { + delete selfWindow.onCompleteOauth; + + const queryParams: Record = {}; + const splitQuery = query.substring(1).split('&'); + + splitQuery.forEach((param) => { + if (param) { + const paramSplit = param.split('='); + queryParams[paramSplit[0]] = paramSplit[1]; + } + }); + + onComplete(); + resolve(queryParams); + }; + } + }); +} + +function executeIntermediateRequest( + payload: Record, + ajaxOptions: Record +): Promise { + return createAjaxRequest(ajaxOptions).request.then( + (data: Record) => { + return requestAction({ + action: 'continueOAuth', + queryParams: { + ...data, + callbackUrl, + }, + ...payload, + }); + } + ); +} + +const useOAuth = () => { + const [oAuthState, setOAuthState] = useState({ + authorizing: false, + result: null, + error: null, + }); + + const setOAuthValue = useCallback((values: Partial) => { + setOAuthState((prev) => ({ ...prev, ...values })); + }, []); + + const resetOAuth = useCallback(() => { + setOAuthState({ + authorizing: false, + result: null, + error: null, + }); + }, []); + + const startOAuth = useCallback( + async (params: StartOAuthParams) => { + const { name, ...otherPayload } = params; + + const actionPayload = { + action: 'startOAuth', + queryParams: { callbackUrl }, + ...otherPayload, + }; + + setOAuthValue({ authorizing: true }); + + try { + let startResponse: OAuthResponse = {}; + + const response = (await requestAction(actionPayload)) as OAuthResponse; + startResponse = response; + + let queryParams: QueryParams | null = null; + + if (response.oauthUrl) { + queryParams = await showOAuthWindow(response.oauthUrl, params); + } else { + const intermediateResponse = await executeIntermediateRequest( + otherPayload, + response // Pass the entire response as ajaxOptions + ); + startResponse = intermediateResponse; + + if (!intermediateResponse.oauthUrl) { + throw new Error('No OAuth URL received from intermediate request'); + } + + queryParams = await showOAuthWindow( + intermediateResponse.oauthUrl, + params, + intermediateResponse.poll || false, + intermediateResponse + ); + } + + const tokenResponse = await requestAction({ + action: 'getOAuthToken', + queryParams: { + ...startResponse, + ...queryParams, + }, + ...otherPayload, + }); + + setOAuthValue({ + authorizing: false, + result: tokenResponse, + error: null, + }); + + return tokenResponse; + } catch (error) { + const oAuthError = error as Error; + setOAuthValue({ + authorizing: false, + result: null, + error: oAuthError, + }); + + throw error; + } + }, + [setOAuthValue] + ); + + return { + ...oAuthState, + startOAuth, + setOAuthValue, + resetOAuth, + }; +}; + +export default useOAuth; diff --git a/frontend/src/Store/Actions/index.js b/frontend/src/Store/Actions/index.js index b79564adc..6281e3962 100644 --- a/frontend/src/Store/Actions/index.js +++ b/frontend/src/Store/Actions/index.js @@ -1,13 +1,11 @@ import * as captcha from './captchaActions'; import * as importSeries from './importSeriesActions'; -import * as oAuth from './oAuthActions'; import * as providerOptions from './providerOptionActions'; import * as settings from './settingsActions'; export default [ captcha, importSeries, - oAuth, providerOptions, settings ]; diff --git a/frontend/src/Store/Actions/oAuthActions.js b/frontend/src/Store/Actions/oAuthActions.js deleted file mode 100644 index 943aa100c..000000000 --- a/frontend/src/Store/Actions/oAuthActions.js +++ /dev/null @@ -1,231 +0,0 @@ -import $ from 'jquery'; -import { createAction } from 'redux-actions'; -import { batchActions } from 'redux-batched-actions'; -import { set } from 'Store/Actions/baseActions'; -import { createThunk, handleThunks } from 'Store/thunks'; -import createAjaxRequest from 'Utilities/createAjaxRequest'; -import requestAction from 'Utilities/requestAction'; -import getSectionState from 'Utilities/State/getSectionState'; -import updateSectionState from 'Utilities/State/updateSectionState'; -import createHandleActions from './Creators/createHandleActions'; - -// -// Variables - -export const section = 'oAuth'; -const callbackUrl = `${window.location.origin}${window.Sonarr.urlBase}/oauth.html`; - -// -// State - -export const defaultState = { - authorizing: false, - result: null, - error: null -}; - -// -// Actions Types - -export const START_OAUTH = 'oAuth/startOAuth'; -export const SET_OAUTH_VALUE = 'oAuth/setOAuthValue'; -export const RESET_OAUTH = 'oAuth/resetOAuth'; - -// -// Action Creators - -export const startOAuth = createThunk(START_OAUTH); -export const setOAuthValue = createAction(SET_OAUTH_VALUE); -export const resetOAuth = createAction(RESET_OAUTH); - -// -// Helpers - -function showOAuthWindow(url, payload, poll = false, ajaxOptions = undefined) { - const deferred = $.Deferred(); - const selfWindow = window; - - const newWindow = window.open(url); - - if ( - !newWindow || - newWindow.closed || - typeof newWindow.closed == 'undefined' - ) { - - // A fake validation error to mimic a 400 response from the API. - const error = { - status: 400, - responseJSON: [ - { - propertyName: payload.name, - errorMessage: 'Pop-ups are being blocked by your browser' - } - ] - }; - - return deferred.reject(error).promise(); - } - - if (poll) { - const pollAction = () => { - requestAction({ - action: 'pollOAuth', - queryParams: ajaxOptions, - ...payload - }).then((response) => { - if (response.success) { - deferred.resolve({}); - } else { - setTimeout(() => { - pollAction(); - }, 5000); - } - }); - }; - - setTimeout(() => { - pollAction(); - }, 5000); - } else { - selfWindow.onCompleteOauth = function(query, onComplete) { - delete selfWindow.onCompleteOauth; - - const queryParams = {}; - const splitQuery = query.substring(1).split('&'); - - splitQuery.forEach((param) => { - if (param) { - const paramSplit = param.split('='); - - queryParams[paramSplit[0]] = paramSplit[1]; - } - }); - - onComplete(); - deferred.resolve(queryParams); - }; - } - - return deferred.promise(); -} - -function executeIntermediateRequest(payload, ajaxOptions) { - return createAjaxRequest(ajaxOptions).request.then((data) => { - return requestAction({ - action: 'continueOAuth', - queryParams: { - ...data, - callbackUrl - }, - ...payload - }); - }); -} - -// -// Action Handlers - -export const actionHandlers = handleThunks({ - - [START_OAUTH]: function(getState, payload, dispatch) { - const { - name, - section: actionSection, - ...otherPayload - } = payload; - - const actionPayload = { - action: 'startOAuth', - queryParams: { callbackUrl }, - ...otherPayload - }; - - dispatch(setOAuthValue({ - authorizing: true - })); - - let startResponse = {}; - - const promise = requestAction(actionPayload) - .then((response) => { - startResponse = response; - - if (response.oauthUrl) { - return showOAuthWindow(response.oauthUrl, payload); - } - - const { poll, ...ajaxOptions } = response; - - return executeIntermediateRequest(otherPayload, ajaxOptions) - .then((intermediateResponse) => { - startResponse = intermediateResponse; - - return showOAuthWindow(intermediateResponse.oauthUrl, payload, poll, intermediateResponse); - }); - }) - .then((queryParams) => { - return requestAction({ - action: 'getOAuthToken', - queryParams: { - ...startResponse, - ...queryParams - }, - ...otherPayload - }); - }) - .then((response) => { - dispatch(setOAuthValue({ - authorizing: false, - result: response, - error: null - })); - }); - - promise.done(() => { - // Clear any previously set save error. - dispatch(set({ - section: actionSection, - saveError: null - })); - }); - - promise.fail((xhr) => { - const actions = [ - setOAuthValue({ - authorizing: false, - result: null, - error: xhr - }) - ]; - - if (xhr.status === 400) { - // Set a save error so the UI can display validation errors to the user. - actions.splice(0, 0, set({ - section: actionSection, - saveError: xhr - })); - } - - dispatch(batchActions(actions)); - }); - } - -}); - -// -// Reducers - -export const reducers = createHandleActions({ - - [SET_OAUTH_VALUE]: function(state, { payload }) { - const newState = Object.assign(getSectionState(state, section), payload); - - return updateSectionState(state, section, newState); - }, - - [RESET_OAUTH]: function(state) { - return updateSectionState(state, section, defaultState); - } - -}, defaultState, section);