From 24214de38295ec366b70f8e5a117619782592a6e Mon Sep 17 00:00:00 2001 From: allgandalf Date: Fri, 22 Aug 2025 10:55:41 +0530 Subject: [PATCH 01/14] pass delegatedAccess as prop --- src/libs/actions/Delegate.ts | 76 ++++++++++++++++++++++++++++-------- 1 file changed, 59 insertions(+), 17 deletions(-) diff --git a/src/libs/actions/Delegate.ts b/src/libs/actions/Delegate.ts index 7177ceb26de1..ed2da69076e1 100644 --- a/src/libs/actions/Delegate.ts +++ b/src/libs/actions/Delegate.ts @@ -20,14 +20,6 @@ import {getCurrentUserAccountID} from './Report'; import updateSessionAuthTokens from './Session/updateSessionAuthTokens'; import updateSessionUser from './Session/updateSessionUser'; -let delegatedAccess: DelegatedAccess; -Onyx.connect({ - key: ONYXKEYS.ACCOUNT, - callback: (val) => { - delegatedAccess = val?.delegatedAccess ?? {}; - }, -}); - let credentials: Credentials = {}; Onyx.connect({ key: ONYXKEYS.CREDENTIALS, @@ -76,11 +68,50 @@ const KEYS_TO_PRESERVE_DELEGATE_ACCESS = [ ONYXKEYS.NETWORK, ]; +type ClearDelegatorErrorsParams = { + delegatedAccess: DelegatedAccess; +}; + +type AddDelegateProps = { + email: string; + role: DelegateRole; + validateCode: string; + delegatedAccess: DelegatedAccess; +}; + +type RemoveDelegateProps = { + email: string; + delegatedAccess: DelegatedAccess; +}; + +type ClearDelegateErrorsByFieldProps = { + email: string; + fieldName: string; + delegatedAccess: DelegatedAccess; +}; + +type UpdateDelegateRoleProps = { + email: string; + role: DelegateRole; + validateCode: string; + delegatedAccess: DelegatedAccess; +}; + +type IsConnectedAsDelegateProps = { + delegatedAccess: DelegatedAccess; +}; + +type ConnectProps = { + email: string; + delegatedAccess: DelegatedAccess; + isFromOldDot?: boolean; +}; + /** * Connects the user as a delegate to another account. * Returns a Promise that resolves to true on success, false on failure, or undefined if not applicable. */ -function connect(email: string, isFromOldDot = false) { +function connect({email, delegatedAccess, isFromOldDot = false}: ConnectProps) { if (!delegatedAccess?.delegators && !isFromOldDot) { return; } @@ -277,7 +308,7 @@ function disconnect() { }); } -function clearDelegatorErrors() { +function clearDelegatorErrors({delegatedAccess}: ClearDelegatorErrorsParams) { if (!delegatedAccess?.delegators) { return; } @@ -288,7 +319,7 @@ function requestValidationCode() { API.write(WRITE_COMMANDS.RESEND_VALIDATE_CODE, null); } -function addDelegate(email: string, role: DelegateRole, validateCode: string) { +function addDelegate({email, role, validateCode, delegatedAccess}: AddDelegateProps) { const existingDelegate = delegatedAccess?.delegates?.find((delegate) => delegate.email === email); const optimisticDelegateData = (): Delegate[] => { @@ -438,7 +469,7 @@ function addDelegate(email: string, role: DelegateRole, validateCode: string) { API.write(WRITE_COMMANDS.ADD_DELEGATE, parameters, {optimisticData, successData, failureData}); } -function removeDelegate(email: string) { +function removeDelegate({email, delegatedAccess}: RemoveDelegateProps) { if (!email) { return; } @@ -510,7 +541,7 @@ function removeDelegate(email: string) { API.write(WRITE_COMMANDS.REMOVE_DELEGATE, parameters, {optimisticData, successData, failureData}); } -function clearDelegateErrorsByField(email: string, fieldName: string) { +function clearDelegateErrorsByField({email, fieldName, delegatedAccess}: ClearDelegateErrorsByFieldProps) { if (!delegatedAccess?.delegates) { return; } @@ -526,11 +557,11 @@ function clearDelegateErrorsByField(email: string, fieldName: string) { }); } -function isConnectedAsDelegate() { +function isConnectedAsDelegate({delegatedAccess}: IsConnectedAsDelegateProps) { return !!delegatedAccess?.delegate; } -function updateDelegateRole(email: string, role: DelegateRole, validateCode: string) { +function updateDelegateRole({email, role, validateCode, delegatedAccess}: UpdateDelegateRoleProps) { if (!delegatedAccess?.delegates) { return; } @@ -614,7 +645,7 @@ function updateDelegateRole(email: string, role: DelegateRole, validateCode: str API.write(WRITE_COMMANDS.UPDATE_DELEGATE_ROLE, parameters, {optimisticData, successData, failureData}); } -function updateDelegateRoleOptimistically(email: string, role: DelegateRole) { +function updateDelegateRoleOptimistically({email, role, delegatedAccess}: UpdateDelegateRoleOptimisticallyProps) { if (!delegatedAccess?.delegates) { return; } @@ -648,7 +679,7 @@ function updateDelegateRoleOptimistically(email: string, role: DelegateRole) { Onyx.update(optimisticData); } -function clearDelegateRolePendingAction(email: string) { +function clearDelegateRolePendingAction({email, delegatedAccess}: ClearDelegateRolePendingActionProps) { if (!delegatedAccess?.delegates) { return; } @@ -693,6 +724,17 @@ function openSecuritySettingsPage() { API.read(READ_COMMANDS.OPEN_SECURITY_SETTINGS_PAGE, null); } +type UpdateDelegateRoleOptimisticallyProps = { + email: string; + role: DelegateRole; + delegatedAccess: DelegatedAccess; +}; + +type ClearDelegateRolePendingActionProps = { + email: string; + delegatedAccess: DelegatedAccess; +}; + export { connect, disconnect, From 035d2319b4950bf4f1ebfa1ff74b09cd53490cf0 Mon Sep 17 00:00:00 2001 From: allgandalf Date: Fri, 22 Aug 2025 11:11:04 +0530 Subject: [PATCH 02/14] update prop passing in functions --- src/components/AccountSwitcher.tsx | 4 ++-- src/libs/Authentication.ts | 14 ++++++++++- .../Navigation/AppNavigator/AuthScreens.tsx | 2 +- src/libs/actions/Delegate.ts | 24 +++++++++++-------- .../AddDelegate/DelegateMagicCodeModal.tsx | 4 ++-- .../UpdateDelegateMagicCodeModal.tsx | 4 ++-- .../UpdateDelegateRolePage.tsx | 7 ++++-- .../ValidateCodeForm/BaseValidateCodeForm.tsx | 8 +++---- .../Security/SecuritySettingsPage.tsx | 4 ++-- 9 files changed, 45 insertions(+), 26 deletions(-) diff --git a/src/components/AccountSwitcher.tsx b/src/components/AccountSwitcher.tsx index 834cf97e5d36..cc64b99b0b9e 100644 --- a/src/components/AccountSwitcher.tsx +++ b/src/components/AccountSwitcher.tsx @@ -156,7 +156,7 @@ function AccountSwitcher({isScreenFocused}: AccountSwitcherProps) { close(() => setShouldShowOfflineModal(true)); return; } - connect(email); + connect({email, delegatedAccess: account?.delegatedAccess}); }, }); }); @@ -166,7 +166,7 @@ function AccountSwitcher({isScreenFocused}: AccountSwitcherProps) { const hideDelegatorMenu = () => { setShouldShowDelegatorMenu(false); - clearDelegatorErrors(); + clearDelegatorErrors({delegatedAccess: account?.delegatedAccess}); }; return ( diff --git a/src/libs/Authentication.ts b/src/libs/Authentication.ts index b92039cc33fb..e0f073defd8f 100644 --- a/src/libs/Authentication.ts +++ b/src/libs/Authentication.ts @@ -1,7 +1,9 @@ import Onyx from 'react-native-onyx'; +import type {OnyxEntry} from 'react-native-onyx'; import CONFIG from '@src/CONFIG'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import type {Account} from '@src/types/onyx'; import type Response from '@src/types/onyx/Response'; import {isConnectedAsDelegate, restoreDelegateSession} from './actions/Delegate'; import updateSessionAuthTokens from './actions/Session/updateSessionAuthTokens'; @@ -33,6 +35,16 @@ Onyx.connect({ }, }); +let account: OnyxEntry; +// Authentication lib is not connected to any changes on the UI +// So it is okay to use connectWithoutView here. +Onyx.connectWithoutView({ + key: ONYXKEYS.ACCOUNT, + callback: (value) => { + account = value; + }, +}); + function Authenticate(parameters: Parameters): Promise { const commandName = 'Authenticate'; @@ -136,7 +148,7 @@ function reauthenticate(command = ''): Promise { // If we reauthenticate due to an expired delegate token, restore the delegate's original account. // This is because the credentials used to reauthenticate were for the delegate's original account, and not for the account they were connected as. - if (isConnectedAsDelegate()) { + if (isConnectedAsDelegate({delegatedAccess: account?.delegatedAccess})) { Log.info('Reauthenticate while connected as a delegate. Restoring original account.'); restoreDelegateSession(response); return true; diff --git a/src/libs/Navigation/AppNavigator/AuthScreens.tsx b/src/libs/Navigation/AppNavigator/AuthScreens.tsx index 4023c7cce481..5d228f2c8c20 100644 --- a/src/libs/Navigation/AppNavigator/AuthScreens.tsx +++ b/src/libs/Navigation/AppNavigator/AuthScreens.tsx @@ -316,7 +316,7 @@ function AuthScreens({session, lastOpenedPublicRoomID, initialLastUpdateIDApplie // or returning from background. If so, we'll assume they have some app data already and we can call reconnectApp() instead of openApp() and connect() for delegator from OldDot. if (SessionUtils.didUserLogInDuringSession() || delegatorEmail) { if (delegatorEmail) { - connect(delegatorEmail, true) + connect({email: delegatorEmail, delegatedAccess: account?.delegatedAccess, isFromOldDot: true}) ?.then((success) => { App.setAppLoading(!!success); }) diff --git a/src/libs/actions/Delegate.ts b/src/libs/actions/Delegate.ts index ed2da69076e1..1cb4657a4a43 100644 --- a/src/libs/actions/Delegate.ts +++ b/src/libs/actions/Delegate.ts @@ -69,41 +69,41 @@ const KEYS_TO_PRESERVE_DELEGATE_ACCESS = [ ]; type ClearDelegatorErrorsParams = { - delegatedAccess: DelegatedAccess; + delegatedAccess: DelegatedAccess | undefined; }; type AddDelegateProps = { email: string; role: DelegateRole; validateCode: string; - delegatedAccess: DelegatedAccess; + delegatedAccess: DelegatedAccess | undefined; }; type RemoveDelegateProps = { email: string; - delegatedAccess: DelegatedAccess; + delegatedAccess: DelegatedAccess | undefined; }; type ClearDelegateErrorsByFieldProps = { email: string; fieldName: string; - delegatedAccess: DelegatedAccess; + delegatedAccess: DelegatedAccess | undefined; }; type UpdateDelegateRoleProps = { email: string; role: DelegateRole; validateCode: string; - delegatedAccess: DelegatedAccess; + delegatedAccess: DelegatedAccess | undefined; }; type IsConnectedAsDelegateProps = { - delegatedAccess: DelegatedAccess; + delegatedAccess: DelegatedAccess | undefined; }; type ConnectProps = { email: string; - delegatedAccess: DelegatedAccess; + delegatedAccess: DelegatedAccess | undefined; isFromOldDot?: boolean; }; @@ -320,6 +320,10 @@ function requestValidationCode() { } function addDelegate({email, role, validateCode, delegatedAccess}: AddDelegateProps) { + if (!delegatedAccess?.delegates) { + return; + } + const existingDelegate = delegatedAccess?.delegates?.find((delegate) => delegate.email === email); const optimisticDelegateData = (): Delegate[] => { @@ -470,7 +474,7 @@ function addDelegate({email, role, validateCode, delegatedAccess}: AddDelegatePr } function removeDelegate({email, delegatedAccess}: RemoveDelegateProps) { - if (!email) { + if (!email || !delegatedAccess?.delegates) { return; } @@ -727,12 +731,12 @@ function openSecuritySettingsPage() { type UpdateDelegateRoleOptimisticallyProps = { email: string; role: DelegateRole; - delegatedAccess: DelegatedAccess; + delegatedAccess: DelegatedAccess | undefined; }; type ClearDelegateRolePendingActionProps = { email: string; - delegatedAccess: DelegatedAccess; + delegatedAccess: DelegatedAccess | undefined; }; export { diff --git a/src/pages/settings/Security/AddDelegate/DelegateMagicCodeModal.tsx b/src/pages/settings/Security/AddDelegate/DelegateMagicCodeModal.tsx index 33e4cf9ccaf1..56e55a4924fa 100644 --- a/src/pages/settings/Security/AddDelegate/DelegateMagicCodeModal.tsx +++ b/src/pages/settings/Security/AddDelegate/DelegateMagicCodeModal.tsx @@ -46,7 +46,7 @@ function DelegateMagicCodeModal({login, role, onClose, isValidateCodeActionModal if (isEmptyObject(validateLoginError) && isEmptyObject(validateCodeAction?.errorFields)) { return; } - clearDelegateErrorsByField(currentDelegate?.email ?? '', 'addDelegate'); + clearDelegateErrorsByField({email: currentDelegate?.email ?? '', fieldName: 'addDelegate', delegatedAccess: account?.delegatedAccess}); }; return ( @@ -60,7 +60,7 @@ function DelegateMagicCodeModal({login, role, onClose, isValidateCodeActionModal isVisible={isValidateCodeActionModalVisible} title={translate('delegate.makeSureItIsYou')} sendValidateCode={() => requestValidateCodeAction()} - handleSubmitForm={(validateCode) => addDelegate(login, role, validateCode)} + handleSubmitForm={(validateCode) => addDelegate({email: login, role, validateCode, delegatedAccess: account?.delegatedAccess})} descriptionPrimary={translate('delegate.enterMagicCode', {contactMethod: account?.primaryLogin ?? ''})} /> ); diff --git a/src/pages/settings/Security/AddDelegate/UpdateDelegateRole/UpdateDelegateMagicCodeModal.tsx b/src/pages/settings/Security/AddDelegate/UpdateDelegateRole/UpdateDelegateMagicCodeModal.tsx index e2342dd652bc..a8c19d74e271 100644 --- a/src/pages/settings/Security/AddDelegate/UpdateDelegateRole/UpdateDelegateMagicCodeModal.tsx +++ b/src/pages/settings/Security/AddDelegate/UpdateDelegateRole/UpdateDelegateMagicCodeModal.tsx @@ -41,7 +41,7 @@ function UpdateDelegateMagicCodeModal({login, role, isValidateCodeActionModalVis if (isEmptyObject(updateDelegateErrors) && isEmptyObject(validateCodeAction?.errorFields)) { return; } - clearDelegateErrorsByField(currentDelegate?.email ?? '', 'updateDelegateRole'); + clearDelegateErrorsByField({email: currentDelegate?.email ?? '', fieldName: 'updateDelegateRole', delegatedAccess: account?.delegatedAccess}); }; return ( @@ -54,7 +54,7 @@ function UpdateDelegateMagicCodeModal({login, role, isValidateCodeActionModalVis isVisible={isValidateCodeActionModalVisible} title={translate('delegate.makeSureItIsYou')} sendValidateCode={() => requestValidateCodeAction()} - handleSubmitForm={(validateCode) => updateDelegateRole(login, role, validateCode)} + handleSubmitForm={(validateCode) => updateDelegateRole({email: login, role, validateCode, delegatedAccess: account?.delegatedAccess})} descriptionPrimary={translate('delegate.enterMagicCode', {contactMethod: account?.primaryLogin ?? ''})} /> ); diff --git a/src/pages/settings/Security/AddDelegate/UpdateDelegateRole/UpdateDelegateRolePage.tsx b/src/pages/settings/Security/AddDelegate/UpdateDelegateRole/UpdateDelegateRolePage.tsx index b527f45c6b7e..621a66a1d3c2 100644 --- a/src/pages/settings/Security/AddDelegate/UpdateDelegateRole/UpdateDelegateRolePage.tsx +++ b/src/pages/settings/Security/AddDelegate/UpdateDelegateRole/UpdateDelegateRolePage.tsx @@ -10,12 +10,14 @@ import Text from '@components/Text'; import TextLink from '@components/TextLink'; import useBeforeRemove from '@hooks/useBeforeRemove'; import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; import useThemeStyles from '@hooks/useThemeStyles'; import {clearDelegateRolePendingAction, updateDelegateRoleOptimistically} from '@libs/actions/Delegate'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; import type SCREENS from '@src/SCREENS'; import type {DelegateRole} from '@src/types/onyx/Account'; import UpdateDelegateMagicCodeModal from './UpdateDelegateMagicCodeModal'; @@ -31,6 +33,7 @@ function UpdateDelegateRolePage({route}: UpdateDelegateRolePageProps) { const [isValidateCodeActionModalVisible, setIsValidateCodeActionModalVisible] = useState(showValidateActionModalFromURL ?? false); const [newRole, setNewRole] = useState | undefined>(newRoleFromURL); const [shouldShowLoading, setShouldShowLoading] = useState(showValidateActionModalFromURL ?? false); + const [account] = useOnyx(ONYXKEYS.ACCOUNT, {canBeMissing: true}); useEffect(() => { Navigation.setParams({showValidateActionModal: isValidateCodeActionModalVisible, newRole}); @@ -47,8 +50,8 @@ function UpdateDelegateRolePage({route}: UpdateDelegateRolePageProps) { useBeforeRemove(() => setIsValidateCodeActionModalVisible(false)); useEffect(() => { - updateDelegateRoleOptimistically(login ?? '', currentRole as DelegateRole); - return () => clearDelegateRolePendingAction(login); + updateDelegateRoleOptimistically({email: login ?? '', role: currentRole as DelegateRole, delegatedAccess: account?.delegatedAccess}); + return () => clearDelegateRolePendingAction({email: login, delegatedAccess: account?.delegatedAccess}); // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps }, [login]); diff --git a/src/pages/settings/Security/AddDelegate/UpdateDelegateRole/ValidateCodeForm/BaseValidateCodeForm.tsx b/src/pages/settings/Security/AddDelegate/UpdateDelegateRole/ValidateCodeForm/BaseValidateCodeForm.tsx index 58a3e915158f..4c2849c31176 100644 --- a/src/pages/settings/Security/AddDelegate/UpdateDelegateRole/ValidateCodeForm/BaseValidateCodeForm.tsx +++ b/src/pages/settings/Security/AddDelegate/UpdateDelegateRole/ValidateCodeForm/BaseValidateCodeForm.tsx @@ -128,10 +128,10 @@ function BaseValidateCodeForm({autoComplete = 'one-time-code', innerRef = () => setValidateCode(text); setFormError({}); if (validateLoginError) { - Delegate.clearDelegateErrorsByField(currentDelegate?.email ?? '', 'updateDelegateRole'); + Delegate.clearDelegateErrorsByField({email: currentDelegate?.email ?? '', fieldName: 'updateDelegateRole', delegatedAccess: account?.delegatedAccess}); } }, - [currentDelegate?.email, validateLoginError], + [currentDelegate?.email, validateLoginError, account?.delegatedAccess], ); /** @@ -150,8 +150,8 @@ function BaseValidateCodeForm({autoComplete = 'one-time-code', innerRef = () => setFormError({}); - Delegate.updateDelegateRole(delegate, role, validateCode); - }, [delegate, role, validateCode]); + Delegate.updateDelegateRole({email: delegate, role, validateCode, delegatedAccess: account?.delegatedAccess}); + }, [delegate, role, validateCode, account?.delegatedAccess]); return ( diff --git a/src/pages/settings/Security/SecuritySettingsPage.tsx b/src/pages/settings/Security/SecuritySettingsPage.tsx index d45d1d33f0a8..9dacc893ef7c 100644 --- a/src/pages/settings/Security/SecuritySettingsPage.tsx +++ b/src/pages/settings/Security/SecuritySettingsPage.tsx @@ -242,7 +242,7 @@ function SecuritySettingsPage() { shouldShowRightIcon: true, pendingAction, shouldForceOpacity: !!pendingAction, - onPendingActionDismiss: () => clearDelegateErrorsByField(email, 'addDelegate'), + onPendingActionDismiss: () => clearDelegateErrorsByField({email, fieldName: 'addDelegate', delegatedAccess: account?.delegatedAccess}), error, onPress, success: selectedEmail === email, @@ -427,7 +427,7 @@ function SecuritySettingsPage() { prompt={translate('delegate.removeCopilotConfirmation')} danger onConfirm={() => { - removeDelegate(selectedDelegate?.email ?? ''); + removeDelegate({email: selectedDelegate?.email ?? '', delegatedAccess: account?.delegatedAccess}); setShouldShowRemoveDelegateModal(false); setSelectedDelegate(undefined); }} From d30a626ac5fb03437ad40a92e8e44ce357ef24a3 Mon Sep 17 00:00:00 2001 From: allgandalf Date: Fri, 22 Aug 2025 11:17:55 +0530 Subject: [PATCH 03/14] fix changed files ESlint --- .../ValidateCodeForm/BaseValidateCodeForm.tsx | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/pages/settings/Security/AddDelegate/UpdateDelegateRole/ValidateCodeForm/BaseValidateCodeForm.tsx b/src/pages/settings/Security/AddDelegate/UpdateDelegateRole/ValidateCodeForm/BaseValidateCodeForm.tsx index 4c2849c31176..7afe42b138a8 100644 --- a/src/pages/settings/Security/AddDelegate/UpdateDelegateRole/ValidateCodeForm/BaseValidateCodeForm.tsx +++ b/src/pages/settings/Security/AddDelegate/UpdateDelegateRole/ValidateCodeForm/BaseValidateCodeForm.tsx @@ -16,9 +16,9 @@ import useOnyx from '@hooks/useOnyx'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; -import * as ErrorUtils from '@libs/ErrorUtils'; -import * as ValidationUtils from '@libs/ValidationUtils'; -import * as Delegate from '@userActions/Delegate'; +import {getLatestError} from '@libs/ErrorUtils'; +import {isValidValidateCode} from '@libs/ValidationUtils'; +import {clearDelegateErrorsByField, requestValidationCode, updateDelegateRole} from '@userActions/Delegate'; import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -67,7 +67,7 @@ function BaseValidateCodeForm({autoComplete = 'one-time-code', innerRef = () => const currentDelegate = account?.delegatedAccess?.delegates?.find((d) => d.email === delegate); const errorFields = account?.delegatedAccess?.errorFields ?? {}; - const validateLoginError = ErrorUtils.getLatestError(errorFields.updateDelegateRole?.[currentDelegate?.email ?? '']); + const validateLoginError = getLatestError(errorFields.updateDelegateRole?.[currentDelegate?.email ?? '']); const shouldDisableResendValidateCode = !!isOffline || currentDelegate?.isLoading; @@ -115,7 +115,7 @@ function BaseValidateCodeForm({autoComplete = 'one-time-code', innerRef = () => if (!login) { return; } - Delegate.requestValidationCode(); + requestValidationCode(); inputValidateCodeRef.current?.clear(); }; @@ -128,7 +128,7 @@ function BaseValidateCodeForm({autoComplete = 'one-time-code', innerRef = () => setValidateCode(text); setFormError({}); if (validateLoginError) { - Delegate.clearDelegateErrorsByField({email: currentDelegate?.email ?? '', fieldName: 'updateDelegateRole', delegatedAccess: account?.delegatedAccess}); + clearDelegateErrorsByField({email: currentDelegate?.email ?? '', fieldName: 'updateDelegateRole', delegatedAccess: account?.delegatedAccess}); } }, [currentDelegate?.email, validateLoginError, account?.delegatedAccess], @@ -143,14 +143,14 @@ function BaseValidateCodeForm({autoComplete = 'one-time-code', innerRef = () => return; } - if (!ValidationUtils.isValidValidateCode(validateCode)) { + if (!isValidValidateCode(validateCode)) { setFormError({validateCode: 'validateCodeForm.error.incorrectMagicCode'}); return; } setFormError({}); - Delegate.updateDelegateRole({email: delegate, role, validateCode, delegatedAccess: account?.delegatedAccess}); + updateDelegateRole({email: delegate, role, validateCode, delegatedAccess: account?.delegatedAccess}); }, [delegate, role, validateCode, account?.delegatedAccess]); return ( From 779feb19285d3018d03c05f1afdefdef108399ec Mon Sep 17 00:00:00 2001 From: allgandalf Date: Fri, 22 Aug 2025 15:43:04 +0530 Subject: [PATCH 04/14] add delegate tests --- tests/actions/DelegateTest.ts | 230 ++++++++++++++++++++++++++++++++++ 1 file changed, 230 insertions(+) create mode 100644 tests/actions/DelegateTest.ts diff --git a/tests/actions/DelegateTest.ts b/tests/actions/DelegateTest.ts new file mode 100644 index 000000000000..f7aa2a942380 --- /dev/null +++ b/tests/actions/DelegateTest.ts @@ -0,0 +1,230 @@ +import Onyx from 'react-native-onyx'; +import {addDelegate, clearDelegateErrorsByField, clearDelegatorErrors, isConnectedAsDelegate, removeDelegate, updateDelegateRole} from '@libs/actions/Delegate'; +import CONST from '@src/CONST'; +import OnyxUpdateManager from '@src/libs/actions/OnyxUpdateManager'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {DelegatedAccess} from '@src/types/onyx/Account'; +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; + +OnyxUpdateManager(); +describe('actions/Delegate', () => { + beforeAll(async () => { + Onyx.init({ + keys: ONYXKEYS, + }); + await waitForBatchedUpdates(); + }); + + beforeEach(async () => { + await Onyx.clear(); + await waitForBatchedUpdates(); + }); + + describe('clearDelegatorErrors', () => { + it('should clear delegator errors', async () => { + const delegatedAccess: DelegatedAccess = { + delegators: [ + { + email: 'test@test.com', + // @ts-expect-error - errorFields is not defined in the type + errorFields: { + addDelegate: { + // eslint-disable-next-line @typescript-eslint/naming-convention + '12211': { + email: 'Invalid email address', + }, + }, + }, + }, + ], + }; + + await Onyx.merge(ONYXKEYS.ACCOUNT, {delegatedAccess}); + await waitForBatchedUpdates(); + + await new Promise((resolve) => { + const connection = Onyx.connect({ + key: ONYXKEYS.ACCOUNT, + callback: (account) => { + // @ts-expect-error - errorFields is not defined in the type + expect(account?.delegatedAccess?.delegators?.at(0)?.errorFields).toBeDefined(); + Onyx.disconnect(connection); + resolve(); + }, + }); + }); + + clearDelegatorErrors({delegatedAccess}); + await waitForBatchedUpdates(); + + await new Promise((resolve) => { + const connection = Onyx.connect({ + key: ONYXKEYS.ACCOUNT, + callback: (account) => { + // @ts-expect-error - errorFields is not defined in the type + expect(account?.delegatedAccess?.delegators?.at(0)?.errorFields).toBeUndefined(); + Onyx.disconnect(connection); + resolve(); + }, + }); + }); + }); + }); + describe('addDelegate', () => { + it('should add a delegate', async () => { + const delegatedAccess: DelegatedAccess = { + delegates: [], + }; + await waitForBatchedUpdates(); + + addDelegate({email: 'test@test.com', role: CONST.DELEGATE_ROLE.ALL, validateCode: '123456', delegatedAccess}); + await waitForBatchedUpdates(); + + await new Promise((resolve) => { + const connection = Onyx.connect({ + key: ONYXKEYS.ACCOUNT, + callback: (account) => { + expect(account?.delegatedAccess?.delegates?.at(0)?.email).toBe('test@test.com'); + expect(account?.delegatedAccess?.delegates?.at(0)?.role).toBe(CONST.DELEGATE_ROLE.ALL); + Onyx.disconnect(connection); + resolve(); + }, + }); + }); + }); + }); + describe('removeDelegate', () => { + it('should remove a delegate', async () => { + const delegatedAccess: DelegatedAccess = { + delegates: [ + { + email: 'test@test.com', + role: CONST.DELEGATE_ROLE.ALL, + }, + ], + }; + + await Onyx.merge(ONYXKEYS.ACCOUNT, {delegatedAccess}); + await waitForBatchedUpdates(); + + removeDelegate({email: 'test@test.com', delegatedAccess}); + await waitForBatchedUpdates(); + + await new Promise((resolve) => { + const connection = Onyx.connect({ + key: ONYXKEYS.ACCOUNT, + callback: (account) => { + expect(account?.delegatedAccess?.delegates?.at(0)?.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE); + Onyx.disconnect(connection); + resolve(); + }, + }); + }); + }); + }); + describe('clearDelegateErrorsByField', () => { + it('should clear a delegate error by field', async () => { + const delegatedAccess: DelegatedAccess = { + delegates: [ + { + email: 'test@test.com', + role: CONST.DELEGATE_ROLE.ALL, + }, + ], + errorFields: { + addDelegate: { + // eslint-disable-next-line @typescript-eslint/naming-convention + 'test@test.com': { + email: 'Invalid email address', + }, + }, + }, + }; + + // Set initial Onyx state + await Onyx.merge(ONYXKEYS.ACCOUNT, {delegatedAccess}); + await waitForBatchedUpdates(); + + // Clear an error in a simple field + clearDelegateErrorsByField({ + email: 'test@test.com', + fieldName: 'addDelegate', + delegatedAccess, + }); + await waitForBatchedUpdates(); + + // Assert + await new Promise((resolve) => { + const connection = Onyx.connect({ + key: ONYXKEYS.ACCOUNT, + callback: (account) => { + const errorFields = account?.delegatedAccess?.errorFields; + + // The targeted errors should be cleared + expect(errorFields?.addDelegate?.['test@test.com']).toBeUndefined(); + + Onyx.disconnect(connection); + resolve(); + }, + }); + }); + }); + }); + + describe('updateDelegateRole', () => { + it('should update a delegate role', async () => { + const delegatedAccess: DelegatedAccess = { + delegates: [ + { + email: 'test@test.com', + role: CONST.DELEGATE_ROLE.ALL, + }, + ], + }; + + await Onyx.merge(ONYXKEYS.ACCOUNT, {delegatedAccess}); + await waitForBatchedUpdates(); + + updateDelegateRole({email: 'test@test.com', role: CONST.DELEGATE_ROLE.SUBMITTER, validateCode: '123456', delegatedAccess}); + await waitForBatchedUpdates(); + + await new Promise((resolve) => { + const connection = Onyx.connect({ + key: ONYXKEYS.ACCOUNT, + callback: (account) => { + expect(account?.delegatedAccess?.delegates?.at(0)?.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE); + expect(account?.delegatedAccess?.delegates?.at(0)?.pendingFields?.role).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE); + Onyx.disconnect(connection); + resolve(); + }, + }); + }); + }); + }); + describe('isConnectedAsDelegate', () => { + it('should return true if the user is connected as a delegate', async () => { + const delegatedAccess: DelegatedAccess = { + delegates: [ + { + email: 'test@test.com', + role: CONST.DELEGATE_ROLE.ALL, + }, + ], + }; + + await Onyx.merge(ONYXKEYS.ACCOUNT, {delegatedAccess}); + await waitForBatchedUpdates(); + + await new Promise((resolve) => { + const connection = Onyx.connect({ + key: ONYXKEYS.ACCOUNT, + callback: (account) => { + expect(isConnectedAsDelegate({delegatedAccess: account?.delegatedAccess})).toBeDefined(); + Onyx.disconnect(connection); + resolve(); + }, + }); + }); + }); + }); +}); From 84e7c286214c24a94b4679b13c0ae8243061fdf8 Mon Sep 17 00:00:00 2001 From: allgandalf Date: Fri, 22 Aug 2025 15:44:18 +0530 Subject: [PATCH 05/14] fix eslint --- .../ValidateCodeForm/BaseValidateCodeForm.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/settings/Security/AddDelegate/UpdateDelegateRole/ValidateCodeForm/BaseValidateCodeForm.tsx b/src/pages/settings/Security/AddDelegate/UpdateDelegateRole/ValidateCodeForm/BaseValidateCodeForm.tsx index 7afe42b138a8..e8ada13d07c5 100644 --- a/src/pages/settings/Security/AddDelegate/UpdateDelegateRole/ValidateCodeForm/BaseValidateCodeForm.tsx +++ b/src/pages/settings/Security/AddDelegate/UpdateDelegateRole/ValidateCodeForm/BaseValidateCodeForm.tsx @@ -60,7 +60,7 @@ function BaseValidateCodeForm({autoComplete = 'one-time-code', innerRef = () => const [formError, setFormError] = useState({}); const [validateCode, setValidateCode] = useState(''); const inputValidateCodeRef = useRef(null); - const [account] = useOnyx(ONYXKEYS.ACCOUNT); + const [account] = useOnyx(ONYXKEYS.ACCOUNT, {canBeMissing: true}); const login = account?.primaryLogin; // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- nullish coalescing doesn't achieve the same result in this case const focusTimeoutRef = useRef(null); From 23442a69caef6bd9bc3975192a104bd26a29a8f4 Mon Sep 17 00:00:00 2001 From: allgandalf Date: Fri, 22 Aug 2025 15:51:36 +0530 Subject: [PATCH 06/14] update package json warning --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 801341ed68bb..68695cac4b7d 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,7 @@ "test:debug": "TZ=utc NODE_OPTIONS='--inspect-brk --experimental-vm-modules' jest --runInBand", "perf-test": "NODE_OPTIONS=--experimental-vm-modules npx reassure", "typecheck": "NODE_OPTIONS=--max_old_space_size=8192 tsc", - "lint": "NODE_OPTIONS=--max_old_space_size=8192 eslint . --max-warnings=244 --cache --cache-location=node_modules/.cache/eslint", + "lint": "NODE_OPTIONS=--max_old_space_size=8192 eslint . --max-warnings=243 --cache --cache-location=node_modules/.cache/eslint", "lint-changed": "NODE_OPTIONS=--max_old_space_size=8192 ./scripts/lintChanged.sh", "lint-watch": "npx eslint-watch --watch --changed", "shellcheck": "./scripts/shellCheck.sh", From 8ca10c5c228d843b8f8ed41115dfe94df03ecfef Mon Sep 17 00:00:00 2001 From: allgandalf Date: Sun, 7 Sep 2025 16:30:04 +0530 Subject: [PATCH 07/14] extract type --- src/libs/actions/Delegate.ts | 95 +++++++++++++++++++----------------- 1 file changed, 49 insertions(+), 46 deletions(-) diff --git a/src/libs/actions/Delegate.ts b/src/libs/actions/Delegate.ts index 8a2442c6f99d..066414f69041 100644 --- a/src/libs/actions/Delegate.ts +++ b/src/libs/actions/Delegate.ts @@ -2,7 +2,7 @@ import HybridAppModule from '@expensify/react-native-hybrid-app'; import Onyx from 'react-native-onyx'; import type {OnyxEntry, OnyxUpdate} from 'react-native-onyx'; import * as API from '@libs/API'; -import type {AddDelegateParams, RemoveDelegateParams, UpdateDelegateRoleParams} from '@libs/API/parameters'; +import type {AddDelegateParams as APIAddDelegateParams, RemoveDelegateParams as APIRemoveDelegateParams, UpdateDelegateRoleParams as APIUpdateDelegateRoleParams} from '@libs/API/parameters'; import {READ_COMMANDS, SIDE_EFFECT_REQUEST_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; import * as ErrorUtils from '@libs/ErrorUtils'; import Log from '@libs/Log'; @@ -69,50 +69,64 @@ const KEYS_TO_PRESERVE_DELEGATE_ACCESS = [ ONYXKEYS.NETWORK, ]; -type ClearDelegatorErrorsParams = { +type WithDelegatedAccess = { + // Optional keeps call sites clean, but still encourages passing `account?.delegatedAccess`. delegatedAccess: DelegatedAccess | undefined; }; -type AddDelegateProps = { +type WithEmail = { email: string; - role: DelegateRole; - validateCode: string; - delegatedAccess: DelegatedAccess | undefined; }; -type RemoveDelegateProps = { - email: string; - delegatedAccess: DelegatedAccess | undefined; -}; - -type ClearDelegateErrorsByFieldProps = { - email: string; - fieldName: string; - delegatedAccess: DelegatedAccess | undefined; +type WithRole = { + role: DelegateRole; }; -type UpdateDelegateRoleProps = { - email: string; - role: DelegateRole; +type WithValidateCode = { validateCode: string; - delegatedAccess: DelegatedAccess | undefined; }; -type IsConnectedAsDelegateProps = { - delegatedAccess: DelegatedAccess | undefined; +type WithFieldName = { + // Constrain to known keys to avoid misspells at call sites + fieldName: 'addDelegate' | 'updateDelegateRole'; // but string could work as well }; -type ConnectProps = { - email: string; - delegatedAccess: DelegatedAccess | undefined; +type WithOldDotFlag = { isFromOldDot?: boolean; }; +// Clear delegator-level errors +type ClearDelegatorErrorsParams = WithDelegatedAccess; + +// Add a delegate (requires code) +type AddDelegateParams = WithEmail & WithRole & WithValidateCode & WithDelegatedAccess; + +// Remove a delegate +type RemoveDelegateParams = WithEmail & WithDelegatedAccess; + +// Clear delegate errors scoped by field +type ClearDelegateErrorsByFieldParams = WithEmail & WithFieldName & WithDelegatedAccess; + +// Update existing delegate role (requires code) +type UpdateDelegateRoleParams = WithEmail & WithRole & WithValidateCode & WithDelegatedAccess; + +// Is connected as delegate? +type IsConnectedAsDelegateParams = WithDelegatedAccess; + +// Connect as delegate +type ConnectParams = WithEmail & WithDelegatedAccess & WithOldDotFlag; + +// Optimistic update to role (no code) +type UpdateDelegateRoleOptimisticallyParams = WithEmail & WithRole & WithDelegatedAccess; + +// Clear pending action for role update +type ClearDelegateRolePendingActionParams = WithEmail & WithDelegatedAccess; + /** * Connects the user as a delegate to another account. * Returns a Promise that resolves to true on success, false on failure, or undefined if not applicable. */ -function connect({email, delegatedAccess, isFromOldDot = false}: ConnectProps) { +function connect({email, delegatedAccess, isFromOldDot = false}: ConnectParams) { if (!delegatedAccess?.delegators && !isFromOldDot) { return; } @@ -320,7 +334,7 @@ function requestValidationCode() { API.write(WRITE_COMMANDS.RESEND_VALIDATE_CODE, null); } -function addDelegate({email, role, validateCode, delegatedAccess}: AddDelegateProps) { +function addDelegate({email, role, validateCode, delegatedAccess}: AddDelegateParams) { if (!delegatedAccess?.delegates) { return; } @@ -469,12 +483,12 @@ function addDelegate({email, role, validateCode, delegatedAccess}: AddDelegatePr }; optimisticData.push(optimisticResetActionCode); - const parameters: AddDelegateParams = {delegateEmail: email, validateCode, role}; + const parameters: APIAddDelegateParams = {delegateEmail: email, validateCode, role}; API.write(WRITE_COMMANDS.ADD_DELEGATE, parameters, {optimisticData, successData, failureData}); } -function removeDelegate({email, delegatedAccess}: RemoveDelegateProps) { +function removeDelegate({email, delegatedAccess}: RemoveDelegateParams) { if (!email || !delegatedAccess?.delegates) { return; } @@ -541,12 +555,12 @@ function removeDelegate({email, delegatedAccess}: RemoveDelegateProps) { }, ]; - const parameters: RemoveDelegateParams = {delegateEmail: email}; + const parameters: APIRemoveDelegateParams = {delegateEmail: email}; API.write(WRITE_COMMANDS.REMOVE_DELEGATE, parameters, {optimisticData, successData, failureData}); } -function clearDelegateErrorsByField({email, fieldName, delegatedAccess}: ClearDelegateErrorsByFieldProps) { +function clearDelegateErrorsByField({email, fieldName, delegatedAccess}: ClearDelegateErrorsByFieldParams) { if (!delegatedAccess?.delegates) { return; } @@ -562,11 +576,11 @@ function clearDelegateErrorsByField({email, fieldName, delegatedAccess}: ClearDe }); } -function isConnectedAsDelegate({delegatedAccess}: IsConnectedAsDelegateProps) { +function isConnectedAsDelegate({delegatedAccess}: IsConnectedAsDelegateParams) { return !!delegatedAccess?.delegate; } -function updateDelegateRole({email, role, validateCode, delegatedAccess}: UpdateDelegateRoleProps) { +function updateDelegateRole({email, role, validateCode, delegatedAccess}: UpdateDelegateRoleParams) { if (!delegatedAccess?.delegates) { return; } @@ -645,12 +659,12 @@ function updateDelegateRole({email, role, validateCode, delegatedAccess}: Update }, ]; - const parameters: UpdateDelegateRoleParams = {delegateEmail: email, validateCode, role}; + const parameters: APIUpdateDelegateRoleParams = {delegateEmail: email, validateCode, role}; API.write(WRITE_COMMANDS.UPDATE_DELEGATE_ROLE, parameters, {optimisticData, successData, failureData}); } -function updateDelegateRoleOptimistically({email, role, delegatedAccess}: UpdateDelegateRoleOptimisticallyProps) { +function updateDelegateRoleOptimistically({email, role, delegatedAccess}: UpdateDelegateRoleOptimisticallyParams) { if (!delegatedAccess?.delegates) { return; } @@ -684,7 +698,7 @@ function updateDelegateRoleOptimistically({email, role, delegatedAccess}: Update Onyx.update(optimisticData); } -function clearDelegateRolePendingAction({email, delegatedAccess}: ClearDelegateRolePendingActionProps) { +function clearDelegateRolePendingAction({email, delegatedAccess}: ClearDelegateRolePendingActionParams) { if (!delegatedAccess?.delegates) { return; } @@ -729,17 +743,6 @@ function openSecuritySettingsPage() { API.read(READ_COMMANDS.OPEN_SECURITY_SETTINGS_PAGE, null); } -type UpdateDelegateRoleOptimisticallyProps = { - email: string; - role: DelegateRole; - delegatedAccess: DelegatedAccess | undefined; -}; - -type ClearDelegateRolePendingActionProps = { - email: string; - delegatedAccess: DelegatedAccess | undefined; -}; - export { connect, disconnect, From b08f983c692014ffee19e68e6c62f6fa2d2ba27d Mon Sep 17 00:00:00 2001 From: allgandalf Date: Sun, 7 Sep 2025 16:33:31 +0530 Subject: [PATCH 08/14] update reviewer suggestion --- .../ValidateCodeForm/BaseValidateCodeForm.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/settings/Security/AddDelegate/UpdateDelegateRole/ValidateCodeForm/BaseValidateCodeForm.tsx b/src/pages/settings/Security/AddDelegate/UpdateDelegateRole/ValidateCodeForm/BaseValidateCodeForm.tsx index e8ada13d07c5..38a64908a2fb 100644 --- a/src/pages/settings/Security/AddDelegate/UpdateDelegateRole/ValidateCodeForm/BaseValidateCodeForm.tsx +++ b/src/pages/settings/Security/AddDelegate/UpdateDelegateRole/ValidateCodeForm/BaseValidateCodeForm.tsx @@ -187,7 +187,7 @@ function BaseValidateCodeForm({autoComplete = 'one-time-code', innerRef = () =>