diff --git a/assets/images/caret-up-down.svg b/assets/images/caret-up-down.svg new file mode 100644 index 000000000000..d08aa2a1ebbd --- /dev/null +++ b/assets/images/caret-up-down.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/CONST.ts b/src/CONST.ts index 30b0b5364d83..be14ba241404 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -387,6 +387,7 @@ const CONST = { REPORT_FIELDS_FEATURE: 'reportFieldsFeature', WORKSPACE_FEEDS: 'workspaceFeeds', NETSUITE_USA_TAX: 'netsuiteUsaTax', + NEW_DOT_COPILOT: 'newDotCopilot', WORKSPACE_RULES: 'workspaceRules', COMBINED_TRACK_SUBMIT: 'combinedTrackSubmit', }, @@ -3877,6 +3878,10 @@ const CONST = { ENABLED: 'ENABLED', DISABLED: 'DISABLED', }, + DELEGATE_ROLE: { + SUBMITTER: 'submitter', + ALL: 'all', + }, STRIPE_GBP_AUTH_STATUSES: { SUCCEEDED: 'succeeded', CARD_AUTHENTICATION_REQUIRED: 'authentication_required', diff --git a/src/components/AccountSwitcher.tsx b/src/components/AccountSwitcher.tsx new file mode 100644 index 000000000000..ba30ea0062b9 --- /dev/null +++ b/src/components/AccountSwitcher.tsx @@ -0,0 +1,202 @@ +import React, {useRef, useState} from 'react'; +import {View} from 'react-native'; +import {useOnyx} from 'react-native-onyx'; +import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useLocalize from '@hooks/useLocalize'; +import useNetwork from '@hooks/useNetwork'; +import usePermissions from '@hooks/usePermissions'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; +import {clearDelegatorErrors, connect, disconnect} from '@libs/actions/Delegate'; +import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils'; +import variables from '@styles/variables'; +import * as Modal from '@userActions/Modal'; +import CONST from '@src/CONST'; +import type {TranslationPaths} from '@src/languages/types'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {PersonalDetails} from '@src/types/onyx'; +import Avatar from './Avatar'; +import ConfirmModal from './ConfirmModal'; +import Icon from './Icon'; +import * as Expensicons from './Icon/Expensicons'; +import type {MenuItemProps} from './MenuItem'; +import MenuItemList from './MenuItemList'; +import type {MenuItemWithLink} from './MenuItemList'; +import Popover from './Popover'; +import {PressableWithFeedback} from './Pressable'; +import Text from './Text'; + +function AccountSwitcher() { + const currentUserPersonalDetails = useCurrentUserPersonalDetails(); + const styles = useThemeStyles(); + const theme = useTheme(); + const {translate} = useLocalize(); + const {isOffline} = useNetwork(); + const {canUseNewDotCopilot} = usePermissions(); + const {shouldUseNarrowLayout} = useResponsiveLayout(); + const [account] = useOnyx(ONYXKEYS.ACCOUNT); + const buttonRef = useRef(null); + + const [shouldShowDelegatorMenu, setShouldShowDelegatorMenu] = useState(false); + const [shouldShowOfflineModal, setShouldShowOfflineModal] = useState(false); + const delegators = account?.delegatedAccess?.delegators ?? []; + + const isActingAsDelegate = !!account?.delegatedAccess?.delegate ?? false; + const canSwitchAccounts = canUseNewDotCopilot && (delegators.length > 0 || isActingAsDelegate); + + const createBaseMenuItem = (personalDetails: PersonalDetails | undefined, error?: TranslationPaths, additionalProps: MenuItemWithLink = {}): MenuItemWithLink => { + return { + title: personalDetails?.displayName ?? personalDetails?.login, + description: personalDetails?.login, + avatarID: personalDetails?.accountID ?? -1, + icon: personalDetails?.avatar ?? '', + iconType: CONST.ICON_TYPE_AVATAR, + outerWrapperStyle: shouldUseNarrowLayout ? {} : styles.accountSwitcherPopover, + numberOfLinesDescription: 1, + errorText: error ? translate(error) : '', + shouldShowRedDotIndicator: !!error, + errorTextStyle: styles.mt2, + ...additionalProps, + }; + }; + + const menuItems = (): MenuItemProps[] => { + const currentUserMenuItem = createBaseMenuItem(currentUserPersonalDetails, undefined, { + wrapperStyle: [styles.buttonDefaultBG], + focused: true, + shouldShowRightIcon: true, + iconRight: Expensicons.Checkmark, + success: true, + key: `${currentUserPersonalDetails?.login}-current`, + }); + + if (isActingAsDelegate) { + const delegateEmail = account?.delegatedAccess?.delegate ?? ''; + + // Avoid duplicating the current user in the list when switching accounts + if (delegateEmail === currentUserPersonalDetails.login) { + return [currentUserMenuItem]; + } + + const delegatePersonalDetails = PersonalDetailsUtils.getPersonalDetailByEmail(delegateEmail); + const error = account?.delegatedAccess?.error; + + return [ + createBaseMenuItem(delegatePersonalDetails, error, { + onPress: () => { + if (isOffline) { + Modal.close(() => setShouldShowOfflineModal(true)); + return; + } + disconnect(); + }, + key: `${delegateEmail}-delegate`, + }), + currentUserMenuItem, + ]; + } + + const delegatorMenuItems: MenuItemProps[] = delegators + .filter(({email}) => email !== currentUserPersonalDetails.login) + .map(({email, role, error}, index) => { + const personalDetails = PersonalDetailsUtils.getPersonalDetailByEmail(email); + return createBaseMenuItem(personalDetails, error, { + badgeText: translate('delegate.role', role), + onPress: () => { + if (isOffline) { + Modal.close(() => setShouldShowOfflineModal(true)); + return; + } + connect(email); + }, + key: `${email}-${index}`, + }); + }); + + return [currentUserMenuItem, ...delegatorMenuItems]; + }; + + return ( + <> + { + setShouldShowDelegatorMenu(!shouldShowDelegatorMenu); + }} + ref={buttonRef} + interactive={canSwitchAccounts} + wrapperStyle={[styles.flexGrow1, styles.flex1, styles.mnw0, styles.justifyContentCenter]} + > + + + + + + {currentUserPersonalDetails?.displayName} + + {canSwitchAccounts && ( + + + + )} + + + {currentUserPersonalDetails?.login} + + + + + {canSwitchAccounts && ( + { + setShouldShowDelegatorMenu(false); + clearDelegatorErrors(); + }} + anchorRef={buttonRef} + anchorPosition={styles.accountSwitcherAnchorPosition} + > + + {translate('delegate.switchAccount')} + + + + )} + setShouldShowOfflineModal(false)} + onCancel={() => setShouldShowOfflineModal(false)} + confirmText={translate('common.buttonConfirm')} + prompt={translate('common.offlinePrompt')} + shouldShowCancelButton={false} + /> + + ); +} + +AccountSwitcher.displayName = 'AccountSwitcher'; + +export default AccountSwitcher; diff --git a/src/components/CurrentUserPersonalDetailsSkeletonView/index.tsx b/src/components/AccountSwitcherSkeletonView/index.tsx similarity index 50% rename from src/components/CurrentUserPersonalDetailsSkeletonView/index.tsx rename to src/components/AccountSwitcherSkeletonView/index.tsx index 21e82c26f769..eb01a23e9ade 100644 --- a/src/components/CurrentUserPersonalDetailsSkeletonView/index.tsx +++ b/src/components/AccountSwitcherSkeletonView/index.tsx @@ -6,10 +6,9 @@ import SkeletonViewContentLoader from '@components/SkeletonViewContentLoader'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; -import variables from '@styles/variables'; import CONST from '@src/CONST'; -type CurrentUserPersonalDetailsSkeletonViewProps = { +type AccountSwitcherSkeletonViewProps = { /** Whether to animate the skeleton view */ shouldAnimate?: boolean; @@ -17,45 +16,43 @@ type CurrentUserPersonalDetailsSkeletonViewProps = { avatarSize?: ValueOf; }; -function CurrentUserPersonalDetailsSkeletonView({shouldAnimate = true, avatarSize = CONST.AVATAR_SIZE.LARGE}: CurrentUserPersonalDetailsSkeletonViewProps) { +function AccountSwitcherSkeletonView({shouldAnimate = true, avatarSize = CONST.AVATAR_SIZE.LARGE}: AccountSwitcherSkeletonViewProps) { const theme = useTheme(); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); const avatarPlaceholderSize = StyleUtils.getAvatarSize(avatarSize); const avatarPlaceholderRadius = avatarPlaceholderSize / 2; - const spaceBetweenAvatarAndHeadline = styles.mb3.marginBottom + styles.mt1.marginTop + (variables.lineHeightXXLarge - variables.fontSizeXLarge) / 2; - const headlineSize = variables.fontSizeXLarge; - const spaceBetweenHeadlineAndLabel = styles.mt1.marginTop + (variables.lineHeightXXLarge - variables.fontSizeXLarge) / 2; - const labelSize = variables.fontSizeLabel; + const startPositionX = 30; + return ( ); } -CurrentUserPersonalDetailsSkeletonView.displayName = 'CurrentUserPersonalDetailsSkeletonView'; -export default CurrentUserPersonalDetailsSkeletonView; +AccountSwitcherSkeletonView.displayName = 'AccountSwitcherSkeletonView'; +export default AccountSwitcherSkeletonView; diff --git a/src/components/Icon/Expensicons.ts b/src/components/Icon/Expensicons.ts index b1adf360bae6..9d59accb498d 100644 --- a/src/components/Icon/Expensicons.ts +++ b/src/components/Icon/Expensicons.ts @@ -33,6 +33,7 @@ import Calendar from '@assets/images/calendar.svg'; import Camera from '@assets/images/camera.svg'; import CarWithKey from '@assets/images/car-with-key.svg'; import Car from '@assets/images/car.svg'; +import CaretUpDown from '@assets/images/caret-up-down.svg'; import Cash from '@assets/images/cash.svg'; import Chair from '@assets/images/chair.svg'; import ChatBubbleAdd from '@assets/images/chatbubble-add.svg'; @@ -387,5 +388,6 @@ export { Filters, CalendarSolid, Filter, + CaretUpDown, Feed, }; diff --git a/src/components/MenuItemList.tsx b/src/components/MenuItemList.tsx index 4ba9260e23ff..c03bd712d74c 100644 --- a/src/components/MenuItemList.tsx +++ b/src/components/MenuItemList.tsx @@ -11,6 +11,9 @@ type MenuItemLink = string | (() => Promise); type MenuItemWithLink = MenuItemProps & { /** The link to open when the menu item is clicked */ link?: MenuItemLink; + + /** A unique key for the menu item */ + key?: string; }; type MenuItemListProps = { @@ -43,7 +46,7 @@ function MenuItemList({menuItems = [], shouldUseSingleExecution = false}: MenuIt <> {menuItems.map((menuItemProps) => ( secondaryInteraction(menuItemProps.link, e) : undefined} ref={popoverAnchor} shouldBlockSelection={!!menuItemProps.link} diff --git a/src/components/PopoverMenu.tsx b/src/components/PopoverMenu.tsx index f7dd40177275..ce022c4f03b4 100644 --- a/src/components/PopoverMenu.tsx +++ b/src/components/PopoverMenu.tsx @@ -270,6 +270,7 @@ function PopoverMenu({ renderTooltipContent={item.renderTooltipContent} numberOfLinesTitle={item.numberOfLinesTitle} interactive={item.interactive} + badgeText={item.badgeText} /> ))} diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index dd175023a52c..a5d8e48ed546 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -389,7 +389,7 @@ function Search({queryJSON, isCustomQuery}: SearchProps) { /> setOfflineModalVisible(false)} secondOptionText={translate('common.buttonConfirm')} diff --git a/src/languages/en.ts b/src/languages/en.ts index f78b544fbfee..4f8ca9065d8a 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -2,6 +2,7 @@ import {CONST as COMMON_CONST, Str} from 'expensify-common'; import {startCase} from 'lodash'; import CONST from '@src/CONST'; import type {Country} from '@src/CONST'; +import type {DelegateRole} from '@src/types/onyx/Account'; import type {ConnectionName, PolicyConnectionSyncStage, SageIntacctMappingName} from '@src/types/onyx/Policy'; import type { AddressLineParams, @@ -375,6 +376,7 @@ export default { filterLogs: 'Filter Logs', network: 'Network', reportID: 'Report ID', + offlinePrompt: "You can't take this action right now.", outstanding: 'Outstanding', days: 'days', }, @@ -3776,7 +3778,6 @@ export default { unhold: 'Unhold', noOptionsAvailable: 'No options available for the selected group of expenses.', }, - offlinePrompt: "You can't take this action right now.", filtersHeader: 'Filters', filters: { date: { @@ -4488,4 +4489,18 @@ export default { updateRoomDescription: 'set the room description to:', clearRoomDescription: 'cleared the room description', }, + delegate: { + switchAccount: 'Switch accounts:', + role: (role: DelegateRole): string => { + switch (role) { + case CONST.DELEGATE_ROLE.ALL: + return 'Full'; + case CONST.DELEGATE_ROLE.SUBMITTER: + return 'Limited'; + default: + return ''; + } + }, + genericError: 'Oops, something went wrong. Please try again.', + }, } satisfies TranslationBase; diff --git a/src/languages/es.ts b/src/languages/es.ts index aeb292b4723d..37f569dad09a 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -1,5 +1,6 @@ import {Str} from 'expensify-common'; import CONST from '@src/CONST'; +import type {DelegateRole} from '@src/types/onyx/Account'; import type {ConnectionName, PolicyConnectionSyncStage, SageIntacctMappingName} from '@src/types/onyx/Policy'; import type { AddressLineParams, @@ -365,6 +366,7 @@ export default { filterLogs: 'Registros de filtrado', network: 'La red', reportID: 'ID del informe', + offlinePrompt: 'No puedes realizar esta acción ahora mismo.', outstanding: 'Pendiente', days: 'días', }, @@ -3826,7 +3828,6 @@ export default { unhold: 'Desbloquear', noOptionsAvailable: 'No hay opciones disponibles para el grupo de gastos seleccionado.', }, - offlinePrompt: 'No puedes realizar esta acción ahora mismo.', filtersHeader: 'Filtros', filters: { date: { @@ -5004,4 +5005,18 @@ export default { updateRoomDescription: 'establece la descripción de la sala a:', clearRoomDescription: 'la descripción de la habitación ha sido borrada', }, + delegate: { + switchAccount: 'Cambiar de cuenta:', + role: (role: DelegateRole): string => { + switch (role) { + case CONST.DELEGATE_ROLE.ALL: + return 'Completo'; + case CONST.DELEGATE_ROLE.SUBMITTER: + return 'Limitado'; + default: + return ''; + } + }, + genericError: '¡Ups! Ha ocurrido un error. Por favor, inténtalo de nuevo.', + }, } satisfies EnglishTranslation; diff --git a/src/libs/API/parameters/ConnectAsDelegateParams.ts b/src/libs/API/parameters/ConnectAsDelegateParams.ts new file mode 100644 index 000000000000..6bd2c666e1b0 --- /dev/null +++ b/src/libs/API/parameters/ConnectAsDelegateParams.ts @@ -0,0 +1,5 @@ +type ConnectAsDelegateParams = { + to: string; +}; + +export default ConnectAsDelegateParams; diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts index e06b819c84a5..e9025b0e18b9 100644 --- a/src/libs/API/parameters/index.ts +++ b/src/libs/API/parameters/index.ts @@ -272,6 +272,7 @@ export type {default as ExportSearchItemsToCSVParams} from './ExportSearchItemsT export type {default as UpdateExpensifyCardLimitParams} from './UpdateExpensifyCardLimitParams'; export type {CreateWorkspaceApprovalParams, UpdateWorkspaceApprovalParams, RemoveWorkspaceApprovalParams} from './WorkspaceApprovalParams'; export type {default as StartIssueNewCardFlowParams} from './StartIssueNewCardFlowParams'; +export type {default as ConnectAsDelegateParams} from './ConnectAsDelegateParams'; export type {default as SetPolicyRulesEnabledParams} from './SetPolicyRulesEnabledParams'; export type {default as SetPolicyExpenseMaxAmountNoReceipt} from './SetPolicyExpenseMaxAmountNoReceipt'; export type {default as SetPolicyExpenseMaxAmount} from './SetPolicyExpenseMaxAmount'; diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index e0255dfb38fc..2eda177891bc 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -831,6 +831,8 @@ const SIDE_EFFECT_REQUEST_COMMANDS = { REVEAL_EXPENSIFY_CARD_DETAILS: 'RevealExpensifyCardDetails', SWITCH_TO_OLD_DOT: 'SwitchToOldDot', TWO_FACTOR_AUTH_VALIDATE: 'TwoFactorAuth_Validate', + CONNECT_AS_DELEGATE: 'ConnectAsDelegate', + DISCONNECT_AS_DELEGATE: 'DisconnectAsDelegate', } as const; type SideEffectRequestCommand = ValueOf; @@ -847,6 +849,8 @@ type SideEffectRequestCommandParameters = { [SIDE_EFFECT_REQUEST_COMMANDS.ADD_PAYMENT_CARD_GBP]: Parameters.AddPaymentCardParams; [SIDE_EFFECT_REQUEST_COMMANDS.ACCEPT_SPOTNANA_TERMS]: null; [SIDE_EFFECT_REQUEST_COMMANDS.TWO_FACTOR_AUTH_VALIDATE]: Parameters.ValidateTwoFactorAuthParams; + [SIDE_EFFECT_REQUEST_COMMANDS.CONNECT_AS_DELEGATE]: Parameters.ConnectAsDelegateParams; + [SIDE_EFFECT_REQUEST_COMMANDS.DISCONNECT_AS_DELEGATE]: EmptyObject; }; type ApiRequestCommandParameters = WriteCommandParameters & ReadCommandParameters & SideEffectRequestCommandParameters; diff --git a/src/libs/Permissions.ts b/src/libs/Permissions.ts index 58bf57449362..466a0418910c 100644 --- a/src/libs/Permissions.ts +++ b/src/libs/Permissions.ts @@ -36,6 +36,10 @@ function canUseNetSuiteUSATax(betas: OnyxEntry): boolean { return !!betas?.includes(CONST.BETAS.NETSUITE_USA_TAX) || canUseAllBetas(betas); } +function canUseNewDotCopilot(betas: OnyxEntry): boolean { + return !!betas?.includes(CONST.BETAS.NEW_DOT_COPILOT) || canUseAllBetas(betas); +} + function canUseWorkspaceRules(betas: OnyxEntry): boolean { return !!betas?.includes(CONST.BETAS.WORKSPACE_RULES) || canUseAllBetas(betas); } @@ -61,6 +65,7 @@ export default { canUseSpotnanaTravel, canUseWorkspaceFeeds, canUseNetSuiteUSATax, + canUseNewDotCopilot, canUseWorkspaceRules, canUseCombinedTrackSubmit, }; diff --git a/src/libs/actions/Delegate.ts b/src/libs/actions/Delegate.ts new file mode 100644 index 000000000000..4797506d1a3c --- /dev/null +++ b/src/libs/actions/Delegate.ts @@ -0,0 +1,159 @@ +import Onyx from 'react-native-onyx'; +import type {OnyxUpdate} from 'react-native-onyx'; +import * as API from '@libs/API'; +import {SIDE_EFFECT_REQUEST_COMMANDS} from '@libs/API/types'; +import Log from '@libs/Log'; +import * as NetworkStore from '@libs/Network/NetworkStore'; +import * as SequentialQueue from '@libs/Network/SequentialQueue'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {DelegatedAccess} from '@src/types/onyx/Account'; +import {confirmReadyToOpenApp, openApp} from './App'; +import updateSessionAuthTokens from './Session/updateSessionAuthTokens'; + +let delegatedAccess: DelegatedAccess; +Onyx.connect({ + key: ONYXKEYS.ACCOUNT, + callback: (val) => { + delegatedAccess = val?.delegatedAccess ?? {}; + }, +}); + +const KEYS_TO_PRESERVE_DELEGATE_ACCESS = [ONYXKEYS.NVP_TRY_FOCUS_MODE, ONYXKEYS.PREFERRED_THEME, ONYXKEYS.NVP_PREFERRED_LOCALE, ONYXKEYS.SESSION]; + +function connect(email: string) { + if (!delegatedAccess?.delegators) { + return; + } + + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.ACCOUNT, + value: { + delegatedAccess: { + delegators: delegatedAccess.delegators.map((delegator) => (delegator.email === email ? {...delegator, error: undefined} : delegator)), + }, + }, + }, + ]; + + const successData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.ACCOUNT, + value: { + delegatedAccess: { + delegators: delegatedAccess.delegators.map((delegator) => (delegator.email === email ? {...delegator, error: undefined} : delegator)), + }, + }, + }, + ]; + + const failureData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.ACCOUNT, + value: { + delegatedAccess: { + delegators: delegatedAccess.delegators.map((delegator) => (delegator.email === email ? {...delegator, error: 'delegate.genericError'} : delegator)), + }, + }, + }, + ]; + + // We need to access the authToken directly from the response to update the session + // eslint-disable-next-line rulesdir/no-api-side-effects-method + API.makeRequestWithSideEffects(SIDE_EFFECT_REQUEST_COMMANDS.CONNECT_AS_DELEGATE, {to: email}, {optimisticData, successData, failureData}) + .then((response) => { + if (!response?.restrictedToken || !response?.encryptedAuthToken) { + Log.alert('[Delegate] No auth token returned while connecting as a delegate'); + Onyx.update(failureData); + return; + } + return SequentialQueue.waitForIdle() + .then(() => Onyx.clear(KEYS_TO_PRESERVE_DELEGATE_ACCESS)) + .then(() => { + // Update authToken in Onyx and in our local variables so that API requests will use the new authToken + updateSessionAuthTokens(response?.restrictedToken, response?.encryptedAuthToken); + + NetworkStore.setAuthToken(response?.restrictedToken ?? null); + confirmReadyToOpenApp(); + openApp(); + }); + }) + .catch((error) => { + Log.alert('[Delegate] Error connecting as delegate', {error}); + Onyx.update(failureData); + }); +} + +function disconnect() { + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.ACCOUNT, + value: { + delegatedAccess: { + error: null, + }, + }, + }, + ]; + + const successData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.ACCOUNT, + value: { + delegatedAccess: { + error: null, + }, + }, + }, + ]; + + const failureData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.ACCOUNT, + value: { + delegatedAccess: { + error: 'delegate.genericError', + }, + }, + }, + ]; + + // We need to access the authToken directly from the response to update the session + // eslint-disable-next-line rulesdir/no-api-side-effects-method + API.makeRequestWithSideEffects(SIDE_EFFECT_REQUEST_COMMANDS.DISCONNECT_AS_DELEGATE, {}, {optimisticData, successData, failureData}) + .then((response) => { + if (!response?.authToken || !response?.encryptedAuthToken) { + Log.alert('[Delegate] No auth token returned while disconnecting as a delegate'); + return; + } + + return SequentialQueue.waitForIdle() + .then(() => Onyx.clear(KEYS_TO_PRESERVE_DELEGATE_ACCESS)) + .then(() => { + // Update authToken in Onyx and in our local variables so that API requests will use the new authToken + updateSessionAuthTokens(response?.authToken, response?.encryptedAuthToken); + + NetworkStore.setAuthToken(response?.authToken ?? null); + confirmReadyToOpenApp(); + openApp(); + }); + }) + .catch((error) => { + Log.alert('[Delegate] Error disconnecting as a delegate', {error}); + }); +} + +function clearDelegatorErrors() { + if (!delegatedAccess?.delegators) { + return; + } + Onyx.merge(ONYXKEYS.ACCOUNT, {delegatedAccess: {delegators: delegatedAccess.delegators.map((delegator) => ({...delegator, error: undefined}))}}); +} + +export {connect, clearDelegatorErrors, disconnect}; diff --git a/src/pages/settings/InitialSettingsPage.tsx b/src/pages/settings/InitialSettingsPage.tsx index 20e42aba01d8..0cfcad3d731d 100755 --- a/src/pages/settings/InitialSettingsPage.tsx +++ b/src/pages/settings/InitialSettingsPage.tsx @@ -6,9 +6,9 @@ import {View} from 'react-native'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import {useOnyx, withOnyx} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; -import AvatarWithImagePicker from '@components/AvatarWithImagePicker'; +import AccountSwitcher from '@components/AccountSwitcher'; +import AccountSwitcherSkeletonView from '@components/AccountSwitcherSkeletonView'; import ConfirmModal from '@components/ConfirmModal'; -import CurrentUserPersonalDetailsSkeletonView from '@components/CurrentUserPersonalDetailsSkeletonView'; import Icon from '@components/Icon'; import * as Expensicons from '@components/Icon/Expensicons'; import MenuItem from '@components/MenuItem'; @@ -37,7 +37,6 @@ import * as ReportActionContextMenu from '@pages/home/report/ContextMenu/ReportA import variables from '@styles/variables'; import * as Link from '@userActions/Link'; import * as PaymentMethods from '@userActions/PaymentMethods'; -import * as PersonalDetails from '@userActions/PersonalDetails'; import * as Session from '@userActions/Session'; import * as Wallet from '@userActions/Wallet'; import CONST from '@src/CONST'; @@ -51,9 +50,6 @@ import {isEmptyObject} from '@src/types/utils/EmptyObject'; import type IconAsset from '@src/types/utils/IconAsset'; type InitialSettingsPageOnyxProps = { - /** The user's session */ - session: OnyxEntry; - /** The user's wallet account */ userWallet: OnyxEntry; @@ -97,14 +93,14 @@ type MenuData = { type Menu = {sectionStyle: StyleProp; sectionTranslationKey: TranslationPaths; items: MenuData[]}; -function InitialSettingsPage({session, userWallet, bankAccountList, fundList, walletTerms, loginList, currentUserPersonalDetails, policies}: InitialSettingsPageProps) { +function InitialSettingsPage({userWallet, bankAccountList, fundList, walletTerms, loginList, currentUserPersonalDetails, policies}: InitialSettingsPageProps) { const network = useNetwork(); const theme = useTheme(); const styles = useThemeStyles(); const {isExecuting, singleExecution} = useSingleExecution(); const waitForNavigate = useWaitForNavigation(); const popoverAnchor = useRef(null); - const {translate, formatPhoneNumber} = useLocalize(); + const {translate} = useLocalize(); const activeCentralPaneRoute = useActiveCentralPaneRoute(); const emojiCode = currentUserPersonalDetails?.status?.emojiCode ?? ''; @@ -361,92 +357,35 @@ function InitialSettingsPage({session, userWallet, bankAccountList, fundList, wa const generalMenuItems = useMemo(() => getMenuItemsSection(generalMenuItemsData), [generalMenuItemsData, getMenuItemsSection]); const workspaceMenuItems = useMemo(() => getMenuItemsSection(workspaceMenuItemsData), [workspaceMenuItemsData, getMenuItemsSection]); - const currentUserDetails = currentUserPersonalDetails; - const avatarURL = currentUserDetails?.avatar ?? ''; - const accountID = currentUserDetails?.accountID ?? '-1'; - const headerContent = ( - + {isEmptyObject(currentUserPersonalDetails) || currentUserPersonalDetails.displayName === undefined ? ( - + ) : ( - <> - - - Navigation.navigate(ROUTES.SETTINGS_SHARE_CODE)} - > - + + + + Navigation.navigate(ROUTES.SETTINGS_STATUS)} + > + + {emojiCode ? ( + {emojiCode} + ) : ( - - - - - Navigation.navigate(ROUTES.SETTINGS_STATUS)} - > - - {emojiCode ? ( - {emojiCode} - ) : ( - - )} - - - - - - Navigation.navigate(ROUTES.PROFILE_AVATAR.getRoute(String(accountID)))} - previewSource={UserUtils.getFullSizeAvatar(avatarURL, accountID)} - originalFileName={currentUserDetails.originalFileName} - headerTitle={translate('profilePage.profileAvatar')} - fallbackIcon={currentUserDetails?.fallbackIcon} - editIconStyle={styles.smallEditIconAccount} - /> - - - {currentUserPersonalDetails.displayName ? currentUserPersonalDetails.displayName : formatPhoneNumber(session?.email ?? '')} - - {!!currentUserPersonalDetails.displayName && ( - - {formatPhoneNumber(session?.email ?? '')} - - )} - + )} + + + + )} ); @@ -526,9 +465,6 @@ export default withCurrentUserPersonalDetails( loginList: { key: ONYXKEYS.LOGIN_LIST, }, - session: { - key: ONYXKEYS.SESSION, - }, policies: { key: ONYXKEYS.COLLECTION.POLICY, }, diff --git a/src/pages/settings/Profile/ProfilePage.tsx b/src/pages/settings/Profile/ProfilePage.tsx index 988b68ca5bfe..b62342aa56f4 100755 --- a/src/pages/settings/Profile/ProfilePage.tsx +++ b/src/pages/settings/Profile/ProfilePage.tsx @@ -2,14 +2,20 @@ import React from 'react'; import {View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import {withOnyx} from 'react-native-onyx'; +import AvatarWithImagePicker from '@components/AvatarWithImagePicker'; import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import Icon from '@components/Icon'; +import * as Expensicons from '@components/Icon/Expensicons'; import * as Illustrations from '@components/Icon/Illustrations'; import MenuItemGroup from '@components/MenuItemGroup'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import {PressableWithFeedback} from '@components/Pressable'; import ScreenWrapper from '@components/ScreenWrapper'; import ScrollView from '@components/ScrollView'; import Section from '@components/Section'; +import Text from '@components/Text'; +import Tooltip from '@components/Tooltip'; import type {WithCurrentUserPersonalDetailsProps} from '@components/withCurrentUserPersonalDetails'; import withCurrentUserPersonalDetails from '@components/withCurrentUserPersonalDetails'; import useLocalize from '@hooks/useLocalize'; @@ -21,6 +27,8 @@ import * as LocalePhoneNumber from '@libs/LocalePhoneNumber'; import Navigation from '@libs/Navigation/Navigation'; import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils'; import * as UserUtils from '@libs/UserUtils'; +import variables from '@styles/variables'; +import * as PersonalDetails from '@userActions/PersonalDetails'; import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -68,6 +76,9 @@ function ProfilePage({ return pronounsKey ? translate(`pronouns.${pronounsKey}` as TranslationPaths) : translate('profilePage.selectYourPronouns'); }; + const avatarURL = currentUserPersonalDetails?.avatar ?? ''; + const accountID = currentUserPersonalDetails?.accountID ?? '-1'; + const contactMethodBrickRoadIndicator = UserUtils.getLoginListBrickRoadIndicator(loginList); const emojiCode = currentUserPersonalDetails?.status?.emojiCode ?? ''; const privateDetails = privatePersonalDetails ?? {}; @@ -143,6 +154,27 @@ function ProfilePage({ childrenStyles={styles.pt5} titleStyles={styles.accountSettingsSectionTitle} > + + Navigation.navigate(ROUTES.PROFILE_AVATAR.getRoute(String(accountID)))} + previewSource={UserUtils.getFullSizeAvatar(avatarURL, accountID)} + originalFileName={currentUserPersonalDetails.originalFileName} + headerTitle={translate('profilePage.profileAvatar')} + fallbackIcon={currentUserPersonalDetails?.fallbackIcon} + editIconStyle={styles.profilePageAvatar} + /> + {publicOptions.map((detail, index) => ( ))} + + + Navigation.navigate(ROUTES.SETTINGS_SHARE_CODE)} + style={[styles.button, styles.flexRow, styles.gap1, styles.ph4]} + > + + {translate('common.share')} + + +
- {translate('workspace.intacct.prerequisitesTitle')} + {translate('workspace.intacct.prerequisitesTitle')} avatarSectionWrapperSkeleton: { width: '100%', - paddingHorizontal: 20, - paddingBottom: 20, }, avatarSectionWrapperSettings: { @@ -4529,6 +4527,10 @@ const styles = (theme: ThemeColors) => overflow: 'hidden', }, + profilePageAvatar: { + borderColor: theme.highlightBG, + }, + justSignedInModalAnimation: (is2FARequired: boolean) => ({ height: is2FARequired ? variables.modalTopIconHeight : variables.modalTopBigIconHeight, }), @@ -5171,6 +5173,15 @@ const styles = (theme: ThemeColors) => marginLeft: 19, backgroundColor: theme.border, }, + + accountSwitcherPopover: { + width: variables.sideBarWidth - 19, + }, + + accountSwitcherAnchorPosition: { + top: 80, + left: 12, + }, } satisfies Styles); type ThemeStyles = ReturnType; diff --git a/src/styles/utils/sizing.ts b/src/styles/utils/sizing.ts index 8d75294564b8..f4be70391eb5 100644 --- a/src/styles/utils/sizing.ts +++ b/src/styles/utils/sizing.ts @@ -52,6 +52,10 @@ export default { minHeight: 0, }, + mnw0: { + minWidth: 0, + }, + mnw2: { minWidth: 8, }, diff --git a/src/styles/utils/spacing.ts b/src/styles/utils/spacing.ts index 25e11cdf4f89..92edd6e7fa18 100644 --- a/src/styles/utils/spacing.ts +++ b/src/styles/utils/spacing.ts @@ -377,7 +377,7 @@ export default { padding: 20, }, - pb6: { + p6: { padding: 24, }, @@ -613,6 +613,10 @@ export default { paddingBottom: 20, }, + pb6: { + paddingBottom: 24, + }, + pb8: { paddingBottom: 32, }, diff --git a/src/types/onyx/Account.ts b/src/types/onyx/Account.ts index e0f6fb79331f..4edb029d02e0 100644 --- a/src/types/onyx/Account.ts +++ b/src/types/onyx/Account.ts @@ -1,11 +1,42 @@ import type {ValueOf} from 'type-fest'; import type CONST from '@src/CONST'; +import type {TranslationPaths} from '@src/languages/types'; import type DismissedReferralBanners from './DismissedReferralBanners'; import type * as OnyxCommon from './OnyxCommon'; /** Two factor authentication steps */ type TwoFactorAuthStep = ValueOf | ''; +/** The role of the delegate */ +type DelegateRole = ValueOf; + +/** Model of delegate */ +type Delegate = { + /** The email of the delegate */ + email: string; + + /** The role of the delegate */ + role: DelegateRole; + + /** Authentication failure errors */ + error?: TranslationPaths; +}; + +/** Model of delegated access data */ +type DelegatedAccess = { + /** The users that can access your account as a delegate */ + delegates?: Delegate[]; + + /** The the users you can access as a delegate */ + delegators?: Delegate[]; + + /** The email of original user when they are acting as a delegate for another account */ + delegate?: string; + + /** Authentication failure errors when disconnecting as a copilot */ + error?: TranslationPaths; +}; + /** Model of user account */ type Account = { /** Whether SAML is enabled for the current account */ @@ -88,7 +119,10 @@ type Account = { /** Indicates whether the user has at least one previous purchase */ hasPurchases?: boolean; + + /** The users you can access as delegate and the users who can access your account as a delegate */ + delegatedAccess?: DelegatedAccess; }; export default Account; -export type {TwoFactorAuthStep}; +export type {TwoFactorAuthStep, DelegateRole, DelegatedAccess}; diff --git a/src/types/onyx/Response.ts b/src/types/onyx/Response.ts index c26280652254..ef558896c55e 100644 --- a/src/types/onyx/Response.ts +++ b/src/types/onyx/Response.ts @@ -41,6 +41,9 @@ type Response = { /** Used to load resources like attachment videos and images */ encryptedAuthToken?: string; + /** User session auth token when connecting as a delegate */ + restrictedToken?: string; + /** Used to pass error messages for error handling purposes */ message?: string;