Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/components/AccountSwitcher.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,7 @@ function AccountSwitcher({isScreenFocused}: AccountSwitcherProps) {
close(() => setShouldShowOfflineModal(true));
return;
}
connect(email);
connect({email, delegatedAccess: account?.delegatedAccess});
Comment on lines 158 to +161

@ikevin127 ikevin127 Aug 22, 2025

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NAB: 🟡 Potential silent no-op when delegatedAccess is not yet available:

  • if account?.delegatedAccess is undefined (e.g., early in app startup), connect() returns early by design and nothing happens. From the user perspective: taps “Switch account” do nothing without feedback.
  • suggested UI guard to prevent a confusing UX, add disabled to createBaseMenuItem:
    badgeText: translate('delegate.role', {role}),
+   disabled: !!account?.delegatedAccess,
    onSelected: () => {

@allgandalf allgandalf Sep 7, 2025

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ikevin127 AFAIK, we don't have the switcher shown on the UI unless we have account object present, please correct me if i am wrong

},
});
});
Expand All @@ -168,7 +168,7 @@ function AccountSwitcher({isScreenFocused}: AccountSwitcherProps) {

const hideDelegatorMenu = () => {
setShouldShowDelegatorMenu(false);
clearDelegatorErrors();
clearDelegatorErrors({delegatedAccess: account?.delegatedAccess});
};

return (
Expand Down
14 changes: 13 additions & 1 deletion src/libs/Authentication.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -43,6 +45,16 @@ Onyx.connectWithoutView({
},
});

let account: OnyxEntry<Account>;
// Authentication lib is not connected to any changes on the UI
// So it is okay to use connectWithoutView here.
Onyx.connectWithoutView({

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ikevin127 , please check this thread for more info:

https://new.expensify.com//r/5512877217123728/7684504551749862656

We decided to use connectWithoutView for this particular lib

key: ONYXKEYS.ACCOUNT,
callback: (value) => {
account = value;
},
});

function Authenticate(parameters: Parameters): Promise<Response | void> {
const commandName = 'Authenticate';

Expand Down Expand Up @@ -154,7 +166,7 @@ function reauthenticate(command = ''): Promise<boolean> {

// 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;
Expand Down
2 changes: 1 addition & 1 deletion src/libs/Navigation/AppNavigator/AuthScreens.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@
let currentAccountID = -1;
let lastUpdateIDAppliedToClient: OnyxEntry<number>;

Onyx.connect({

Check warning on line 116 in src/libs/Navigation/AppNavigator/AuthScreens.tsx

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

Onyx.connect() is deprecated. Use useOnyx() hook instead and pass the data as parameters to a pure function
key: ONYXKEYS.SESSION,
callback: (value) => {
// When signed out, val hasn't accountID
Expand All @@ -132,7 +132,7 @@
},
});

Onyx.connect({

Check warning on line 135 in src/libs/Navigation/AppNavigator/AuthScreens.tsx

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

Onyx.connect() is deprecated. Use useOnyx() hook instead and pass the data as parameters to a pure function
key: ONYXKEYS.PERSONAL_DETAILS_LIST,
callback: (value) => {
if (!value || !isEmptyObject(timezone)) {
Expand All @@ -153,7 +153,7 @@
},
});

Onyx.connect({

Check warning on line 156 in src/libs/Navigation/AppNavigator/AuthScreens.tsx

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

Onyx.connect() is deprecated. Use useOnyx() hook instead and pass the data as parameters to a pure function
key: ONYXKEYS.ONYX_UPDATES_LAST_UPDATE_ID_APPLIED_TO_CLIENT,
callback: (value) => {
lastUpdateIDAppliedToClient = value;
Expand Down Expand Up @@ -303,7 +303,7 @@
// 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);
})
Expand Down
123 changes: 67 additions & 56 deletions src/libs/actions/Delegate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
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';
Expand All @@ -20,40 +20,32 @@
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({

Check warning on line 24 in src/libs/actions/Delegate.ts

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

Onyx.connect() is deprecated. Use useOnyx() hook instead and pass the data as parameters to a pure function
key: ONYXKEYS.CREDENTIALS,
callback: (value) => (credentials = value ?? {}),
});

let stashedCredentials: Credentials = {};
Onyx.connect({

Check warning on line 30 in src/libs/actions/Delegate.ts

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

Onyx.connect() is deprecated. Use useOnyx() hook instead and pass the data as parameters to a pure function
key: ONYXKEYS.STASHED_CREDENTIALS,
callback: (value) => (stashedCredentials = value ?? {}),
});

let session: Session = {};
Onyx.connect({

Check warning on line 36 in src/libs/actions/Delegate.ts

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

Onyx.connect() is deprecated. Use useOnyx() hook instead and pass the data as parameters to a pure function
key: ONYXKEYS.SESSION,
callback: (value) => (session = value ?? {}),
});

let stashedSession: Session = {};
Onyx.connect({

Check warning on line 42 in src/libs/actions/Delegate.ts

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

Onyx.connect() is deprecated. Use useOnyx() hook instead and pass the data as parameters to a pure function
key: ONYXKEYS.STASHED_SESSION,
callback: (value) => (stashedSession = value ?? {}),
});

let activePolicyID: OnyxEntry<string>;
Onyx.connect({

Check warning on line 48 in src/libs/actions/Delegate.ts

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

Onyx.connect() is deprecated. Use useOnyx() hook instead and pass the data as parameters to a pure function
key: ONYXKEYS.NVP_ACTIVE_POLICY_ID,
callback: (newActivePolicyID) => {
activePolicyID = newActivePolicyID;
Expand All @@ -79,11 +71,61 @@
ONYXKEYS.IS_DEBUG_MODE_ENABLED,
];

type WithDelegatedAccess = {
// Optional keeps call sites clean, but still encourages passing `account?.delegatedAccess`.
delegatedAccess: DelegatedAccess | undefined;
};

type WithEmail = {
email: string;
};

type WithRole = {
role: DelegateRole;
};

type WithValidateCode = {
validateCode: string;
};

type WithFieldName = {
// Constrain to known keys to avoid misspells at call sites
fieldName: 'addDelegate' | 'updateDelegateRole'; // but string could work as well
};

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;

// 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: string, isFromOldDot = false) {
function connect({email, delegatedAccess, isFromOldDot = false}: ConnectParams) {
if (!delegatedAccess?.delegators && !isFromOldDot) {
return;
}
Expand Down Expand Up @@ -280,7 +322,7 @@
});
}

function clearDelegatorErrors() {
function clearDelegatorErrors({delegatedAccess}: ClearDelegatorErrorsParams) {
if (!delegatedAccess?.delegators) {
return;
}
Expand All @@ -291,7 +333,11 @@
API.write(WRITE_COMMANDS.RESEND_VALIDATE_CODE, null);
}

function addDelegate(email: string, role: DelegateRole, validateCode: string) {
function addDelegate({email, role, validateCode, delegatedAccess}: AddDelegateParams) {
if (!delegatedAccess?.delegates) {
return;
}

const existingDelegate = delegatedAccess?.delegates?.find((delegate) => delegate.email === email);

const optimisticDelegateData = (): Delegate[] => {
Expand Down Expand Up @@ -436,13 +482,13 @@
};
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: string) {
if (!email) {
function removeDelegate({email, delegatedAccess}: RemoveDelegateParams) {
if (!email || !delegatedAccess?.delegates) {
return;
}

Expand Down Expand Up @@ -508,12 +554,12 @@
},
];

const parameters: RemoveDelegateParams = {delegateEmail: email};
const parameters: APIRemoveDelegateParams = {delegateEmail: email};

API.write(WRITE_COMMANDS.REMOVE_DELEGATE, parameters, {optimisticData, successData, failureData});
}

function clearDelegateErrorsByField(email: string, fieldName: string) {
function clearDelegateErrorsByField({email, fieldName, delegatedAccess}: ClearDelegateErrorsByFieldParams) {
if (!delegatedAccess?.delegates) {
return;
}
Expand All @@ -529,11 +575,11 @@
});
}

function isConnectedAsDelegate() {
function isConnectedAsDelegate({delegatedAccess}: IsConnectedAsDelegateParams) {
return !!delegatedAccess?.delegate;
}

function updateDelegateRole(email: string, role: DelegateRole, validateCode: string) {
function updateDelegateRole({email, role, validateCode, delegatedAccess}: UpdateDelegateRoleParams) {
if (!delegatedAccess?.delegates) {
return;
}
Expand Down Expand Up @@ -612,46 +658,12 @@
},
];

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: string, role: DelegateRole) {
if (!delegatedAccess?.delegates) {
return;
}

const optimisticData: OnyxUpdate[] = [
{
onyxMethod: Onyx.METHOD.MERGE,
key: ONYXKEYS.ACCOUNT,
value: {
delegatedAccess: {
errorFields: {
updateDelegateRole: {
[email]: null,
},
},
delegates: delegatedAccess.delegates.map((delegate) =>
delegate.email === email
? {
...delegate,
role,
pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE,
pendingFields: {role: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE},
}
: delegate,
),
},
},
},
];

Onyx.update(optimisticData);
}

function clearDelegateRolePendingAction(email: string) {
function clearDelegateRolePendingAction({email, delegatedAccess}: ClearDelegateRolePendingActionParams) {
if (!delegatedAccess?.delegates) {
return;
}
Expand Down Expand Up @@ -705,7 +717,6 @@
clearDelegateErrorsByField,
restoreDelegateSession,
isConnectedAsDelegate,
updateDelegateRoleOptimistically,
clearDelegateRolePendingAction,
updateDelegateRole,
removeDelegate,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,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 (
Expand All @@ -56,7 +56,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 ?? ''})}
/>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -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 ?? ''})}
/>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,11 @@ import TextLink from '@components/TextLink';
import useBeforeRemove from '@hooks/useBeforeRemove';
import useLocalize from '@hooks/useLocalize';
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 type SCREENS from '@src/SCREENS';
import type {DelegateRole} from '@src/types/onyx/Account';
import UpdateDelegateMagicCodeModal from './UpdateDelegateMagicCodeModal';

type UpdateDelegateRolePageProps = PlatformStackScreenProps<SettingsNavigatorParamList, typeof SCREENS.SETTINGS.DELEGATE.UPDATE_DELEGATE_ROLE>;
Expand Down Expand Up @@ -46,12 +44,6 @@ function UpdateDelegateRolePage({route}: UpdateDelegateRolePageProps) {
}));

useBeforeRemove(() => setIsValidateCodeActionModalVisible(false));
useEffect(() => {
updateDelegateRoleOptimistically(login ?? '', currentRole as DelegateRole);
return () => clearDelegateRolePendingAction(login);
// eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps
}, [login]);

return (
<ScreenWrapper
includeSafeAreaPaddingBottom={false}
Expand Down
Loading
Loading