diff --git a/src/languages/de.ts b/src/languages/de.ts index 736eab6999d3..1cec84db8ddd 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -7935,6 +7935,9 @@ Hier ist ein *Testbeleg*, um dir zu zeigen, wie es funktioniert:`, addAdmin: 'Admin hinzufügen', invite: 'Einladen', addAdminError: 'Dieser Benutzer kann nicht als Admin hinzugefügt werden. Bitte versuche es erneut.', + revokeAdminAccess: 'Administratorzugriff widerrufen', + cantRevokeAdminAccess: 'Adminzugriff kann dem technischen Ansprechpartner nicht entzogen werden', + error: {removeAdmin: 'Dieser Benutzer kann nicht als Admin entfernt werden. Bitte versuchen Sie es erneut.'}, }, }, gps: { diff --git a/src/languages/en.ts b/src/languages/en.ts index a0c754e62dbb..e3be80bd4f39 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -7828,6 +7828,11 @@ const translations = { addAdmin: 'Add admin', invite: 'Invite', addAdminError: 'Unable to add this member as an admin. Please try again.', + revokeAdminAccess: 'Revoke admin access', + cantRevokeAdminAccess: "Can't revoke admin access from the technical contact", + error: { + removeAdmin: 'Unable to remove this user as an Admin. Please try again.', + }, }, }, }; diff --git a/src/languages/es.ts b/src/languages/es.ts index 2ed90a809ac4..dccbf5528ca3 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -7974,6 +7974,9 @@ ${amount} para ${merchant} - ${date}`, addAdmin: 'Añadir administrador', invite: 'Invitar', addAdminError: 'No se pudo añadir a este miembro como administrador. Por favor, inténtalo de nuevo.', + revokeAdminAccess: 'Revocar acceso de administrador', + cantRevokeAdminAccess: 'No se puede revocar el acceso de administrador del contacto técnico', + error: {removeAdmin: 'No se pudo eliminar a este usuario como administrador. Por favor, inténtalo de nuevo.'}, }, }, gps: { diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 6974bab3986c..9887f7c0068a 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -7940,6 +7940,9 @@ Voici un *reçu test* pour vous montrer comment cela fonctionne :`, addAdmin: 'Ajouter un administrateur', invite: 'Inviter', addAdminError: 'Impossible d’ajouter ce membre en tant qu’administrateur. Veuillez réessayer.', + revokeAdminAccess: 'Révoquer l’accès administrateur', + cantRevokeAdminAccess: 'Impossible de révoquer l’accès administrateur au contact technique', + error: {removeAdmin: 'Impossible de supprimer cet utilisateur en tant qu’administrateur. Veuillez réessayer.'}, }, }, gps: { diff --git a/src/languages/it.ts b/src/languages/it.ts index 0c6a41bf918b..314d375882cc 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -7918,6 +7918,9 @@ Ecco una *ricevuta di prova* per mostrarti come funziona:`, addAdmin: 'Aggiungi amministratore', invite: 'Invita', addAdminError: 'Impossibile aggiungere questo membro come amministratore. Riprova.', + revokeAdminAccess: 'Revoca accesso amministratore', + cantRevokeAdminAccess: 'Impossibile revocare i privilegi di amministratore dal referente tecnico', + error: {removeAdmin: 'Impossibile rimuovere questo utente come amministratore. Riprova.'}, }, }, gps: { diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 385fc00e960b..2ed752b2245e 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -7856,6 +7856,9 @@ Expensify の使い方をお見せするための*テストレシート*がこ addAdmin: '管理者を追加', invite: '招待', addAdminError: 'このメンバーを管理者として追加できません。もう一度お試しください。', + revokeAdminAccess: '管理者アクセスを取り消す', + cantRevokeAdminAccess: '技術連絡先から管理者アクセス権を取り消すことはできません', + error: {removeAdmin: 'このユーザーを管理者として削除できません。もう一度お試しください。'}, }, }, gps: { diff --git a/src/languages/nl.ts b/src/languages/nl.ts index d405ca615944..c8608d4d4453 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -7904,6 +7904,9 @@ Hier is een *testbon* om je te laten zien hoe het werkt:`, addAdmin: 'Beheerder toevoegen', invite: 'Uitnodigen', addAdminError: 'Kan dit lid niet als beheerder toevoegen. Probeer het opnieuw.', + revokeAdminAccess: 'Beheerdersrechten intrekken', + cantRevokeAdminAccess: 'Kan de beheerdersrechten niet intrekken van de technische contactpersoon', + error: {removeAdmin: 'Kan deze gebruiker niet als beheerder verwijderen. Probeer het opnieuw.'}, }, }, gps: { diff --git a/src/languages/pl.ts b/src/languages/pl.ts index a60e02d7c502..ca3c7d455be3 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -7888,6 +7888,9 @@ Oto *paragon testowy*, który pokazuje, jak to działa:`, addAdmin: 'Dodaj administratora', invite: 'Zaproś', addAdminError: 'Nie można dodać tego członka jako administratora. Spróbuj ponownie.', + revokeAdminAccess: 'Cofnij uprawnienia administratora', + cantRevokeAdminAccess: 'Nie można odebrać uprawnień administratora kontaktowi technicznemu', + error: {removeAdmin: 'Nie można usunąć tego użytkownika jako administratora. Spróbuj ponownie.'}, }, }, gps: { diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 5c6d81a4e554..696ca54c8810 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -7894,6 +7894,9 @@ Aqui está um *recibo de teste* para mostrar como funciona:`, addAdmin: 'Adicionar administrador', invite: 'Convidar', addAdminError: 'Não foi possível adicionar este membro como administrador. Tente novamente.', + revokeAdminAccess: 'Revogar acesso de administrador', + cantRevokeAdminAccess: 'Não é possível revogar o acesso de administrador do contato técnico', + error: {removeAdmin: 'Não foi possível remover este usuário como Administrador. Tente novamente.'}, }, }, gps: { diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 1783d8af0106..c0bd7b3d2e6f 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -7712,6 +7712,9 @@ ${reportName} addAdmin: '添加管理员', invite: '邀请', addAdminError: '无法将此成员添加为管理员。请重试。', + revokeAdminAccess: '撤销管理员访问权限', + cantRevokeAdminAccess: '无法撤销技术联系人的管理员访问权限', + error: {removeAdmin: '无法将此用户移除管理员角色。请重试。'}, }, }, gps: { diff --git a/src/libs/API/parameters/RemoveDomainAdminParams.ts b/src/libs/API/parameters/RemoveDomainAdminParams.ts new file mode 100644 index 000000000000..bac1692f5cf4 --- /dev/null +++ b/src/libs/API/parameters/RemoveDomainAdminParams.ts @@ -0,0 +1,6 @@ +type RemoveDomainAdminParams = { + domainAccountID: number; + targetAccountID: number; +}; + +export default RemoveDomainAdminParams; diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts index bece054a0237..b96f3f6d7c66 100644 --- a/src/libs/API/parameters/index.ts +++ b/src/libs/API/parameters/index.ts @@ -454,3 +454,4 @@ export type {default as UpdateSamlRequiredParams} from './UpdateSamlRequiredPara export type {default as SetPolicyRequireCompanyCardsEnabledParams} from './SetPolicyRequireCompanyCardsEnabled'; export type {default as SetTechnicalContactEmailParams} from './SetTechnicalContactEmailParams'; export type {default as ToggleConsolidatedDomainBillingParams} from './ToggleConsolidatedDomainBillingParams'; +export type {default as RemoveDomainAdminParams} from './RemoveDomainAdminParams'; diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index 0e2fca6d06cd..00a08c966c88 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -531,6 +531,7 @@ const WRITE_COMMANDS = { SET_TECHNICAL_CONTACT_EMAIL: 'SetTechnicalContactEmail', TOGGLE_CONSOLIDATED_DOMAIN_BILLING: 'ToggleConsolidatedDomainBilling', ADD_DOMAIN_ADMIN: 'AddDomainAdmin', + REMOVE_DOMAIN_ADMIN: 'RemoveDomainAdmin', } as const; type WriteCommand = ValueOf; @@ -1082,6 +1083,7 @@ type WriteCommandParameters = { [WRITE_COMMANDS.CREATE_DOMAIN]: Parameters.DomainParams; [WRITE_COMMANDS.SET_TECHNICAL_CONTACT_EMAIL]: Parameters.SetTechnicalContactEmailParams; [WRITE_COMMANDS.TOGGLE_CONSOLIDATED_DOMAIN_BILLING]: Parameters.ToggleConsolidatedDomainBillingParams; + [WRITE_COMMANDS.REMOVE_DOMAIN_ADMIN]: Parameters.RemoveDomainAdminParams; [WRITE_COMMANDS.ADD_DOMAIN_ADMIN]: Parameters.AddAdminToDomainParams; }; diff --git a/src/libs/actions/Domain.ts b/src/libs/actions/Domain.ts index f0750f498b79..25e041c7798b 100644 --- a/src/libs/actions/Domain.ts +++ b/src/libs/actions/Domain.ts @@ -1,7 +1,7 @@ import Onyx from 'react-native-onyx'; import type {OnyxUpdate} from 'react-native-onyx'; import * as API from '@libs/API'; -import type {AddAdminToDomainParams, SetTechnicalContactEmailParams, ToggleConsolidatedDomainBillingParams} from '@libs/API/parameters'; +import type {AddAdminToDomainParams, RemoveDomainAdminParams, SetTechnicalContactEmailParams, ToggleConsolidatedDomainBillingParams} from '@libs/API/parameters'; import {READ_COMMANDS, SIDE_EFFECT_REQUEST_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; import {getMicroSecondOnyxErrorWithTranslationKey} from '@libs/ErrorUtils'; import {getAuthToken} from '@libs/Network/NetworkStore'; @@ -625,15 +625,9 @@ function addAdminToDomain(domainAccountID: number, accountID: number, targetEmai } /** - * Removes an error after trying to add admin + * Removes an error and pending actions after trying to add admin */ -function clearAddAdminError(domainAccountID: number, accountID: number) { - const PERMISSION_KEY = `${ONYXKEYS.COLLECTION.EXPENSIFY_ADMIN_ACCESS_PREFIX}${accountID}`; - - Onyx.merge(`${ONYXKEYS.COLLECTION.DOMAIN}${domainAccountID}`, { - [PERMISSION_KEY]: null, - }); - +function clearAdminError(domainAccountID: number, accountID: number) { Onyx.merge(`${ONYXKEYS.COLLECTION.DOMAIN_ERRORS}${domainAccountID}`, { adminErrors: { [accountID]: null, @@ -646,6 +640,66 @@ function clearAddAdminError(domainAccountID: number, accountID: number) { }, }); } +/** + * Removes admin access for a domain member + */ +function revokeDomainAdminAccess(domainAccountID: number, accountID: number) { + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.DOMAIN_PENDING_ACTIONS}${domainAccountID}`, + value: { + admin: { + [accountID]: { + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, + }, + }, + }, + }, + ]; + const successData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.DOMAIN_PENDING_ACTIONS}${domainAccountID}`, + value: { + admin: { + [accountID]: { + pendingAction: null, + }, + }, + }, + }, + ]; + const failureData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.DOMAIN_PENDING_ACTIONS}${domainAccountID}`, + value: { + admin: { + [accountID]: { + pendingAction: null, + }, + }, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.DOMAIN_ERRORS}${domainAccountID}`, + value: { + adminErrors: { + [accountID]: {errors: getMicroSecondOnyxErrorWithTranslationKey('domain.admins.error.removeAdmin')}, + }, + }, + }, + ]; + + const parameters: RemoveDomainAdminParams = { + domainAccountID, + targetAccountID: accountID, + }; + + API.write(WRITE_COMMANDS.REMOVE_DOMAIN_ADMIN, parameters, {optimisticData, successData, failureData}); +} export { getDomainValidationCode, @@ -666,5 +720,6 @@ export { toggleConsolidatedDomainBilling, clearToggleConsolidatedDomainBillingErrors, addAdminToDomain, - clearAddAdminError, + clearAdminError, + revokeDomainAdminAccess, }; diff --git a/src/pages/domain/Admins/DomainAdminDetailsPage.tsx b/src/pages/domain/Admins/DomainAdminDetailsPage.tsx index e7007125178e..0d9eb0b582a4 100644 --- a/src/pages/domain/Admins/DomainAdminDetailsPage.tsx +++ b/src/pages/domain/Admins/DomainAdminDetailsPage.tsx @@ -6,14 +6,17 @@ import Avatar from '@components/Avatar'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import MenuItem from '@components/MenuItem'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import {ModalActions} from '@components/Modal/Global/ModalContext'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import ScreenWrapper from '@components/ScreenWrapper'; import ScrollView from '@components/ScrollView'; import Text from '@components/Text'; +import useConfirmModal from '@hooks/useConfirmModal'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import useThemeStyles from '@hooks/useThemeStyles'; +import {revokeDomainAdminAccess} from '@libs/actions/Domain'; import {getDisplayNameOrDefault, getPhoneNumber} from '@libs/PersonalDetailsUtils'; import Navigation from '@navigation/Navigation'; import type {PlatformStackScreenProps} from '@navigation/PlatformStackNavigation/types'; @@ -23,6 +26,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; +import {adminAccountIDsSelector, domainSettingsPrimaryContactSelector} from '@src/selectors/Domain'; import type {PersonalDetailsList} from '@src/types/onyx'; type DomainAdminDetailsPageProps = PlatformStackScreenProps; @@ -31,7 +35,17 @@ function DomainAdminDetailsPage({route}: DomainAdminDetailsPageProps) { const {domainAccountID, accountID} = route.params; const styles = useThemeStyles(); const {translate, formatPhoneNumber} = useLocalize(); - const icons = useMemoizedLazyExpensifyIcons(['Info'] as const); + const icons = useMemoizedLazyExpensifyIcons(['Info', 'ClosedSign'] as const); + + const [primaryContact] = useOnyx(`${ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_DOMAIN_MEMBER}${domainAccountID}`, { + selector: domainSettingsPrimaryContactSelector, + canBeMissing: true, + }); + + const [adminAccountIDs] = useOnyx(`${ONYXKEYS.COLLECTION.DOMAIN}${domainAccountID}`, { + canBeMissing: true, + selector: adminAccountIDsSelector, + }); // eslint-disable-next-line rulesdir/no-inline-useOnyx-selector const [adminPersonalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, { @@ -41,10 +55,32 @@ function DomainAdminDetailsPage({route}: DomainAdminDetailsPageProps) { const displayName = formatPhoneNumber(getDisplayNameOrDefault(adminPersonalDetails)); const memberLogin = adminPersonalDetails?.login ?? ''; + const isCurrentUserPrimaryContact = primaryContact === memberLogin; const isSMSLogin = Str.isSMSLogin(memberLogin); const phoneNumber = getPhoneNumber(adminPersonalDetails); const fallbackIcon = adminPersonalDetails?.fallbackIcon ?? ''; + const domainHasOnlyOneAdmin = adminAccountIDs?.length === 1; + const {showConfirmModal} = useConfirmModal(); + + const handleRevokeAdminAccess = async () => { + const confirmResult = await showConfirmModal({ + title: translate('domain.admins.revokeAdminAccess'), + prompt: translate('workspace.people.removeMemberPrompt', {memberName: displayName}), + confirmText: translate('common.remove'), + cancelText: translate('common.cancel'), + + shouldShowCancelButton: true, + danger: true, + }); + if (confirmResult.action !== ModalActions.CONFIRM) { + return; + } + + revokeDomainAdminAccess(route.params.domainAccountID, route.params.accountID); + Navigation.dismissModal(); + }; + return ( + {!domainHasOnlyOneAdmin && ( + + )} , errors: getLatestError(domainErrors?.adminErrors?.[accountID]?.errors), - pendingAction: domainPendingActions?.admin?.[accountID]?.pendingAction, + pendingAction: domainPendingAction?.[accountID]?.pendingAction, + isInteractive: !isPendingActionDelete, + isDisabled: isPendingActionDelete, }); } @@ -183,7 +194,7 @@ function DomainAdminsPage({route}: DomainAdminsPageProps) { showScrollIndicator={false} addBottomSafeAreaPadding customListHeader={getCustomListHeader()} - onDismissError={(item: AdminOption) => clearAddAdminError(domainAccountID, item.accountID)} + onDismissError={(item: AdminOption) => clearAdminError(domainAccountID, item.accountID)} /> diff --git a/src/selectors/Domain.ts b/src/selectors/Domain.ts index 3ad5a02d53e5..8bf073bbede1 100644 --- a/src/selectors/Domain.ts +++ b/src/selectors/Domain.ts @@ -1,7 +1,7 @@ import {Str} from 'expensify-common'; import type {OnyxEntry} from 'react-native-onyx'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {CardFeeds, Domain, SamlMetadata} from '@src/types/onyx'; +import type {CardFeeds, Domain, DomainPendingActions, DomainSettings, SamlMetadata} from '@src/types/onyx'; import getEmptyArray from '@src/types/utils/getEmptyArray'; const domainMemberSamlSettingsSelector = (domainSettings: OnyxEntry) => domainSettings?.settings; @@ -18,6 +18,8 @@ const domainSamlSettingsStateSelector = (domain: OnyxEntry) => const domainNameSelector = (domain: OnyxEntry) => (domain?.email ? Str.extractEmailDomain(domain.email) : undefined); +const domainSettingsPrimaryContactSelector = (domainSettings: OnyxEntry) => domainSettings?.settings?.technicalContactEmail; + const metaIdentitySelector = (samlMetadata: OnyxEntry) => samlMetadata?.metaIdentity; /** @@ -55,12 +57,16 @@ const technicalContactSettingsSelector = (domainMemberSharedNVP: OnyxEntry) => domain?.email; +const adminPendingActionSelector = (pendingAction: OnyxEntry) => pendingAction?.admin ?? {}; + export { domainMemberSamlSettingsSelector, + domainSettingsPrimaryContactSelector, domainSamlSettingsStateSelector, domainNameSelector, metaIdentitySelector, adminAccountIDsSelector, technicalContactSettingsSelector, domainEmailSelector, + adminPendingActionSelector, }; diff --git a/src/types/onyx/CardFeeds.ts b/src/types/onyx/CardFeeds.ts index 5c0b70783870..204b6540b56d 100644 --- a/src/types/onyx/CardFeeds.ts +++ b/src/types/onyx/CardFeeds.ts @@ -148,6 +148,9 @@ type DomainSettings = { /** Encrypted SCIM token, exists only when Okta is enabled for the domain by support */ oktaSCIM?: string; + + /** Email to primary contact from the domain */ + technicalContactEmail?: string; }; }; diff --git a/src/types/onyx/DomainPendingActions.ts b/src/types/onyx/DomainPendingActions.ts index 9bf6a78d497f..fda27e5a1cf4 100644 --- a/src/types/onyx/DomainPendingActions.ts +++ b/src/types/onyx/DomainPendingActions.ts @@ -1,7 +1,8 @@ import type * as OnyxCommon from './OnyxCommon'; /** - * General pending action structure for domain admins + * General pending action structure for domain admins. + * Pending actions structure is dictated by how `domain_` updates are handled in the app to prevent them from resetting unintentionally. */ type GeneralDomainAdminPendingAction = { /** diff --git a/tests/unit/DomainSelectorsTest.ts b/tests/unit/DomainSelectorsTest.ts index 15fe44fbd13d..51c332323428 100644 --- a/tests/unit/DomainSelectorsTest.ts +++ b/tests/unit/DomainSelectorsTest.ts @@ -1,9 +1,11 @@ -import {adminAccountIDsSelector, domainEmailSelector, technicalContactSettingsSelector} from '@selectors/Domain'; +import {adminAccountIDsSelector, adminPendingActionSelector, domainEmailSelector, domainSettingsPrimaryContactSelector, technicalContactSettingsSelector} from '@selectors/Domain'; import type {OnyxEntry} from 'react-native-onyx'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {CardFeeds, Domain} from '@src/types/onyx'; +import type {CardFeeds, Domain, DomainPendingActions, DomainSettings} from '@src/types/onyx'; describe('domainSelectors', () => { + const userID1 = 123; + const userID2 = 456; describe('adminAccountIDsSelector', () => { it('Should return an empty array if the domain object is undefined', () => { expect(adminAccountIDsSelector(undefined)).toEqual([]); @@ -118,4 +120,47 @@ describe('domainSelectors', () => { expect(domainEmailSelector(domain)).toBeUndefined(); }); }); + + describe('domainSettingsPrimaryContactSelector', () => { + it.each([ + ['undefined', undefined, undefined], + ['empty object', {} as OnyxEntry, undefined], + ['settings without technicalContactEmail', {settings: {}} as OnyxEntry, undefined], + ])('Should return undefined when domainSettings is %s', (_description, domainSettings, expected) => { + expect(domainSettingsPrimaryContactSelector(domainSettings)).toBe(expected); + }); + + it('Should return the technical contact email when it exists', () => { + const domainSettings = { + settings: { + technicalContactEmail: 'admin@example.com', + }, + } as OnyxEntry; + + expect(domainSettingsPrimaryContactSelector(domainSettings)).toBe('admin@example.com'); + }); + }); + + describe('adminPendingActionSelector', () => { + it.each([ + ['undefined', undefined, {}], + ['empty object', {} as OnyxEntry, {}], + ])('Should return empty object when pendingAction is %s', (_description, pendingAction, expected) => { + expect(adminPendingActionSelector(pendingAction)).toEqual(expected); + }); + + it('Should return the admin pending actions when they exist', () => { + const pendingAction: OnyxEntry = { + admin: { + [userID1]: {pendingAction: 'update'}, + [userID2]: {pendingAction: 'delete'}, + }, + }; + + expect(adminPendingActionSelector(pendingAction)).toEqual({ + [userID1]: {pendingAction: 'update'}, + [userID2]: {pendingAction: 'delete'}, + }); + }); + }); });