From 6aba6bd6722187df784e20c14d5816f1bc7292d7 Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Mon, 7 Sep 2026 18:57:25 -0300 Subject: [PATCH 1/5] feat: add the admin user status switch and the status-disabled hints --- .../UserMenu/hooks/useStatusItems.tsx | 20 ++++++++++++ .../account/profile/AccountProfileForm.tsx | 25 ++++++++++----- .../profile/UserStatusDisabledInfo.tsx | 16 ++++++++++ .../views/admin/users/AdminUserForm.tsx | 31 +++++++++++++++++++ 4 files changed, 84 insertions(+), 8 deletions(-) create mode 100644 apps/meteor/client/views/account/profile/UserStatusDisabledInfo.tsx diff --git a/apps/meteor/client/navbar/NavBarSettingsToolbar/UserMenu/hooks/useStatusItems.tsx b/apps/meteor/client/navbar/NavBarSettingsToolbar/UserMenu/hooks/useStatusItems.tsx index 4a35dc22d1437..77f349ce2e898 100644 --- a/apps/meteor/client/navbar/NavBarSettingsToolbar/UserMenu/hooks/useStatusItems.tsx +++ b/apps/meteor/client/navbar/NavBarSettingsToolbar/UserMenu/hooks/useStatusItems.tsx @@ -1,4 +1,5 @@ import type { ICustomUserStatus, IUser, UserStatus as UserStatusEnum } from '@rocket.chat/core-typings'; +import { UserStatus as UserStatusEnumValue } from '@rocket.chat/core-typings'; import { Box, Icon, RadioButton } from '@rocket.chat/fuselage'; import type { GenericMenuItemProps } from '@rocket.chat/ui-client'; import { clientCallbacks } from '@rocket.chat/ui-client'; @@ -75,6 +76,8 @@ export const useStatusItems = (user?: IUser): GenericMenuItemProps[] => { staleTime: Infinity, }); + const workspacePresenceDisabled = useSetting('Accounts_UserStatus_Enabled', true) === false; + const userPresenceDisabled = user?.presenceDisabledByAdmin === true; const handleStatusDisabledModal = useStatusDisabledModal(); const handleCustomStatus = useCustomStatusModalHandler(); const handleStatusVisibility = useStatusVisibilityModalHandler(); @@ -82,6 +85,21 @@ export const useStatusItems = (user?: IUser): GenericMenuItemProps[] => { const customStatusExpiration = useExpirationText(user?.statusExpiresAt); return useMemo(() => { + if (userPresenceDisabled || workspacePresenceDisabled) { + return [ + { + id: 'user-status-disabled', + status: , + content: t('Offline'), + addon: ( + + + + ), + }, + ]; + } + if (presenceDisabled) { return [ { @@ -174,6 +192,8 @@ export const useStatusItems = (user?: IUser): GenericMenuItemProps[] => { return [...items, ...presetItems, ...customItems, ...actionItems]; }, [ presenceDisabled, + userPresenceDisabled, + workspacePresenceDisabled, allowUserStatusMessageChange, t, handleStatusDisabledModal, diff --git a/apps/meteor/client/views/account/profile/AccountProfileForm.tsx b/apps/meteor/client/views/account/profile/AccountProfileForm.tsx index c7a1a5c5f6a47..649d1a97c6b62 100644 --- a/apps/meteor/client/views/account/profile/AccountProfileForm.tsx +++ b/apps/meteor/client/views/account/profile/AccountProfileForm.tsx @@ -30,10 +30,12 @@ import type { AllHTMLAttributes, ChangeEvent } from 'react'; import { useCallback, useEffect, useMemo } from 'react'; import { Controller, useFormContext } from 'react-hook-form'; +import UserStatusDisabledInfo from './UserStatusDisabledInfo'; import type { AccountProfileFormValues } from './getProfileInitialValues'; import { useAccountProfileSettings } from './useAccountProfileSettings'; import { getUserEmailAddress } from '../../../../lib/getUserEmailAddress'; import UserAutoCompleteMultiple from '../../../components/UserAutoCompleteMultiple'; +import { UserStatus as UserStatusIndicator } from '../../../components/UserStatus'; import UserStatusMenu from '../../../components/UserStatusMenu'; import UserAvatarEditor from '../../../components/avatar/UserAvatarEditor'; import { useUpdateAvatar } from '../../../hooks/useUpdateAvatar'; @@ -47,7 +49,9 @@ const AccountProfileForm = (props: AllHTMLAttributes) => { const { isMobile } = useLayout(); const setPreferences = useEndpoint('POST', '/v1/users.setPreferences'); - const statusVisibilityEnabled = useSetting('Accounts_StatusVisibility_Enabled', false); + const workspacePresenceDisabled = useSetting('Accounts_UserStatus_Enabled', true) === false; + const presenceDisabledByAdmin = user?.presenceDisabledByAdmin === true || workspacePresenceDisabled; + const statusVisibilityEnabled = useSetting('Accounts_StatusVisibility_Enabled', false) && !presenceDisabledByAdmin; const checkUsernameAvailability = useEndpoint('GET', '/v1/users.checkUsernameAvailability'); const sendConfirmationEmail = useEndpoint('POST', '/v1/users.sendConfirmationEmail'); @@ -270,15 +274,20 @@ const AccountProfileForm = (props: AllHTMLAttributes) => { : undefined} startAddon={ - } - /> + presenceDisabledByAdmin ? ( + + ) : ( + } + /> + ) } /> )} @@ -286,7 +295,7 @@ const AccountProfileForm = (props: AllHTMLAttributes) => { {errors.statusText && {errors.statusText.message}} {!allowUserStatusMessageChange && {t('StatusMessage_Change_Disabled')}} - {allowUserStatusMessageChange && {t('Status_you_can_use_emoji')}} + {allowUserStatusMessageChange && !presenceDisabledByAdmin && {t('Status_you_can_use_emoji')}} {t('Status_clear_after')} diff --git a/apps/meteor/client/views/account/profile/UserStatusDisabledInfo.tsx b/apps/meteor/client/views/account/profile/UserStatusDisabledInfo.tsx new file mode 100644 index 0000000000000..75a0ab9a01c90 --- /dev/null +++ b/apps/meteor/client/views/account/profile/UserStatusDisabledInfo.tsx @@ -0,0 +1,16 @@ +import { Box, Icon } from '@rocket.chat/fuselage'; +import { useTranslation } from 'react-i18next'; + +type UserStatusDisabledInfoProps = { workspace: boolean }; + +const UserStatusDisabledInfo = ({ workspace }: UserStatusDisabledInfoProps) => { + const { t } = useTranslation(); + + return ( + + + + ); +}; + +export default UserStatusDisabledInfo; diff --git a/apps/meteor/client/views/admin/users/AdminUserForm.tsx b/apps/meteor/client/views/admin/users/AdminUserForm.tsx index 69153be8d1087..c13ffa1e728cc 100644 --- a/apps/meteor/client/views/admin/users/AdminUserForm.tsx +++ b/apps/meteor/client/views/admin/users/AdminUserForm.tsx @@ -30,6 +30,7 @@ import { useTranslation, } from '@rocket.chat/ui-contexts'; import { useMutation, useQueryClient } from '@tanstack/react-query'; +import type { ChangeEvent } from 'react'; import { useId, useMemo, useState } from 'react'; import { Controller, useForm } from 'react-hook-form'; import { Trans } from 'react-i18next'; @@ -42,6 +43,7 @@ import { useShowVoipExtension } from './useShowVoipExtension'; import { parseCSV } from '../../../../lib/utils/parseCSV'; import UserAvatarEditor from '../../../components/avatar/UserAvatarEditor'; import { useEndpointMutation } from '../../../hooks/useEndpointMutation'; +import { useHasLicenseModule } from '../../../hooks/useHasLicenseModule'; import { useUpdateAvatar } from '../../../hooks/useUpdateAvatar'; import { USER_STATUS_TEXT_MAX_LENGTH, BIO_TEXT_MAX_LENGTH } from '../../../lib/constants'; @@ -84,6 +86,7 @@ const getInitialValue = ({ requirePasswordChange: isNewUserPage && isSmtpEnabled && (data?.requirePasswordChange ?? true), customFields: data?.customFields ?? {}, statusText: data?.statusText ?? '', + presenceDisabledByAdmin: data?.presenceDisabledByAdmin === true, freeSwitchExtension: data?.freeSwitchExtension ?? '', ...(isNewUserPage && { joinDefaultChannels: true }), sendWelcomeEmail: isSmtpEnabled, @@ -99,6 +102,8 @@ const AdminUserForm = ({ userData, onReload, context, refetchUserFormData, roleD const customFieldsMetadata = useAccountsCustomFields(); const defaultRoles = useSetting('Accounts_Registration_Users_Default_Roles', ''); + const { data: hasPresenceLicense = false } = useHasLicenseModule('unlimited-presence'); + const userStatusEnabled = useSetting('Accounts_UserStatus_Enabled', true); const isVerificationNeeded = useSetting('Accounts_EmailVerification'); const defaultUserRoles = parseCSV(defaultRoles); @@ -190,6 +195,7 @@ const AdminUserForm = ({ userData, onReload, context, refetchUserFormData, roleD const nicknameId = useId(); const passwordId = useId(); const rolesId = useId(); + const userStatusId = useId(); const joinDefaultChannelsId = useId(); const sendWelcomeEmailId = useId(); const setRandomPasswordId = useId(); @@ -418,6 +424,31 @@ const AdminUserForm = ({ userData, onReload, context, refetchUserFormData, roleD {errors?.roles && {errors.roles.message}} + {hasPresenceLicense && userStatusEnabled && ( + + + {t('User_Status')} + + ( + ) => onChange(!event.currentTarget.checked)} + checked={value !== true} + /> + )} + /> + + + + {t('User_status_admin_toggle_Description')} + + + )} {isNewUserPage && ( From 753a5239676a8f1ae51cd2702877305a08f79a9d Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Mon, 7 Sep 2026 19:21:12 -0300 Subject: [PATCH 2/5] fix: name the status-disabled hint icons for screen readers --- .../NavBarSettingsToolbar/UserMenu/hooks/useStatusItems.tsx | 6 +++++- .../client/views/account/profile/UserStatusDisabledInfo.tsx | 4 +++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/apps/meteor/client/navbar/NavBarSettingsToolbar/UserMenu/hooks/useStatusItems.tsx b/apps/meteor/client/navbar/NavBarSettingsToolbar/UserMenu/hooks/useStatusItems.tsx index 77f349ce2e898..01b4fa16765c0 100644 --- a/apps/meteor/client/navbar/NavBarSettingsToolbar/UserMenu/hooks/useStatusItems.tsx +++ b/apps/meteor/client/navbar/NavBarSettingsToolbar/UserMenu/hooks/useStatusItems.tsx @@ -86,13 +86,17 @@ export const useStatusItems = (user?: IUser): GenericMenuItemProps[] => { return useMemo(() => { if (userPresenceDisabled || workspacePresenceDisabled) { + const statusDisabledReason = workspacePresenceDisabled + ? t('User_status_disabled_on_this_workspace') + : t('User_status_disabled_by_an_admin'); + return [ { id: 'user-status-disabled', status: , content: t('Offline'), addon: ( - + ), diff --git a/apps/meteor/client/views/account/profile/UserStatusDisabledInfo.tsx b/apps/meteor/client/views/account/profile/UserStatusDisabledInfo.tsx index 75a0ab9a01c90..f6a9aa18168d9 100644 --- a/apps/meteor/client/views/account/profile/UserStatusDisabledInfo.tsx +++ b/apps/meteor/client/views/account/profile/UserStatusDisabledInfo.tsx @@ -6,8 +6,10 @@ type UserStatusDisabledInfoProps = { workspace: boolean }; const UserStatusDisabledInfo = ({ workspace }: UserStatusDisabledInfoProps) => { const { t } = useTranslation(); + const reason = workspace ? t('User_status_disabled_on_this_workspace') : t('User_status_disabled_by_an_admin'); + return ( - + ); From 8fe8cf5616ed3a8088f28286972f4021d5de2ed9 Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Mon, 7 Sep 2026 19:43:49 -0300 Subject: [PATCH 3/5] fix: keep status expiration read-only when an admin disabled the user status --- .../client/views/account/profile/AccountProfileForm.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/meteor/client/views/account/profile/AccountProfileForm.tsx b/apps/meteor/client/views/account/profile/AccountProfileForm.tsx index 649d1a97c6b62..d038b93ce6875 100644 --- a/apps/meteor/client/views/account/profile/AccountProfileForm.tsx +++ b/apps/meteor/client/views/account/profile/AccountProfileForm.tsx @@ -78,7 +78,7 @@ const AccountProfileForm = (props: AllHTMLAttributes) => { const { email, avatar, username, name: userFullName, statusDuration, statusType, statusText } = watch(); - const isExpirationDisabled = statusType === UserStatus.ONLINE && !statusText?.trim(); + const isExpirationDisabled = presenceDisabledByAdmin || (statusType === UserStatus.ONLINE && !statusText?.trim()); useEffect(() => { if (isExpirationDisabled) { @@ -178,7 +178,7 @@ const AccountProfileForm = (props: AllHTMLAttributes) => { await setPreferences({ data: { statusVisibilityDenied } }); } - if (statusDirty) { + if (statusDirty && !presenceDisabledByAdmin) { await setUserStatus({ status: statusType, ...(allowUserStatusMessageChange && { message: statusText }), From be27674c47a446da2ccfd9557cb02df82c94a66d Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Tue, 15 Sep 2026 13:21:27 -0300 Subject: [PATCH 4/5] feat: add the admin status and presence page with per-user visibility rules --- .../UserMenu/hooks/useStatusItems.tsx | 6 +- .../customUserStatus/CustomUserStatusForm.tsx | 4 +- .../CustomUserStatusRoute.tsx | 97 ------ .../CustomUserStatusTable.tsx | 11 +- .../hooks/useStatusDisabledModal.tsx | 2 +- apps/meteor/client/views/admin/routes.tsx | 8 +- .../meteor/client/views/admin/sidebarItems.ts | 4 +- .../admin/statusAndPresence/SettingsTab.tsx | 27 ++ .../StatusAndPresencePage.tsx | 124 ++++++++ .../StatusAndPresenceRoute.tsx | 72 +++++ .../StatusAndPresenceTabs.tsx | 44 +++ .../UserPresenceEditorForm.tsx | 283 ++++++++++++++++++ .../statusAndPresence/UserPresenceTab.tsx | 82 +++++ .../statusAndPresence/UserPresenceTabRow.tsx | 44 +++ .../useManagedPresenceUsers.ts | 17 ++ .../views/admin/users/AdminUserForm.tsx | 103 +++++-- .../admin/users/UsersTable/UsersTableRow.tsx | 6 + .../users/hooks/useManageUserStatusAction.ts | 20 ++ .../e2e/admin-status-and-presence.spec.ts | 106 +++++++ packages/i18n/src/locales/en.i18n.json | 38 ++- 20 files changed, 947 insertions(+), 151 deletions(-) delete mode 100644 apps/meteor/client/views/admin/customUserStatus/CustomUserStatusRoute.tsx create mode 100644 apps/meteor/client/views/admin/statusAndPresence/SettingsTab.tsx create mode 100644 apps/meteor/client/views/admin/statusAndPresence/StatusAndPresencePage.tsx create mode 100644 apps/meteor/client/views/admin/statusAndPresence/StatusAndPresenceRoute.tsx create mode 100644 apps/meteor/client/views/admin/statusAndPresence/StatusAndPresenceTabs.tsx create mode 100644 apps/meteor/client/views/admin/statusAndPresence/UserPresenceEditorForm.tsx create mode 100644 apps/meteor/client/views/admin/statusAndPresence/UserPresenceTab.tsx create mode 100644 apps/meteor/client/views/admin/statusAndPresence/UserPresenceTabRow.tsx create mode 100644 apps/meteor/client/views/admin/statusAndPresence/useManagedPresenceUsers.ts create mode 100644 apps/meteor/client/views/admin/users/hooks/useManageUserStatusAction.ts create mode 100644 apps/meteor/tests/e2e/admin-status-and-presence.spec.ts diff --git a/apps/meteor/client/navbar/NavBarSettingsToolbar/UserMenu/hooks/useStatusItems.tsx b/apps/meteor/client/navbar/NavBarSettingsToolbar/UserMenu/hooks/useStatusItems.tsx index 01b4fa16765c0..01f099ab9190e 100644 --- a/apps/meteor/client/navbar/NavBarSettingsToolbar/UserMenu/hooks/useStatusItems.tsx +++ b/apps/meteor/client/navbar/NavBarSettingsToolbar/UserMenu/hooks/useStatusItems.tsx @@ -1,5 +1,5 @@ -import type { ICustomUserStatus, IUser, UserStatus as UserStatusEnum } from '@rocket.chat/core-typings'; -import { UserStatus as UserStatusEnumValue } from '@rocket.chat/core-typings'; +import type { ICustomUserStatus, IUser } from '@rocket.chat/core-typings'; +import { UserStatus as UserStatusEnum } from '@rocket.chat/core-typings'; import { Box, Icon, RadioButton } from '@rocket.chat/fuselage'; import type { GenericMenuItemProps } from '@rocket.chat/ui-client'; import { clientCallbacks } from '@rocket.chat/ui-client'; @@ -93,7 +93,7 @@ export const useStatusItems = (user?: IUser): GenericMenuItemProps[] => { return [ { id: 'user-status-disabled', - status: , + status: , content: t('Offline'), addon: ( diff --git a/apps/meteor/client/views/admin/customUserStatus/CustomUserStatusForm.tsx b/apps/meteor/client/views/admin/customUserStatus/CustomUserStatusForm.tsx index f7e29944850c4..2e3a8a687c2f6 100644 --- a/apps/meteor/client/views/admin/customUserStatus/CustomUserStatusForm.tsx +++ b/apps/meteor/client/views/admin/customUserStatus/CustomUserStatusForm.tsx @@ -54,7 +54,7 @@ const CustomUserStatusForm = ({ onClose, onReload, status }: CustomUserStatusFor }); onReload(); - route.push({}); + route.push({ tab: 'custom-status' }); } catch (error) { dispatchToastMessage({ type: 'error', message: error }); } @@ -72,7 +72,7 @@ const CustomUserStatusForm = ({ onClose, onReload, status }: CustomUserStatusFor await deleteStatus({ customUserStatusId: status?._id ?? '' }); dispatchToastMessage({ type: 'success', message: t('Custom_User_Status_Has_Been_Deleted') }); onReload(); - route.push({}); + route.push({ tab: 'custom-status' }); } catch (error) { dispatchToastMessage({ type: 'error', message: error }); } finally { diff --git a/apps/meteor/client/views/admin/customUserStatus/CustomUserStatusRoute.tsx b/apps/meteor/client/views/admin/customUserStatus/CustomUserStatusRoute.tsx deleted file mode 100644 index da1dff090c084..0000000000000 --- a/apps/meteor/client/views/admin/customUserStatus/CustomUserStatusRoute.tsx +++ /dev/null @@ -1,97 +0,0 @@ -import { Button, ButtonGroup } from '@rocket.chat/fuselage'; -import { - ContextualbarHeader, - ContextualbarClose, - ContextualbarTitle, - ContextualbarDialog, - Page, - PageHeader, - PageContent, -} from '@rocket.chat/ui-client'; -import { useRoute, useRouteParameter, usePermission, useTranslation, useSetting } from '@rocket.chat/ui-contexts'; -import { useCallback, useRef, useEffect } from 'react'; - -import CustomUserActiveConnections from './CustomUserActiveConnections'; -import CustomUserStatusFormWithData from './CustomUserStatusFormWithData'; -import CustomUserStatusService from './CustomUserStatusService'; -import CustomUserStatusTable from './CustomUserStatusTable'; -import { useIsEnterprise } from '../../../hooks/useIsEnterprise'; -import NotAuthorizedPage from '../../notAuthorized/NotAuthorizedPage'; - -const CustomUserStatusRoute = () => { - const t = useTranslation(); - const route = useRoute('user-status'); - const context = useRouteParameter('context'); - const id = useRouteParameter('id'); - const canManageUserStatus = usePermission('manage-user-status'); - const { data: license } = useIsEnterprise(); - const presenceDisabled = useSetting('Presence_broadcast_disabled', false); - - useEffect(() => { - presenceDisabled && route.push({ context: 'presence-service' }); - }, [presenceDisabled, route]); - - const handleItemClick = (id: string): void => { - route.push({ - context: 'edit', - id, - }); - }; - - const handleNewButtonClick = useCallback(() => { - route.push({ context: 'new' }); - }, [route]); - - const handlePresenceServiceClick = useCallback(() => { - route.push({ context: 'presence-service' }); - }, [route]); - - const handleClose = useCallback(() => { - route.push({}); - }, [route]); - - const reload = useRef(() => null); - - const handleReload = useCallback(() => { - reload.current(); - }, [reload]); - - if (!canManageUserStatus) { - return ; - } - - return ( - - - - {!license?.isEnterprise && } - - - - - - - - - - {context && ( - - - - {context === 'edit' && t('Custom_User_Status_Edit')} - {context === 'new' && t('Custom_User_Status_Add')} - {context === 'presence-service' && t('Presence_service_cap')} - - - - {context === 'presence-service' && } - {(context === 'new' || context === 'edit') && ( - - )} - - )} - - ); -}; - -export default CustomUserStatusRoute; diff --git a/apps/meteor/client/views/admin/customUserStatus/CustomUserStatusTable/CustomUserStatusTable.tsx b/apps/meteor/client/views/admin/customUserStatus/CustomUserStatusTable/CustomUserStatusTable.tsx index 86d750176ff55..dbfe20e6cc138 100644 --- a/apps/meteor/client/views/admin/customUserStatus/CustomUserStatusTable/CustomUserStatusTable.tsx +++ b/apps/meteor/client/views/admin/customUserStatus/CustomUserStatusTable/CustomUserStatusTable.tsx @@ -12,7 +12,7 @@ import { } from '@rocket.chat/ui-client'; import { useEndpoint } from '@rocket.chat/ui-contexts'; import { useQuery } from '@tanstack/react-query'; -import type { MutableRefObject } from 'react'; +import type { MutableRefObject, ReactNode } from 'react'; import { useState, useMemo, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; @@ -23,10 +23,11 @@ import GenericNoResult from '../../../../components/GenericNoResults'; export type CustomUserStatusProps = { reload: MutableRefObject<() => void>; onClick: (id: string) => void; + children?: ReactNode; }; // TODO: Missing error state -const CustomUserStatus = ({ reload, onClick }: CustomUserStatusProps) => { +const CustomUserStatus = ({ reload, onClick, children }: CustomUserStatusProps) => { const { t } = useTranslation(); const [text, setText] = useState(''); const { current, itemsPerPage, setItemsPerPage: onSetItemsPerPage, setCurrent: onSetCurrent, ...paginationProps } = usePagination(); @@ -69,14 +70,16 @@ const CustomUserStatus = ({ reload, onClick }: CustomUserStatusProps) => { return ( <> - setText(event.target.value)} /> + setText(event.target.value)}> + {children} + {data.length === 0 && } {data && data.length > 0 && ( <> - {t('Name')} + {t('Status_name')} { const setModal = useSetModal(); const closeModal = useStableCallback(() => setModal()); const handleGoToSettings = useStableCallback(() => { - userStatusRoute.push({ context: 'presence-service' }); + userStatusRoute.push({ tab: 'custom-status', context: 'presence-service' }); closeModal(); }); const isAdmin = useRole('admin'); diff --git a/apps/meteor/client/views/admin/routes.tsx b/apps/meteor/client/views/admin/routes.tsx index b12a37ec3aeec..79abaa149cfa0 100644 --- a/apps/meteor/client/views/admin/routes.tsx +++ b/apps/meteor/client/views/admin/routes.tsx @@ -49,8 +49,8 @@ declare module '@rocket.chat/ui-contexts' { pattern: '/admin/integrations/:context?/:type?/:id?'; }; 'user-status': { - pathname: `/admin/user-status${`/${string}` | ''}${`/${string}` | ''}`; - pattern: '/admin/user-status/:context?/:id?'; + pathname: `/admin/user-status${`/${string}` | ''}${`/${string}` | ''}${`/${string}` | ''}`; + pattern: '/admin/user-status/:tab?/:context?/:id?'; }; 'emoji-custom': { pathname: `/admin/emoji${`/${string}` | ''}${`/${string}` | ''}`; @@ -176,9 +176,9 @@ registerAdminRoute('/integrations/:context?/:type?/:id?', { component: lazy(() => import('./integrations/IntegrationsRoute')), }); -registerAdminRoute('/user-status/:context?/:id?', { +registerAdminRoute('/user-status/:tab?/:context?/:id?', { name: 'user-status', - component: lazy(() => import('./customUserStatus/CustomUserStatusRoute')), + component: lazy(() => import('./statusAndPresence/StatusAndPresenceRoute')), }); registerAdminRoute('/emoji/:context?/:id?', { diff --git a/apps/meteor/client/views/admin/sidebarItems.ts b/apps/meteor/client/views/admin/sidebarItems.ts index 58f907c5f02c3..edf4406135103 100644 --- a/apps/meteor/client/views/admin/sidebarItems.ts +++ b/apps/meteor/client/views/admin/sidebarItems.ts @@ -62,9 +62,9 @@ export const { }, { href: '/admin/user-status', - i18nLabel: 'User_Status', + i18nLabel: 'Status_and_presence', icon: 'user', - permissionGranted: (): boolean => hasAtLeastOnePermission(['manage-user-status']), + permissionGranted: (): boolean => hasAtLeastOnePermission(['manage-user-status', 'edit-other-user-info']), }, { href: '/admin/permissions', diff --git a/apps/meteor/client/views/admin/statusAndPresence/SettingsTab.tsx b/apps/meteor/client/views/admin/statusAndPresence/SettingsTab.tsx new file mode 100644 index 0000000000000..32edce4e75568 --- /dev/null +++ b/apps/meteor/client/views/admin/statusAndPresence/SettingsTab.tsx @@ -0,0 +1,27 @@ +import { AccordionItem, Callout, FieldGroup } from '@rocket.chat/fuselage'; +import type { ReactNode } from 'react'; +import { memo } from 'react'; +import { useTranslation } from 'react-i18next'; + +import Setting from '../settings/Setting'; +import SettingsGroupPage from '../settings/SettingsGroupPage'; + +const SettingsTab = ({ tabs, headerButtons }: { tabs: ReactNode; headerButtons?: ReactNode }) => { + const { t } = useTranslation(); + + return ( + + + + + + + + {t('Admins_cannot_see_concealed_presence')} + + + + ); +}; + +export default memo(SettingsTab); diff --git a/apps/meteor/client/views/admin/statusAndPresence/StatusAndPresencePage.tsx b/apps/meteor/client/views/admin/statusAndPresence/StatusAndPresencePage.tsx new file mode 100644 index 0000000000000..ddef56d6cc78a --- /dev/null +++ b/apps/meteor/client/views/admin/statusAndPresence/StatusAndPresencePage.tsx @@ -0,0 +1,124 @@ +import { Button, ButtonGroup } from '@rocket.chat/fuselage'; +import { + ContextualbarHeader, + ContextualbarClose, + ContextualbarTitle, + ContextualbarDialog, + Page, + PageHeader, + PageContent, +} from '@rocket.chat/ui-client'; +import { useRoute, useRouteParameter, useTranslation } from '@rocket.chat/ui-contexts'; +import type { ReactElement } from 'react'; +import { memo, useCallback, useRef, useState } from 'react'; + +import SettingsTab from './SettingsTab'; +import StatusAndPresenceTabs from './StatusAndPresenceTabs'; +import type { StatusAndPresenceTab } from './StatusAndPresenceTabs'; +import UserPresenceEditorForm from './UserPresenceEditorForm'; +import UserPresenceTab from './UserPresenceTab'; +import type { ManagedPresenceUser } from './useManagedPresenceUsers'; +import { useIsEnterprise } from '../../../hooks/useIsEnterprise'; +import CustomUserActiveConnections from '../customUserStatus/CustomUserActiveConnections'; +import CustomUserStatusFormWithData from '../customUserStatus/CustomUserStatusFormWithData'; +import CustomUserStatusService from '../customUserStatus/CustomUserStatusService'; +import CustomUserStatusTable from '../customUserStatus/CustomUserStatusTable'; + +export type StatusAndPresencePageProps = { + tab: StatusAndPresenceTab; + canManageCustomStatus: boolean; + canManageUserPresence: boolean; + canViewSettings: boolean; +}; + +const StatusAndPresencePage = ({ tab, canManageCustomStatus, canManageUserPresence, canViewSettings }: StatusAndPresencePageProps) => { + const t = useTranslation(); + const route = useRoute('user-status'); + const context = useRouteParameter('context'); + const id = useRouteParameter('id'); + const { data: license } = useIsEnterprise(); + + const [editing, setEditing] = useState<{ user?: ManagedPresenceUser }>(); + + const handleTabChange = useCallback((next: StatusAndPresenceTab) => route.push({ tab: next }), [route]); + + const handleItemClick = useCallback((id: string) => route.push({ tab, context: 'edit', id }), [route, tab]); + + const handleNewButtonClick = useCallback(() => route.push({ tab, context: 'new' }), [route, tab]); + + const handlePresenceServiceClick = useCallback(() => route.push({ tab, context: 'presence-service' }), [route, tab]); + + const handleClose = useCallback(() => { + setEditing(undefined); + route.push({ tab }); + }, [route, tab]); + + const handleEdit = useCallback((user?: ManagedPresenceUser) => setEditing({ user }), []); + + const reload = useRef(() => null); + + const handleReload = useCallback(() => reload.current(), [reload]); + + const tabs = ( + + ); + + const headerButtons = ; + + const contextualBar: { title: string; content: ReactElement } | undefined = + (context === 'presence-service' && { title: t('Presence_service_cap'), content: }) || + (canManageCustomStatus && + (context === 'new' || context === 'edit') && { + title: t(context === 'new' ? 'Custom_User_Status_Add' : 'Custom_User_Status_Edit'), + content: , + }) || + (canManageUserPresence && + editing && { + title: t('Manage_user_presence'), + content: , + }) || + undefined; + + return ( + + + {tab === 'settings' ? ( + + ) : ( + <> + + {canManageCustomStatus && !license?.isEnterprise && } + {headerButtons} + + {tabs} + + {tab === 'custom-status' && ( + + + + )} + {tab === 'user-presence' && } + + + )} + + {contextualBar && ( + + + {contextualBar.title} + + + {contextualBar.content} + + )} + + ); +}; + +export default memo(StatusAndPresencePage); diff --git a/apps/meteor/client/views/admin/statusAndPresence/StatusAndPresenceRoute.tsx b/apps/meteor/client/views/admin/statusAndPresence/StatusAndPresenceRoute.tsx new file mode 100644 index 0000000000000..1fc4f0a5b2d4f --- /dev/null +++ b/apps/meteor/client/views/admin/statusAndPresence/StatusAndPresenceRoute.tsx @@ -0,0 +1,72 @@ +import { + useIsPrivilegedSettingsContext, + usePermission, + useRoute, + useRouteParameter, + useRouter, + useSetting, +} from '@rocket.chat/ui-contexts'; +import { memo, useEffect, useLayoutEffect } from 'react'; + +import StatusAndPresencePage from './StatusAndPresencePage'; +import type { StatusAndPresenceTab } from './StatusAndPresenceTabs'; +import { useHasLicenseModule } from '../../../hooks/useHasLicenseModule'; +import NotAuthorizedPage from '../../notAuthorized/NotAuthorizedPage'; +import EditableSettingsProvider from '../settings/EditableSettingsProvider'; + +const TAB_ORDER = ['settings', 'custom-status', 'user-presence'] as const; + +const StatusAndPresenceRoute = () => { + const route = useRoute('user-status'); + const router = useRouter(); + const tab = useRouteParameter('tab'); + const context = useRouteParameter('context'); + const presenceDisabled = useSetting('Presence_broadcast_disabled', false); + + const canManageCustomStatus = usePermission('manage-user-status'); + const canViewSettings = useIsPrivilegedSettingsContext(); + const { data: hasUnlimitedPresence } = useHasLicenseModule('unlimited-presence'); + const canManageUserPresence = usePermission('edit-other-user-info') && !!hasUnlimitedPresence; + + const allowed: Record = { + 'settings': canViewSettings, + 'custom-status': canManageCustomStatus, + 'user-presence': canManageUserPresence, + }; + + const firstAllowedTab = TAB_ORDER.find((name) => allowed[name]); + const currentTab = TAB_ORDER.find((name) => name === tab && allowed[name]); + + useLayoutEffect(() => { + if (firstAllowedTab && !currentTab) { + router.navigate({ name: 'user-status', params: { tab: firstAllowedTab } }, { replace: true }); + } + }, [router, firstAllowedTab, currentTab]); + + useEffect(() => { + if (presenceDisabled && currentTab && context !== 'presence-service') { + route.push({ tab: currentTab, context: 'presence-service' }); + } + }, [presenceDisabled, currentTab, context, route]); + + if (!firstAllowedTab) { + return ; + } + + if (!currentTab) { + return null; + } + + return ( + + + + ); +}; + +export default memo(StatusAndPresenceRoute); diff --git a/apps/meteor/client/views/admin/statusAndPresence/StatusAndPresenceTabs.tsx b/apps/meteor/client/views/admin/statusAndPresence/StatusAndPresenceTabs.tsx new file mode 100644 index 0000000000000..eb2cfb022e851 --- /dev/null +++ b/apps/meteor/client/views/admin/statusAndPresence/StatusAndPresenceTabs.tsx @@ -0,0 +1,44 @@ +import { Tabs, TabsItem } from '@rocket.chat/fuselage'; +import { useTranslation } from 'react-i18next'; + +export type StatusAndPresenceTab = 'settings' | 'custom-status' | 'user-presence'; + +type StatusAndPresenceTabsProps = { + currentTab: StatusAndPresenceTab; + onChange: (tab: StatusAndPresenceTab) => void; + canManageCustomStatus: boolean; + canManageUserPresence: boolean; + canViewSettings: boolean; +}; + +const StatusAndPresenceTabs = ({ + currentTab, + onChange, + canManageCustomStatus, + canManageUserPresence, + canViewSettings, +}: StatusAndPresenceTabsProps) => { + const { t } = useTranslation(); + + return ( + + {canViewSettings && ( + onChange('settings')}> + {t('Settings')} + + )} + {canManageCustomStatus && ( + onChange('custom-status')}> + {t('Custom_status')} + + )} + {canManageUserPresence && ( + onChange('user-presence')}> + {t('User_Status')} + + )} + + ); +}; + +export default StatusAndPresenceTabs; diff --git a/apps/meteor/client/views/admin/statusAndPresence/UserPresenceEditorForm.tsx b/apps/meteor/client/views/admin/statusAndPresence/UserPresenceEditorForm.tsx new file mode 100644 index 0000000000000..51404271ea73b --- /dev/null +++ b/apps/meteor/client/views/admin/statusAndPresence/UserPresenceEditorForm.tsx @@ -0,0 +1,283 @@ +import { + Box, + Button, + ButtonGroup, + Field, + FieldError, + FieldGroup, + FieldHint, + FieldLabel, + FieldRow, + IconButton, + TextInput, + ToggleSwitch, +} from '@rocket.chat/fuselage'; +import { ContextualbarFooter, ContextualbarScrollableContent, GenericModal, UserAutoComplete } from '@rocket.chat/ui-client'; +import { useEndpoint, useSetModal, useToastMessageDispatch } from '@rocket.chat/ui-contexts'; +import { useQueryClient } from '@tanstack/react-query'; +import type { ChangeEvent } from 'react'; +import { useCallback, useId } from 'react'; +import { Controller, useForm } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; + +import type { ManagedPresenceUser } from './useManagedPresenceUsers'; +import UserAutoCompleteMultiple from '../../../components/UserAutoCompleteMultiple'; + +type UserPresenceEditorFormValues = { + username: string; + presenceEnabled: boolean; + statusText: string; + hiddenFrom: string[]; +}; + +type UserPresenceEditorFormProps = { + user?: ManagedPresenceUser; + onClose: () => void; +}; + +const UserPresenceEditorForm = ({ user, onClose }: UserPresenceEditorFormProps) => { + const { t } = useTranslation(); + const userId = user?._id; + const defaultValues: UserPresenceEditorFormValues = { + username: user?.username ?? '', + presenceEnabled: !user?.presenceDisabledByAdmin, + statusText: user?.statusText ?? '', + hiddenFrom: user?.statusVisibilityDeniedByAdmin ?? [], + }; + const formId = useId(); + const usernameFieldId = useId(); + const presenceFieldId = useId(); + const statusTextFieldId = useId(); + const hiddenFromFieldId = useId(); + + const setModal = useSetModal(); + const dispatchToastMessage = useToastMessageDispatch(); + const queryClient = useQueryClient(); + + const getUserInfo = useEndpoint('GET', '/v1/users.info'); + const updateUser = useEndpoint('POST', '/v1/users.update'); + + const { + control, + handleSubmit, + watch, + formState: { isSubmitting, errors, dirtyFields }, + } = useForm({ defaultValues, mode: 'all' }); + + const presenceEnabled = watch('presenceEnabled'); + + const invalidateManagedPresenceUsers = useCallback( + () => queryClient.invalidateQueries({ queryKey: ['admin', 'managed-presence-users'] }), + [queryClient], + ); + + const applyPresence = useCallback( + async ( + { + targetUserId, + presenceEnabled, + hiddenFrom, + statusText, + }: { targetUserId: string; presenceEnabled: boolean; hiddenFrom: string[]; statusText?: string }, + message: string, + ) => { + try { + await updateUser({ + userId: targetUserId, + data: { + presenceDisabledByAdmin: !presenceEnabled, + statusVisibilityDeniedByAdmin: hiddenFrom, + ...(statusText !== undefined && { statusText }), + }, + }); + + invalidateManagedPresenceUsers(); + dispatchToastMessage({ type: 'success', message }); + onClose(); + } catch (error) { + dispatchToastMessage({ type: 'error', message: error }); + } + }, + [updateUser, invalidateManagedPresenceUsers, dispatchToastMessage, onClose], + ); + + const confirm = useCallback( + (title: string, description: string, confirmText: string, onConfirm: () => Promise, variant?: 'danger') => { + const handleCancel = (): void => setModal(null); + + setModal( + { + await onConfirm(); + setModal(null); + }} + onCancel={handleCancel} + onClose={handleCancel} + > + {description} + , + ); + }, + [setModal], + ); + + const handleSave = useCallback( + async ({ username, presenceEnabled, hiddenFrom, statusText }: UserPresenceEditorFormValues) => { + if (userId && presenceEnabled && hiddenFrom.length === 0 && statusText === '') { + confirm(t('Reset_user_status_settings'), t('Reset_user_status_settings_description', { name: username }), t('Reset'), () => + applyPresence( + { targetUserId: userId, presenceEnabled, hiddenFrom, statusText: '' }, + t('Status_settings_reset_to_workspace_default', { name: username }), + ), + ); + return; + } + + let targetUserId = userId; + let replacesExistingRule = false; + + if (!targetUserId) { + try { + const { user: found } = await getUserInfo({ username }); + + targetUserId = found._id; + replacesExistingRule = found.presenceDisabledByAdmin === true || !!found.statusVisibilityDeniedByAdmin?.length; + } catch (error) { + dispatchToastMessage({ type: 'error', message: error }); + return; + } + } + + const resolvedId = targetUserId; + + if (!resolvedId) { + return; + } + + const apply = () => + applyPresence( + { targetUserId: resolvedId, presenceEnabled, hiddenFrom, statusText: dirtyFields.statusText ? statusText : undefined }, + t('Presence_settings_updated', { name: username }), + ); + + if (replacesExistingRule) { + confirm(t('Replace_user_status_settings'), t('Replace_user_status_settings_description', { name: username }), t('Replace'), apply); + return; + } + + return apply(); + }, + [userId, getUserInfo, dispatchToastMessage, confirm, applyPresence, t, dirtyFields.statusText], + ); + + const handleRemoveClick = useCallback(() => { + const username = user?.username ?? ''; + + confirm( + t('Remove_user_presence_settings'), + t('Remove_user_presence_settings_description', { name: username }), + t('Remove'), + () => + applyPresence( + { targetUserId: userId ?? '', presenceEnabled: true, hiddenFrom: [], statusText: '' }, + t('Status_settings_removed', { name: username }), + ), + 'danger', + ); + }, [userId, user?.username, confirm, applyPresence, t]); + + return ( + <> + + + + {t('User')} + + ( + + )} + /> + + {errors.username && {errors.username.message}} + {t('User_has_no_visibility_into_changes_made_in_this_panel')} + + + + {t('Show_status')} + + ( + ) => onChange(event.currentTarget.checked)} + /> + )} + /> + + + {t('User_presence_admin_hint')} + + + {t('StatusMessage')} + + ( + + )} + /> + + + + {t('Hide_presence_from')} + + ( + + )} + /> + + {t('Hide_presence_from_hint')} + + + + + + + + {userId && } + + + + ); +}; + +export default UserPresenceEditorForm; diff --git a/apps/meteor/client/views/admin/statusAndPresence/UserPresenceTab.tsx b/apps/meteor/client/views/admin/statusAndPresence/UserPresenceTab.tsx new file mode 100644 index 0000000000000..8911bbd1dea7c --- /dev/null +++ b/apps/meteor/client/views/admin/statusAndPresence/UserPresenceTab.tsx @@ -0,0 +1,82 @@ +import { Button, Pagination } from '@rocket.chat/fuselage'; +import { useDebouncedValue } from '@rocket.chat/fuselage-hooks'; +import { + GenericTable, + GenericTableHeader, + GenericTableHeaderCell, + GenericTableBody, + GenericTableLoadingTable, + usePagination, +} from '@rocket.chat/ui-client'; +import { useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import UserPresenceTabRow from './UserPresenceTabRow'; +import type { ManagedPresenceUser } from './useManagedPresenceUsers'; +import { useManagedPresenceUsers } from './useManagedPresenceUsers'; +import FilterByText from '../../../components/FilterByText'; +import GenericNoResults from '../../../components/GenericNoResults'; + +type UserPresenceTabProps = { + onEdit: (user?: ManagedPresenceUser) => void; +}; + +const UserPresenceTab = ({ onEdit }: UserPresenceTabProps) => { + const { t } = useTranslation(); + const [text, setText] = useState(''); + const { current, itemsPerPage, setItemsPerPage: onSetItemsPerPage, setCurrent: onSetCurrent, ...paginationProps } = usePagination(); + + const query = useDebouncedValue( + useMemo(() => ({ searchTerm: text, count: itemsPerPage, offset: current }), [text, itemsPerPage, current]), + 500, + ); + + useEffect(() => { + onSetCurrent(0); + }, [text, onSetCurrent]); + + const { data, isLoading, isSuccess } = useManagedPresenceUsers(query); + + const headers = ( + <> + {t('Name')} + {t('Status')} + {t('Hidden_from')} + + ); + + return ( + <> + setText(event.target.value)}> + + + {isSuccess && data.users.length === 0 && ( + + )} + {(isLoading || (isSuccess && data.users.length > 0)) && ( + <> + + {headers} + + {isLoading && } + {isSuccess && data.users.map((user) => )} + + + {isSuccess && ( + + )} + + )} + + ); +}; + +export default UserPresenceTab; diff --git a/apps/meteor/client/views/admin/statusAndPresence/UserPresenceTabRow.tsx b/apps/meteor/client/views/admin/statusAndPresence/UserPresenceTabRow.tsx new file mode 100644 index 0000000000000..561a463e9612c --- /dev/null +++ b/apps/meteor/client/views/admin/statusAndPresence/UserPresenceTabRow.tsx @@ -0,0 +1,44 @@ +import { UserStatus as Status } from '@rocket.chat/core-typings'; +import { Box } from '@rocket.chat/fuselage'; +import { UserAvatar } from '@rocket.chat/ui-avatar'; +import { GenericTableRow, GenericTableCell } from '@rocket.chat/ui-client'; +import { useTranslation } from 'react-i18next'; + +import type { ManagedPresenceUser } from './useManagedPresenceUsers'; +import { UserStatus } from '../../../components/UserStatus'; + +type UserPresenceTabRowProps = { + user: ManagedPresenceUser; + onClick: (user: ManagedPresenceUser) => void; +}; + +const UserPresenceTabRow = ({ user, onClick }: UserPresenceTabRowProps) => { + const { t } = useTranslation(); + const { _id, username, name, status, presenceDisabledByAdmin, statusVisibilityDeniedByAdmin } = user; + + const handleClick = () => onClick(user); + + return ( + + + + + + + + + + {name || username} + + + + + {presenceDisabledByAdmin ? t('Hidden') : t('Visible')} + + {presenceDisabledByAdmin ? t('Everyone') : statusVisibilityDeniedByAdmin.join(', ')} + + + ); +}; + +export default UserPresenceTabRow; diff --git a/apps/meteor/client/views/admin/statusAndPresence/useManagedPresenceUsers.ts b/apps/meteor/client/views/admin/statusAndPresence/useManagedPresenceUsers.ts new file mode 100644 index 0000000000000..647041994212c --- /dev/null +++ b/apps/meteor/client/views/admin/statusAndPresence/useManagedPresenceUsers.ts @@ -0,0 +1,17 @@ +import type { OperationResult } from '@rocket.chat/rest-typings'; +import { useEndpoint } from '@rocket.chat/ui-contexts'; +import { useQuery } from '@tanstack/react-query'; + +export type ManagedPresenceUser = OperationResult<'GET', '/v1/users.listStatusVisibility'>['users'][number]; + +export const useManagedPresenceUsers = ({ searchTerm, count, offset }: { searchTerm: string; count: number; offset: number }) => { + const listStatusVisibility = useEndpoint('GET', '/v1/users.listStatusVisibility'); + + return useQuery({ + queryKey: ['admin', 'managed-presence-users', searchTerm, count, offset], + queryFn: () => listStatusVisibility({ searchTerm, count, offset }), + meta: { + apiErrorToastMessage: true, + }, + }); +}; diff --git a/apps/meteor/client/views/admin/users/AdminUserForm.tsx b/apps/meteor/client/views/admin/users/AdminUserForm.tsx index c13ffa1e728cc..fb5c5491a076f 100644 --- a/apps/meteor/client/views/admin/users/AdminUserForm.tsx +++ b/apps/meteor/client/views/admin/users/AdminUserForm.tsx @@ -23,6 +23,7 @@ import { validateEmail } from '@rocket.chat/tools'; import { CustomFieldsForm, ContextualbarScrollableContent, ContextualbarFooter } from '@rocket.chat/ui-client'; import { useAccountsCustomFields, + usePermission, useSetting, useEndpoint, useRouter, @@ -41,6 +42,7 @@ import PasswordFieldSkeleton from './PasswordFieldSkeleton'; import { useSmtpQuery } from './hooks/useSmtpQuery'; import { useShowVoipExtension } from './useShowVoipExtension'; import { parseCSV } from '../../../../lib/utils/parseCSV'; +import UserAutoCompleteMultiple from '../../../components/UserAutoCompleteMultiple'; import UserAvatarEditor from '../../../components/avatar/UserAvatarEditor'; import { useEndpointMutation } from '../../../hooks/useEndpointMutation'; import { useHasLicenseModule } from '../../../hooks/useHasLicenseModule'; @@ -57,7 +59,12 @@ export type AdminUserFormProps = { }; export type UserFormProps = Omit< - UserCreateParamsPOST & { avatar: AvatarObject; passwordConfirmation: string; freeSwitchExtension?: string }, + UserCreateParamsPOST & { + avatar: AvatarObject; + passwordConfirmation: string; + freeSwitchExtension?: string; + statusVisibilityDeniedByAdmin?: string[]; + }, 'fields' >; @@ -87,6 +94,7 @@ const getInitialValue = ({ customFields: data?.customFields ?? {}, statusText: data?.statusText ?? '', presenceDisabledByAdmin: data?.presenceDisabledByAdmin === true, + statusVisibilityDeniedByAdmin: data?.statusVisibilityDeniedByAdmin ?? [], freeSwitchExtension: data?.freeSwitchExtension ?? '', ...(isNewUserPage && { joinDefaultChannels: true }), sendWelcomeEmail: isSmtpEnabled, @@ -104,6 +112,7 @@ const AdminUserForm = ({ userData, onReload, context, refetchUserFormData, roleD const defaultRoles = useSetting('Accounts_Registration_Users_Default_Roles', ''); const { data: hasPresenceLicense = false } = useHasLicenseModule('unlimited-presence'); const userStatusEnabled = useSetting('Accounts_UserStatus_Enabled', true); + const canViewFullOtherUserInfo = usePermission('view-full-other-user-info'); const isVerificationNeeded = useSetting('Accounts_EmailVerification'); const defaultUserRoles = parseCSV(defaultRoles); @@ -129,7 +138,9 @@ const AdminUserForm = ({ userData, onReload, context, refetchUserFormData, roleD const showVoipExtension = useShowVoipExtension(); - const { avatar, username, setRandomPassword, password, name: userFullName } = watch(); + const { avatar, username, setRandomPassword, password, name: userFullName, presenceDisabledByAdmin } = watch(); + const showUserStatusSection = hasPresenceLicense && userStatusEnabled && canViewFullOtherUserInfo; + const statusFieldsDisabled = showUserStatusSection && presenceDisabledByAdmin === true; const { mutateAsync: eventStats } = useEndpointMutation('POST', '/v1/statistics.telemetry'); const updateUserAction = useEndpoint('POST', '/v1/users.update'); @@ -176,10 +187,13 @@ const AdminUserForm = ({ userData, onReload, context, refetchUserFormData, roleD }); const handleSaveUser = useStableCallback(async (userFormPayload: UserFormProps) => { - const { avatar, passwordConfirmation, ...userFormData } = userFormPayload; + const { avatar, passwordConfirmation, statusVisibilityDeniedByAdmin, ...userFormData } = userFormPayload; if (!isNewUserPage && userData?._id) { - return handleUpdateUser.mutateAsync({ userId: userData?._id, data: userFormData }); + return handleUpdateUser.mutateAsync({ + userId: userData?._id, + data: { ...userFormData, ...(showUserStatusSection && statusVisibilityDeniedByAdmin && { statusVisibilityDeniedByAdmin }) }, + }); } return handleCreateUser.mutateAsync({ ...userFormData, fields: '' }); @@ -191,11 +205,12 @@ const AdminUserForm = ({ userData, onReload, context, refetchUserFormData, roleD const emailId = useId(); const verifiedId = useId(); const statusTextId = useId(); + const userStatusId = useId(); + const hiddenFromId = useId(); const bioId = useId(); const nicknameId = useId(); const passwordId = useId(); const rolesId = useId(); - const userStatusId = useId(); const joinDefaultChannelsId = useId(); const sendWelcomeEmailId = useId(); const setRandomPasswordId = useId(); @@ -424,31 +439,6 @@ const AdminUserForm = ({ userData, onReload, context, refetchUserFormData, roleD {errors?.roles && {errors.roles.message}} - {hasPresenceLicense && userStatusEnabled && ( - - - {t('User_Status')} - - ( - ) => onChange(!event.currentTarget.checked)} - checked={value !== true} - /> - )} - /> - - - - {t('User_status_admin_toggle_Description')} - - - )} {isNewUserPage && ( @@ -505,8 +495,35 @@ const AdminUserForm = ({ userData, onReload, context, refetchUserFormData, roleD )} + {showUserStatusSection && ( + + + {t('Show_status')} + + ( + ) => onChange(!event.currentTarget.checked)} + checked={value !== true} + /> + )} + /> + + + + {t('User_presence_admin_hint')} + + + )} - {t('StatusMessage')} + + {t('StatusMessage')} + )} + {showUserStatusSection && !isNewUserPage && ( + + + {t('Hide_presence_from')} + + + ( + + )} + /> + + {t('Hide_presence_from_hint')} + + )} {t('Bio')} diff --git a/apps/meteor/client/views/admin/users/UsersTable/UsersTableRow.tsx b/apps/meteor/client/views/admin/users/UsersTable/UsersTableRow.tsx index ba8cb61146a70..713d9450df142 100644 --- a/apps/meteor/client/views/admin/users/UsersTable/UsersTableRow.tsx +++ b/apps/meteor/client/views/admin/users/UsersTable/UsersTableRow.tsx @@ -14,6 +14,7 @@ import type { AdminUsersTab } from '../AdminUsersPage'; import { useChangeAdminStatusAction } from '../hooks/useChangeAdminStatusAction'; import { useChangeUserStatusAction } from '../hooks/useChangeUserStatusAction'; import { useDeleteUserAction } from '../hooks/useDeleteUserAction'; +import { useManageUserStatusAction } from '../hooks/useManageUserStatusAction'; import { useResetE2EEKeyAction } from '../hooks/useResetE2EEKeyAction'; import { useResetTOTPAction } from '../hooks/useResetTOTPAction'; import { useSendWelcomeEmailMutation } from '../hooks/useSendWelcomeEmailMutation'; @@ -81,6 +82,7 @@ const UsersTableRow = ({ user, tab, isMobile, isLaptop, isSeatsCapExceeded, show const changeAdminStatusAction = useChangeAdminStatusAction(username, isAdmin, onReload); const changeUserStatusAction = useChangeUserStatusAction(userId, isActive, onReload); + const manageUserStatusAction = useManageUserStatusAction(); const deleteUserAction = useDeleteUserAction(userId, onReload, onReload); const resetTOTPAction = useResetTOTPAction(userId); const resetE2EKeyAction = useResetE2EEKeyAction(userId); @@ -102,6 +104,9 @@ const UsersTableRow = ({ user, tab, isMobile, isLaptop, isSeatsCapExceeded, show !isFederatedUser && { changeUserStatusAction, }), + ...(manageUserStatusAction && { + manageUserStatusAction, + }), ...(deleteUserAction && { deleteUserAction, }), @@ -109,6 +114,7 @@ const UsersTableRow = ({ user, tab, isMobile, isLaptop, isSeatsCapExceeded, show [ changeAdminStatusAction, changeUserStatusAction, + manageUserStatusAction, deleteUserAction, isFederatedUser, isNotPendingDeactivatedNorFederated, diff --git a/apps/meteor/client/views/admin/users/hooks/useManageUserStatusAction.ts b/apps/meteor/client/views/admin/users/hooks/useManageUserStatusAction.ts new file mode 100644 index 0000000000000..7a07ec43ace89 --- /dev/null +++ b/apps/meteor/client/views/admin/users/hooks/useManageUserStatusAction.ts @@ -0,0 +1,20 @@ +import { usePermission, useRoute } from '@rocket.chat/ui-contexts'; +import { useTranslation } from 'react-i18next'; + +import type { AdminUserAction } from './useAdminUserInfoActions'; +import { useHasLicenseModule } from '../../../../hooks/useHasLicenseModule'; + +export const useManageUserStatusAction = (): AdminUserAction | undefined => { + const { t } = useTranslation(); + const statusRoute = useRoute('user-status'); + const canEditOtherUserInfo = usePermission('edit-other-user-info'); + const { data: hasUnlimitedPresence } = useHasLicenseModule('unlimited-presence'); + + return canEditOtherUserInfo && hasUnlimitedPresence + ? { + icon: 'circle-unfilled', + content: t('Manage_status'), + onClick: () => statusRoute.push({ tab: 'user-presence' }), + } + : undefined; +}; diff --git a/apps/meteor/tests/e2e/admin-status-and-presence.spec.ts b/apps/meteor/tests/e2e/admin-status-and-presence.spec.ts new file mode 100644 index 0000000000000..0c6f2449638bb --- /dev/null +++ b/apps/meteor/tests/e2e/admin-status-and-presence.spec.ts @@ -0,0 +1,106 @@ +import type { APIRequestContext, BrowserContext, Page } from '@playwright/test'; + +import { DEFAULT_USER_CREDENTIALS, IS_EE } from './config/constants'; +import { Users } from './fixtures/userStates'; +import { Authenticated, Login } from './page-objects'; +import { Listbox } from './page-objects/fragments/listbox'; +import { expectPollUserStatus } from './utils/expectPollUserStatus'; +import { expect, test } from './utils/test'; +import type { ITestUser } from './utils/user-helpers'; +import { createTestUser } from './utils/user-helpers'; + +const getStatusAsViewer = async (viewerApi: APIRequestContext, username: string): Promise => { + const response = await viewerApi.get('/users.info', { params: { username } }); + const body = await response.json(); + return body.user?.status; +}; + +test.describe('Admin > Status and presence > User status', () => { + test.skip(!IS_EE); + test.use({ storageState: Users.admin.state }); + + let hiddenUser: ITestUser; + let blockedViewer: ITestUser; + let controlViewer: ITestUser; + let hiddenUserContext: BrowserContext; + let hiddenUserPage: Page; + + test.beforeAll(async ({ api, browser }) => { + hiddenUser = await createTestUser(api); + blockedViewer = await createTestUser(api); + controlViewer = await createTestUser(api); + + hiddenUserContext = await browser.newContext(); + hiddenUserPage = await hiddenUserContext.newPage(); + + await hiddenUserPage.goto('/login'); + await new Login(hiddenUserPage).login(hiddenUser.data.username, DEFAULT_USER_CREDENTIALS.password); + await new Authenticated(hiddenUserPage).waitForDisplay(); + + await expectPollUserStatus(api, hiddenUser.data.username, 'online'); + }); + + test.afterAll(async () => { + await hiddenUserPage.close(); + await hiddenUserContext.close(); + await hiddenUser.delete(); + await blockedViewer.delete(); + await controlViewer.delete(); + }); + + test('hides a user from a named viewer and restores it through the confirmation modal', async ({ page, api }) => { + const listbox = new Listbox(page); + const row = page.locator('tr', { hasText: hiddenUser.data.name || hiddenUser.data.username }).first(); + const asBlockedViewer = await api.login({ username: blockedViewer.data.username, password: DEFAULT_USER_CREDENTIALS.password }); + + await test.step('open Status and presence > User status', async () => { + await page.goto('/admin/user-status'); + await page.getByRole('tab', { name: 'User status', exact: true }).click(); + }); + + await test.step('create a rule hiding the user from the blocked viewer', async () => { + await page.getByRole('button', { name: 'Manage user status', exact: true }).click(); + + const dialog = page.getByRole('dialog', { name: 'Manage user status' }); + await expect(dialog).toBeVisible(); + + await dialog.getByRole('combobox', { name: 'User', exact: true }).pressSequentially(hiddenUser.data.username); + await listbox.selectOption(hiddenUser.data.username); + + await dialog.getByLabel('Hide status from', { exact: true }).getByRole('textbox').fill(blockedViewer.data.username); + await listbox.selectOption(blockedViewer.data.username); + + await dialog.getByRole('button', { name: 'Save', exact: true }).click(); + await expect(dialog).not.toBeVisible(); + }); + + await test.step('the table lists the new rule', async () => { + await expect(row).toContainText(blockedViewer.data.username); + }); + + await test.step('the blocked viewer sees the user as offline while the control viewer sees the real status', async () => { + const asControlViewer = await api.login({ username: controlViewer.data.username, password: DEFAULT_USER_CREDENTIALS.password }); + + await expect.poll(async () => getStatusAsViewer(asBlockedViewer, hiddenUser.data.username)).toBe('offline'); + await expect.poll(async () => getStatusAsViewer(asControlViewer, hiddenUser.data.username)).toBe('online'); + }); + + await test.step('the admin removes the rule through the confirmation modal', async () => { + await row.click(); + + const editDialog = page.getByRole('dialog', { name: 'Manage user status' }); + await expect(editDialog).toBeVisible(); + await editDialog.getByRole('button', { name: 'Remove user status settings', exact: true }).click(); + + const confirmModal = page.getByRole('dialog', { name: 'Remove user status settings' }); + await confirmModal.getByRole('button', { name: 'Remove', exact: true }).click(); + + await expect(confirmModal).not.toBeVisible(); + await expect(editDialog).not.toBeVisible(); + }); + + await test.step('the blocked viewer sees the real status again', async () => { + await expect.poll(async () => getStatusAsViewer(asBlockedViewer, hiddenUser.data.username)).toBe('online'); + }); + }); +}); diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index 2af09c0b847cc..cd947e08ed6e9 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -230,16 +230,16 @@ "Accounts_AllowEmailNotifications": "Allow Email Notifications", "Accounts_AllowFeaturePreview": "Allow Feature Preview", "Accounts_AllowFeaturePreview_Description": "Make feature preview available to all workspace members.", - "Accounts_AllowInvisibleStatusOption": "Allow Invisible status option", - "Accounts_AllowInvisibleStatusOption_Description": "Let users appear offline while still connected.", + "Accounts_AllowInvisibleStatusOption": "Offline status", + "Accounts_AllowInvisibleStatusOption_Description": "Let users appear offline while still signed in.", "Accounts_AllowPasswordChange": "Allow Password Change", "Accounts_AllowPasswordChangeForOAuthUsers": "Allow Password Change for OAuth Users", "Accounts_AllowRealNameChange": "Allow Name Change", "Accounts_AllowUserAvatarChange": "Allow User Avatar Change", "Accounts_AllowUsernameChange": "Allow Username Change", "Accounts_AllowUserProfileChange": "Allow User Profile Change", - "Accounts_AllowUserStatusMessageChange": "Allow Custom Status Message", - "Accounts_AllowUserStatusMessageChange_Description": "Let users set a custom status message.", + "Accounts_AllowUserStatusMessageChange": "Custom status", + "Accounts_AllowUserStatusMessageChange_Description": "Let users set their own custom status message.", "Accounts_AvatarBlockUnauthenticatedAccess": "Block Unauthenticated Access to Avatars", "Accounts_AvatarCacheTime": "Avatar cache time", "Accounts_AvatarCacheTime_description": "Number of seconds the http protocol is told to cache the avatar images.", @@ -460,8 +460,8 @@ "Accounts_UserAddedEmail_Default": "

Welcome to [Site_Name]

Go to [Site_URL] and try the best open source chat solution available today!

You may login using your email: [email] and password: [password]. You may be required to change it after your first login.", "Accounts_UserAddedEmail_Description": "You may use the following placeholders: \n - `[name]`, `[fname]`, `[lname]` for the user's full name, first name or last name, respectively. \n - `[email]` for the user's email. \n - `[password]` for the user's password. \n - `[Site_Name]` and `[Site_URL]` for the Application Name and URL respectively. ", "Accounts_UserAddedEmailSubject_Default": "You have been added to [Site_Name]", - "Accounts_UserStatus_Enabled": "User status", - "Accounts_UserStatus_Enabled_Description": "Let users see each other's status. If disabled, users will always appear offline. Edit [users](/admin/users) to manage individually.", + "Accounts_UserStatus_Enabled": "Show status", + "Accounts_UserStatus_Enabled_Description": "Let users see each other's status. If disabled, users will always appear offline.", "Accounts_Verify_Email_For_External_Accounts": "Mark Email for External Accounts Verified", "Action": "Action", "Action_Available_After_Custom_Content_Added": "This action will become available after the custom content has been added", @@ -549,6 +549,7 @@ "admin-no-videoconf-provider-app": "**Conference call not enabled**: Conference call apps are available in the Rocket.Chat marketplace.", "admin-video-conf-provider-not-configured": "**Conference call not enabled**: Configure conference calls in order to make it available on this workspace.", "Administration": "Administration", + "Admins_cannot_see_concealed_presence": "Admins cannot see who users conceal their status from.", "Adult_images_are_not_allowed": "Adult images are not allowed", "Advanced_contact_profile": "Advanced contact profile", "Advanced_contact_profile_description": "Manage multiple emails and phone numbers for a single contact, enabling a comprehensive multi-channel history that keeps you well-informed and improves communication efficiency.", @@ -1928,6 +1929,7 @@ "Custom_Sound_Info": "Custom Sound Info", "Custom_Sound_Saved_Successfully": "Custom sound saved successfully", "Custom_Status": "Custom...", + "Custom_status": "Custom status", "Custom_time_range": "Custom time range", "Custom_Translations": "Custom Translations", "Custom_Translations_Description": "Should be a valid JSON where keys are languages containing a dictionary of key and translations. Example: `{\"en\": {\"Channels\": \"Rooms\"},\"pt\": {\"Channels\": \"Salas\"}}`", @@ -2693,6 +2695,7 @@ "every_minute": "Once every minute", "every_second": "Once every second", "every_six_hours": "Once every six hours", + "Everyone": "Everyone", "Everyone_can_access_this_channel": "Everyone can access this channel", "Exact": "Exact", "Example_payload": "Example payload", @@ -3140,6 +3143,7 @@ "Hi": "Hi", "Hi_username": "Hi [name]", "Hidden": "Hidden", + "Hidden_from": "Hidden from", "Hide": "Hide", "Hide_additional_fields": "Hide additional fields", "Hide_chat": "Hide chat", @@ -3148,6 +3152,8 @@ "Hide_Group_Warning": "Are you sure you want to hide the group \"{{roomName}}\"?", "Hide_Livechat_Warning": "Are you sure you want to hide the chat with \"{{roomName}}\"?", "Hide_On_Workspace": "Hide on workspace", + "Hide_presence_from": "Hide status from", + "Hide_presence_from_hint": "Selected users will not see this user's status, always appearing offline to them.", "Hide_Private_Warning": "Are you sure you want to hide the discussion with \"{{roomName}}\"?", "Hide_roles": "Hide Roles", "Hide_room": "Hide", @@ -4047,7 +4053,9 @@ "Manage_Omnichannel": "Manage Omnichannel", "Manage_server_list": "Manage server list", "Manage_servers": "Manage servers", + "Manage_status": "Manage status", "Manage_subscription": "Manage subscription", + "Manage_user_presence": "Manage user status", "Manage_which_devices": "Manage which devices are connecting to this workspace to help ensure security. Information such as device ID, login data is included as is the ability to log out devices remotely.", "Manage_workspace": "Manage workspace", "manage-abac-admin-room-attributes": "Manage ABAC Room Attributes", @@ -4655,6 +4663,8 @@ "No_integration_found": "No integration found by the provided id.", "No_Limit": "No Limit", "No_livechats": "You have no livechats", + "No_managed_users": "No managed users", + "No_managed_users_description": "Users with individual status settings will appear here.", "No_managers_yet": "No managers yet", "No_managers_yet_description": "Managers have access to all omnichannel controls, being able to monitor and take actions.", "No_marketplace_matches_for": "No Marketplace matches for", @@ -5197,6 +5207,7 @@ "Presence_broadcast_disabled_Description": "This shows if the presence broadcast has been disabled automatically. This can happen if you don't have an Premium License and have more than 200 concurrent connections.", "Presence_service": "Presence service", "Presence_service_cap": "Presence service cap", + "Presence_settings_updated": "{{name}}'s status settings updated", "Presence_status_on_a_call": "On a call", "Presence_status_outlook_in_a_meeting": "Outlook: In a meeting", "Preview": "Preview", @@ -5503,6 +5514,8 @@ "Remove_RocketChat_Watermark": "Remove Rocket.Chat watermark", "Remove_RocketChat_Watermark_InfoText": "Watermark is automatically removed when a paid license is active.", "Remove_someone_from_room": "Remove someone from the room", + "Remove_user_presence_settings": "Remove user status settings", + "Remove_user_presence_settings_description": "{{name}}'s status will revert to the workspace default.", "remove-canned-responses": "Remove Canned Responses", "remove-canned-responses_description": "Permission to remove canned responses", "remove-closed-livechat-room": "Remove Closed Omnichannel Room", @@ -5525,6 +5538,9 @@ "Removed__username__from_the_team": "removed @{{user_removed}} from this team", "Removed_User": "Removed User", "Renews_DATE": "Renews {{date}}", + "Replace": "Replace", + "Replace_user_status_settings": "Replace user status settings", + "Replace_user_status_settings_description": "{{name}} already has status settings. Saving will replace them.", "Replay": "Replay", "Replied_on": "Replied on", "Replies": "Replies", @@ -5577,6 +5593,8 @@ "Reset_priorities": "Reset priorities", "Reset_section_settings": "Restore defaults", "Reset_TOTP": "Reset TOTP", + "Reset_user_status_settings": "Reset user status settings", + "Reset_user_status_settings_description": "{{name}}'s status will reset to the workspace default.", "reset-other-user-e2e-key": "Reset Other User E2E Key", "Resize": "Resize", "Responding": "Responding", @@ -6098,6 +6116,7 @@ "Show_roles": "Show roles", "Show_room_counter_on_sidebar": "Show room counter on sidebar", "Show_Setup_Wizard": "Show Setup Wizard", + "Show_status": "Show status", "Show_the_keyboard_shortcut_list": "Show the keyboard shortcut list", "Show_To_Workspace": "Show to workspace", "Show_usernames": "Show usernames", @@ -6292,15 +6311,19 @@ "Status": "Status", "Status_1_hour": "1 hour", "Status_30_minutes": "30 minutes", + "Status_and_presence": "Status and presence", "Status_choose_date_and_time": "Choose date and time", "Status_clear_after": "Clear status after", "Status_dont_clear": "Don't clear", "Status_expiration_date": "Expiration date", "Status_expiration_must_be_future": "Expiration must be in the future", "Status_expiration_time": "Expiration time", + "Status_name": "Status name", "Status_new_status_warning": "New status can be changed by: calendar integrations, voice calls or videoconferencing", "Status_new_status_warning_after_call": "New status will be applied after the current call ends", "Status_set_your_status": "Set your status", + "Status_settings_removed": "{{name}}'s status settings removed", + "Status_settings_reset_to_workspace_default": "{{name}}'s status settings reset to workspace default", "Status_you_can_use_emoji": "You can use emoji", "StatusMessage": "Status message", "StatusMessage_Change_Disabled": "Your Rocket.Chat administrator has disabled the changing of status messages", @@ -6977,6 +7000,7 @@ "User_has_been_unbanned": "unbanned {{user_unbanned}}", "User_has_been_unignored": "User is no longer ignored", "User_has_been_unmuted": "unmuted {{user_unmuted}}", + "User_has_no_visibility_into_changes_made_in_this_panel": "User has no visibility into changes made in this panel.", "User_Info": "User Info", "User_info": "User info", "User_Interface": "User Interface", @@ -7009,6 +7033,7 @@ "User_not_found_or_incorrect_password": "User not found or incorrect password", "User_or_channel_name": "User or channel name", "User_Presence": "User Presence", + "User_presence_admin_hint": "Let other users see this user's status. If disabled, they will always appear offline.", "User_rejected_invitation_to_room": "rejected invitation to room", "User_removed": "User removed", "User_removed_by": "User {{user_removed}} removed by {{user_by}}.", @@ -7018,7 +7043,6 @@ "User_Settings": "User Settings", "User_started_a_new_conversation": "{{username}} started a new conversation", "User_Status": "User status", - "User_status_admin_toggle_Description": "Let other users see this user's status. If disabled, this user will always appear offline.", "User_status_disabled": "User status temporarily disabled to maintain performance.", "User_status_disabled_by_an_admin": "User status disabled by an admin", "User_status_disabled_learn_more": "User status disabled", From a82e66fbaf85edcfac0bdf0e3321ea1e3b1654f1 Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Thu, 17 Sep 2026 09:12:48 -0300 Subject: [PATCH 5/5] refactor: route the user presence editor and align the UI with the frontend docs --- apps/meteor/client/lib/queryKeys.ts | 6 + .../profile/UserStatusDisabledInfo.tsx | 2 +- .../meteor/client/views/admin/sidebarItems.ts | 2 +- .../admin/statusAndPresence/SettingsTab.tsx | 10 +- .../StatusAndPresencePage.tsx | 46 +++-- .../StatusAndPresenceRoute.tsx | 29 +-- .../StatusAndPresenceTabs.tsx | 2 +- .../UserPresenceEditorForm.tsx | 172 +++++++++--------- .../UserPresenceEditorFormWithData.tsx | 21 +++ .../statusAndPresence/UserPresenceTab.tsx | 2 +- .../statusAndPresence/UserPresenceTabRow.tsx | 12 +- .../useManagedPresenceUsers.ts | 35 +++- .../views/admin/users/AdminUserForm.tsx | 2 +- .../admin/users/UsersTable/UsersTableRow.tsx | 2 +- .../users/hooks/useManageUserStatusAction.ts | 12 +- .../e2e/admin-status-and-presence.spec.ts | 38 ++-- .../page-objects/admin-status-and-presence.ts | 38 ++++ apps/meteor/tests/e2e/page-objects/index.ts | 1 + .../tests/e2e/utils/getUserStatusAsViewer.ts | 10 + apps/meteor/tests/e2e/utils/index.ts | 1 + 20 files changed, 288 insertions(+), 155 deletions(-) create mode 100644 apps/meteor/client/views/admin/statusAndPresence/UserPresenceEditorFormWithData.tsx create mode 100644 apps/meteor/tests/e2e/page-objects/admin-status-and-presence.ts create mode 100644 apps/meteor/tests/e2e/utils/getUserStatusAsViewer.ts diff --git a/apps/meteor/client/lib/queryKeys.ts b/apps/meteor/client/lib/queryKeys.ts index da9979eef2da2..e920d3b377f49 100644 --- a/apps/meteor/client/lib/queryKeys.ts +++ b/apps/meteor/client/lib/queryKeys.ts @@ -196,3 +196,9 @@ export const messagesQueryKeys = { all: ['messages'] as const, message: (messageId: IMessage['_id']) => [...messagesQueryKeys.all, messageId] as const, }; + +export const managedPresenceQueryKeys = { + all: ['admin', 'managed-presence-users'] as const, + list: (query: PaginatedRequest) => [...managedPresenceQueryKeys.all, query] as const, + byUsername: (username?: IUser['username']) => [...managedPresenceQueryKeys.all, 'byUsername', username] as const, +}; diff --git a/apps/meteor/client/views/account/profile/UserStatusDisabledInfo.tsx b/apps/meteor/client/views/account/profile/UserStatusDisabledInfo.tsx index f6a9aa18168d9..8e103c2a3469d 100644 --- a/apps/meteor/client/views/account/profile/UserStatusDisabledInfo.tsx +++ b/apps/meteor/client/views/account/profile/UserStatusDisabledInfo.tsx @@ -1,7 +1,7 @@ import { Box, Icon } from '@rocket.chat/fuselage'; import { useTranslation } from 'react-i18next'; -type UserStatusDisabledInfoProps = { workspace: boolean }; +export type UserStatusDisabledInfoProps = { workspace: boolean }; const UserStatusDisabledInfo = ({ workspace }: UserStatusDisabledInfoProps) => { const { t } = useTranslation(); diff --git a/apps/meteor/client/views/admin/sidebarItems.ts b/apps/meteor/client/views/admin/sidebarItems.ts index edf4406135103..f87facd034eb3 100644 --- a/apps/meteor/client/views/admin/sidebarItems.ts +++ b/apps/meteor/client/views/admin/sidebarItems.ts @@ -64,7 +64,7 @@ export const { href: '/admin/user-status', i18nLabel: 'Status_and_presence', icon: 'user', - permissionGranted: (): boolean => hasAtLeastOnePermission(['manage-user-status', 'edit-other-user-info']), + permissionGranted: (): boolean => hasAtLeastOnePermission(['manage-user-status']), }, { href: '/admin/permissions', diff --git a/apps/meteor/client/views/admin/statusAndPresence/SettingsTab.tsx b/apps/meteor/client/views/admin/statusAndPresence/SettingsTab.tsx index 32edce4e75568..15a84c77e12c5 100644 --- a/apps/meteor/client/views/admin/statusAndPresence/SettingsTab.tsx +++ b/apps/meteor/client/views/admin/statusAndPresence/SettingsTab.tsx @@ -1,12 +1,16 @@ import { AccordionItem, Callout, FieldGroup } from '@rocket.chat/fuselage'; import type { ReactNode } from 'react'; -import { memo } from 'react'; import { useTranslation } from 'react-i18next'; import Setting from '../settings/Setting'; import SettingsGroupPage from '../settings/SettingsGroupPage'; -const SettingsTab = ({ tabs, headerButtons }: { tabs: ReactNode; headerButtons?: ReactNode }) => { +export type SettingsTabProps = { + tabs: ReactNode; + headerButtons?: ReactNode; +}; + +const SettingsTab = ({ tabs, headerButtons }: SettingsTabProps) => { const { t } = useTranslation(); return ( @@ -24,4 +28,4 @@ const SettingsTab = ({ tabs, headerButtons }: { tabs: ReactNode; headerButtons?: ); }; -export default memo(SettingsTab); +export default SettingsTab; diff --git a/apps/meteor/client/views/admin/statusAndPresence/StatusAndPresencePage.tsx b/apps/meteor/client/views/admin/statusAndPresence/StatusAndPresencePage.tsx index ddef56d6cc78a..c2033d991602c 100644 --- a/apps/meteor/client/views/admin/statusAndPresence/StatusAndPresencePage.tsx +++ b/apps/meteor/client/views/admin/statusAndPresence/StatusAndPresencePage.tsx @@ -1,4 +1,5 @@ import { Button, ButtonGroup } from '@rocket.chat/fuselage'; +import { useStableCallback } from '@rocket.chat/fuselage-hooks'; import { ContextualbarHeader, ContextualbarClose, @@ -8,14 +9,15 @@ import { PageHeader, PageContent, } from '@rocket.chat/ui-client'; -import { useRoute, useRouteParameter, useTranslation } from '@rocket.chat/ui-contexts'; +import { useRouteParameter, useRouter } from '@rocket.chat/ui-contexts'; import type { ReactElement } from 'react'; -import { memo, useCallback, useRef, useState } from 'react'; +import { memo, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; import SettingsTab from './SettingsTab'; import StatusAndPresenceTabs from './StatusAndPresenceTabs'; import type { StatusAndPresenceTab } from './StatusAndPresenceTabs'; -import UserPresenceEditorForm from './UserPresenceEditorForm'; +import UserPresenceEditorFormWithData from './UserPresenceEditorFormWithData'; import UserPresenceTab from './UserPresenceTab'; import type { ManagedPresenceUser } from './useManagedPresenceUsers'; import { useIsEnterprise } from '../../../hooks/useIsEnterprise'; @@ -32,32 +34,33 @@ export type StatusAndPresencePageProps = { }; const StatusAndPresencePage = ({ tab, canManageCustomStatus, canManageUserPresence, canViewSettings }: StatusAndPresencePageProps) => { - const t = useTranslation(); - const route = useRoute('user-status'); + const { t } = useTranslation(); + const router = useRouter(); const context = useRouteParameter('context'); const id = useRouteParameter('id'); const { data: license } = useIsEnterprise(); - const [editing, setEditing] = useState<{ user?: ManagedPresenceUser }>(); + const navigate = useStableCallback((params: { tab: StatusAndPresenceTab; context?: string; id?: string }) => + router.navigate({ name: 'user-status', params }), + ); - const handleTabChange = useCallback((next: StatusAndPresenceTab) => route.push({ tab: next }), [route]); + const handleTabChange = useStableCallback((next: StatusAndPresenceTab) => navigate({ tab: next })); - const handleItemClick = useCallback((id: string) => route.push({ tab, context: 'edit', id }), [route, tab]); + const handleItemClick = useStableCallback((id: string) => navigate({ tab, context: 'edit', id })); - const handleNewButtonClick = useCallback(() => route.push({ tab, context: 'new' }), [route, tab]); + const handleNewButtonClick = useStableCallback(() => navigate({ tab, context: 'new' })); - const handlePresenceServiceClick = useCallback(() => route.push({ tab, context: 'presence-service' }), [route, tab]); + const handlePresenceServiceClick = useStableCallback(() => navigate({ tab, context: 'presence-service' })); - const handleClose = useCallback(() => { - setEditing(undefined); - route.push({ tab }); - }, [route, tab]); + const handleClose = useStableCallback(() => navigate({ tab })); - const handleEdit = useCallback((user?: ManagedPresenceUser) => setEditing({ user }), []); + const handleEdit = useStableCallback((user?: ManagedPresenceUser) => + navigate(user?.username ? { tab, context: 'edit', id: user.username } : { tab, context: 'new' }), + ); const reload = useRef(() => null); - const handleReload = useCallback(() => reload.current(), [reload]); + const handleReload = useStableCallback(() => reload.current()); const tabs = ( ); - const headerButtons = ; + const headerButtons = canManageCustomStatus ? : undefined; const contextualBar: { title: string; content: ReactElement } | undefined = - (context === 'presence-service' && { title: t('Presence_service_cap'), content: }) || (canManageCustomStatus && + context === 'presence-service' && { title: t('Presence_service_cap'), content: }) || + (canManageCustomStatus && + tab === 'custom-status' && (context === 'new' || context === 'edit') && { title: t(context === 'new' ? 'Custom_User_Status_Add' : 'Custom_User_Status_Edit'), content: , }) || (canManageUserPresence && - editing && { + tab === 'user-presence' && + (context === 'new' || context === 'edit') && { title: t('Manage_user_presence'), - content: , + content: , }) || undefined; diff --git a/apps/meteor/client/views/admin/statusAndPresence/StatusAndPresenceRoute.tsx b/apps/meteor/client/views/admin/statusAndPresence/StatusAndPresenceRoute.tsx index 1fc4f0a5b2d4f..6371ffd6e0752 100644 --- a/apps/meteor/client/views/admin/statusAndPresence/StatusAndPresenceRoute.tsx +++ b/apps/meteor/client/views/admin/statusAndPresence/StatusAndPresenceRoute.tsx @@ -1,15 +1,9 @@ -import { - useIsPrivilegedSettingsContext, - usePermission, - useRoute, - useRouteParameter, - useRouter, - useSetting, -} from '@rocket.chat/ui-contexts'; +import { useIsPrivilegedSettingsContext, usePermission, useRouteParameter, useRouter, useSetting } from '@rocket.chat/ui-contexts'; import { memo, useEffect, useLayoutEffect } from 'react'; import StatusAndPresencePage from './StatusAndPresencePage'; import type { StatusAndPresenceTab } from './StatusAndPresenceTabs'; +import PageSkeleton from '../../../components/PageSkeleton'; import { useHasLicenseModule } from '../../../hooks/useHasLicenseModule'; import NotAuthorizedPage from '../../notAuthorized/NotAuthorizedPage'; import EditableSettingsProvider from '../settings/EditableSettingsProvider'; @@ -17,7 +11,6 @@ import EditableSettingsProvider from '../settings/EditableSettingsProvider'; const TAB_ORDER = ['settings', 'custom-status', 'user-presence'] as const; const StatusAndPresenceRoute = () => { - const route = useRoute('user-status'); const router = useRouter(); const tab = useRouteParameter('tab'); const context = useRouteParameter('context'); @@ -25,7 +18,7 @@ const StatusAndPresenceRoute = () => { const canManageCustomStatus = usePermission('manage-user-status'); const canViewSettings = useIsPrivilegedSettingsContext(); - const { data: hasUnlimitedPresence } = useHasLicenseModule('unlimited-presence'); + const { data: hasUnlimitedPresence, isPending: isLicensePending } = useHasLicenseModule('unlimited-presence'); const canManageUserPresence = usePermission('edit-other-user-info') && !!hasUnlimitedPresence; const allowed: Record = { @@ -38,16 +31,24 @@ const StatusAndPresenceRoute = () => { const currentTab = TAB_ORDER.find((name) => name === tab && allowed[name]); useLayoutEffect(() => { + if (isLicensePending) { + return; + } + if (firstAllowedTab && !currentTab) { router.navigate({ name: 'user-status', params: { tab: firstAllowedTab } }, { replace: true }); } - }, [router, firstAllowedTab, currentTab]); + }, [router, firstAllowedTab, currentTab, isLicensePending]); useEffect(() => { - if (presenceDisabled && currentTab && context !== 'presence-service') { - route.push({ tab: currentTab, context: 'presence-service' }); + if (presenceDisabled && canManageCustomStatus && currentTab && context !== 'presence-service') { + router.navigate({ name: 'user-status', params: { tab: currentTab, context: 'presence-service' } }); } - }, [presenceDisabled, currentTab, context, route]); + }, [presenceDisabled, canManageCustomStatus, currentTab, context, router]); + + if (isLicensePending) { + return ; + } if (!firstAllowedTab) { return ; diff --git a/apps/meteor/client/views/admin/statusAndPresence/StatusAndPresenceTabs.tsx b/apps/meteor/client/views/admin/statusAndPresence/StatusAndPresenceTabs.tsx index eb2cfb022e851..fea64082017fc 100644 --- a/apps/meteor/client/views/admin/statusAndPresence/StatusAndPresenceTabs.tsx +++ b/apps/meteor/client/views/admin/statusAndPresence/StatusAndPresenceTabs.tsx @@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next'; export type StatusAndPresenceTab = 'settings' | 'custom-status' | 'user-presence'; -type StatusAndPresenceTabsProps = { +export type StatusAndPresenceTabsProps = { currentTab: StatusAndPresenceTab; onChange: (tab: StatusAndPresenceTab) => void; canManageCustomStatus: boolean; diff --git a/apps/meteor/client/views/admin/statusAndPresence/UserPresenceEditorForm.tsx b/apps/meteor/client/views/admin/statusAndPresence/UserPresenceEditorForm.tsx index 51404271ea73b..ab02d68444588 100644 --- a/apps/meteor/client/views/admin/statusAndPresence/UserPresenceEditorForm.tsx +++ b/apps/meteor/client/views/admin/statusAndPresence/UserPresenceEditorForm.tsx @@ -1,5 +1,4 @@ import { - Box, Button, ButtonGroup, Field, @@ -12,16 +11,20 @@ import { TextInput, ToggleSwitch, } from '@rocket.chat/fuselage'; +import { useStableCallback } from '@rocket.chat/fuselage-hooks'; import { ContextualbarFooter, ContextualbarScrollableContent, GenericModal, UserAutoComplete } from '@rocket.chat/ui-client'; import { useEndpoint, useSetModal, useToastMessageDispatch } from '@rocket.chat/ui-contexts'; import { useQueryClient } from '@tanstack/react-query'; import type { ChangeEvent } from 'react'; -import { useCallback, useId } from 'react'; +import { useId } from 'react'; import { Controller, useForm } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; import type { ManagedPresenceUser } from './useManagedPresenceUsers'; +import { useFindManagedUser } from './useManagedPresenceUsers'; import UserAutoCompleteMultiple from '../../../components/UserAutoCompleteMultiple'; +import { useFormSubmitWithDirtyCheck } from '../../../hooks/useFormSubmitWithDirtyCheck'; +import { managedPresenceQueryKeys } from '../../../lib/queryKeys'; type UserPresenceEditorFormValues = { username: string; @@ -30,16 +33,17 @@ type UserPresenceEditorFormValues = { hiddenFrom: string[]; }; -type UserPresenceEditorFormProps = { +export type UserPresenceEditorFormProps = { user?: ManagedPresenceUser; + defaultUsername?: string; onClose: () => void; }; -const UserPresenceEditorForm = ({ user, onClose }: UserPresenceEditorFormProps) => { +const UserPresenceEditorForm = ({ user, defaultUsername, onClose }: UserPresenceEditorFormProps) => { const { t } = useTranslation(); const userId = user?._id; const defaultValues: UserPresenceEditorFormValues = { - username: user?.username ?? '', + username: user?.username ?? defaultUsername ?? '', presenceEnabled: !user?.presenceDisabledByAdmin, statusText: user?.statusText ?? '', hiddenFrom: user?.statusVisibilityDeniedByAdmin ?? [], @@ -56,22 +60,18 @@ const UserPresenceEditorForm = ({ user, onClose }: UserPresenceEditorFormProps) const getUserInfo = useEndpoint('GET', '/v1/users.info'); const updateUser = useEndpoint('POST', '/v1/users.update'); + const findManagedUser = useFindManagedUser(); const { control, handleSubmit, watch, - formState: { isSubmitting, errors, dirtyFields }, - } = useForm({ defaultValues, mode: 'all' }); + formState: { isSubmitting, isDirty, errors, dirtyFields }, + } = useForm({ defaultValues }); const presenceEnabled = watch('presenceEnabled'); - const invalidateManagedPresenceUsers = useCallback( - () => queryClient.invalidateQueries({ queryKey: ['admin', 'managed-presence-users'] }), - [queryClient], - ); - - const applyPresence = useCallback( + const applyPresence = useStableCallback( async ( { targetUserId, @@ -91,18 +91,21 @@ const UserPresenceEditorForm = ({ user, onClose }: UserPresenceEditorFormProps) }, }); - invalidateManagedPresenceUsers(); + queryClient.invalidateQueries({ queryKey: managedPresenceQueryKeys.all }); dispatchToastMessage({ type: 'success', message }); onClose(); + + return true; } catch (error) { dispatchToastMessage({ type: 'error', message: error }); + + return false; } }, - [updateUser, invalidateManagedPresenceUsers, dispatchToastMessage, onClose], ); - const confirm = useCallback( - (title: string, description: string, confirmText: string, onConfirm: () => Promise, variant?: 'danger') => { + const confirm = useStableCallback( + (title: string, description: string, confirmText: string, onConfirm: () => Promise, variant?: 'danger') => { const handleCancel = (): void => setModal(null); setModal( @@ -111,8 +114,9 @@ const UserPresenceEditorForm = ({ user, onClose }: UserPresenceEditorFormProps) title={title} confirmText={confirmText} onConfirm={async () => { - await onConfirm(); - setModal(null); + if (await onConfirm()) { + setModal(null); + } }} onCancel={handleCancel} onClose={handleCancel} @@ -121,59 +125,57 @@ const UserPresenceEditorForm = ({ user, onClose }: UserPresenceEditorFormProps) , ); }, - [setModal], ); - const handleSave = useCallback( - async ({ username, presenceEnabled, hiddenFrom, statusText }: UserPresenceEditorFormValues) => { - if (userId && presenceEnabled && hiddenFrom.length === 0 && statusText === '') { - confirm(t('Reset_user_status_settings'), t('Reset_user_status_settings_description', { name: username }), t('Reset'), () => - applyPresence( - { targetUserId: userId, presenceEnabled, hiddenFrom, statusText: '' }, - t('Status_settings_reset_to_workspace_default', { name: username }), - ), - ); - return; - } + const handleSave = useStableCallback(async ({ username, presenceEnabled, hiddenFrom, statusText }: UserPresenceEditorFormValues) => { + if (userId && presenceEnabled && hiddenFrom.length === 0 && statusText === '') { + confirm(t('Reset_user_status_settings'), t('Reset_user_status_settings_description', { name: username }), t('Reset'), () => + applyPresence( + { targetUserId: userId, presenceEnabled, hiddenFrom, statusText: '' }, + t('Status_settings_reset_to_workspace_default', { name: username }), + ), + ); + return; + } - let targetUserId = userId; - let replacesExistingRule = false; + let targetUserId = userId; + let replacesExistingRule = false; - if (!targetUserId) { - try { - const { user: found } = await getUserInfo({ username }); + if (!targetUserId) { + try { + const managed = await findManagedUser(username); - targetUserId = found._id; - replacesExistingRule = found.presenceDisabledByAdmin === true || !!found.statusVisibilityDeniedByAdmin?.length; - } catch (error) { - dispatchToastMessage({ type: 'error', message: error }); - return; - } + replacesExistingRule = Boolean(managed); + targetUserId = managed?._id ?? (await getUserInfo({ username })).user._id; + } catch (error) { + dispatchToastMessage({ type: 'error', message: error }); + return; } + } - const resolvedId = targetUserId; + const resolvedId = targetUserId; - if (!resolvedId) { - return; - } + if (!resolvedId) { + return; + } - const apply = () => - applyPresence( - { targetUserId: resolvedId, presenceEnabled, hiddenFrom, statusText: dirtyFields.statusText ? statusText : undefined }, - t('Presence_settings_updated', { name: username }), - ); + const apply = () => + applyPresence( + { targetUserId: resolvedId, presenceEnabled, hiddenFrom, statusText: dirtyFields.statusText ? statusText : undefined }, + t('Presence_settings_updated', { name: username }), + ); - if (replacesExistingRule) { - confirm(t('Replace_user_status_settings'), t('Replace_user_status_settings_description', { name: username }), t('Replace'), apply); - return; - } + if (replacesExistingRule) { + confirm(t('Replace_user_status_settings'), t('Replace_user_status_settings_description', { name: username }), t('Replace'), apply); + return; + } - return apply(); - }, - [userId, getUserInfo, dispatchToastMessage, confirm, applyPresence, t, dirtyFields.statusText], - ); + await apply(); + }); - const handleRemoveClick = useCallback(() => { + const submit = useFormSubmitWithDirtyCheck(handleSave, { isDirty }); + + const handleRemoveClick = useStableCallback(() => { const username = user?.username ?? ''; confirm( @@ -187,11 +189,11 @@ const UserPresenceEditorForm = ({ user, onClose }: UserPresenceEditorFormProps) ), 'danger', ); - }, [userId, user?.username, confirm, applyPresence, t]); + }); return ( <> - + {t('User')} @@ -206,32 +208,37 @@ const UserPresenceEditorForm = ({ user, onClose }: UserPresenceEditorFormProps) value={value} onChange={onChange} disabled={Boolean(userId)} + error={Boolean(errors.username)} + aria-invalid={errors.username ? 'true' : 'false'} + aria-describedby={`${usernameFieldId}-error ${usernameFieldId}-hint`} placeholder={value ? undefined : t('Select_user')} /> )} /> - {errors.username && {errors.username.message}} - {t('User_has_no_visibility_into_changes_made_in_this_panel')} + {errors.username && ( + + {errors.username.message} + + )} + {t('User_has_no_visibility_into_changes_made_in_this_panel')} - + {t('Show_status')} - - ( - ) => onChange(event.currentTarget.checked)} - /> - )} - /> - - + ( + ) => onChange(event.currentTarget.checked)} + /> + )} + /> + {t('User_presence_admin_hint')} @@ -258,19 +265,20 @@ const UserPresenceEditorForm = ({ user, onClose }: UserPresenceEditorFormProps) value={value} onChange={onChange} disabled={!presenceEnabled} + aria-describedby={`${hiddenFromFieldId}-hint`} placeholder={t('Select_users')} /> )} /> - {t('Hide_presence_from_hint')} + {t('Hide_presence_from_hint')} - {userId && } diff --git a/apps/meteor/client/views/admin/statusAndPresence/UserPresenceEditorFormWithData.tsx b/apps/meteor/client/views/admin/statusAndPresence/UserPresenceEditorFormWithData.tsx new file mode 100644 index 0000000000000..12bb49dca0a96 --- /dev/null +++ b/apps/meteor/client/views/admin/statusAndPresence/UserPresenceEditorFormWithData.tsx @@ -0,0 +1,21 @@ +import { ContextualbarSkeletonBody } from '@rocket.chat/ui-client'; + +import UserPresenceEditorForm from './UserPresenceEditorForm'; +import { useManagedPresenceUser } from './useManagedPresenceUsers'; + +export type UserPresenceEditorFormWithDataProps = { + username?: string; + onClose: () => void; +}; + +const UserPresenceEditorFormWithData = ({ username, onClose }: UserPresenceEditorFormWithDataProps) => { + const { data, isLoading } = useManagedPresenceUser(username); + + if (isLoading) { + return ; + } + + return ; +}; + +export default UserPresenceEditorFormWithData; diff --git a/apps/meteor/client/views/admin/statusAndPresence/UserPresenceTab.tsx b/apps/meteor/client/views/admin/statusAndPresence/UserPresenceTab.tsx index 8911bbd1dea7c..b39301fbe58e8 100644 --- a/apps/meteor/client/views/admin/statusAndPresence/UserPresenceTab.tsx +++ b/apps/meteor/client/views/admin/statusAndPresence/UserPresenceTab.tsx @@ -17,7 +17,7 @@ import { useManagedPresenceUsers } from './useManagedPresenceUsers'; import FilterByText from '../../../components/FilterByText'; import GenericNoResults from '../../../components/GenericNoResults'; -type UserPresenceTabProps = { +export type UserPresenceTabProps = { onEdit: (user?: ManagedPresenceUser) => void; }; diff --git a/apps/meteor/client/views/admin/statusAndPresence/UserPresenceTabRow.tsx b/apps/meteor/client/views/admin/statusAndPresence/UserPresenceTabRow.tsx index 561a463e9612c..0d6035b27e7c9 100644 --- a/apps/meteor/client/views/admin/statusAndPresence/UserPresenceTabRow.tsx +++ b/apps/meteor/client/views/admin/statusAndPresence/UserPresenceTabRow.tsx @@ -2,12 +2,13 @@ import { UserStatus as Status } from '@rocket.chat/core-typings'; import { Box } from '@rocket.chat/fuselage'; import { UserAvatar } from '@rocket.chat/ui-avatar'; import { GenericTableRow, GenericTableCell } from '@rocket.chat/ui-client'; +import type { KeyboardEvent } from 'react'; import { useTranslation } from 'react-i18next'; import type { ManagedPresenceUser } from './useManagedPresenceUsers'; import { UserStatus } from '../../../components/UserStatus'; -type UserPresenceTabRowProps = { +export type UserPresenceTabRowProps = { user: ManagedPresenceUser; onClick: (user: ManagedPresenceUser) => void; }; @@ -18,8 +19,15 @@ const UserPresenceTabRow = ({ user, onClick }: UserPresenceTabRowProps) => { const handleClick = () => onClick(user); + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + handleClick(); + } + }; + return ( - + diff --git a/apps/meteor/client/views/admin/statusAndPresence/useManagedPresenceUsers.ts b/apps/meteor/client/views/admin/statusAndPresence/useManagedPresenceUsers.ts index 647041994212c..b2a1018dc202f 100644 --- a/apps/meteor/client/views/admin/statusAndPresence/useManagedPresenceUsers.ts +++ b/apps/meteor/client/views/admin/statusAndPresence/useManagedPresenceUsers.ts @@ -1,17 +1,50 @@ +import { useStableCallback } from '@rocket.chat/fuselage-hooks'; import type { OperationResult } from '@rocket.chat/rest-typings'; import { useEndpoint } from '@rocket.chat/ui-contexts'; import { useQuery } from '@tanstack/react-query'; +import { managedPresenceQueryKeys } from '../../../lib/queryKeys'; + export type ManagedPresenceUser = OperationResult<'GET', '/v1/users.listStatusVisibility'>['users'][number]; export const useManagedPresenceUsers = ({ searchTerm, count, offset }: { searchTerm: string; count: number; offset: number }) => { const listStatusVisibility = useEndpoint('GET', '/v1/users.listStatusVisibility'); return useQuery({ - queryKey: ['admin', 'managed-presence-users', searchTerm, count, offset], + queryKey: managedPresenceQueryKeys.list({ searchTerm, count, offset }), queryFn: () => listStatusVisibility({ searchTerm, count, offset }), meta: { apiErrorToastMessage: true, }, }); }; + +export const useFindManagedUser = () => { + const listStatusVisibility = useEndpoint('GET', '/v1/users.listStatusVisibility'); + + return useStableCallback(async (username: string) => { + const count = 50; + + for (let offset = 0; ; offset += count) { + const { users, total } = await listStatusVisibility({ searchTerm: username, count, offset }); + const found = users.find((user) => user.username === username); + + if (found || !users.length || offset + users.length >= total) { + return found; + } + } + }); +}; + +export const useManagedPresenceUser = (username?: string) => { + const findManagedUser = useFindManagedUser(); + + return useQuery({ + queryKey: managedPresenceQueryKeys.byUsername(username), + enabled: Boolean(username), + queryFn: async () => (username ? ((await findManagedUser(username)) ?? null) : null), + meta: { + apiErrorToastMessage: true, + }, + }); +}; diff --git a/apps/meteor/client/views/admin/users/AdminUserForm.tsx b/apps/meteor/client/views/admin/users/AdminUserForm.tsx index fb5c5491a076f..a445927b6b64e 100644 --- a/apps/meteor/client/views/admin/users/AdminUserForm.tsx +++ b/apps/meteor/client/views/admin/users/AdminUserForm.tsx @@ -140,7 +140,7 @@ const AdminUserForm = ({ userData, onReload, context, refetchUserFormData, roleD const { avatar, username, setRandomPassword, password, name: userFullName, presenceDisabledByAdmin } = watch(); const showUserStatusSection = hasPresenceLicense && userStatusEnabled && canViewFullOtherUserInfo; - const statusFieldsDisabled = showUserStatusSection && presenceDisabledByAdmin === true; + const statusFieldsDisabled = !userStatusEnabled || (showUserStatusSection && presenceDisabledByAdmin === true); const { mutateAsync: eventStats } = useEndpointMutation('POST', '/v1/statistics.telemetry'); const updateUserAction = useEndpoint('POST', '/v1/users.update'); diff --git a/apps/meteor/client/views/admin/users/UsersTable/UsersTableRow.tsx b/apps/meteor/client/views/admin/users/UsersTable/UsersTableRow.tsx index 713d9450df142..48a6a320c26a0 100644 --- a/apps/meteor/client/views/admin/users/UsersTable/UsersTableRow.tsx +++ b/apps/meteor/client/views/admin/users/UsersTable/UsersTableRow.tsx @@ -82,7 +82,7 @@ const UsersTableRow = ({ user, tab, isMobile, isLaptop, isSeatsCapExceeded, show const changeAdminStatusAction = useChangeAdminStatusAction(username, isAdmin, onReload); const changeUserStatusAction = useChangeUserStatusAction(userId, isActive, onReload); - const manageUserStatusAction = useManageUserStatusAction(); + const manageUserStatusAction = useManageUserStatusAction(username); const deleteUserAction = useDeleteUserAction(userId, onReload, onReload); const resetTOTPAction = useResetTOTPAction(userId); const resetE2EKeyAction = useResetE2EEKeyAction(userId); diff --git a/apps/meteor/client/views/admin/users/hooks/useManageUserStatusAction.ts b/apps/meteor/client/views/admin/users/hooks/useManageUserStatusAction.ts index 7a07ec43ace89..0ad81602420fa 100644 --- a/apps/meteor/client/views/admin/users/hooks/useManageUserStatusAction.ts +++ b/apps/meteor/client/views/admin/users/hooks/useManageUserStatusAction.ts @@ -1,20 +1,20 @@ -import { usePermission, useRoute } from '@rocket.chat/ui-contexts'; +import { usePermission, useRouter } from '@rocket.chat/ui-contexts'; import { useTranslation } from 'react-i18next'; import type { AdminUserAction } from './useAdminUserInfoActions'; import { useHasLicenseModule } from '../../../../hooks/useHasLicenseModule'; -export const useManageUserStatusAction = (): AdminUserAction | undefined => { +export const useManageUserStatusAction = (username?: string): AdminUserAction | undefined => { const { t } = useTranslation(); - const statusRoute = useRoute('user-status'); + const router = useRouter(); const canEditOtherUserInfo = usePermission('edit-other-user-info'); - const { data: hasUnlimitedPresence } = useHasLicenseModule('unlimited-presence'); + const { data: hasUnlimitedPresence = false } = useHasLicenseModule('unlimited-presence'); - return canEditOtherUserInfo && hasUnlimitedPresence + return canEditOtherUserInfo && hasUnlimitedPresence && username ? { icon: 'circle-unfilled', content: t('Manage_status'), - onClick: () => statusRoute.push({ tab: 'user-presence' }), + onClick: () => router.navigate({ name: 'user-status', params: { tab: 'user-presence', context: 'edit', id: username } }), } : undefined; }; diff --git a/apps/meteor/tests/e2e/admin-status-and-presence.spec.ts b/apps/meteor/tests/e2e/admin-status-and-presence.spec.ts index 0c6f2449638bb..c2eee98fe19bc 100644 --- a/apps/meteor/tests/e2e/admin-status-and-presence.spec.ts +++ b/apps/meteor/tests/e2e/admin-status-and-presence.spec.ts @@ -1,20 +1,14 @@ -import type { APIRequestContext, BrowserContext, Page } from '@playwright/test'; +import type { BrowserContext, Page } from '@playwright/test'; import { DEFAULT_USER_CREDENTIALS, IS_EE } from './config/constants'; import { Users } from './fixtures/userStates'; -import { Authenticated, Login } from './page-objects'; -import { Listbox } from './page-objects/fragments/listbox'; +import { AdminStatusAndPresence, Authenticated, Login } from './page-objects'; import { expectPollUserStatus } from './utils/expectPollUserStatus'; +import { getUserStatusAsViewer } from './utils/getUserStatusAsViewer'; import { expect, test } from './utils/test'; import type { ITestUser } from './utils/user-helpers'; import { createTestUser } from './utils/user-helpers'; -const getStatusAsViewer = async (viewerApi: APIRequestContext, username: string): Promise => { - const response = await viewerApi.get('/users.info', { params: { username } }); - const body = await response.json(); - return body.user?.status; -}; - test.describe('Admin > Status and presence > User status', () => { test.skip(!IS_EE); test.use({ storageState: Users.admin.state }); @@ -33,8 +27,10 @@ test.describe('Admin > Status and presence > User status', () => { hiddenUserContext = await browser.newContext(); hiddenUserPage = await hiddenUserContext.newPage(); - await hiddenUserPage.goto('/login'); - await new Login(hiddenUserPage).login(hiddenUser.data.username, DEFAULT_USER_CREDENTIALS.password); + const login = new Login(hiddenUserPage); + + await login.goto('/login'); + await login.login(hiddenUser.data.username, DEFAULT_USER_CREDENTIALS.password); await new Authenticated(hiddenUserPage).waitForDisplay(); await expectPollUserStatus(api, hiddenUser.data.username, 'online'); @@ -49,20 +45,20 @@ test.describe('Admin > Status and presence > User status', () => { }); test('hides a user from a named viewer and restores it through the confirmation modal', async ({ page, api }) => { - const listbox = new Listbox(page); - const row = page.locator('tr', { hasText: hiddenUser.data.name || hiddenUser.data.username }).first(); + const admin = new AdminStatusAndPresence(page); + const { listbox } = admin; + const row = admin.rowOf(hiddenUser.data.name || hiddenUser.data.username); const asBlockedViewer = await api.login({ username: blockedViewer.data.username, password: DEFAULT_USER_CREDENTIALS.password }); await test.step('open Status and presence > User status', async () => { - await page.goto('/admin/user-status'); - await page.getByRole('tab', { name: 'User status', exact: true }).click(); + await admin.goto(); + await admin.openUserStatusTab(); }); await test.step('create a rule hiding the user from the blocked viewer', async () => { - await page.getByRole('button', { name: 'Manage user status', exact: true }).click(); + await admin.openEditor(); - const dialog = page.getByRole('dialog', { name: 'Manage user status' }); - await expect(dialog).toBeVisible(); + const dialog = admin.editor; await dialog.getByRole('combobox', { name: 'User', exact: true }).pressSequentially(hiddenUser.data.username); await listbox.selectOption(hiddenUser.data.username); @@ -81,8 +77,8 @@ test.describe('Admin > Status and presence > User status', () => { await test.step('the blocked viewer sees the user as offline while the control viewer sees the real status', async () => { const asControlViewer = await api.login({ username: controlViewer.data.username, password: DEFAULT_USER_CREDENTIALS.password }); - await expect.poll(async () => getStatusAsViewer(asBlockedViewer, hiddenUser.data.username)).toBe('offline'); - await expect.poll(async () => getStatusAsViewer(asControlViewer, hiddenUser.data.username)).toBe('online'); + await expect.poll(async () => getUserStatusAsViewer(asBlockedViewer, hiddenUser.data.username)).toBe('offline'); + await expect.poll(async () => getUserStatusAsViewer(asControlViewer, hiddenUser.data.username)).toBe('online'); }); await test.step('the admin removes the rule through the confirmation modal', async () => { @@ -100,7 +96,7 @@ test.describe('Admin > Status and presence > User status', () => { }); await test.step('the blocked viewer sees the real status again', async () => { - await expect.poll(async () => getStatusAsViewer(asBlockedViewer, hiddenUser.data.username)).toBe('online'); + await expect.poll(async () => getUserStatusAsViewer(asBlockedViewer, hiddenUser.data.username)).toBe('online'); }); }); }); diff --git a/apps/meteor/tests/e2e/page-objects/admin-status-and-presence.ts b/apps/meteor/tests/e2e/page-objects/admin-status-and-presence.ts new file mode 100644 index 0000000000000..fb66c2e6d2817 --- /dev/null +++ b/apps/meteor/tests/e2e/page-objects/admin-status-and-presence.ts @@ -0,0 +1,38 @@ +import type { Locator, Page } from '@playwright/test'; + +import { Admin, AdminSectionsHref } from './admin'; +import { Listbox } from './fragments/listbox'; + +export class AdminStatusAndPresence extends Admin { + readonly listbox: Listbox; + + constructor(page: Page) { + super(page); + this.listbox = new Listbox(page); + } + + protected readonly route = AdminSectionsHref.userStatus; + + protected readonly title = 'Status and presence'; + + get userStatusTab(): Locator { + return this.page.getByRole('tab', { name: 'User status', exact: true }); + } + + get editor(): Locator { + return this.page.getByRole('dialog', { name: 'Manage user status' }); + } + + async openUserStatusTab(): Promise { + await this.userStatusTab.click(); + } + + async openEditor(): Promise { + await this.page.getByRole('button', { name: 'Manage user status', exact: true }).click(); + await this.editor.waitFor({ state: 'visible' }); + } + + rowOf(name: string): Locator { + return this.page.locator('tr', { hasText: name }).first(); + } +} diff --git a/apps/meteor/tests/e2e/page-objects/index.ts b/apps/meteor/tests/e2e/page-objects/index.ts index 5982ffc1544ba..9a6c40d082bc9 100644 --- a/apps/meteor/tests/e2e/page-objects/index.ts +++ b/apps/meteor/tests/e2e/page-objects/index.ts @@ -8,6 +8,7 @@ export * from './admin-email-inboxes'; export * from './admin-rooms'; export * from './admin-users'; export * from './admin-settings'; +export * from './admin-status-and-presence'; export * from './admin-engagement'; export * from './admin-info'; export * from './admin-integrations'; diff --git a/apps/meteor/tests/e2e/utils/getUserStatusAsViewer.ts b/apps/meteor/tests/e2e/utils/getUserStatusAsViewer.ts new file mode 100644 index 0000000000000..e588a19e559db --- /dev/null +++ b/apps/meteor/tests/e2e/utils/getUserStatusAsViewer.ts @@ -0,0 +1,10 @@ +import type { APIRequestContext } from '@playwright/test'; + +import { API_PREFIX } from '../config/constants'; + +export const getUserStatusAsViewer = async (viewerApi: APIRequestContext, username: string): Promise => { + const response = await viewerApi.get(`${API_PREFIX}/users.info`, { params: { username } }); + const body = await response.json(); + + return body.user?.status; +}; diff --git a/apps/meteor/tests/e2e/utils/index.ts b/apps/meteor/tests/e2e/utils/index.ts index 3ad033332a7a2..02b4a0c8834c0 100644 --- a/apps/meteor/tests/e2e/utils/index.ts +++ b/apps/meteor/tests/e2e/utils/index.ts @@ -2,6 +2,7 @@ export * from './create-target-channel'; export * from './setSettingValueById'; export * from './getSettingValueById'; export * from './getPermissionRoles'; +export * from './getUserStatusAsViewer'; export * from './updatePermissions'; export * from './setUserPreferences'; export * from './updateOwnUserInfo';