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/navbar/NavBarSettingsToolbar/UserMenu/hooks/useStatusItems.tsx b/apps/meteor/client/navbar/NavBarSettingsToolbar/UserMenu/hooks/useStatusItems.tsx index 4a35dc22d1437..01f099ab9190e 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 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'; @@ -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,25 @@ export const useStatusItems = (user?: IUser): GenericMenuItemProps[] => { const customStatusExpiration = useExpirationText(user?.statusExpiresAt); 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: ( + + + + ), + }, + ]; + } + if (presenceDisabled) { return [ { @@ -174,6 +196,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..d038b93ce6875 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'); @@ -74,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) { @@ -174,7 +178,7 @@ const AccountProfileForm = (props: AllHTMLAttributes) => { await setPreferences({ data: { statusVisibilityDenied } }); } - if (statusDirty) { + if (statusDirty && !presenceDisabledByAdmin) { await setUserStatus({ status: statusType, ...(allowUserStatusMessageChange && { message: statusText }), @@ -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..8e103c2a3469d --- /dev/null +++ b/apps/meteor/client/views/account/profile/UserStatusDisabledInfo.tsx @@ -0,0 +1,18 @@ +import { Box, Icon } from '@rocket.chat/fuselage'; +import { useTranslation } from 'react-i18next'; + +export 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 ( + + + + ); +}; + +export default UserStatusDisabledInfo; 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..f87facd034eb3 100644 --- a/apps/meteor/client/views/admin/sidebarItems.ts +++ b/apps/meteor/client/views/admin/sidebarItems.ts @@ -62,7 +62,7 @@ export const { }, { href: '/admin/user-status', - i18nLabel: 'User_Status', + i18nLabel: 'Status_and_presence', icon: 'user', permissionGranted: (): boolean => hasAtLeastOnePermission(['manage-user-status']), }, 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..15a84c77e12c5 --- /dev/null +++ b/apps/meteor/client/views/admin/statusAndPresence/SettingsTab.tsx @@ -0,0 +1,31 @@ +import { AccordionItem, Callout, FieldGroup } from '@rocket.chat/fuselage'; +import type { ReactNode } from 'react'; +import { useTranslation } from 'react-i18next'; + +import Setting from '../settings/Setting'; +import SettingsGroupPage from '../settings/SettingsGroupPage'; + +export type SettingsTabProps = { + tabs: ReactNode; + headerButtons?: ReactNode; +}; + +const SettingsTab = ({ tabs, headerButtons }: SettingsTabProps) => { + const { t } = useTranslation(); + + return ( + + + + + + + + {t('Admins_cannot_see_concealed_presence')} + + + + ); +}; + +export default 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..c2033d991602c --- /dev/null +++ b/apps/meteor/client/views/admin/statusAndPresence/StatusAndPresencePage.tsx @@ -0,0 +1,130 @@ +import { Button, ButtonGroup } from '@rocket.chat/fuselage'; +import { useStableCallback } from '@rocket.chat/fuselage-hooks'; +import { + ContextualbarHeader, + ContextualbarClose, + ContextualbarTitle, + ContextualbarDialog, + Page, + PageHeader, + PageContent, +} from '@rocket.chat/ui-client'; +import { useRouteParameter, useRouter } from '@rocket.chat/ui-contexts'; +import type { ReactElement } 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 UserPresenceEditorFormWithData from './UserPresenceEditorFormWithData'; +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 router = useRouter(); + const context = useRouteParameter('context'); + const id = useRouteParameter('id'); + const { data: license } = useIsEnterprise(); + + const navigate = useStableCallback((params: { tab: StatusAndPresenceTab; context?: string; id?: string }) => + router.navigate({ name: 'user-status', params }), + ); + + const handleTabChange = useStableCallback((next: StatusAndPresenceTab) => navigate({ tab: next })); + + const handleItemClick = useStableCallback((id: string) => navigate({ tab, context: 'edit', id })); + + const handleNewButtonClick = useStableCallback(() => navigate({ tab, context: 'new' })); + + const handlePresenceServiceClick = useStableCallback(() => navigate({ tab, context: 'presence-service' })); + + const handleClose = useStableCallback(() => navigate({ tab })); + + const handleEdit = useStableCallback((user?: ManagedPresenceUser) => + navigate(user?.username ? { tab, context: 'edit', id: user.username } : { tab, context: 'new' }), + ); + + const reload = useRef(() => null); + + const handleReload = useStableCallback(() => reload.current()); + + const tabs = ( + + ); + + const headerButtons = canManageCustomStatus ? : undefined; + + const contextualBar: { title: string; content: ReactElement } | undefined = + (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 && + tab === 'user-presence' && + (context === 'new' || context === 'edit') && { + 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..6371ffd6e0752 --- /dev/null +++ b/apps/meteor/client/views/admin/statusAndPresence/StatusAndPresenceRoute.tsx @@ -0,0 +1,73 @@ +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'; + +const TAB_ORDER = ['settings', 'custom-status', 'user-presence'] as const; + +const StatusAndPresenceRoute = () => { + 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, isPending: isLicensePending } = 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 (isLicensePending) { + return; + } + + if (firstAllowedTab && !currentTab) { + router.navigate({ name: 'user-status', params: { tab: firstAllowedTab } }, { replace: true }); + } + }, [router, firstAllowedTab, currentTab, isLicensePending]); + + useEffect(() => { + if (presenceDisabled && canManageCustomStatus && currentTab && context !== 'presence-service') { + router.navigate({ name: 'user-status', params: { tab: currentTab, context: 'presence-service' } }); + } + }, [presenceDisabled, canManageCustomStatus, currentTab, context, router]); + + if (isLicensePending) { + return ; + } + + 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..fea64082017fc --- /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'; + +export 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..ab02d68444588 --- /dev/null +++ b/apps/meteor/client/views/admin/statusAndPresence/UserPresenceEditorForm.tsx @@ -0,0 +1,291 @@ +import { + Button, + ButtonGroup, + Field, + FieldError, + FieldGroup, + FieldHint, + FieldLabel, + FieldRow, + IconButton, + 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 { 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; + presenceEnabled: boolean; + statusText: string; + hiddenFrom: string[]; +}; + +export type UserPresenceEditorFormProps = { + user?: ManagedPresenceUser; + defaultUsername?: string; + onClose: () => void; +}; + +const UserPresenceEditorForm = ({ user, defaultUsername, onClose }: UserPresenceEditorFormProps) => { + const { t } = useTranslation(); + const userId = user?._id; + const defaultValues: UserPresenceEditorFormValues = { + username: user?.username ?? defaultUsername ?? '', + 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 findManagedUser = useFindManagedUser(); + + const { + control, + handleSubmit, + watch, + formState: { isSubmitting, isDirty, errors, dirtyFields }, + } = useForm({ defaultValues }); + + const presenceEnabled = watch('presenceEnabled'); + + const applyPresence = useStableCallback( + 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 }), + }, + }); + + queryClient.invalidateQueries({ queryKey: managedPresenceQueryKeys.all }); + dispatchToastMessage({ type: 'success', message }); + onClose(); + + return true; + } catch (error) { + dispatchToastMessage({ type: 'error', message: error }); + + return false; + } + }, + ); + + const confirm = useStableCallback( + (title: string, description: string, confirmText: string, onConfirm: () => Promise, variant?: 'danger') => { + const handleCancel = (): void => setModal(null); + + setModal( + { + if (await onConfirm()) { + setModal(null); + } + }} + onCancel={handleCancel} + onClose={handleCancel} + > + {description} + , + ); + }, + ); + + 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; + + if (!targetUserId) { + try { + const managed = await findManagedUser(username); + + replacesExistingRule = Boolean(managed); + targetUserId = managed?._id ?? (await getUserInfo({ username })).user._id; + } 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; + } + + await apply(); + }); + + const submit = useFormSubmitWithDirtyCheck(handleSave, { isDirty }); + + const handleRemoveClick = useStableCallback(() => { + 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', + ); + }); + + 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/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 new file mode 100644 index 0000000000000..b39301fbe58e8 --- /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'; + +export 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..0d6035b27e7c9 --- /dev/null +++ b/apps/meteor/client/views/admin/statusAndPresence/UserPresenceTabRow.tsx @@ -0,0 +1,52 @@ +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'; + +export 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); + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + handleClick(); + } + }; + + 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..b2a1018dc202f --- /dev/null +++ b/apps/meteor/client/views/admin/statusAndPresence/useManagedPresenceUsers.ts @@ -0,0 +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: 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 69153be8d1087..a445927b6b64e 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, @@ -30,6 +31,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'; @@ -40,8 +42,10 @@ 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'; import { useUpdateAvatar } from '../../../hooks/useUpdateAvatar'; import { USER_STATUS_TEXT_MAX_LENGTH, BIO_TEXT_MAX_LENGTH } from '../../../lib/constants'; @@ -55,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' >; @@ -84,6 +93,8 @@ const getInitialValue = ({ requirePasswordChange: isNewUserPage && isSmtpEnabled && (data?.requirePasswordChange ?? true), customFields: data?.customFields ?? {}, statusText: data?.statusText ?? '', + presenceDisabledByAdmin: data?.presenceDisabledByAdmin === true, + statusVisibilityDeniedByAdmin: data?.statusVisibilityDeniedByAdmin ?? [], freeSwitchExtension: data?.freeSwitchExtension ?? '', ...(isNewUserPage && { joinDefaultChannels: true }), sendWelcomeEmail: isSmtpEnabled, @@ -99,6 +110,9 @@ 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 canViewFullOtherUserInfo = usePermission('view-full-other-user-info'); const isVerificationNeeded = useSetting('Accounts_EmailVerification'); const defaultUserRoles = parseCSV(defaultRoles); @@ -124,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 = !userStatusEnabled || (showUserStatusSection && presenceDisabledByAdmin === true); const { mutateAsync: eventStats } = useEndpointMutation('POST', '/v1/statistics.telemetry'); const updateUserAction = useEndpoint('POST', '/v1/users.update'); @@ -171,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: '' }); @@ -186,6 +205,8 @@ 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(); @@ -474,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..48a6a320c26a0 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(username); 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..0ad81602420fa --- /dev/null +++ b/apps/meteor/client/views/admin/users/hooks/useManageUserStatusAction.ts @@ -0,0 +1,20 @@ +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 = (username?: string): AdminUserAction | undefined => { + const { t } = useTranslation(); + const router = useRouter(); + const canEditOtherUserInfo = usePermission('edit-other-user-info'); + const { data: hasUnlimitedPresence = false } = useHasLicenseModule('unlimited-presence'); + + return canEditOtherUserInfo && hasUnlimitedPresence && username + ? { + icon: 'circle-unfilled', + content: t('Manage_status'), + 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 new file mode 100644 index 0000000000000..c2eee98fe19bc --- /dev/null +++ b/apps/meteor/tests/e2e/admin-status-and-presence.spec.ts @@ -0,0 +1,102 @@ +import type { BrowserContext, Page } from '@playwright/test'; + +import { DEFAULT_USER_CREDENTIALS, IS_EE } from './config/constants'; +import { Users } from './fixtures/userStates'; +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'; + +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(); + + 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'); + }); + + 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 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 admin.goto(); + await admin.openUserStatusTab(); + }); + + await test.step('create a rule hiding the user from the blocked viewer', async () => { + await admin.openEditor(); + + const dialog = admin.editor; + + 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 () => 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 () => { + 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 () => 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'; 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",