diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 4789213e0fff0..72618615dd36b 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -9285,6 +9285,7 @@ const CONST = { }, BULK_ACTION_TYPES: { CLOSE_ACCOUNT: 'closeAccount', + MOVE_TO_GROUP: 'moveToGroup', }, }, }, diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index cc6a4747174a2..5dae9efde53b8 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -422,6 +422,9 @@ const ONYXKEYS = { /** A map of the user's security group IDs they belong to in specific domains */ MY_DOMAIN_SECURITY_GROUPS: 'myDomainSecurityGroups', + /** Selected domain member account IDs for the move-to-group operation */ + DOMAIN_MEMBERS_SELECTED_FOR_MOVE: 'domainMembersSelectedForMove', + // The theme setting set by the user in preferences. // This can be either "light", "dark" or "system" PREFERRED_THEME: 'nvp_preferredTheme', @@ -1371,6 +1374,7 @@ type OnyxValuesMapping = { [ONYXKEYS.IS_BETA]: boolean; [ONYXKEYS.IS_CHECKING_PUBLIC_ROOM]: boolean; [ONYXKEYS.MY_DOMAIN_SECURITY_GROUPS]: Record; + [ONYXKEYS.DOMAIN_MEMBERS_SELECTED_FOR_MOVE]: string[]; [ONYXKEYS.VERIFY_3DS_SUBSCRIPTION]: string; [ONYXKEYS.PREFERRED_THEME]: ValueOf; [ONYXKEYS.MAPBOX_ACCESS_TOKEN]: OnyxTypes.MapboxAccessToken; diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 952a920d1f6e0..be0bba0e2eef8 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -4011,6 +4011,10 @@ const ROUTES = { route: 'domain/:domainAccountID/members/:accountID/reset-two-factor-auth', getRoute: (domainAccountID: number, accountID: number) => `domain/${domainAccountID}/members/${accountID}/reset-two-factor-auth` as const, }, + DOMAIN_MEMBERS_MOVE_TO_GROUP: { + route: 'domain/:domainAccountID/members/move', + getRoute: (domainAccountID: number) => `domain/${domainAccountID}/members/move` as const, + }, MULTIFACTOR_AUTHENTICATION_MAGIC_CODE: `multifactor-authentication/magic-code`, MULTIFACTOR_AUTHENTICATION_BIOMETRICS_TEST: 'multifactor-authentication/scenario/biometrics-test', diff --git a/src/SCREENS.ts b/src/SCREENS.ts index 51fffec8051df..c2911da70cd71 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -964,6 +964,7 @@ const SCREENS = { MEMBER_RESET_TWO_FACTOR_AUTH: 'Member_Reset_Two_Factor_Auth', MEMBER_FORCE_TWO_FACTOR_AUTH: 'Member_Force_Two_Factor_Auth', MEMBER_LOCK_ACCOUNT: 'Member_Lock_Account', + MEMBERS_MOVE_TO_GROUP: 'Members_Move_To_Group', }, MULTIFACTOR_AUTHENTICATION: { MAGIC_CODE: 'Multifactor_Authentication_Magic_Code', diff --git a/src/hooks/useClearSelectedDomainMembersOnMoveComplete.ts b/src/hooks/useClearSelectedDomainMembersOnMoveComplete.ts new file mode 100644 index 0000000000000..b9f0f24402d21 --- /dev/null +++ b/src/hooks/useClearSelectedDomainMembersOnMoveComplete.ts @@ -0,0 +1,28 @@ +import {useEffect} from 'react'; +import ONYXKEYS from '@src/ONYXKEYS'; +import useOnyx from './useOnyx'; +import usePrevious from './usePrevious'; + +/** + * Clears local member selection after move flow completion by reacting to a + * transition of `DOMAIN_MEMBERS_SELECTED_FOR_MOVE` from non-empty to empty. + */ +function useClearSelectedDomainMembersOnMoveComplete(clearSelectedMembers: () => void) { + const [selectedMemberAccountIDs] = useOnyx(ONYXKEYS.DOMAIN_MEMBERS_SELECTED_FOR_MOVE, {initWithStoredValues: false}); + const prevSelectedMemberAccountIDs = usePrevious(selectedMemberAccountIDs); + const selectedCount = selectedMemberAccountIDs?.length ?? 0; + const previousSelectedCount = prevSelectedMemberAccountIDs?.length ?? 0; + + useEffect(() => { + const hadSelectionBefore = previousSelectedCount > 0; + const hasNoSelectionNow = selectedCount === 0; + + if (!hadSelectionBefore || !hasNoSelectionNow) { + return; + } + + clearSelectedMembers(); + }, [selectedCount, previousSelectedCount, clearSelectedMembers]); +} + +export default useClearSelectedDomainMembersOnMoveComplete; diff --git a/src/languages/de.ts b/src/languages/de.ts index bd820231e28ff..5ebdef0fef610 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -8696,12 +8696,15 @@ Hier ist ein *Testbeleg*, um dir zu zeigen, wie es funktioniert:`, removeMember: 'Dieser Benutzer kann nicht entfernt werden. Bitte versuche es erneut.', addMember: 'Dieses Mitglied kann nicht hinzugefügt werden. Bitte versuche es erneut.', vacationDelegate: 'Dieser Benutzer kann nicht als Urlaubsvertretung festgelegt werden. Bitte versuche es erneut.', + moveMember: 'Dieses Mitglied kann nicht verschoben werden. Bitte versuchen Sie es erneut.', }, reportSuspiciousActivityPrompt: (email: string) => `Bist du sicher? Dadurch wird das Konto von ${email} gesperrt.

Unser Team wird das Konto anschließend überprüfen und unbefugten Zugriff entfernen. Um den Zugriff wiederherzustellen, muss die Person mit Concierge zusammenarbeiten.`, reportSuspiciousActivityConfirmationPrompt: 'Wir überprüfen das Konto, um sicherzustellen, dass es sicher entsperrt werden kann, und melden uns bei Fragen über Concierge.', cannotSetVacationDelegateForMember: (email: string) => `Du kannst keine Urlaubsvertretung für ${email} festlegen, weil sie derzeit die Vertretung für folgende Mitglieder sind:`, emptyMembers: {title: 'Keine Mitglieder in dieser Gruppe', subtitle: 'Fügen Sie ein Mitglied hinzu oder versuchen Sie, den Filter oben zu ändern.'}, + moveToGroup: 'In Gruppe verschieben', + chooseWhereToMove: ({count}: {count: number}) => `Wählen Sie aus, wohin Sie ${count} ${count === 1 ? 'Mitglied' : 'Mitglieder'} verschieben möchten.`, }, common: { settings: 'Einstellungen', diff --git a/src/languages/en.ts b/src/languages/en.ts index b4e1508bb9210..8c38a7802605d 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -8680,9 +8680,12 @@ const translations = { one: 'Close account', other: 'Close accounts', }), + moveToGroup: 'Move to group', + chooseWhereToMove: ({count}: {count: number}) => `Choose where to move ${count} ${count === 1 ? 'member' : 'members'}.`, error: { addMember: 'Unable to add this member. Please try again.', removeMember: 'Unable to remove this user. Please try again.', + moveMember: 'Unable to move this member. Please try again.', vacationDelegate: 'Unable to set this user as a vacation delegate. Please try again.', }, cannotSetVacationDelegateForMember: (email: string) => `You can't set a vacation delegate for ${email} because they're currently the delegate for the following members:`, diff --git a/src/languages/es.ts b/src/languages/es.ts index ab9197e032d18..50fc2be5752e8 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -8966,6 +8966,7 @@ ${amount} para ${merchant} - ${date}`, removeMember: 'No se pudo eliminar a este usuario. Por favor, inténtalo de nuevo.', addMember: 'No se pudo añadir este miembro. Por favor, inténtalo de nuevo.', vacationDelegate: 'No se pudo establecer a este usuario como delegado de vacaciones. Por favor, inténtalo de nuevo.', + moveMember: 'No se pudo mover este miembro. Por favor, inténtalo de nuevo.', }, cannotSetVacationDelegateForMember: (email: string) => `No puedes establecer un delegado de vacaciones para ${email} porque actualmente es el delegado de los siguientes miembros:`, @@ -8974,6 +8975,8 @@ ${amount} para ${merchant} - ${date}`, `¿Estás seguro? Esto bloqueará la cuenta de ${email}.

