From 84a6a91c09f146e0c345d8bf28b6c48efe8d2f88 Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Wed, 19 Aug 2026 18:15:35 +0000 Subject: [PATCH 1/6] test: cover anonymous read API paths (#41843) --- apps/meteor/tests/end-to-end/api/methods.ts | 93 +++++++++++++++++---- 1 file changed, 79 insertions(+), 14 deletions(-) diff --git a/apps/meteor/tests/end-to-end/api/methods.ts b/apps/meteor/tests/end-to-end/api/methods.ts index 38f71aaa8c1c6..bc9f4488cf9ef 100644 --- a/apps/meteor/tests/end-to-end/api/methods.ts +++ b/apps/meteor/tests/end-to-end/api/methods.ts @@ -5,7 +5,7 @@ import { expect } from 'chai'; import { after, before, describe, it } from 'mocha'; import { retry } from './helpers/retry'; -import { api, credentials, getCredentials, methodCall, request } from '../../data/api-data'; +import { api, credentials, getCredentials, methodCall, methodCallAnon, request } from '../../data/api-data'; import { sendMessage, sendSimpleMessage } from '../../data/chat.helper'; import { CI_MAX_ROOMS_PER_GUEST as maxRoomsPerGuest } from '../../data/constants'; import { closeOmnichannelRoom, createAgent, createLivechatRoom, createVisitor, makeAgentAvailable } from '../../data/livechat/rooms'; @@ -857,7 +857,75 @@ describe('Meteor.methods', () => { .end(done); }); - after(() => deleteRoom({ type: 'p', roomId: rid })); + let publicRid: IRoom['_id']; + let publicMessageId: IMessage['_id']; + + before('create public channel with a message', async () => { + publicRid = (await createRoom({ type: 'c', name: `methods-test-public-${Date.now()}` })).body.channel._id; + publicMessageId = (await sendMessage({ message: { rid: publicRid, msg: 'public message' } })).body.message._id; + }); + + after(() => Promise.all([deleteRoom({ type: 'p', roomId: rid }), deleteRoom({ type: 'c', roomId: publicRid })])); + + describe('anonymous read', () => { + before(() => updateSetting('Accounts_AllowAnonymousRead', true)); + after(() => updateSetting('Accounts_AllowAnonymousRead', false)); + + it('should return public channel messages to an anonymous caller when anonymous read is enabled', async () => { + const res = await request + .post(methodCallAnon('loadHistory')) + .send({ + message: JSON.stringify({ + id: 'id', + msg: 'method', + method: 'loadHistory', + params: [publicRid], + }), + }) + .expect('Content-Type', 'application/json') + .expect(200); + + const data = JSON.parse(res.body.message); + expect(data.result).to.have.a.property('messages').that.is.an('array'); + expect(data.result.messages.map((m: IMessage) => m._id)).to.include(publicMessageId); + }); + + it('should not return private group messages to an anonymous caller even when anonymous read is enabled', async () => { + const res = await request + .post(methodCallAnon('loadHistory')) + .send({ + message: JSON.stringify({ + id: 'id', + msg: 'method', + method: 'loadHistory', + params: [rid], + }), + }) + .expect('Content-Type', 'application/json') + .expect(200); + + const data = JSON.parse(res.body.message); + expect(data.result).to.equal(false); + }); + }); + + it('should fail for an anonymous caller when anonymous read is disabled', async () => { + const res = await request + .post(methodCallAnon('loadHistory')) + .send({ + message: JSON.stringify({ + id: 'id', + msg: 'method', + method: 'loadHistory', + params: [publicRid], + }), + }) + .expect('Content-Type', 'application/json') + .expect(400); + + const data = JSON.parse(res.body.message); + expect(data.error).to.have.property('error', 'error-invalid-user'); + }); it('should fail if not logged in', async () => { const res = await request @@ -2539,7 +2607,7 @@ describe('Meteor.methods', () => { }), }; - const res = await request.post('/api/v1/method.callAnon/getRoomByTypeAndName').set('Content-Type', 'application/json').send(payload); + const res = await request.post(methodCallAnon('getRoomByTypeAndName')).send(payload); expect(res.body).to.have.property('message'); const parsedMessage = JSON.parse(res.body.message); @@ -2668,17 +2736,14 @@ describe('Meteor.methods', () => { it('should return the room object for a Public Channel if anonymous read is enabled', async () => { await updateSetting('Accounts_AllowAnonymousRead', true); - const res = await request - .post(methodCall('getRoomByTypeAndName')) - .set(credentials) - .send({ - message: JSON.stringify({ - method: 'getRoomByTypeAndName', - params: ['c', room._id], - id: 'id', - msg: 'method', - }), - }); + const res = await request.post(methodCallAnon('getRoomByTypeAndName')).send({ + message: JSON.stringify({ + method: 'getRoomByTypeAndName', + params: ['c', room._id], + id: 'id', + msg: 'method', + }), + }); expect(res.body.success).to.equal(true); const parsedResponse = JSON.parse(res.body.message); From 26aec8227892a64aea8bad45f3b53a7f82257198 Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Wed, 19 Aug 2026 18:54:19 +0000 Subject: [PATCH 2/6] fix: anonymous spotlight search crashing when anonymous read is enabled (#41876) Co-authored-by: Tasso Evangelista Co-authored-by: Claude Opus 5 (1M context) --- .changeset/fix-anonymous-spotlight-callers.md | 5 ++ apps/meteor/server/lib/spotlight.js | 4 +- apps/meteor/tests/end-to-end/api/methods.ts | 53 +++++++++++++ .../tests/end-to-end/api/miscellaneous.ts | 78 ++++++++++++++++--- 4 files changed, 127 insertions(+), 13 deletions(-) create mode 100644 .changeset/fix-anonymous-spotlight-callers.md 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/apps/meteor/server/lib/spotlight.js b/apps/meteor/server/lib/spotlight.js index 42fa1fbbb333e..6f6edceb8c63e 100644 --- a/apps/meteor/server/lib/spotlight.js +++ b/apps/meteor/server/lib/spotlight.js @@ -11,7 +11,7 @@ import { readSecondaryPreferred } from '../database/readSecondaryPreferred'; export class Spotlight { async fetchRooms(userId, rooms) { - if (!settings.get('Store_Last_Message') || (await hasPermissionAsync(userId, 'preview-c-room'))) { + if (!settings.get('Store_Last_Message') || (userId && (await hasPermissionAsync(userId, 'preview-c-room')))) { return rooms; } @@ -191,7 +191,7 @@ export class Spotlight { return users; } - const canListOutsiders = await hasAllPermissionAsync(userId, ['view-outside-room', 'view-d-room']); + const canListOutsiders = !!userId && (await hasAllPermissionAsync(userId, ['view-outside-room', 'view-d-room'])); const canListInsiders = canListOutsiders || (rid && (await canAccessRoomAsync(room, { _id: userId }))); const insiderExtraQuery = []; diff --git a/apps/meteor/tests/end-to-end/api/methods.ts b/apps/meteor/tests/end-to-end/api/methods.ts index bc9f4488cf9ef..b74ceb24624db 100644 --- a/apps/meteor/tests/end-to-end/api/methods.ts +++ b/apps/meteor/tests/end-to-end/api/methods.ts @@ -2773,6 +2773,59 @@ describe('Meteor.methods', () => { }); }); + describe('[@spotlight]', () => { + let testChannel: IRoom; + + before(async () => { + testChannel = (await createRoom({ type: 'c', name: `methods-spotlight-${Date.now()}` })).body.channel; + }); + + after(async () => { + await Promise.all([deleteRoom({ type: 'c', roomId: testChannel._id }), updateSetting('Accounts_AllowAnonymousRead', false)]); + }); + + const callAnonymousSpotlight = async (text: string) => { + const res = await request + .post(methodCallAnon('spotlight')) + .send({ + message: JSON.stringify({ + msg: 'method', + id: 'id', + method: 'spotlight', + params: [text], + }), + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + + const parsedResponse = JSON.parse(res.body.message); + expect(parsedResponse).to.not.have.property('error'); + + return parsedResponse.result as { rooms: IRoom[]; users: IUser[] }; + }; + + it('should return no rooms or users for an anonymous user when anonymous read is disabled', async () => { + await updateSetting('Accounts_AllowAnonymousRead', false); + + // The unprefixed query also runs the user search with no user id, which used to throw. + const result = await callAnonymousSpotlight(testChannel.name as string); + + expect(result).to.have.property('rooms').and.to.be.an('array').that.is.empty; + expect(result).to.have.property('users').and.to.be.an('array').that.is.empty; + }); + + it('should return public rooms but no users for an anonymous user when anonymous read is enabled', async () => { + await updateSetting('Accounts_AllowAnonymousRead', true); + + const result = await callAnonymousSpotlight(testChannel.name as string); + + expect(result.rooms.map((room) => room._id)).to.include(testChannel._id); + expect(result).to.have.property('users').and.to.be.an('array').that.is.empty; + }); + }); + describe('[@setUserActiveStatus]', () => { let testUser: TestUser; let testUser2: TestUser; diff --git a/apps/meteor/tests/end-to-end/api/miscellaneous.ts b/apps/meteor/tests/end-to-end/api/miscellaneous.ts index 649a037ff4c55..946175395c5a7 100644 --- a/apps/meteor/tests/end-to-end/api/miscellaneous.ts +++ b/apps/meteor/tests/end-to-end/api/miscellaneous.ts @@ -534,18 +534,74 @@ describe('miscellaneous', () => { expect(res.body).to.have.property('users').and.to.be.an('array'); expect(res.body.users.map((u: { username: string }) => u.username)).to.not.include(adminUsername); }); - it('should allow anonymous (unauthenticated) requests', async () => { - const res = await request - .get(api('spotlight')) - .query({ - query: `#${testChannel.name}`, - }) - .expect('Content-Type', 'application/json') - .expect(200); + describe('anonymous (unauthenticated) requests', () => { + after(() => updateSetting('Accounts_AllowAnonymousRead', false)); - expect(res.body).to.have.property('success', true); - expect(res.body).to.have.property('rooms').and.to.be.an('array'); - expect(res.body).to.have.property('users').and.to.be.an('array'); + it('should return no rooms when anonymous read is disabled', async () => { + await updateSetting('Accounts_AllowAnonymousRead', false); + + const res = await request + .get(api('spotlight')) + .query({ + query: `#${testChannel.name}`, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('rooms').and.to.be.an('array').that.is.empty; + expect(res.body).to.have.property('users').and.to.be.an('array').that.is.empty; + }); + + // An unprefixed query keeps `type.users` enabled, so it also runs the user search with no + // user id - the code path that used to throw before the anonymous callers were guarded. + it('should return no rooms or users for an unprefixed query when anonymous read is disabled', async () => { + await updateSetting('Accounts_AllowAnonymousRead', false); + + const res = await request + .get(api('spotlight')) + .query({ + query: testChannel.name, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('rooms').and.to.be.an('array').that.is.empty; + expect(res.body).to.have.property('users').and.to.be.an('array').that.is.empty; + }); + + it('should return public rooms but no users when anonymous read is enabled', async () => { + await updateSetting('Accounts_AllowAnonymousRead', true); + + const res = await request + .get(api('spotlight')) + .query({ + query: `#${testChannel.name}`, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body.rooms.map((r: { _id: string }) => r._id)).to.include(testChannel._id); + expect(res.body).to.have.property('users').and.to.be.an('array').that.is.empty; + }); + + it('should return public rooms but no users for an unprefixed query when anonymous read is enabled', async () => { + await updateSetting('Accounts_AllowAnonymousRead', true); + + const res = await request + .get(api('spotlight')) + .query({ + query: testChannel.name, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body.rooms.map((r: { _id: string }) => r._id)).to.include(testChannel._id); + expect(res.body).to.have.property('users').and.to.be.an('array').that.is.empty; + }); }); }); From 097884fb750bb68396a299230e2f20219dfe0e5a Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Wed, 19 Aug 2026 19:23:18 +0000 Subject: [PATCH 3/6] feat: import SAML IdP configuration from a metadata URL (#41481) --- .changeset/fruity-views-begin.md | 7 + .../SettingsGroupSelector.tsx | 5 + .../groups/SAMLGroupPage/SAMLGroupPage.tsx | 81 +++++++ .../SAMLGroupPage/SamlMetadataModal.spec.tsx | 122 ++++++++++ .../SAMLGroupPage/SamlMetadataModal.tsx | 184 ++++++++++++++ .../settings/groups/SAMLGroupPage/index.ts | 1 + apps/meteor/server/api/index.ts | 1 + apps/meteor/server/api/v1/saml.ts | 82 +++++++ apps/meteor/server/lib/saml/lib/Utils.ts | 10 + .../lib/saml/lib/parsers/IdpMetadata.ts | 141 +++++++++++ apps/meteor/tests/end-to-end/api/SAML.ts | 50 ++++ .../unit/server/lib/saml/idpMetadata.spec.ts | 225 ++++++++++++++++++ packages/i18n/src/locales/en.i18n.json | 16 ++ packages/rest-typings/src/index.ts | 1 + packages/rest-typings/src/v1/saml.ts | 45 ++++ packages/server-fetch/src/index.ts | 4 +- 16 files changed, 973 insertions(+), 2 deletions(-) create mode 100644 .changeset/fruity-views-begin.md create mode 100644 apps/meteor/client/views/admin/settings/groups/SAMLGroupPage/SAMLGroupPage.tsx create mode 100644 apps/meteor/client/views/admin/settings/groups/SAMLGroupPage/SamlMetadataModal.spec.tsx create mode 100644 apps/meteor/client/views/admin/settings/groups/SAMLGroupPage/SamlMetadataModal.tsx create mode 100644 apps/meteor/client/views/admin/settings/groups/SAMLGroupPage/index.ts create mode 100644 apps/meteor/server/api/v1/saml.ts create mode 100644 apps/meteor/server/lib/saml/lib/parsers/IdpMetadata.ts create mode 100644 apps/meteor/tests/end-to-end/api/SAML.ts create mode 100644 apps/meteor/tests/unit/server/lib/saml/idpMetadata.spec.ts create mode 100644 packages/rest-typings/src/v1/saml.ts 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/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/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/v1/saml.ts b/apps/meteor/server/api/v1/saml.ts new file mode 100644 index 0000000000000..b11fd93dfa008 --- /dev/null +++ b/apps/meteor/server/api/v1/saml.ts @@ -0,0 +1,82 @@ +import { + isSamlParseMetadata, + validateBadRequestErrorResponse, + validateForbiddenErrorResponse, + validateSamlParseMetadataSuccessResponse, + validateUnauthorizedErrorResponse, +} from '@rocket.chat/rest-typings'; +import { FetchError, serverFetch as fetch } from '@rocket.chat/server-fetch'; + +import { parseIdpMetadata } from '../../lib/saml/lib/parsers/IdpMetadata'; +import { settings } from '../../settings'; +import type { ExtractRoutesFromAPI } from '../ApiClass'; +import { API } from '../api'; + +const samlEndpoints = API.v1.post( + 'saml.parseMetadata', + { + authRequired: true, + permissionsRequired: ['test-admin-options'], + body: isSamlParseMetadata, + response: { + 200: validateSamlParseMetadataSuccessResponse, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + const { url } = this.bodyParams; + + let response; + try { + response = await fetch( + url, + { + ignoreSsrfValidation: false, + allowList: settings.get('SSRF_Allowlist'), + timeout: 20_000, + size: 1_000_000, + headers: { Accept: 'application/samlmetadata+xml, application/xml, text/xml' }, + }, + settings.get('Allow_Invalid_SelfSigned_Certs'), + ); + } catch (err) { + this.logger.error({ msg: 'Failed to fetch SAML IdP metadata', err }); + if (err instanceof Error && err.message === 'error-ssrf-validation-failed') { + return API.v1.failure('SAML_Metadata_url_blocked'); + } + return API.v1.failure('SAML_Metadata_fetch_failed'); + } + + if (!response.ok) { + response.body.resume(); + return API.v1.failure('SAML_Metadata_fetch_failed'); + } + + let xml: string; + try { + xml = await response.text(); + } catch (err) { + if (err instanceof FetchError && err.type === 'max-size') { + return API.v1.failure('SAML_Metadata_too_large'); + } + return API.v1.failure('SAML_Metadata_fetch_failed'); + } + + try { + const { warnings, ...values } = parseIdpMetadata(xml); + return API.v1.success({ ...values, warnings }); + } catch (err) { + this.logger.warn({ msg: 'Failed to parse SAML IdP metadata', err }); + return API.v1.failure('SAML_Metadata_invalid'); + } + }, +); + +export type SAMLEndpoints = ExtractRoutesFromAPI; + +declare module '@rocket.chat/rest-typings' { + // eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface, @typescript-eslint/no-empty-object-type + interface Endpoints extends SAMLEndpoints {} +} diff --git a/apps/meteor/server/lib/saml/lib/Utils.ts b/apps/meteor/server/lib/saml/lib/Utils.ts index 5e5ded5c28443..efaf3490c072d 100644 --- a/apps/meteor/server/lib/saml/lib/Utils.ts +++ b/apps/meteor/server/lib/saml/lib/Utils.ts @@ -1,3 +1,4 @@ +import crypto from 'node:crypto'; import { EventEmitter } from 'node:events'; import zlib from 'node:zlib'; @@ -117,6 +118,15 @@ export class SAMLUtils { return lines.join('\n'); } + public static isParsableCertificate(cert: string): boolean { + try { + void new crypto.X509Certificate(this.certToPEM(cert)); + return true; + } catch { + return false; + } + } + public static fillTemplateData(template: string, data: Record): string { let newTemplate = template; diff --git a/apps/meteor/server/lib/saml/lib/parsers/IdpMetadata.ts b/apps/meteor/server/lib/saml/lib/parsers/IdpMetadata.ts new file mode 100644 index 0000000000000..72433f7b93088 --- /dev/null +++ b/apps/meteor/server/lib/saml/lib/parsers/IdpMetadata.ts @@ -0,0 +1,141 @@ +import xmldom from '@xmldom/xmldom'; + +import { SAMLUtils } from '../Utils'; + +const MD_NS = 'urn:oasis:names:tc:SAML:2.0:metadata'; +const DS_NS = 'http://www.w3.org/2000/09/xmldsig#'; +const REDIRECT_BINDING = 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect'; +const SAML2_PROTOCOL = 'urn:oasis:names:tc:SAML:2.0:protocol'; + +type IdpMetadataResult = { + entryPoint?: string; + idpSLORedirectURL?: string; + cert?: string; + identifierFormat?: string; + warnings: string[]; +}; + +type ExtractedValue = { + value?: string; + warning?: string; +}; + +export class InvalidIdpMetadataError extends Error {} + +const isHttpUrl = (value: string): boolean => { + try { + const { protocol } = new URL(value); + return protocol === 'http:' || protocol === 'https:'; + } catch { + return false; + } +}; + +const childrenOf = (el: Element | Document, tag: string): Element[] => + Array.from(el.getElementsByTagNameNS(MD_NS, tag)).filter((child) => child.parentNode === el); + +const parseIdpDescriptor = (xml: string): Element => { + const doc = new xmldom.DOMParser({ + errorHandler: () => { + throw new InvalidIdpMetadataError('invalid-xml'); + }, + }).parseFromString(xml, 'text/xml'); + + if (!doc) { + throw new InvalidIdpMetadataError('invalid-xml'); + } + + const entityDescriptor = childrenOf(doc, 'EntityDescriptor')[0]; + if (!entityDescriptor) { + throw new InvalidIdpMetadataError('root-is-not-entity-descriptor'); + } + + const idpDescriptors = childrenOf(entityDescriptor, 'IDPSSODescriptor'); + if (!idpDescriptors.length) { + throw new InvalidIdpMetadataError('no-idp-sso-descriptor'); + } + + const idpDescriptor = idpDescriptors.find((descriptor) => + (descriptor.getAttribute('protocolSupportEnumeration') ?? '').split(/\s+/).includes(SAML2_PROTOCOL), + ); + if (!idpDescriptor) { + throw new InvalidIdpMetadataError('no-saml2-idp-sso-descriptor'); + } + + return idpDescriptor; +}; + +const extractSigningCert = (idp: Element): ExtractedValue => { + const certs = childrenOf(idp, 'KeyDescriptor') + .filter((kd) => { + const use = kd.getAttribute('use'); + return !use || use === 'signing'; + }) + .map((kd) => kd.getElementsByTagNameNS(DS_NS, 'X509Certificate')[0]?.textContent?.trim()) + .map((raw) => (raw ? SAMLUtils.normalizeCert(raw) : undefined)) + .filter((cert): cert is string => !!cert && SAMLUtils.isParsableCertificate(cert)); + + if (!certs.length) { + return { warning: 'SAML_Metadata_warning_no_valid_cert' }; + } + + const warning = certs.length > 1 ? 'SAML_Metadata_warning_multiple_certs' : undefined; + + return { value: certs[0], warning }; +}; + +const findRedirectLocation = (services: Element[]): string | undefined => + services + .filter((s) => s.getAttribute('Binding') === REDIRECT_BINDING) + .map((s) => s.getAttribute('Location')) + .find((location): location is string => !!location && isHttpUrl(location)); + +const extractEntryPoint = (idp: Element): ExtractedValue => { + const value = findRedirectLocation(childrenOf(idp, 'SingleSignOnService')); + const warning = value ? undefined : 'SAML_Metadata_warning_no_redirect_binding'; + + return { value, warning }; +}; + +const extractSloUrl = (idp: Element): ExtractedValue => { + const services = childrenOf(idp, 'SingleLogoutService'); + if (!services.length) { + return {}; + } + + const value = findRedirectLocation(services); + const warning = value ? undefined : 'SAML_Metadata_warning_no_slo_redirect_binding'; + + return { value, warning }; +}; + +const extractIdentifierFormat = (idp: Element): ExtractedValue => { + const formats = childrenOf(idp, 'NameIDFormat') + .map((n) => n.textContent?.trim()) + .filter((v): v is string => !!v); + + const warning = formats.length > 1 ? 'SAML_Metadata_warning_multiple_nameid_formats' : undefined; + + return { + value: formats[0], + warning, + }; +}; + +export function parseIdpMetadata(xml: string): IdpMetadataResult { + const idp = parseIdpDescriptor(xml); + + const cert = extractSigningCert(idp); + const entryPoint = extractEntryPoint(idp); + const sloUrl = extractSloUrl(idp); + const identifierFormat = extractIdentifierFormat(idp); + const warnings = [cert, entryPoint, sloUrl, identifierFormat].flatMap(({ warning }) => warning ?? []); + + return { + cert: cert.value, + entryPoint: entryPoint.value, + idpSLORedirectURL: sloUrl.value, + identifierFormat: identifierFormat.value, + warnings, + }; +} diff --git a/apps/meteor/tests/end-to-end/api/SAML.ts b/apps/meteor/tests/end-to-end/api/SAML.ts new file mode 100644 index 0000000000000..fce4e037b8d4d --- /dev/null +++ b/apps/meteor/tests/end-to-end/api/SAML.ts @@ -0,0 +1,50 @@ +import { expect } from 'chai'; +import { after, before, describe, it } from 'mocha'; +import type { Response } from 'supertest'; + +import { getCredentials, api, request, credentials } from '../../data/api-data'; +import { updatePermission } from '../../data/permissions.helper'; + +describe('SAML', () => { + before((done) => getCredentials(done)); + + describe('[/saml.parseMetadata]', () => { + after(() => updatePermission('test-admin-options', ['admin'])); + + it('should fail without the test-admin-options permission', async () => { + await updatePermission('test-admin-options', []); + await request + .post(api('saml.parseMetadata')) + .set(credentials) + .send({ url: 'https://example.com/metadata.xml' }) + .expect('Content-Type', 'application/json') + .expect(403); + await updatePermission('test-admin-options', ['admin']); + }); + + it('should reject a body without url', async () => { + await request + .post(api('saml.parseMetadata')) + .set(credentials) + .send({}) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + }); + }); + + it('should reject a URL blocked by SSRF protection', async () => { + await request + .post(api('saml.parseMetadata')) + .set(credentials) + .send({ url: 'http://169.254.169.254/latest/meta-data' }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + expect(res.body.error).to.equal('SAML_Metadata_url_blocked'); + }); + }); + }); +}); diff --git a/apps/meteor/tests/unit/server/lib/saml/idpMetadata.spec.ts b/apps/meteor/tests/unit/server/lib/saml/idpMetadata.spec.ts new file mode 100644 index 0000000000000..ceed5a9e38bfe --- /dev/null +++ b/apps/meteor/tests/unit/server/lib/saml/idpMetadata.spec.ts @@ -0,0 +1,225 @@ +import { expect } from 'chai'; +import { describe, it } from 'mocha'; + +import { InvalidIdpMetadataError, parseIdpMetadata } from '../../../../../server/lib/saml/lib/parsers/IdpMetadata'; + +// Real self-signed cert (CN=idp.test) — the parser validates X.509, so fixtures must be real certs. +const TEST_CERT = + 'MIIDBzCCAe+gAwIBAgIUZhaSm8CbG7FmgCQ2wi7+HLFQMokwDQYJKoZIhvcNAQELBQAwEzERMA8GA1UEAwwIaWRwLnRlc3QwHhcNMjYwNzE4MTk1NzIwWhcNMzYwNzE1MTk1NzIwWjATMREwDwYDVQQDDAhpZHAudGVzdDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALbr9h69QN4dqnBTjnV7lWotx4FN2cktfL0Y2qVwQjw3q+OylaKCzio7Kvp2V2sQDsKDNgJnsLLP5TNjWqkXCfbbXRP/Iz3xlyyOoLNYJrZA4Sqn9/dFy6Chq5FSrMTwzqPCxx3nVDy/EpGUMknG7p3B0Ix18YFxQLsN5a/MpZXslrCusdl2LLnYkp6ztp44ZZlXHIaQhzeGnAZqzshvARAY9Ur41h4nSzpCgKVGACSi4LWRJeLc8/IXF+JM2MOR4GInCQLb1z31QPRRZ+3yWH3vKIfZ5YkPF6T0uPYw3hmhe0p7ECdOcSfckyeNT0WnvT8WKmeuOAnmmfqYPtgGBrkCAwEAAaNTMFEwHQYDVR0OBBYEFKQ/LDp2SiRg1jnOLLn/EkunXgaSMB8GA1UdIwQYMBaAFKQ/LDp2SiRg1jnOLLn/EkunXgaSMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEBACGBYnhbQXkSTerNUN0Wu2sy1MfFUzMYTP3SaXaCMJQhlZXUxtnu1PYTuqCgNkgaiV6b/KUBLVkqX4BaDmP0O5SI3di/uYlAJsBUsDCdaXToKsnjpf9tMvci2TGmPMZMDx9Jbr37G0NR9vewTOFvTkcjBQoXVu5oFbr75EDmxu3hqe8KHiavnX8C57zzpZ8kn37ScP+0Zadu0VYCtKEzfNKp48rCOF3BtYugGcxQWdcvbqurNF9Fyk9laSl8cTLHFeDe6zdWig32n3nHzhANOcezQ/wsUY5XUfUpRUl90rend7zqNq0tFZzOiZDl1MjCs7HCYtTRKEkuwlxgXdvP5Tg='; + +// Second distinct self-signed cert (CN=idp2.test), generated the same way, used to prove +// document-order-first selection among multiple KeyDescriptors. +const TEST_CERT_2 = + 'MIIDCTCCAfGgAwIBAgIUS4xN3PfQV0ROnf5nSMMFrN90pmQwDQYJKoZIhvcNAQELBQAwFDESMBAGA1UEAwwJaWRwMi50ZXN0MB4XDTI2MDcxODIwMTAzN1oXDTM2MDcxNTIwMTAzN1owFDESMBAGA1UEAwwJaWRwMi50ZXN0MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAvAdfR0s3uE/Rnv59RRTwNRuYVTgJoBMoP+xxMhlm8FfZZTsBqv3vt2eqyLaxnChpS+UWnpf56zZpFrNgEAH+dEFPIkhqRL7nUmKSOCXu3nCTS5K03PEWJzbWcjgtbZ6A7Q7BE9bRmBqkkbUi22XQML06A2GIzPE8xliVhnYH5q4GRNIYf6Eh0eV88XZrsXvYgHC46/O/MUMLV5a6tCq2SPvbBKJSI01YQcE/W/C7NtiHUnmkpvWFrJ7Wn2akAwVeA8rL5SJ2rMbO1CB9JgaspjOGdkLqRc2LsZDO2Fmsk1BS6EGaEnxFIggq/LwQmdeu0nK8bmwLZOAB1MgGXbBq/wIDAQABo1MwUTAdBgNVHQ4EFgQUfgd/rj1KGMuiBR7byOQU0zl/bAMwHwYDVR0jBBgwFoAUfgd/rj1KGMuiBR7byOQU0zl/bAMwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAGxdEIVNeoN+BV82FbNGmFPXiQRO1FZk0pUqPczQ5TbN8XvTFWfY2YeawgVn0ec6+eA1M97znDAM5KPF+9RHdk5HRrjS+BX+uJcFKlbWEbZJShk4PookxLq6ELZQ5HPSGgGvyqSueeAI0RMg6aBcZOIzsVJduSeOQNAmkvppN6rTryFVBHvkyI9qYLu9bxZW/BvyUfXmvU+yPRDa98s/WzQowIKktNPTCoxwn6KfGLlmeH5nm9ra48aRKFCXuSVhiuph3lmD4IfbUDYijpfe3kLsmp1Up9bhVH7WUiyT/sD2NFBZp60UAohjWGolNXhosrUOELBiZVuR75gvLmLFV5Q=='; + +const REDIRECT = 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect'; +const POST = 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST'; + +const keyDescriptor = (cert: string, use = 'signing') => ` + + + ${cert} + + `; + +const metadata = ({ + keys = keyDescriptor(TEST_CERT), + slo = ``, + sso = ``, + nameIdFormats = '', +} = {}) => ` + + + ${keys} + ${nameIdFormats} + ${slo} + ${sso} + +`; + +describe('parseIdpMetadata', () => { + it('extracts cert, entry point and SLO url from typical metadata', () => { + const result = parseIdpMetadata(metadata()); + expect(result.cert).to.be.a('string').and.to.include(TEST_CERT.substring(0, 40)); + expect(result.entryPoint).to.equal('https://idp.test/sso'); + expect(result.idpSLORedirectURL).to.equal('https://idp.test/slo'); + expect(result.warnings).to.deep.equal([]); + }); + + it('omits the SLO url without warning when metadata has no SingleLogoutService', () => { + const result = parseIdpMetadata(metadata({ slo: '' })); + expect(result.idpSLORedirectURL).to.be.undefined; + expect(result.entryPoint).to.equal('https://idp.test/sso'); + expect(result.warnings).to.not.include('SAML_Metadata_warning_no_slo_redirect_binding'); + }); + + it('uses the first signing cert (document order) and warns when there are multiple', () => { + const result = parseIdpMetadata(metadata({ keys: keyDescriptor(TEST_CERT) + keyDescriptor(TEST_CERT_2) })); + expect(result.cert).to.be.a('string').and.to.include(TEST_CERT.substring(0, 40)); + expect(result.cert).to.not.include(TEST_CERT_2.substring(0, 40)); + expect(result.warnings).to.include('SAML_Metadata_warning_multiple_certs'); + }); + + it('accepts a KeyDescriptor without a use attribute', () => { + const result = parseIdpMetadata(metadata({ keys: keyDescriptor(TEST_CERT, '') })); + expect(result.cert).to.be.a('string'); + }); + + it('skips a KeyDescriptor whose content is not a valid X.509 certificate and warns', () => { + const result = parseIdpMetadata(metadata({ keys: keyDescriptor('aGVsbG8gd29ybGQ=') })); + expect(result.cert).to.be.undefined; + expect(result.warnings).to.include('SAML_Metadata_warning_no_valid_cert'); + }); + + it('does not warn about multiple certs when only one of them is valid', () => { + const result = parseIdpMetadata(metadata({ keys: keyDescriptor('aGVsbG8gd29ybGQ=') + keyDescriptor(TEST_CERT_2) })); + expect(result.cert).to.be.a('string').and.to.include(TEST_CERT_2.substring(0, 40)); + expect(result.warnings).to.not.include('SAML_Metadata_warning_multiple_certs'); + expect(result.warnings).to.not.include('SAML_Metadata_warning_no_valid_cert'); + }); + + it('warns that no valid cert was found when every KeyDescriptor is invalid', () => { + const result = parseIdpMetadata(metadata({ keys: keyDescriptor('aGVsbG8gd29ybGQ=') + keyDescriptor('bm90IGEgY2VydA==') })); + expect(result.cert).to.be.undefined; + expect(result.warnings).to.include('SAML_Metadata_warning_no_valid_cert'); + expect(result.warnings).to.not.include('SAML_Metadata_warning_multiple_certs'); + }); + + it('omits the entry point when only HTTP-POST SSO bindings exist', () => { + const result = parseIdpMetadata(metadata({ sso: `` })); + expect(result.entryPoint).to.be.undefined; + expect(result.warnings).to.include('SAML_Metadata_warning_no_redirect_binding'); + }); + + it('skips a Redirect SSO service with an invalid Location and uses the next valid one', () => { + const sso = ` + + `; + const result = parseIdpMetadata(metadata({ sso })); + expect(result.entryPoint).to.equal('https://idp.test/sso-2'); + expect(result.warnings).to.not.include('SAML_Metadata_warning_no_redirect_binding'); + }); + + it('omits the entry point and warns when the only Redirect SSO Location is not http(s)', () => { + const sso = ``; + const result = parseIdpMetadata(metadata({ sso })); + expect(result.entryPoint).to.be.undefined; + expect(result.warnings).to.include('SAML_Metadata_warning_no_redirect_binding'); + }); + + it('omits the SLO url and warns when no SingleLogoutService uses the HTTP-Redirect binding', () => { + const slo = ``; + const result = parseIdpMetadata(metadata({ slo })); + expect(result.idpSLORedirectURL).to.be.undefined; + expect(result.warnings).to.include('SAML_Metadata_warning_no_slo_redirect_binding'); + }); + + it('picks the Redirect SingleLogoutService even when a POST one comes first', () => { + const slo = ` + + `; + const result = parseIdpMetadata(metadata({ slo })); + expect(result.idpSLORedirectURL).to.equal('https://idp.test/slo-redirect'); + expect(result.warnings).to.not.include('SAML_Metadata_warning_no_slo_redirect_binding'); + }); + + it('ignores services nested under Extensions instead of declared directly on the descriptor', () => { + const sso = ` + + + + + `; + const result = parseIdpMetadata(metadata({ sso, slo: '' })); + expect(result.entryPoint).to.equal('https://idp.test/sso'); + expect(result.idpSLORedirectURL).to.be.undefined; + }); + + it('omits the entry point and warns when there is no SingleSignOnService element at all', () => { + const result = parseIdpMetadata(metadata({ sso: '' })); + expect(result.entryPoint).to.be.undefined; + expect(result.warnings).to.include('SAML_Metadata_warning_no_redirect_binding'); + }); + + it('extracts the NameIDFormat', () => { + const result = parseIdpMetadata( + metadata({ nameIdFormats: 'urn:oasis:names:tc:SAML:2.0:nameid-format:emailAddress' }), + ); + expect(result.identifierFormat).to.equal('urn:oasis:names:tc:SAML:2.0:nameid-format:emailAddress'); + expect(result.warnings).to.not.include('SAML_Metadata_warning_multiple_nameid_formats'); + }); + + it('uses the first NameIDFormat (document order) and warns when there are multiple', () => { + const nameIdFormats = ` + urn:oasis:names:tc:SAML:2.0:nameid-format:emailAddress + urn:oasis:names:tc:SAML:2.0:nameid-format:transient`; + const result = parseIdpMetadata(metadata({ nameIdFormats })); + expect(result.identifierFormat).to.equal('urn:oasis:names:tc:SAML:2.0:nameid-format:emailAddress'); + expect(result.warnings).to.include('SAML_Metadata_warning_multiple_nameid_formats'); + }); + + it('picks the SAML 2.0 role when another protocol comes first', () => { + const mixedRoles = ` + + + + + + + +`; + const result = parseIdpMetadata(mixedRoles); + expect(result.entryPoint).to.equal('https://mixed.test/saml2/sso'); + }); + + it('rejects metadata whose only IDPSSODescriptor does not support SAML 2.0', () => { + const saml1Only = ` + + + + +`; + expect(() => parseIdpMetadata(saml1Only)).to.throw(InvalidIdpMetadataError); + }); + + it('rejects SP-only metadata (no IDPSSODescriptor)', () => { + const spOnly = ` + + +`; + expect(() => parseIdpMetadata(spOnly)).to.throw(InvalidIdpMetadataError); + }); + + it('rejects a document whose root is not EntityDescriptor', () => { + expect(() => parseIdpMetadata('nope')).to.throw(InvalidIdpMetadataError); + }); + + it('rejects a federation aggregate wrapping EntityDescriptors', () => { + const aggregate = ` + + + + + + +`; + expect(() => parseIdpMetadata(aggregate)).to.throw(InvalidIdpMetadataError); + }); + + it('rejects an otherwise-valid document with an unclosed inner element', () => { + // xmldom auto-closes the unclosed into a structurally-complete document (every guard + // passes), so this only throws because the parser treats the `warning` it emits as fatal. + const unclosedInner = ` + + + + + ${TEST_CERT} + + + + +`; + expect(() => parseIdpMetadata(unclosedInner)).to.throw(InvalidIdpMetadataError); + }); +}); diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index 5117925e00379..ae0bc80a9be37 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -4857,6 +4857,7 @@ "SAML_General": "General", "SAML_Identifier_Format": "Identifier Format", "SAML_Identifier_Format_Description": "Leave this empty to omit the NameID Policy from the request.", + "SAML_Import_metadata": "Import IdP metadata", "SAML_LogoutRequest_Template": "Logout Request Template", "SAML_LogoutRequest_Template_Description": "The following variables are available: \n- *\\_\\_newId\\_\\_*: Randomly generated id string \n- *\\_\\_instant\\_\\_*: Current timestamp \n- *\\_\\_idpSLORedirectURL\\_\\_*: The IDP Single LogOut URL to redirect to. \n- *\\_\\_issuer\\_\\_*: The value of the *Custom Issuer* setting. \n- *\\_\\_identifierFormat\\_\\_*: The value of the *Identifier Format* setting. \n- *\\_\\_nameID\\_\\_*: The NameID received from the IdP when the user logged in. \n- *\\_\\_sessionIndex\\_\\_*: The sessionIndex received from the IdP when the user logged in.", "SAML_LogoutResponse_Template": "Logout Response Template", @@ -4865,6 +4866,21 @@ "SAML_Metadata_Certificate_Template_Description": "The following variables are available: \n- *\\_\\_certificate\\_\\_*: The private certificate for assertion encryption.", "SAML_Metadata_Template": "Metadata Template", "SAML_Metadata_Template_Description": "The following variables are available: \n- *\\_\\_sloLocation\\_\\_*: The Rocket.Chat Single LogOut URL. \n- *\\_\\_issuer\\_\\_*: The value of the *Custom Issuer* setting. \n- *\\_\\_identifierFormat\\_\\_*: The value of the *Identifier Format* setting. \n- *\\_\\_certificateTag\\_\\_*: If a private certificate is configured, this will include the *Metadata Certificate Template*, otherwise it will be ignored. \n- *\\_\\_callbackUrl\\_\\_*: The Rocket.Chat callback URL.", + "SAML_Metadata_applied": "Values applied to the SAML settings. Review them and click Save changes.", + "SAML_Metadata_fetch": "Fetch metadata", + "SAML_Metadata_fetch_failed": "Could not fetch the IdP metadata. Check the URL and try again.", + "SAML_Metadata_invalid": "The document is not valid SAML IdP metadata.", + "SAML_Metadata_modal_description": "Enter the URL of your Identity Provider metadata document. The certificate, entry point and single logout URL will be extracted for you to review before saving.", + "SAML_Metadata_no_values": "No importable values were found in the metadata.", + "SAML_Metadata_not_found": "Not found in metadata", + "SAML_Metadata_too_large": "The metadata document is too large.", + "SAML_Metadata_url": "Metadata URL", + "SAML_Metadata_url_blocked": "This URL is blocked by SSRF protection. If your IdP is hosted on a private network, add its host to the SSRF allowlist in Admin > General > SSRF Protection.", + "SAML_Metadata_warning_multiple_certs": "The metadata lists more than one valid signing certificate; the first one was used.", + "SAML_Metadata_warning_multiple_nameid_formats": "The IdP publishes multiple NameID formats; the first one was used.", + "SAML_Metadata_warning_no_redirect_binding": "No usable HTTP-Redirect single sign-on URL was found in the metadata.", + "SAML_Metadata_warning_no_valid_cert": "No valid signing certificate was found in the metadata.", + "SAML_Metadata_warning_no_slo_redirect_binding": "No usable HTTP-Redirect single logout URL was found in the metadata, so the IDP SLO Redirect URL was left unchanged.", "SAML_NameIdPolicy_Template": "NameID Policy Template", "SAML_NameIdPolicy_Template_Description": "You can use any variable from the Authorize Request Template here.", "SAML_Role_Attribute_Name": "Role Attribute Name", diff --git a/packages/rest-typings/src/index.ts b/packages/rest-typings/src/index.ts index c5798ee9a7743..4694a86ade68a 100644 --- a/packages/rest-typings/src/index.ts +++ b/packages/rest-typings/src/index.ts @@ -238,6 +238,7 @@ export type * from './helpers/WithItemCount'; export * from './v1/emojiCustom'; export type * from './v1/instances'; export * from './v1/ldap'; +export * from './v1/saml'; export * from './v1/users'; export * from './v1/users/UsersSetAvatarParamsPOST'; export * from './v1/users/UsersSetPreferenceParamsPOST'; diff --git a/packages/rest-typings/src/v1/saml.ts b/packages/rest-typings/src/v1/saml.ts new file mode 100644 index 0000000000000..a50ee827f0275 --- /dev/null +++ b/packages/rest-typings/src/v1/saml.ts @@ -0,0 +1,45 @@ +import { ajv } from './Ajv'; + +type SamlParseMetadataProps = { + url: string; +}; + +const samlParseMetadataPropsSchema = { + type: 'object', + properties: { + url: { + type: 'string', + minLength: 1, + }, + }, + required: ['url'], + additionalProperties: false, +}; + +export const isSamlParseMetadata = ajv.compile(samlParseMetadataPropsSchema); + +type SamlParseMetadataResult = { + entryPoint?: string; + idpSLORedirectURL?: string; + cert?: string; + identifierFormat?: string; + warnings: string[]; +}; + +const samlParseMetadataSuccessResponseSchema = { + type: 'object', + properties: { + entryPoint: { type: 'string' }, + idpSLORedirectURL: { type: 'string' }, + cert: { type: 'string' }, + identifierFormat: { type: 'string' }, + warnings: { type: 'array', items: { type: 'string' } }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['warnings', 'success'], + additionalProperties: false, +}; + +export const validateSamlParseMetadataSuccessResponse = ajv.compile( + samlParseMetadataSuccessResponseSchema, +); diff --git a/packages/server-fetch/src/index.ts b/packages/server-fetch/src/index.ts index 73f2dd2970f50..e42064f446a5a 100644 --- a/packages/server-fetch/src/index.ts +++ b/packages/server-fetch/src/index.ts @@ -6,7 +6,7 @@ import { censorUrl } from '@rocket.chat/tools'; import { AbortController } from 'abort-controller'; import { HttpProxyAgent } from 'http-proxy-agent'; import { HttpsProxyAgent } from 'https-proxy-agent'; -import fetch, { Response } from 'node-fetch'; +import fetch, { FetchError, Response } from 'node-fetch'; import { getProxyForUrl } from 'proxy-from-env'; import { checkForSsrfWithIp, parseSsrfAllowlist } from './checkForSsrf'; @@ -193,6 +193,6 @@ export async function serverFetch(input: string, options?: ExtendedFetchOptions, throw new Error('error-processing-request'); } -export { Response }; +export { FetchError, Response }; export type { ExtendedFetchOptions }; export { parseSsrfAllowlist }; From 0e3f55511a46cb483b53afe08ef1f3cf95719c3b Mon Sep 17 00:00:00 2001 From: dougfabris Date: Wed, 19 Aug 2026 19:29:20 +0000 Subject: [PATCH 4/6] fix: Message list scroll position lost after switching channels and returning (#41805) Co-authored-by: gabriellsh <40830821+gabriellsh@users.noreply.github.com> --- .changeset/jolly-poets-tan.md | 5 ++ .../MessageList/hooks/useKeepAtBottom.spec.ts | 61 +++++++++++++++++++ .../client/views/room/body/RoomBody.tsx | 5 +- .../room/body/hooks/useIsAtBottomRef.spec.ts | 40 ++++++++++++ .../views/room/body/hooks/useIsAtBottomRef.ts | 9 +++ 5 files changed, 118 insertions(+), 2 deletions(-) create mode 100644 .changeset/jolly-poets-tan.md create mode 100644 apps/meteor/client/views/room/MessageList/hooks/useKeepAtBottom.spec.ts create mode 100644 apps/meteor/client/views/room/body/hooks/useIsAtBottomRef.spec.ts create mode 100644 apps/meteor/client/views/room/body/hooks/useIsAtBottomRef.ts 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/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); +}; From b89a8d411ef65f6931a5fd1cd057740bc00cd9ba Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Wed, 19 Aug 2026 20:47:40 +0000 Subject: [PATCH 5/6] feat: status visibility (#41747) Co-authored-by: Tasso Evangelista Co-authored-by: Kevin Aleman Co-authored-by: Claude Opus 5 (1M context) --- .changeset/odd-steaks-pull.md | 11 ++ .../UserAutoCompleteMultiple.tsx | 7 +- apps/meteor/client/lib/queryKeys.ts | 3 +- .../UserMenu/EditStatusVisibilityModal.tsx | 79 +++++++++ .../UserMenu/hooks/useStatusItems.tsx | 18 +- .../hooks/useStatusVisibilityModalHandler.tsx | 9 + .../account/profile/AccountProfileForm.tsx | 29 ++++ .../profile/getProfileInitialValues.ts | 2 + apps/meteor/jest.config.ts | 2 + apps/meteor/server/api/lib/getUserInfo.ts | 18 +- .../server/api/lib/queryFiltersStatus.spec.ts | 33 ++++ .../server/api/lib/queryFiltersStatus.ts | 11 ++ apps/meteor/server/api/v1/im.ts | 8 +- apps/meteor/server/api/v1/users.ts | 72 +++++--- .../lib/notifications/core/lib/Presence.ts | 89 +++++++++- .../statusVisibility/StatusVisibilityGate.ts | 58 +++++++ .../lib/statusVisibility/hiddenUsers.ts | 21 +++ .../lib/statusVisibility/redactStatus.ts | 7 + .../lib/statusVisibility/resolveUsers.spec.ts | 46 ++++++ .../lib/statusVisibility/resolveUsers.ts | 34 ++++ .../server/lib/users/getFullUserData.ts | 18 +- .../meteor-methods/users/getUserStatusText.ts | 5 +- .../users/saveUserPreferences.ts | 17 +- .../modules/listeners/listeners.module.ts | 51 ++++-- apps/meteor/server/publications/spotlight.ts | 5 +- apps/meteor/server/services/startup.ts | 2 + .../services/statusVisibility/service.spec.ts | 156 ++++++++++++++++++ .../services/statusVisibility/service.ts | 113 +++++++++++++ apps/meteor/server/settings/accounts.ts | 11 ++ ee/apps/ddp-streamer/tsconfig.json | 12 +- packages/core-services/src/events/Events.ts | 1 + packages/core-services/src/index.ts | 3 + .../src/types/IStatusVisibilityService.ts | 9 + .../core-typings/src/PresenceStatusCode.ts | 10 ++ packages/core-typings/src/index.ts | 2 +- packages/i18n/src/locales/en.i18n.json | 8 + .../model-typings/src/models/IUsersModel.ts | 3 + packages/models/src/models/Users.ts | 23 +++ .../v1/users/UsersSetPreferenceParamsPOST.ts | 5 + 39 files changed, 960 insertions(+), 51 deletions(-) create mode 100644 .changeset/odd-steaks-pull.md create mode 100644 apps/meteor/client/navbar/NavBarSettingsToolbar/UserMenu/EditStatusVisibilityModal.tsx create mode 100644 apps/meteor/client/navbar/NavBarSettingsToolbar/UserMenu/hooks/useStatusVisibilityModalHandler.tsx create mode 100644 apps/meteor/server/api/lib/queryFiltersStatus.spec.ts create mode 100644 apps/meteor/server/api/lib/queryFiltersStatus.ts create mode 100644 apps/meteor/server/lib/statusVisibility/StatusVisibilityGate.ts create mode 100644 apps/meteor/server/lib/statusVisibility/hiddenUsers.ts create mode 100644 apps/meteor/server/lib/statusVisibility/redactStatus.ts create mode 100644 apps/meteor/server/lib/statusVisibility/resolveUsers.spec.ts create mode 100644 apps/meteor/server/lib/statusVisibility/resolveUsers.ts create mode 100644 apps/meteor/server/services/statusVisibility/service.spec.ts create mode 100644 apps/meteor/server/services/statusVisibility/service.ts create mode 100644 packages/core-services/src/types/IStatusVisibilityService.ts 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/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/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