diff --git a/.changeset/fix-anonymous-spotlight-callers.md b/.changeset/fix-anonymous-spotlight-callers.md new file mode 100644 index 0000000000000..ce29a97dd6955 --- /dev/null +++ b/.changeset/fix-anonymous-spotlight-callers.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Fixes room search (`spotlight`) failing for anonymous visitors when "Allow Anonymous Read" is enabled diff --git a/.changeset/fruity-views-begin.md b/.changeset/fruity-views-begin.md new file mode 100644 index 0000000000000..971ccdc6630a5 --- /dev/null +++ b/.changeset/fruity-views-begin.md @@ -0,0 +1,7 @@ +--- +'@rocket.chat/rest-typings': patch +'@rocket.chat/i18n': patch +'@rocket.chat/meteor': patch +--- + +Adds an Import IdP metadata option to SAML settings that fetches the Identity Provider metadata from a URL and prefills the matching setting fields — certificate, entry point and IDP SLO redirect URL, plus identifier format on Enterprise — for the admin to review before saving. diff --git a/.changeset/jolly-poets-tan.md b/.changeset/jolly-poets-tan.md new file mode 100644 index 0000000000000..8bf743516df49 --- /dev/null +++ b/.changeset/jolly-poets-tan.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Fixes the issue where the message list kept jumping to the latest messages instead of restoring the previous position when switching channels. diff --git a/.changeset/odd-steaks-pull.md b/.changeset/odd-steaks-pull.md new file mode 100644 index 0000000000000..2f0944e6fd7bf --- /dev/null +++ b/.changeset/odd-steaks-pull.md @@ -0,0 +1,11 @@ +--- +'@rocket.chat/core-services': minor +'@rocket.chat/core-typings': minor +'@rocket.chat/i18n': minor +'@rocket.chat/meteor': minor +'@rocket.chat/model-typings': minor +'@rocket.chat/models': minor +'@rocket.chat/rest-typings': minor +--- + +Adds status visibility, letting users hide their presence and status message from specific people they choose. Blocked people see that user as offline, indistinguishable from genuinely offline, and the block can be lifted at any time — changes apply live, without a reload. diff --git a/apps/meteor/client/components/UserAutoCompleteMultiple/UserAutoCompleteMultiple.tsx b/apps/meteor/client/components/UserAutoCompleteMultiple/UserAutoCompleteMultiple.tsx index 2535e940235e2..8a65bcb2d4e58 100644 --- a/apps/meteor/client/components/UserAutoCompleteMultiple/UserAutoCompleteMultiple.tsx +++ b/apps/meteor/client/components/UserAutoCompleteMultiple/UserAutoCompleteMultiple.tsx @@ -14,6 +14,7 @@ export type UserAutoCompleteMultipleProps = { value: Array | undefined; placeholder?: string; federated?: boolean; + exceptions?: string[]; error?: string; } & Omit, 'is' | 'onChange' | 'value'>; @@ -30,7 +31,7 @@ type UserAutoCompleteOptions = { const matrixRegex = new RegExp('@(.*:.*)'); const UserAutoCompleteMultiple = forwardRef( - ({ onChange, value, placeholder, federated, ...props }, ref) => { + ({ onChange, value, placeholder, federated, exceptions, ...props }, ref) => { const [filter, setFilter] = useState(''); const [selectedCache, setSelectedCache] = useState({}); @@ -38,10 +39,10 @@ const UserAutoCompleteMultiple = forwardRef { - const users = await getUsers({ selector: JSON.stringify({ term: debouncedFilter }) }); + const users = await getUsers({ selector: JSON.stringify({ term: debouncedFilter, ...(exceptions?.length && { exceptions }) }) }); const options = users.items.map((item): [string, UserAutoCompleteOptionType] => [item.username, item]); // Add extra option if filter text matches `username:server` diff --git a/apps/meteor/client/lib/queryKeys.ts b/apps/meteor/client/lib/queryKeys.ts index c703743e567d6..da9979eef2da2 100644 --- a/apps/meteor/client/lib/queryKeys.ts +++ b/apps/meteor/client/lib/queryKeys.ts @@ -122,7 +122,8 @@ export const usersQueryKeys = { all: ['users'] as const, userInfo: ({ uid, username }: { uid?: IUser['_id']; username?: IUser['username'] }) => [...usersQueryKeys.all, 'info', { uid, username }] as const, - userAutoComplete: (filter: string, federated: boolean) => [...usersQueryKeys.all, 'autocomplete', filter, federated] as const, + userAutoComplete: (filter: string, federated: boolean, exceptions: string[] = []) => + [...usersQueryKeys.all, 'autocomplete', filter, federated, exceptions] as const, }; export const teamsQueryKeys = { diff --git a/apps/meteor/client/navbar/NavBarSettingsToolbar/UserMenu/EditStatusVisibilityModal.tsx b/apps/meteor/client/navbar/NavBarSettingsToolbar/UserMenu/EditStatusVisibilityModal.tsx new file mode 100644 index 0000000000000..350da348f60e6 --- /dev/null +++ b/apps/meteor/client/navbar/NavBarSettingsToolbar/UserMenu/EditStatusVisibilityModal.tsx @@ -0,0 +1,79 @@ +import { Box } from '@rocket.chat/fuselage'; +import { Field, FieldGroup, FieldHint, FieldLabel, FieldRow } from '@rocket.chat/fuselage-forms'; +import { GenericModal } from '@rocket.chat/ui-client'; +import { useEndpoint, useToastMessageDispatch, useUser } from '@rocket.chat/ui-contexts'; +import type { ComponentProps } from 'react'; +import { Controller, useForm } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; + +import UserAutoCompleteMultiple from '../../../components/UserAutoCompleteMultiple'; + +export type EditStatusVisibilityModalProps = { + onClose: () => void; +}; + +type StatusVisibilityFormValues = { + statusVisibilityDenied: string[]; +}; + +export const EditStatusVisibilityModal = ({ onClose }: EditStatusVisibilityModalProps) => { + const { t } = useTranslation(); + const user = useUser(); + const dispatchToastMessage = useToastMessageDispatch(); + const setPreferences = useEndpoint('POST', '/v1/users.setPreferences'); + + const { + control, + handleSubmit, + formState: { isDirty, isSubmitting }, + } = useForm({ + defaultValues: { + statusVisibilityDenied: user?.settings?.preferences?.statusVisibilityDenied ?? [], + }, + }); + + const handleSave = async ({ statusVisibilityDenied }: StatusVisibilityFormValues) => { + try { + await setPreferences({ data: { statusVisibilityDenied } }); + dispatchToastMessage({ type: 'success', message: t('Accounts_StatusVisibility_Saved') }); + onClose(); + } catch (error) { + dispatchToastMessage({ type: 'error', message: error }); + } + }; + + return ( + ) => } + > + + + {t('Accounts_StatusVisibility_HideFromUsers')} + + ( + + )} + /> + + {t('Accounts_StatusVisibility_HideFromUsers_Description')} + + + + ); +}; + +export default EditStatusVisibilityModal; diff --git a/apps/meteor/client/navbar/NavBarSettingsToolbar/UserMenu/hooks/useStatusItems.tsx b/apps/meteor/client/navbar/NavBarSettingsToolbar/UserMenu/hooks/useStatusItems.tsx index 618dde497dcef..0e7519fba569b 100644 --- a/apps/meteor/client/navbar/NavBarSettingsToolbar/UserMenu/hooks/useStatusItems.tsx +++ b/apps/meteor/client/navbar/NavBarSettingsToolbar/UserMenu/hooks/useStatusItems.tsx @@ -8,6 +8,7 @@ import { useCallback, useEffect, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { useCustomStatusModalHandler } from './useCustomStatusModalHandler'; +import { useStatusVisibilityModalHandler } from './useStatusVisibilityModalHandler'; import MarkdownText from '../../../../components/MarkdownText'; import { UserStatus } from '../../../../components/UserStatus'; import { useExpirationText } from '../../../../hooks/useExpirationText'; @@ -76,6 +77,8 @@ export const useStatusItems = (user?: IUser): GenericMenuItemProps[] => { const handleStatusDisabledModal = useStatusDisabledModal(); const handleCustomStatus = useCustomStatusModalHandler(); + const handleStatusVisibility = useStatusVisibilityModalHandler(); + const statusVisibilityEnabled = useSetting('Accounts_StatusVisibility_Enabled', false); const customStatusExpiration = useExpirationText(user?.statusExpiresAt); return useMemo(() => { @@ -159,7 +162,18 @@ export const useStatusItems = (user?: IUser): GenericMenuItemProps[] => { ) : []; - return [...items, ...presetItems, ...customItems]; + const actionItems: GenericMenuItemProps[] = []; + + if (statusVisibilityEnabled) { + actionItems.push({ + id: 'status-visibility-edit', + icon: 'eye-off', + content: t('Accounts_StatusVisibility_HideFrom'), + onClick: handleStatusVisibility, + }); + } + + return [...items, ...presetItems, ...customItems, ...actionItems]; }, [ presenceDisabled, allowUserStatusMessageChange, @@ -170,6 +184,8 @@ export const useStatusItems = (user?: IUser): GenericMenuItemProps[] => { customStatusExpiration, statuses, handleCustomStatus, + handleStatusVisibility, + statusVisibilityEnabled, setStatusMutation, ]); }; diff --git a/apps/meteor/client/navbar/NavBarSettingsToolbar/UserMenu/hooks/useStatusVisibilityModalHandler.tsx b/apps/meteor/client/navbar/NavBarSettingsToolbar/UserMenu/hooks/useStatusVisibilityModalHandler.tsx new file mode 100644 index 0000000000000..893d4f6f70ed4 --- /dev/null +++ b/apps/meteor/client/navbar/NavBarSettingsToolbar/UserMenu/hooks/useStatusVisibilityModalHandler.tsx @@ -0,0 +1,9 @@ +import { useSetModal } from '@rocket.chat/ui-contexts'; + +import { EditStatusVisibilityModal } from '../EditStatusVisibilityModal'; + +export const useStatusVisibilityModalHandler = () => { + const setModal = useSetModal(); + + return () => setModal( setModal(null)} />); +}; diff --git a/apps/meteor/client/views/account/profile/AccountProfileForm.tsx b/apps/meteor/client/views/account/profile/AccountProfileForm.tsx index 3537bc3d55e39..c7a1a5c5f6a47 100644 --- a/apps/meteor/client/views/account/profile/AccountProfileForm.tsx +++ b/apps/meteor/client/views/account/profile/AccountProfileForm.tsx @@ -23,6 +23,7 @@ import { useEndpoint, useUser, useLayout, + useSetting, } from '@rocket.chat/ui-contexts'; import { useMutation } from '@tanstack/react-query'; import type { AllHTMLAttributes, ChangeEvent } from 'react'; @@ -32,6 +33,7 @@ import { Controller, useFormContext } from 'react-hook-form'; import type { AccountProfileFormValues } from './getProfileInitialValues'; import { useAccountProfileSettings } from './useAccountProfileSettings'; import { getUserEmailAddress } from '../../../../lib/getUserEmailAddress'; +import UserAutoCompleteMultiple from '../../../components/UserAutoCompleteMultiple'; import UserStatusMenu from '../../../components/UserStatusMenu'; import UserAvatarEditor from '../../../components/avatar/UserAvatarEditor'; import { useUpdateAvatar } from '../../../hooks/useUpdateAvatar'; @@ -44,6 +46,8 @@ const AccountProfileForm = (props: AllHTMLAttributes) => { const dispatchToastMessage = useToastMessageDispatch(); const { isMobile } = useLayout(); + const setPreferences = useEndpoint('POST', '/v1/users.setPreferences'); + const statusVisibilityEnabled = useSetting('Accounts_StatusVisibility_Enabled', false); const checkUsernameAvailability = useEndpoint('GET', '/v1/users.checkUsernameAvailability'); const sendConfirmationEmail = useEndpoint('POST', '/v1/users.sendConfirmationEmail'); @@ -138,6 +142,7 @@ const AccountProfileForm = (props: AllHTMLAttributes) => { nickname, bio, customFields, + statusVisibilityDenied, } = values; const expiresAt = STATUS_DURATION_OPTIONS.find((o) => o.value === statusDuration)?.getExpiresAt?.({ @@ -165,6 +170,10 @@ const AccountProfileForm = (props: AllHTMLAttributes) => { customFields, }); + if (dirtyFields.statusVisibilityDenied) { + await setPreferences({ data: { statusVisibilityDenied } }); + } + if (statusDirty) { await setUserStatus({ status: statusType, @@ -338,6 +347,26 @@ const AccountProfileForm = (props: AllHTMLAttributes) => { {errors.statusDuration && {errors.statusDuration.message}} {t('Status_new_status_warning')} + {statusVisibilityEnabled && ( + + {t('Accounts_StatusVisibility_HideStatusFromUsers')} + + ( + + )} + /> + + {t('Accounts_StatusVisibility_HideFromUsers_Description')} + + )} {t('Nickname')} diff --git a/apps/meteor/client/views/account/profile/getProfileInitialValues.ts b/apps/meteor/client/views/account/profile/getProfileInitialValues.ts index a1132a7b0d618..cf8b22db9df55 100644 --- a/apps/meteor/client/views/account/profile/getProfileInitialValues.ts +++ b/apps/meteor/client/views/account/profile/getProfileInitialValues.ts @@ -13,6 +13,7 @@ export type AccountProfileFormValues = { bio: string; customFields: Record; nickname: string; + statusVisibilityDenied: string[]; } & UserStatusInitialValues; export const getProfileInitialValues = (user: IUser | null): AccountProfileFormValues => { @@ -25,6 +26,7 @@ export const getProfileInitialValues = (user: IUser | null): AccountProfileFormV bio: user?.bio ?? '', customFields: user?.customFields ?? {}, nickname: user?.nickname ?? '', + statusVisibilityDenied: user?.settings?.preferences?.statusVisibilityDenied ?? [], ...getUserStatusInitialValues(user), }; }; diff --git a/apps/meteor/client/views/admin/settings/SettingsGroupSelector/SettingsGroupSelector.tsx b/apps/meteor/client/views/admin/settings/SettingsGroupSelector/SettingsGroupSelector.tsx index 02ba6a46b3c1d..3d22b724cade7 100644 --- a/apps/meteor/client/views/admin/settings/SettingsGroupSelector/SettingsGroupSelector.tsx +++ b/apps/meteor/client/views/admin/settings/SettingsGroupSelector/SettingsGroupSelector.tsx @@ -6,6 +6,7 @@ import BaseGroupPage from '../groups/BaseGroupPage'; import EnterpriseGroupPage from '../groups/EnterpriseGroupPage'; import LDAPGroupPage from '../groups/LDAPGroupPage'; import OAuthGroupPage from '../groups/OAuthGroupPage'; +import SAMLGroupPage from '../groups/SAMLGroupPage'; export type SettingsGroupSelectorProps = { groupId: ISetting['_id']; @@ -27,6 +28,10 @@ const SettingsGroupSelector = ({ groupId, onClickBack }: SettingsGroupSelectorPr return ; } + if (groupId === 'SAML') { + return ; + } + if (groupId === 'Assets') { return ; } diff --git a/apps/meteor/client/views/admin/settings/groups/SAMLGroupPage/SAMLGroupPage.tsx b/apps/meteor/client/views/admin/settings/groups/SAMLGroupPage/SAMLGroupPage.tsx new file mode 100644 index 0000000000000..6b6e6eacbd0fe --- /dev/null +++ b/apps/meteor/client/views/admin/settings/groups/SAMLGroupPage/SAMLGroupPage.tsx @@ -0,0 +1,81 @@ +import type { ISetting } from '@rocket.chat/core-typings'; +import { Button } from '@rocket.chat/fuselage'; +import { useStableCallback } from '@rocket.chat/fuselage-hooks'; +import { useEndpoint, useSetModal, useToastMessageDispatch, useSettingStructure } from '@rocket.chat/ui-contexts'; +import { memo, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; + +import SamlMetadataModal from './SamlMetadataModal'; +import type { SamlMetadataValues } from './SamlMetadataModal'; +import { useEditableSettings, useEditableSettingsDispatch } from '../../../EditableSettingsContext'; +import BaseGroupPage from '../BaseGroupPage'; + +type SAMLGroupPageProps = ISetting & { + onClickBack?: () => void; +}; + +function SAMLGroupPage({ _id, i18nLabel, onClickBack, ...group }: SAMLGroupPageProps) { + const { t } = useTranslation(); + const dispatchToastMessage = useToastMessageDispatch(); + const parseMetadata = useEndpoint('POST', '/v1/saml.parseMetadata'); + const setModal = useSetModal(); + const closeModal = useStableCallback(() => setModal()); + const dispatch = useEditableSettingsDispatch(); + + const certSetting = useSettingStructure('SAML_Custom_Default_cert'); + const entryPointSetting = useSettingStructure('SAML_Custom_Default_entry_point'); + const sloSetting = useSettingStructure('SAML_Custom_Default_idp_slo_redirect_url'); + const identifierFormatSetting = useSettingStructure('SAML_Custom_Default_identifier_format'); + + const editableSettings = useEditableSettings(useMemo(() => ({ group: _id }), [_id])); + const changed = useMemo(() => editableSettings.some(({ changed }) => changed), [editableSettings]); + + const handleApply = useStableCallback((values: SamlMetadataValues) => { + // identifier_format is only registered on Enterprise installs; skip any setting that isn't present. + const add = (setting: ISetting | undefined, value?: string) => + setting && value !== undefined ? [{ _id: setting._id, value, changed: JSON.stringify(setting.value) !== JSON.stringify(value) }] : []; + + const changes = [ + ...add(certSetting, values.cert), + ...add(entryPointSetting, values.entryPoint), + ...add(sloSetting, values.idpSLORedirectURL), + ...add(identifierFormatSetting, values.identifierFormat), + ]; + + dispatch(changes); + closeModal(); + + if (changes.length === 0) { + dispatchToastMessage({ type: 'warning', message: t('SAML_Metadata_no_values') }); + return; + } + + dispatchToastMessage({ type: 'success', message: t('SAML_Metadata_applied') }); + }); + + const handleImportClick = () => + setModal( + parseMetadata({ url })} + onApply={handleApply} + showIdentifierFormat={identifierFormatSetting !== undefined} + />, + ); + + return ( + + {t('SAML_Import_metadata')} + + } + /> + ); +} + +export default memo(SAMLGroupPage); diff --git a/apps/meteor/client/views/admin/settings/groups/SAMLGroupPage/SamlMetadataModal.spec.tsx b/apps/meteor/client/views/admin/settings/groups/SAMLGroupPage/SamlMetadataModal.spec.tsx new file mode 100644 index 0000000000000..b027bd8106a01 --- /dev/null +++ b/apps/meteor/client/views/admin/settings/groups/SAMLGroupPage/SamlMetadataModal.spec.tsx @@ -0,0 +1,122 @@ +import { mockAppRoot } from '@rocket.chat/mock-providers'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import SamlMetadataModal from './SamlMetadataModal'; + +const setup = (overrides: Partial[0]> = {}) => { + const props = { + onClose: jest.fn(), + onFetch: jest.fn().mockResolvedValue({ + cert: 'CERTDATA', + entryPoint: 'https://idp.test/sso', + idpSLORedirectURL: 'https://idp.test/slo', + warnings: [], + }), + onApply: jest.fn(), + ...overrides, + }; + render(, { wrapper: mockAppRoot().build() }); + return props; +}; + +it('fetches metadata and shows the preview, then applies edited values', async () => { + const props = setup(); + + await userEvent.type(screen.getByLabelText('SAML_Metadata_url'), 'https://idp.test/metadata.xml'); + await userEvent.click(screen.getByText('SAML_Metadata_fetch')); + + await waitFor(() => expect(props.onFetch).toHaveBeenCalledWith('https://idp.test/metadata.xml')); + + const entryPointInput = await screen.findByLabelText('SAML_Custom_Entry_point'); + expect(entryPointInput).toHaveValue('https://idp.test/sso'); + expect(screen.getByLabelText('SAML_Custom_Cert')).toHaveValue('CERTDATA'); + expect(screen.getByLabelText('SAML_Custom_IDP_SLO_Redirect_URL')).toHaveValue('https://idp.test/slo'); + + await userEvent.clear(entryPointInput); + await userEvent.type(entryPointInput, 'https://idp.test/sso-edited'); + await userEvent.click(screen.getByText('Apply')); + + expect(props.onApply).toHaveBeenCalledWith({ + cert: 'CERTDATA', + entryPoint: 'https://idp.test/sso-edited', + idpSLORedirectURL: 'https://idp.test/slo', + }); +}); + +it('shows a warning callout when the fetch result carries warnings', async () => { + setup({ + onFetch: jest.fn().mockResolvedValue({ cert: 'CERTDATA', warnings: ['SAML_Metadata_warning_multiple_certs'] }), + }); + + await userEvent.type(screen.getByLabelText('SAML_Metadata_url'), 'https://idp.test/metadata.xml'); + await userEvent.click(screen.getByText('SAML_Metadata_fetch')); + + expect(await screen.findByText('SAML_Metadata_warning_multiple_certs')).toBeInTheDocument(); +}); + +it('returns to Fetch mode when the URL is edited after a successful fetch', async () => { + setup(); + + await userEvent.type(screen.getByLabelText('SAML_Metadata_url'), 'https://idp.test/metadata.xml'); + await userEvent.click(screen.getByText('SAML_Metadata_fetch')); + + expect(await screen.findByText('Apply')).toBeInTheDocument(); + + await userEvent.type(screen.getByLabelText('SAML_Metadata_url'), '2'); + + expect(await screen.findByText('SAML_Metadata_fetch')).toBeInTheDocument(); + expect(screen.queryByText('Apply')).not.toBeInTheDocument(); +}); + +it('shows the Identifier Format row only when showIdentifierFormat is true', async () => { + setup({ + showIdentifierFormat: true, + onFetch: jest.fn().mockResolvedValue({ + cert: 'CERTDATA', + entryPoint: 'https://idp.test/sso', + idpSLORedirectURL: 'https://idp.test/slo', + identifierFormat: 'urn:oasis:names:tc:SAML:2.0:nameid-format:transient', + warnings: [], + }), + }); + + await userEvent.type(screen.getByLabelText('SAML_Metadata_url'), 'https://idp.test/metadata.xml'); + await userEvent.click(screen.getByText('SAML_Metadata_fetch')); + + expect(await screen.findByLabelText('SAML_Identifier_Format')).toHaveValue('urn:oasis:names:tc:SAML:2.0:nameid-format:transient'); +}); + +it('does not show the Identifier Format row when showIdentifierFormat is false', async () => { + setup(); + + await userEvent.type(screen.getByLabelText('SAML_Metadata_url'), 'https://idp.test/metadata.xml'); + await userEvent.click(screen.getByText('SAML_Metadata_fetch')); + + expect(await screen.findByLabelText('SAML_Custom_Cert')).toBeInTheDocument(); + expect(screen.queryByLabelText('SAML_Identifier_Format')).not.toBeInTheDocument(); +}); + +it('calls onClose when Cancel is clicked', async () => { + const props = setup(); + await userEvent.click(screen.getByText('Cancel')); + expect(props.onClose).toHaveBeenCalled(); +}); + +it('shows a danger callout and allows retry when fetch fails', async () => { + setup({ + onFetch: jest.fn().mockRejectedValueOnce({ success: false, error: 'SAML_Metadata_fetch_failed' }), + }); + + await userEvent.type(screen.getByLabelText('SAML_Metadata_url'), 'https://idp.test/metadata.xml'); + await userEvent.click(screen.getByText('SAML_Metadata_fetch')); + + expect(await screen.findByText('SAML_Metadata_fetch_failed')).toBeInTheDocument(); + + const confirmButton = screen.getByText('SAML_Metadata_fetch'); + expect(confirmButton).toBeInTheDocument(); + expect(confirmButton).not.toBeDisabled(); + + const urlInput = screen.getByLabelText('SAML_Metadata_url') as HTMLInputElement; + expect(urlInput.value).toBe('https://idp.test/metadata.xml'); +}); diff --git a/apps/meteor/client/views/admin/settings/groups/SAMLGroupPage/SamlMetadataModal.tsx b/apps/meteor/client/views/admin/settings/groups/SAMLGroupPage/SamlMetadataModal.tsx new file mode 100644 index 0000000000000..36c2a3150c5bc --- /dev/null +++ b/apps/meteor/client/views/admin/settings/groups/SAMLGroupPage/SamlMetadataModal.tsx @@ -0,0 +1,184 @@ +import { Box, Callout, Field, FieldLabel, FieldRow, TextAreaInput, TextInput } from '@rocket.chat/fuselage'; +import { GenericModal } from '@rocket.chat/ui-client'; +import type { ChangeEvent } from 'react'; +import { useId, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +export type SamlMetadataValues = { + cert?: string; + entryPoint?: string; + idpSLORedirectURL?: string; + identifierFormat?: string; +}; + +type SamlMetadataFetchResult = SamlMetadataValues & { warnings: string[] }; + +type SamlMetadataModalProps = { + onClose: () => void; + onFetch: (url: string) => Promise; + onApply: (values: SamlMetadataValues) => void; + showIdentifierFormat?: boolean; +}; + +const KNOWN_SAML_METADATA_ERROR_KEYS = [ + 'SAML_Metadata_url_blocked', + 'SAML_Metadata_fetch_failed', + 'SAML_Metadata_too_large', + 'SAML_Metadata_invalid', +]; + +const getSamlMetadataErrorKey = (error: unknown): string => { + const key = (error as { error?: unknown } | undefined)?.error; + return typeof key === 'string' && KNOWN_SAML_METADATA_ERROR_KEYS.includes(key) ? key : 'SAML_Metadata_fetch_failed'; +}; + +const SamlMetadataModal = ({ onClose, onFetch, onApply, showIdentifierFormat = false }: SamlMetadataModalProps) => { + const { t } = useTranslation(); + const [url, setUrl] = useState(''); + const [fetching, setFetching] = useState(false); + const [error, setError] = useState(null); + const [warnings, setWarnings] = useState([]); + const [values, setValues] = useState(null); + const urlFieldId = useId(); + const certFieldId = useId(); + const entryPointFieldId = useId(); + const sloFieldId = useId(); + const identifierFormatFieldId = useId(); + + const handleFetch = async () => { + if (!url.trim()) { + return; + } + setFetching(true); + setError(null); + try { + const { cert, entryPoint, idpSLORedirectURL, identifierFormat, warnings: fetchWarnings } = await onFetch(url.trim()); + setValues({ cert, entryPoint, idpSLORedirectURL, identifierFormat }); + setWarnings(fetchWarnings); + } catch (e) { + setError(getSamlMetadataErrorKey(e)); + } finally { + setFetching(false); + } + }; + + const handleApply = () => { + if (values) { + onApply(values); + } + }; + + const setValue = (key: keyof SamlMetadataValues) => (event: ChangeEvent) => { + const { value } = event.currentTarget; + setValues((current) => ({ ...current, [key]: value })); + }; + + return ( + ( + { + e.preventDefault(); + if (values) { + handleApply(); + } else { + void handleFetch(); + } + }} + {...props} + /> + )} + onCancel={onClose} + onClose={onClose} + confirmDisabled={fetching || (!values && url.trim() === '')} + confirmLoading={fetching} + > + + {t('SAML_Metadata_modal_description')} + + {t('SAML_Metadata_url')} + + ) => { + setUrl(e.currentTarget.value); + setValues(null); + setError(null); + setWarnings([]); + }} + /> + + + {error && ( + + {t(error as Parameters[0])} + + )} + {warnings.map((warning) => ( + + {t(warning as Parameters[0])} + + ))} + {values && ( + <> + + {t('SAML_Custom_Entry_point')} + + + + + + {t('SAML_Custom_IDP_SLO_Redirect_URL')} + + + + + + {t('SAML_Custom_Cert')} + + + + + {showIdentifierFormat && ( + + {t('SAML_Identifier_Format')} + + + + + )} + + )} + + + ); +}; + +export default SamlMetadataModal; diff --git a/apps/meteor/client/views/admin/settings/groups/SAMLGroupPage/index.ts b/apps/meteor/client/views/admin/settings/groups/SAMLGroupPage/index.ts new file mode 100644 index 0000000000000..44fa3d6d3784a --- /dev/null +++ b/apps/meteor/client/views/admin/settings/groups/SAMLGroupPage/index.ts @@ -0,0 +1 @@ +export { default } from './SAMLGroupPage'; diff --git a/apps/meteor/client/views/room/MessageList/hooks/useKeepAtBottom.spec.ts b/apps/meteor/client/views/room/MessageList/hooks/useKeepAtBottom.spec.ts new file mode 100644 index 0000000000000..e7317311878a1 --- /dev/null +++ b/apps/meteor/client/views/room/MessageList/hooks/useKeepAtBottom.spec.ts @@ -0,0 +1,61 @@ +import { renderHook } from '@testing-library/react'; + +import { useKeepAtBottom } from './useKeepAtBottom'; + +let resizeCallbacks: ResizeObserverCallback[] = []; + +class MockResizeObserver { + constructor(cb: ResizeObserverCallback) { + resizeCallbacks.push(cb); + } + + observe = jest.fn(); + + unobserve = jest.fn(); + + disconnect = jest.fn(); +} + +// Rows are measured asynchronously after the list remounts (avatars, images, reactions), +// so the observer fires repeatedly while the restored position is being applied. +const settleList = (times: number) => { + for (let i = 0; i < times; i++) { + resizeCallbacks.forEach((cb) => cb([], {} as ResizeObserver)); + } +}; + +const mountList = (isAtBottom: { current: boolean }) => { + const { result } = renderHook(() => useKeepAtBottom(isAtBottom)); + + const node = document.createElement('div'); + node.appendChild(document.createElement('div')); + result.current.keepAtBottomRef(node); + + const scrollToEnd = jest.fn(); + result.current.setKeepAtBottom(scrollToEnd); + + return scrollToEnd; +}; + +describe('useKeepAtBottom', () => { + beforeEach(() => { + resizeCallbacks = []; + (global as any).ResizeObserver = MockResizeObserver; + }); + + it('does not pull the list to the latest messages when the room was left mid-history', () => { + const scrollToEnd = mountList({ current: false }); + + settleList(3); + + expect(scrollToEnd).not.toHaveBeenCalled(); + }); + + it('keeps the list at the bottom when the room was left at the bottom', () => { + const scrollToEnd = mountList({ current: true }); + + settleList(3); + + expect(scrollToEnd).toHaveBeenCalledTimes(3); + }); +}); diff --git a/apps/meteor/client/views/room/body/RoomBody.tsx b/apps/meteor/client/views/room/body/RoomBody.tsx index cfe1b4bbc1323..eb6a9890ff395 100644 --- a/apps/meteor/client/views/room/body/RoomBody.tsx +++ b/apps/meteor/client/views/room/body/RoomBody.tsx @@ -3,7 +3,7 @@ import { isTruthy } from '@rocket.chat/tools'; import { CustomVirtuaScrollbars, useEmbeddedLayout } from '@rocket.chat/ui-client'; import { usePermission, useRole, useSetting, useTranslation, useUser, useUserPreference, useRoomToolbox } from '@rocket.chat/ui-contexts'; import type { MouseEvent } from 'react'; -import { memo, useCallback, useMemo, useRef, useState } from 'react'; +import { memo, useCallback, useMemo, useState } from 'react'; import { useMergedRefsV2 } from '../../../hooks/useMergedRefsV2'; import { BubbleDate } from '../BubbleDate'; @@ -17,6 +17,7 @@ import UploadProgressIndicator from './UploadProgress'; import ComposerContainer from '../composer/ComposerContainer'; import { useFileUpload } from './hooks/useFileUpload'; import { useGoToHomeOnRemoved } from './hooks/useGoToHomeOnRemoved'; +import { useIsAtBottomRef } from './hooks/useIsAtBottomRef'; import { useQuoteMessageByUrl } from './hooks/useQuoteMessageByUrl'; import { useReadMessageWindowEvents } from './hooks/useReadMessageWindowEvents'; import RoomComposer from '../composer/RoomComposer/RoomComposer'; @@ -48,7 +49,7 @@ const RoomBody = () => { const subscription = useRoomSubscription(); const [shouldJumpToBottom, setShouldJumpToBottom] = useState(false); - const isAtBottom = useRef(true); + const isAtBottom = useIsAtBottomRef(room._id); const [isJumpingToMessage, setIsJumpingToMessage] = useState(false); const retentionPolicy = useRetentionPolicy(room); diff --git a/apps/meteor/client/views/room/body/hooks/useIsAtBottomRef.spec.ts b/apps/meteor/client/views/room/body/hooks/useIsAtBottomRef.spec.ts new file mode 100644 index 0000000000000..311d7edac76e1 --- /dev/null +++ b/apps/meteor/client/views/room/body/hooks/useIsAtBottomRef.spec.ts @@ -0,0 +1,40 @@ +import { renderHook } from '@testing-library/react'; + +import { useIsAtBottomRef } from './useIsAtBottomRef'; +import { RoomManager } from '../../../../lib/RoomManager'; + +jest.mock('../../../../lib/RoomManager', () => ({ + RoomManager: { getStore: jest.fn() }, +})); + +const mockStore = (store: { atBottom: boolean } | undefined) => (RoomManager.getStore as jest.Mock).mockReturnValue(store); + +describe('useIsAtBottomRef', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('starts at the bottom when the room has no stored position yet', () => { + mockStore(undefined); + + const { result } = renderHook(() => useIsAtBottomRef('rid')); + + expect(result.current.current).toBe(true); + }); + + it('starts away from the bottom when the room was left scrolled mid-history', () => { + mockStore({ atBottom: false }); + + const { result } = renderHook(() => useIsAtBottomRef('rid')); + + expect(result.current.current).toBe(false); + }); + + it('starts at the bottom when the room was left at the bottom', () => { + mockStore({ atBottom: true }); + + const { result } = renderHook(() => useIsAtBottomRef('rid')); + + expect(result.current.current).toBe(true); + }); +}); diff --git a/apps/meteor/client/views/room/body/hooks/useIsAtBottomRef.ts b/apps/meteor/client/views/room/body/hooks/useIsAtBottomRef.ts new file mode 100644 index 0000000000000..c1bd0b941fa84 --- /dev/null +++ b/apps/meteor/client/views/room/body/hooks/useIsAtBottomRef.ts @@ -0,0 +1,9 @@ +import type { RefObject } from 'react'; +import { useRef } from 'react'; + +import { RoomManager } from '../../../../lib/RoomManager'; + +/** Starting from `true` makes `useKeepAtBottom` pull a restored mid-history position down to the latest messages, so seed it from the position persisted for the room being opened. */ +export const useIsAtBottomRef = (rid: string): RefObject => { + return useRef(RoomManager.getStore(rid)?.atBottom ?? true); +}; diff --git a/apps/meteor/jest.config.ts b/apps/meteor/jest.config.ts index 6eca9ca6c4a26..3210637a256a5 100644 --- a/apps/meteor/jest.config.ts +++ b/apps/meteor/jest.config.ts @@ -52,6 +52,8 @@ export default { '/server/api/v1/middlewares/*.spec.ts', '/server/lib/cloud/version-check/**/*.spec.ts', '/server/lib/auth-providers/apple/**.spec.ts', + '/server/lib/statusVisibility/*.spec.ts', + '/server/services/statusVisibility/*.spec.ts', ], coveragePathIgnorePatterns: ['/node_modules/'], }, diff --git a/apps/meteor/server/api/index.ts b/apps/meteor/server/api/index.ts index 4551b64b3299d..59424f4a35b5f 100644 --- a/apps/meteor/server/api/index.ts +++ b/apps/meteor/server/api/index.ts @@ -22,6 +22,7 @@ import './v1/integrations'; import './v1/invites'; import './v1/import'; import './v1/ldap'; +import './v1/saml'; import './v1/media-calls'; import './v1/misc'; import './v1/permissions'; diff --git a/apps/meteor/server/api/lib/getUserInfo.ts b/apps/meteor/server/api/lib/getUserInfo.ts index adfbedefe4bef..685555a8c81de 100644 --- a/apps/meteor/server/api/lib/getUserInfo.ts +++ b/apps/meteor/server/api/lib/getUserInfo.ts @@ -2,6 +2,7 @@ import { isOAuthUser, type IMeApiUser, type IUser, type IUserEmail, type IUserCa import semver from 'semver'; import { Info } from '../../../app/utils/rocketchat.info'; +import { resolveUsersByIds } from '../../lib/statusVisibility/resolveUsers'; import { getURL } from '../../lib/utils/getURL'; import { getUserPreference } from '../../lib/utils/lib/getUserPreference'; import { settings } from '../../settings'; @@ -14,7 +15,7 @@ const isVerifiedEmail = (me: IUser): false | IUserEmail | undefined => { return me.emails.find((email) => email.verified); }; -const getUserPreferences = async (me: IUser): Promise> => { +const getPreferencesWithDefaults = async (me: IUser): Promise> => { const defaultUserSettingPrefix = 'Accounts_Default_User_Preferences_'; const allDefaultUserSettings = settings.getByRegexp(new RegExp(`^${defaultUserSettingPrefix}.*$`)); @@ -87,7 +88,18 @@ const getUserCalendar = (email: false | IUserEmail | undefined): IUserCalendar = export async function getUserInfo(me: IUser, pullPreferences = true): Promise { const verifiedEmail = isVerifiedEmail(me); - const userPreferences = me.settings?.preferences ?? {}; + const { statusVisibilityDenied, ...savedPreferences } = me.settings?.preferences ?? {}; + + const preferences = pullPreferences + ? { + ...(await getPreferencesWithDefaults(me)), + ...savedPreferences, + ...(settings.get('Accounts_StatusVisibility_Enabled') && + statusVisibilityDenied?.length && { + statusVisibilityDenied: (await resolveUsersByIds(statusVisibilityDenied)).usernames, + }), + } + : undefined; return { ...me, @@ -95,7 +107,7 @@ export async function getUserInfo(me: IUser, pullPreferences = true): Promise { + it('should return false for a query without status fields', () => { + expect(queryFiltersStatus({ username: { $regex: 'ana' } })).to.be.equal(false); + }); + + it('should return true for a top level status filter', () => { + expect(queryFiltersStatus({ status: 'online' })).to.be.equal(true); + }); + + it('should return true for a status filter nested in $or', () => { + expect(queryFiltersStatus({ $or: [{ username: { $regex: '' } }, { status: 'online' }] })).to.be.equal(true); + }); + + it('should return true for a status filter nested in $and inside $or', () => { + expect(queryFiltersStatus({ $or: [{ $and: [{ statusText: { $regex: 'lunch' } }] }] })).to.be.equal(true); + }); + + it('should return true for any redacted status field', () => { + for (const field of ['statusText', 'statusSource', 'statusExpiresAt', 'statusDefault', 'statusConnection']) { + expect(queryFiltersStatus({ [field]: { $exists: true } })).to.be.equal(true); + } + }); + + it('should return false for null and primitive values', () => { + expect(queryFiltersStatus(null)).to.be.equal(false); + expect(queryFiltersStatus('status')).to.be.equal(false); + expect(queryFiltersStatus(undefined)).to.be.equal(false); + }); +}); diff --git a/apps/meteor/server/api/lib/queryFiltersStatus.ts b/apps/meteor/server/api/lib/queryFiltersStatus.ts new file mode 100644 index 0000000000000..d7c68da02dba5 --- /dev/null +++ b/apps/meteor/server/api/lib/queryFiltersStatus.ts @@ -0,0 +1,11 @@ +export function queryFiltersStatus(query: unknown): boolean { + if (Array.isArray(query)) { + return query.some(queryFiltersStatus); + } + + if (query === null || typeof query !== 'object') { + return false; + } + + return Object.entries(query).some(([key, value]) => key.startsWith('status') || queryFiltersStatus(value)); +} diff --git a/apps/meteor/server/api/v1/im.ts b/apps/meteor/server/api/v1/im.ts index 79b79d3db95ab..8f91f3b8aac64 100644 --- a/apps/meteor/server/api/v1/im.ts +++ b/apps/meteor/server/api/v1/im.ts @@ -26,6 +26,8 @@ import { hasPermissionAsync } from '../../lib/authorization/hasPermission'; import { eraseRoom } from '../../lib/eraseRoom'; import { openRoom } from '../../lib/openRoom'; import { getRoomByNameOrIdWithOptionToJoin } from '../../lib/rooms/getRoomByNameOrIdWithOptionToJoin'; +import { getUsersHiddenFrom } from '../../lib/statusVisibility/hiddenUsers'; +import { redactStatus } from '../../lib/statusVisibility/redactStatus'; import { blockUserMethod } from '../../lib/users/blockUser'; import { unblockUserMethod } from '../../lib/users/unblockUser'; import { normalizeMessagesForUser } from '../../lib/utils/lib/normalizeMessagesForUser'; @@ -549,8 +551,10 @@ const dmMembersAction = (_path: Path): TypedAction