Nuestro equipo revisará la cuenta y eliminará cualquier acceso no autorizado. Para recuperar el acceso, deberá comunicarse con Concierge.`, reportSuspiciousActivityConfirmationPrompt: 'Revisaremos la cuenta para verificar que sea seguro desbloquearla y nos comunicaremos a través de Concierge si tenemos alguna pregunta.', + moveToGroup: 'Mover al grupo', + chooseWhereToMove: ({count}: {count: number}) => `Elige a dónde mover ${count} ${count === 1 ? 'miembro' : 'miembros'}.`, }, common: { settings: 'Configuración', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 4964d388b12d6..1e7b2505a5a2d 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -8719,6 +8719,7 @@ Voici un *reçu test* pour vous montrer comment ça fonctionne :`, removeMember: 'Impossible de supprimer cet utilisateur. Veuillez réessayer.', addMember: 'Impossible d’ajouter ce membre. Veuillez réessayer.', vacationDelegate: 'Impossible de définir cet utilisateur comme délégué de vacances. Veuillez réessayer.', + moveMember: 'Impossible de déplacer ce membre. Veuillez réessayer.', }, cannotSetVacationDelegateForMember: (email: string) => `Vous ne pouvez pas définir un délégué de vacances pour ${email}, car cette personne est actuellement le délégué des membres suivants :`, @@ -8727,6 +8728,8 @@ Voici un *reçu test* pour vous montrer comment ça fonctionne :`, reportSuspiciousActivityConfirmationPrompt: 'Nous examinerons le compte pour vérifier qu’il est sûr de le déverrouiller et nous vous contacterons via Concierge si nous avons des questions.', emptyMembers: {title: 'Aucun membre dans ce groupe', subtitle: 'Ajoutez un membre ou essayez de modifier le filtre ci-dessus.'}, + moveToGroup: 'Déplacer vers le groupe', + chooseWhereToMove: ({count}: {count: number}) => `Choisissez où déplacer ${count} ${count === 1 ? 'membre' : 'membres'}.`, }, common: { settings: 'Paramètres', diff --git a/src/languages/it.ts b/src/languages/it.ts index 741ea51e048bb..32c369e7e811d 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -8685,12 +8685,15 @@ Ecco una *ricevuta di prova* per mostrarti come funziona:`, removeMember: 'Impossibile rimuovere questo utente. Riprova.', addMember: 'Impossibile aggiungere questo membro. Riprova.', vacationDelegate: 'Impossibile impostare questo utente come delegato per le ferie. Riprova.', + moveMember: 'Impossibile spostare questo membro. Riprova.', }, cannotSetVacationDelegateForMember: (email: string) => `Non puoi impostare un delegato per le vacanze per ${email} perché al momento è il delegato per i seguenti membri:`, reportSuspiciousActivityPrompt: (email: string) => `Sei sicuro? Questo bloccherà l’account di ${email}.

Il nostro team esaminerà quindi l’account e rimuoverà qualsiasi accesso non autorizzato. Per riottenere l’accesso, dovranno collaborare con Concierge.`, reportSuspiciousActivityConfirmationPrompt: 'Esamineremo l’account per verificare che sia sicuro sbloccarlo e ti contatteremo tramite Concierge per qualsiasi domanda.', emptyMembers: {title: 'Nessun membro in questo gruppo', subtitle: 'Aggiungi un membro o prova a cambiare il filtro qui sopra.'}, + moveToGroup: 'Sposta nel gruppo', + chooseWhereToMove: ({count}: {count: number}) => `Scegli dove spostare ${count} ${count === 1 ? 'membro' : 'membri'}.`, }, common: { settings: 'Impostazioni', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index d75514e28111f..3a738041f22bc 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -8576,12 +8576,15 @@ ${reportName} removeMember: 'このユーザーを削除できません。もう一度お試しください。', addMember: 'このメンバーを追加できませんでした。もう一度お試しください。', vacationDelegate: 'このユーザーを休暇代理人として設定できませんでした。もう一度お試しください。', + moveMember: 'このメンバーを移動できませんでした。もう一度お試しください。', }, cannotSetVacationDelegateForMember: (email: string) => `${email} に休暇代理人を設定できません。現在、このユーザーは次のメンバーの代理人になっています。`, reportSuspiciousActivityPrompt: (email: string) => `本当によろしいですか?これにより、${email} さんのアカウントがロックされます。

その後、当社のチームがアカウントを確認し、不正アクセスを削除します。アクセスを回復するには、Concierge と連携して対応してもらう必要があります。`, reportSuspiciousActivityConfirmationPrompt: 'アカウントが安全にロック解除できることを確認するために審査し、質問がある場合はConciergeを通じてご連絡します。', emptyMembers: {title: 'このグループにはメンバーがいません', subtitle: 'メンバーを追加するか、上のフィルターを変更してみてください。'}, + moveToGroup: 'グループへ移動', + chooseWhereToMove: ({count}: {count: number}) => `${count} ${count === 1 ? 'メンバー' : 'メンバー'} を移動する先を選択してください。`, }, common: { settings: '設定', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index e2c7ef1695199..80c96dbeb11f5 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -8659,6 +8659,7 @@ Hier is een *proefbon* om je te laten zien hoe het werkt:`, removeMember: 'Kan deze gebruiker niet verwijderen. Probeer het opnieuw.', addMember: 'Kan dit lid niet toevoegen. Probeer het opnieuw.', vacationDelegate: 'Kan deze gebruiker niet als vakantiemandataris instellen. Probeer het opnieuw.', + moveMember: 'Kan dit lid niet verplaatsen. Probeer het opnieuw.', }, cannotSetVacationDelegateForMember: (email: string) => `Je kunt geen vakantiemandataris instellen voor ${email} omdat die persoon momenteel gedelegeerde is voor de volgende leden:`, @@ -8667,6 +8668,8 @@ Hier is een *proefbon* om je te laten zien hoe het werkt:`, reportSuspiciousActivityConfirmationPrompt: 'We beoordelen de account om te verifiëren dat het veilig is om deze te ontgrendelen en nemen via Concierge contact op als we vragen hebben.', emptyMembers: {title: 'Geen leden in deze groep', subtitle: 'Voeg een lid toe of probeer het filter hierboven te wijzigen.'}, + moveToGroup: 'Verplaatsen naar groep', + chooseWhereToMove: ({count}: {count: number}) => `Kies waar je ${count} ${count === 1 ? 'lid' : 'leden'} naartoe wilt verplaatsen.`, }, common: { settings: 'Instellingen', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 9855bfb024e4a..e16a961830027 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -8641,12 +8641,15 @@ Oto *paragon testowy*, żeby pokazać Ci, jak to działa:`, removeMember: 'Nie można usunąć tego użytkownika. Spróbuj ponownie.', addMember: 'Nie można dodać tego członka. Spróbuj ponownie.', vacationDelegate: 'Nie można ustawić tego użytkownika jako zastępującego na czas nieobecności. Spróbuj ponownie.', + moveMember: 'Nie można przenieść tego członka. Spróbuj ponownie.', }, cannotSetVacationDelegateForMember: (email: string) => `Nie możesz ustawić zastępstwa urlopowego dla ${email}, ponieważ jest on/ona obecnie zastępcą dla następujących członków:`, reportSuspiciousActivityPrompt: (email: string) => `Czy na pewno? To zablokuje konto użytkownika ${email}.

Nasz zespół następnie przejrzy konto i usunie wszelki nieautoryzowany dostęp. Aby odzyskać dostęp, będą musieli współpracować z Concierge.`, reportSuspiciousActivityConfirmationPrompt: 'Przejrzymy konto, aby potwierdzić, że bezpiecznie je odblokować, i skontaktujemy się przez Concierge w razie pytań.', emptyMembers: {title: 'Brak członków w tej grupie', subtitle: 'Dodaj członka lub spróbuj zmienić filtr powyżej.'}, + moveToGroup: 'Przenieś do grupy', + chooseWhereToMove: ({count}: {count: number}) => `Wybierz, dokąd przenieść ${count} ${count === 1 ? 'członek' : 'członkowie'}.`, }, common: { settings: 'Ustawienia', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 542114c7c64d5..a96c3e5b935aa 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -8650,12 +8650,15 @@ Aqui está um *comprovante de teste* para mostrar como funciona:`, removeMember: 'Não foi possível remover este usuário. Tente novamente.', addMember: 'Não foi possível adicionar este membro. Tente novamente.', vacationDelegate: 'Não foi possível definir este usuário como delegado de férias. Tente novamente.', + moveMember: 'Não foi possível mover este membro. Tente novamente.', }, cannotSetVacationDelegateForMember: (email: string) => `Você não pode definir um procurador de férias para ${email} porque esta pessoa já é procuradora dos seguintes membros:`, reportSuspiciousActivityPrompt: (email: string) => `Tem certeza? Isso irá bloquear a conta de ${email}.

Nossa equipe irá então analisar a conta e remover qualquer acesso não autorizado. Para recuperar o acesso, será necessário que trabalhem com a Concierge.`, reportSuspiciousActivityConfirmationPrompt: 'Vamos revisar a conta para verificar se é seguro desbloqueá-la e entraremos em contato via Concierge caso haja dúvidas.', emptyMembers: {title: 'Nenhum membro neste grupo', subtitle: 'Adicione um membro ou tente mudar o filtro acima.'}, + moveToGroup: 'Mover para grupo', + chooseWhereToMove: ({count}: {count: number}) => `Escolha para onde mover ${count} ${count === 1 ? 'membro' : 'membros'}.`, }, common: { settings: 'Configurações', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 2016926e8d786..fc0bb796bf5c7 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -8423,12 +8423,15 @@ ${reportName} removeMember: '无法移除此用户。请重试。', addMember: '无法添加此成员。请重试。', vacationDelegate: '无法将此用户设置为休假代理人。请重试。', + moveMember: '无法移动此成员。请重试。', }, cannotSetVacationDelegateForMember: (email: string) => `您无法为 ${email} 设置休假代理人,因为 TA 当前是以下成员的代理人:`, reportSuspiciousActivityPrompt: (email: string) => `你确定要这样做吗?这将锁定 ${email} 的账户。

我们的团队随后会审核该账户并移除任何未经授权的访问。若要重新获得访问权限,他们需要与 Concierge 配合处理。`, reportSuspiciousActivityConfirmationPrompt: '我们会审核账户以确认解锁是否安全,如有任何问题将通过 Concierge 与您联系。', emptyMembers: {title: '此群组中没有成员', subtitle: '添加成员或尝试更改上方的筛选条件。'}, + moveToGroup: '移至群组', + chooseWhereToMove: ({count}: {count: number}) => `选择将 ${count} 个 ${count === 1 ? '成员' : '成员'} 移动到哪里。`, }, common: { settings: '设置', diff --git a/src/libs/API/parameters/ChangeDomainSecurityGroupParams.ts b/src/libs/API/parameters/ChangeDomainSecurityGroupParams.ts new file mode 100644 index 0000000000000..f0b7ce63df86e --- /dev/null +++ b/src/libs/API/parameters/ChangeDomainSecurityGroupParams.ts @@ -0,0 +1,8 @@ +type ChangeDomainSecurityGroupParams = { + domainName: string; + newID: string; + employeeEmail: string; + domainAccountID: number; +}; + +export default ChangeDomainSecurityGroupParams; diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts index 7c8a0253e00cd..8b75ed7854909 100644 --- a/src/libs/API/parameters/index.ts +++ b/src/libs/API/parameters/index.ts @@ -482,6 +482,7 @@ export type {default as SetTechnicalContactEmailParams} from './SetTechnicalCont export type {default as ToggleConsolidatedDomainBillingParams} from './ToggleConsolidatedDomainBillingParams'; export type {default as RemoveDomainAdminParams} from './RemoveDomainAdminParams'; export type {default as DeleteDomainMemberParams} from './DeleteDomainMemberParams'; +export type {default as ChangeDomainSecurityGroupParams} from './ChangeDomainSecurityGroupParams'; export type {default as DeleteDomainParams} from './DeleteDomainParams'; export type {default as GetDuplicateTransactionDetailsParams} from './GetDuplicateTransactionDetailsParams'; export type {default as SetPolicyCategoryReceiptsAndItemizedReceiptRequiredParams} from './SetPolicyCategoryReceiptsAndItemizedReceiptRequiredParams'; diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index f630ee64c9245..eabce70d54d33 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -568,6 +568,7 @@ const WRITE_COMMANDS = { SET_TWO_FACTOR_AUTH_EXEMPT_EMAIL_FOR_DOMAIN: 'SetTwoFactorAuthExemptEmailForDomain', RESET_DOMAIN_MEMBER_TWO_FACTOR_AUTH: 'ResetDomainMemberTwoFactorAuth', EXPORT_DOMAIN_MEMBERS_CSV: 'ExportDomainMembersCSV', + CHANGE_DOMAIN_SECURITY_GROUP: 'ChangeDomainSecurityGroup', } as const; type WriteCommand = ValueOf; @@ -1153,6 +1154,7 @@ type WriteCommandParameters = { [WRITE_COMMANDS.SET_TWO_FACTOR_AUTH_EXEMPT_EMAIL_FOR_DOMAIN]: Parameters.SetTwoFactorAuthExemptEmailForDomainParams; [WRITE_COMMANDS.RESET_DOMAIN_MEMBER_TWO_FACTOR_AUTH]: Parameters.ResetDomainMemberTwoFactorAuthParams; [WRITE_COMMANDS.EXPORT_DOMAIN_MEMBERS_CSV]: Parameters.ExportDomainMembersCSVParams; + [WRITE_COMMANDS.CHANGE_DOMAIN_SECURITY_GROUP]: Parameters.ChangeDomainSecurityGroupParams; }; const READ_COMMANDS = { diff --git a/src/libs/DomainUtils.ts b/src/libs/DomainUtils.ts index e210206e45ae8..2de883b1c9b39 100644 --- a/src/libs/DomainUtils.ts +++ b/src/libs/DomainUtils.ts @@ -1,6 +1,9 @@ +import CONST from '@src/CONST'; import type DomainErrors from '@src/types/onyx/DomainErrors'; import type {DomainMemberErrors} from '@src/types/onyx/DomainErrors'; +import type DomainPendingAction from '@src/types/onyx/DomainPendingActions'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; +import {getLatestError} from './ErrorUtils'; /** * Checks if domain has any errors. Used to determine whether to show a red brick road indicator on domain row. @@ -38,11 +41,49 @@ function hasDomainMembersErrors(domainErrors?: DomainErrors): boolean { } function hasDomainMemberDetailsErrors(memberDetailsErrors: DomainMemberErrors): boolean { - return !isEmptyObject(memberDetailsErrors?.vacationDelegateErrors) || !isEmptyObject(memberDetailsErrors?.twoFactorAuthExemptEmailsError); + return ( + !isEmptyObject(memberDetailsErrors?.vacationDelegateErrors) || + !isEmptyObject(memberDetailsErrors?.twoFactorAuthExemptEmailsError) || + !isEmptyObject(memberDetailsErrors?.changeDomainSecurityGroupErrors) + ); } function hasDomainMembersSettingsErrors(domainErrors?: DomainErrors): boolean { return !isEmptyObject(domainErrors?.setTwoFactorAuthRequiredError); } -export {hasDomainErrors, hasDomainAdminsSettingsErrors, hasDomainAdminsErrors, hasDomainMembersErrors, hasDomainMemberDetailsErrors, hasDomainMembersSettingsErrors}; +/** + * Computes display props for a domain member row by merging errors and pending actions + * keyed by both accountID and email, since the backend may store them under either key. + * @param accountID - The numeric account ID of the member. + * @param domainPendingActions - Pending actions map for all domain members. + * @param domainErrors - All domain-level errors from Onyx. + * @param email - Optional email of the member; used to look up email-keyed errors and pending actions. + * @returns The latest merged error, the active pending action, and a brick road indicator if detail errors exist. + */ +function getMemberCustomRowProps(accountID: number, domainPendingActions: DomainPendingAction['member'], domainErrors: DomainErrors | undefined, email?: string) { + const emailErrors = email ? domainErrors?.memberErrors?.[email] : undefined; + const accountIDErrors = domainErrors?.memberErrors?.[accountID]; + const emailPendingActions = email ? domainPendingActions?.[email] : undefined; + const accountIDPendingActions = domainPendingActions?.[accountID]; + + const mergedErrors: DomainMemberErrors = { + errors: { + ...getLatestError(accountIDErrors?.errors), + ...getLatestError(accountIDErrors?.lockAccountErrors), + ...getLatestError({...accountIDErrors?.changeDomainSecurityGroupErrors, ...emailErrors?.changeDomainSecurityGroupErrors}), + ...getLatestError(emailErrors?.errors), + }, + // vacationDelegateErrors and twoFactorAuthExemptEmailsError appear on detail. Here used to set brickRoadIndicator to inform user about action to be taken on detail. + vacationDelegateErrors: {...getLatestError(accountIDErrors?.vacationDelegateErrors), ...getLatestError(emailErrors?.vacationDelegateErrors)}, + twoFactorAuthExemptEmailsError: {...getLatestError(accountIDErrors?.twoFactorAuthExemptEmailsError), ...getLatestError(emailErrors?.twoFactorAuthExemptEmailsError)}, + }; + + return { + errors: getLatestError(mergedErrors.errors), + pendingAction: emailPendingActions?.pendingAction ?? accountIDPendingActions?.pendingAction ?? accountIDPendingActions?.lockAccount ?? emailPendingActions?.changeDomainSecurityGroup, + brickRoadIndicator: hasDomainMemberDetailsErrors(mergedErrors) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined, + }; +} + +export {hasDomainErrors, hasDomainAdminsSettingsErrors, hasDomainAdminsErrors, hasDomainMembersErrors, hasDomainMemberDetailsErrors, hasDomainMembersSettingsErrors, getMemberCustomRowProps}; diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx index 69427c1a1949d..4bd9d2be249b0 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx @@ -911,6 +911,7 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../../pages/domain/Members/TwoFactorAuth/DomainMemberForceTwoFactorAuthPage').default, [SCREENS.DOMAIN.MEMBER_RESET_TWO_FACTOR_AUTH]: () => require('../../../../pages/domain/Members/TwoFactorAuth/DomainMemberResetTwoFactorAuthPage').default, [SCREENS.DOMAIN.MEMBER_LOCK_ACCOUNT]: () => require('../../../../pages/domain/Members/DomainReportSuspiciousActivityPage').default, + [SCREENS.DOMAIN.MEMBERS_MOVE_TO_GROUP]: () => require('../../../../pages/domain/Members/MoveUsersBetweenGroupsPage').default, }); const TwoFactorAuthenticatorStackNavigator = createModalStackNavigator({ diff --git a/src/libs/Navigation/linkingConfig/RELATIONS/DOMAIN_TO_RHP.ts b/src/libs/Navigation/linkingConfig/RELATIONS/DOMAIN_TO_RHP.ts index befebb211f4c5..b0a4b1f2b48e4 100755 --- a/src/libs/Navigation/linkingConfig/RELATIONS/DOMAIN_TO_RHP.ts +++ b/src/libs/Navigation/linkingConfig/RELATIONS/DOMAIN_TO_RHP.ts @@ -9,6 +9,7 @@ const DOMAIN_TO_RHP: Partial['config'] = { [SCREENS.DOMAIN.MEMBER_LOCK_ACCOUNT]: { path: ROUTES.DOMAIN_LOCK_ACCOUNT.route, }, + [SCREENS.DOMAIN.MEMBERS_MOVE_TO_GROUP]: { + path: ROUTES.DOMAIN_MEMBERS_MOVE_TO_GROUP.route, + }, }, }, [SCREENS.RIGHT_MODAL.TWO_FACTOR_AUTH]: { diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index d9447e03d14d9..96115792dd05f 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -1567,6 +1567,9 @@ type SettingsNavigatorParamList = { domainAccountID: number; accountID: number; }; + [SCREENS.DOMAIN.MEMBERS_MOVE_TO_GROUP]: { + domainAccountID: number; + }; } & ReimbursementAccountNavigatorParamList; type DomainCardNavigatorParamList = { diff --git a/src/libs/actions/Domain.ts b/src/libs/actions/Domain.ts index ad79c006e2527..7d79b955103ba 100644 --- a/src/libs/actions/Domain.ts +++ b/src/libs/actions/Domain.ts @@ -5,6 +5,7 @@ import * as API from '@libs/API'; import type { AddAdminToDomainParams, AddMemberToDomainParams, + ChangeDomainSecurityGroupParams, DeleteDomainMemberParams, DeleteDomainParams, RemoveDomainAdminParams, @@ -24,6 +25,7 @@ import {generateAccountID} from '@libs/UserUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Domain, DomainSecurityGroup, UserSecurityGroupData} from '@src/types/onyx'; +import type {SecurityGroupKey} from '@src/types/onyx/Domain'; import type {PendingAction} from '@src/types/onyx/OnyxCommon'; import type {BaseVacationDelegate} from '@src/types/onyx/VacationDelegate'; import type PrefixedRecord from '@src/types/utils/PrefixedRecord'; @@ -1691,6 +1693,132 @@ function exportMembersToCSV(domainAccountID: number, onDownloadFailed: () => voi fileDownload(translate, getCommandURL({command: WRITE_COMMANDS.EXPORT_DOMAIN_MEMBERS_CSV}), fileName, '', false, formData, CONST.NETWORK.METHOD.POST, onDownloadFailed); } +/** + * Moves a domain member from one security group to another with optimistic updates. + * @param domainAccountID - The account ID of the domain + * @param domainName - The name of the domain + * @param employeeEmail - The email of the member being moved + * @param accountID - The account ID of the member being moved + * @param currentSecurityGroupKey - The Onyx key of the member's current security group + * @param currentSecurityGroup - The current security group data + * @param targetSecurityGroupKey - The Onyx key of the target security group + */ +function changeDomainSecurityGroup( + domainAccountID: number, + domainName: string, + employeeEmail: string, + accountID: number, + currentSecurityGroupKey: SecurityGroupKey, + currentSecurityGroup: Partial, + targetSecurityGroupKey: SecurityGroupKey, +) { + const accountIDStr = String(accountID); + + const optimisticData: Array> = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.DOMAIN}${domainAccountID}`, + value: { + [currentSecurityGroupKey]: { + shared: { + [accountIDStr]: null, + }, + }, + [targetSecurityGroupKey]: { + shared: { + [accountIDStr]: 'read', + }, + }, + } as PrefixedRecord>, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.DOMAIN_PENDING_ACTIONS}${domainAccountID}`, + value: { + member: {[employeeEmail]: {changeDomainSecurityGroup: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE as PendingAction}}, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.DOMAIN_ERRORS}${domainAccountID}`, + value: { + memberErrors: {[employeeEmail]: null}, + }, + }, + ]; + + const successData: Array> = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.DOMAIN_PENDING_ACTIONS}${domainAccountID}`, + value: { + member: {[employeeEmail]: null}, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.DOMAIN_ERRORS}${domainAccountID}`, + value: { + memberErrors: {[employeeEmail]: null}, + }, + }, + ]; + + const failureData: Array> = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.DOMAIN}${domainAccountID}`, + value: { + [currentSecurityGroupKey]: { + shared: { + [accountIDStr]: currentSecurityGroup.shared?.[accountIDStr], + }, + }, + [targetSecurityGroupKey]: { + shared: { + [accountIDStr]: null, + }, + }, + } as PrefixedRecord>, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.DOMAIN_PENDING_ACTIONS}${domainAccountID}`, + value: { + member: {[employeeEmail]: null}, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.DOMAIN_ERRORS}${domainAccountID}`, + value: { + memberErrors: { + [employeeEmail]: {changeDomainSecurityGroupErrors: getMicroSecondOnyxErrorWithTranslationKey('domain.members.error.moveMember')}, + }, + }, + }, + ]; + + const newID = targetSecurityGroupKey.replace(CONST.DOMAIN.DOMAIN_SECURITY_GROUP_PREFIX, ''); + + const parameters: ChangeDomainSecurityGroupParams = { + domainName, + newID, + employeeEmail, + domainAccountID, + }; + + API.write(WRITE_COMMANDS.CHANGE_DOMAIN_SECURITY_GROUP, parameters, {optimisticData, successData, failureData}); +} + +function setDomainMembersSelectedForMove(memberAccountIDs: string[]) { + Onyx.set(ONYXKEYS.DOMAIN_MEMBERS_SELECTED_FOR_MOVE, memberAccountIDs); +} + +function clearDomainMembersSelectedForMove() { + Onyx.set(ONYXKEYS.DOMAIN_MEMBERS_SELECTED_FOR_MOVE, []); +} + export { getDomainValidationCode, validateDomain, @@ -1727,4 +1855,7 @@ export { clearTwoFactorAuthExemptEmailsErrors, resetDomainMemberTwoFactorAuth, exportMembersToCSV, + changeDomainSecurityGroup, + setDomainMembersSelectedForMove, + clearDomainMembersSelectedForMove, }; diff --git a/src/pages/domain/Members/DomainMembersPage.tsx b/src/pages/domain/Members/DomainMembersPage.tsx index 246bd67be5ba4..f3abe8be259e4 100644 --- a/src/pages/domain/Members/DomainMembersPage.tsx +++ b/src/pages/domain/Members/DomainMembersPage.tsx @@ -11,6 +11,7 @@ import DropdownButton from '@components/Search/FilterDropdowns/DropdownButton'; import SingleSelectPopup from '@components/Search/FilterDropdowns/SingleSelectPopup'; import CustomListHeader from '@components/SelectionListWithModal/CustomListHeader'; import Text from '@components/Text'; +import useClearSelectedDomainMembersOnMoveComplete from '@hooks/useClearSelectedDomainMembersOnMoveComplete'; import useConfirmModal from '@hooks/useConfirmModal'; import useDomainDocumentTitle from '@hooks/useDomainDocumentTitle'; import useDomainGroupFilter from '@hooks/useDomainGroupFilter'; @@ -22,10 +23,9 @@ import useOnyx from '@hooks/useOnyx'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useSearchBackPress from '@hooks/useSearchBackPress'; import useThemeStyles from '@hooks/useThemeStyles'; -import {clearDomainMemberError, closeUserAccount, exportMembersToCSV} from '@libs/actions/Domain'; +import {clearDomainMemberError, closeUserAccount, exportMembersToCSV, setDomainMembersSelectedForMove} from '@libs/actions/Domain'; import {turnOffMobileSelectionMode} from '@libs/actions/MobileSelectionMode'; -import {hasDomainMemberDetailsErrors, hasDomainMembersSettingsErrors} from '@libs/DomainUtils'; -import {getLatestError} from '@libs/ErrorUtils'; +import {getMemberCustomRowProps, hasDomainMembersSettingsErrors} from '@libs/DomainUtils'; import Navigation from '@navigation/Navigation'; import type {PlatformStackScreenProps} from '@navigation/PlatformStackNavigation/types'; import type {DomainSplitNavigatorParamList} from '@navigation/types'; @@ -34,7 +34,6 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; -import type {DomainMemberErrors} from '@src/types/onyx/DomainErrors'; type DomainMembersPageProps = PlatformStackScreenProps; @@ -43,7 +42,7 @@ function DomainMembersPage({route}: DomainMembersPageProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); const illustrations = useMemoizedLazyIllustrations(['Profile']); - const icons = useMemoizedLazyExpensifyIcons(['Plus', 'Gear', 'DotIndicator', 'RemoveMembers', 'Download']); + const icons = useMemoizedLazyExpensifyIcons(['Plus', 'Gear', 'DotIndicator', 'RemoveMembers', 'Download', 'Transfer']); const {shouldUseNarrowLayout} = useResponsiveLayout(); const [selectedMembers, setSelectedMembers] = useState([]); const clearSelectedMembers = () => setSelectedMembers([]); @@ -119,6 +118,7 @@ function DomainMembersPage({route}: DomainMembersPageProps) { /> ); }; + useClearSelectedDomainMembersOnMoveComplete(clearSelectedMembers); useSearchBackPress({ onClearSelection: clearSelectedMembers, @@ -179,6 +179,15 @@ function DomainMembersPage({route}: DomainMembersPageProps) { setIsModalVisible(true); }, }, + { + text: translate('domain.members.moveToGroup'), + value: CONST.DOMAIN.MEMBERS.BULK_ACTION_TYPES.MOVE_TO_GROUP, + icon: icons.Transfer, + onSelected: () => { + setDomainMembersSelectedForMove(selectedMembers); + Navigation.navigate(ROUTES.DOMAIN_MEMBERS_MOVE_TO_GROUP.getRoute(domainAccountID)); + }, + }, ]; const onDownloadCSV = () => { @@ -261,25 +270,7 @@ function DomainMembersPage({route}: DomainMembersPageProps) { ); }; - const getCustomRowProps = (accountID: number, email?: string) => { - const emailPendingAction = email ? domainPendingActions?.[email]?.pendingAction : undefined; - const accountIDPendingAction = domainPendingActions?.[accountID]?.pendingAction ?? domainPendingActions?.[accountID]?.lockAccount; - - const emailErrors = email ? domainErrors?.memberErrors?.[email] : undefined; - const accountIDErrors = domainErrors?.memberErrors?.[accountID]; - const emailError = email ? getLatestError(emailErrors?.errors) : undefined; - const vacationDelegatesEmailError = email ? getLatestError(emailErrors?.vacationDelegateErrors) : undefined; - const twoFactorAuthExemptEmailsError = email ? getLatestError(emailErrors?.twoFactorAuthExemptEmailsError) : undefined; - - const mergedErrors: DomainMemberErrors = { - errors: {...getLatestError(accountIDErrors?.errors), ...getLatestError(accountIDErrors?.lockAccountErrors), ...emailError}, - vacationDelegateErrors: {...getLatestError(accountIDErrors?.vacationDelegateErrors), ...vacationDelegatesEmailError}, - twoFactorAuthExemptEmailsError: {...getLatestError(accountIDErrors?.twoFactorAuthExemptEmailsError), ...twoFactorAuthExemptEmailsError}, - }; - const brickRoadIndicator = hasDomainMemberDetailsErrors(mergedErrors) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined; - - return {errors: getLatestError(mergedErrors?.errors), pendingAction: emailPendingAction ?? accountIDPendingAction, brickRoadIndicator}; - }; + const getCustomRowProps = (accountID: number, email?: string) => getMemberCustomRowProps(accountID, domainPendingActions, domainErrors, email); return ( <> diff --git a/src/pages/domain/Members/MoveUsersBetweenGroupsPage.tsx b/src/pages/domain/Members/MoveUsersBetweenGroupsPage.tsx new file mode 100644 index 0000000000000..7b34db96c06a9 --- /dev/null +++ b/src/pages/domain/Members/MoveUsersBetweenGroupsPage.tsx @@ -0,0 +1,112 @@ +import {domainNameSelector, groupsSelector} from '@selectors/Domain'; +import React, {useState} from 'react'; +import Button from '@components/Button'; +import FixedFooter from '@components/FixedFooter'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import ScreenWrapper from '@components/ScreenWrapper'; +import SelectionList from '@components/SelectionList'; +import SingleSelectListItem from '@components/SelectionList/ListItem/SingleSelectListItem'; +import type {ListItem} from '@components/SelectionList/ListItem/types'; +import Text from '@components/Text'; +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; +import useThemeStyles from '@hooks/useThemeStyles'; +import {changeDomainSecurityGroup, clearDomainMembersSelectedForMove} from '@libs/actions/Domain'; +import {getLoginByAccountID} from '@libs/PersonalDetailsUtils'; +import Navigation from '@navigation/Navigation'; +import type {PlatformStackScreenProps} from '@navigation/PlatformStackNavigation/types'; +import type {SettingsNavigatorParamList} from '@navigation/types'; +import DomainNotFoundPageWrapper from '@pages/domain/DomainNotFoundPageWrapper'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import type SCREENS from '@src/SCREENS'; + +type SecurityGroupItem = ListItem & { + value: string; +}; + +type MoveUsersBetweenGroupsPageProps = PlatformStackScreenProps; + +function MoveUsersBetweenGroupsPage({route}: MoveUsersBetweenGroupsPageProps) { + const {domainAccountID} = route.params; + const {translate} = useLocalize(); + const styles = useThemeStyles(); + + const [selectedGroupId, setSelectedGroupId] = useState(); + const [domainName] = useOnyx(`${ONYXKEYS.COLLECTION.DOMAIN}${domainAccountID}`, {selector: domainNameSelector}); + const [securityGroups] = useOnyx(`${ONYXKEYS.COLLECTION.DOMAIN}${domainAccountID}`, {selector: groupsSelector}); + const [selectedMemberAccountIDs] = useOnyx(ONYXKEYS.DOMAIN_MEMBERS_SELECTED_FOR_MOVE); + + const memberCount = selectedMemberAccountIDs?.length ?? 0; + + const data: SecurityGroupItem[] = (securityGroups ?? []).map(({id, details}) => ({ + text: details.name ?? '', + keyForList: id, + value: id, + isSelected: id === selectedGroupId, + })); + + const handleSelectRow = (item: SecurityGroupItem) => { + setSelectedGroupId(item.value); + }; + + const handleSave = () => { + if (!selectedGroupId || !selectedMemberAccountIDs?.length || !domainName) { + return; + } + + for (const accountIDString of selectedMemberAccountIDs) { + const accountID = Number(accountIDString); + const memberLogin = getLoginByAccountID(accountID); + const currentGroup = securityGroups?.find((g) => accountIDString in (g.details.shared ?? {})); + const currentGroupData = currentGroup ? {key: `${CONST.DOMAIN.DOMAIN_SECURITY_GROUP_PREFIX}${currentGroup.id}` as const, securityGroup: currentGroup.details} : undefined; + const newSecurityGroupKey: `${typeof CONST.DOMAIN.DOMAIN_SECURITY_GROUP_PREFIX}${string}` = `${CONST.DOMAIN.DOMAIN_SECURITY_GROUP_PREFIX}${selectedGroupId}`; + + if (!memberLogin || !currentGroupData || newSecurityGroupKey === currentGroupData.key) { + continue; + } + + changeDomainSecurityGroup(domainAccountID, domainName, memberLogin, accountID, currentGroupData.key, currentGroupData.securityGroup, newSecurityGroupKey); + } + + clearDomainMembersSelectedForMove(); + Navigation.goBack(ROUTES.DOMAIN_MEMBERS.getRoute(domainAccountID)); + }; + + return ( + + + { + clearDomainMembersSelectedForMove(); + Navigation.goBack(ROUTES.DOMAIN_MEMBERS.getRoute(domainAccountID)); + }} + /> + {translate('domain.members.chooseWhereToMove', {count: memberCount})} + + data={data} + onSelectRow={handleSelectRow} + ListItem={SingleSelectListItem} + initiallyFocusedItemKey={selectedGroupId} + /> + +