Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-anonymous-spotlight-callers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@rocket.chat/meteor': patch
---

Fixes room search (`spotlight`) failing for anonymous visitors when "Allow Anonymous Read" is enabled
7 changes: 7 additions & 0 deletions .changeset/fruity-views-begin.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/jolly-poets-tan.md
Original file line number Diff line number Diff line change
@@ -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.
11 changes: 11 additions & 0 deletions .changeset/odd-steaks-pull.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export type UserAutoCompleteMultipleProps = {
value: Array<string> | undefined;
placeholder?: string;
federated?: boolean;
exceptions?: string[];
error?: string;
} & Omit<AllHTMLAttributes<HTMLInputElement>, 'is' | 'onChange' | 'value'>;

Expand All @@ -30,18 +31,18 @@ type UserAutoCompleteOptions = {
const matrixRegex = new RegExp('@(.*:.*)');

const UserAutoCompleteMultiple = forwardRef<HTMLInputElement, UserAutoCompleteMultipleProps>(
({ onChange, value, placeholder, federated, ...props }, ref) => {
({ onChange, value, placeholder, federated, exceptions, ...props }, ref) => {
const [filter, setFilter] = useState('');
const [selectedCache, setSelectedCache] = useState<UserAutoCompleteOptions>({});

const debouncedFilter = useDebouncedValue(filter, 500);
const getUsers = useEndpoint('GET', '/v1/users.autocomplete');

const { data } = useQuery({
queryKey: usersQueryKeys.userAutoComplete(debouncedFilter, federated ?? false),
queryKey: usersQueryKeys.userAutoComplete(debouncedFilter, federated ?? false, exceptions),

queryFn: async () => {
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`
Expand Down
3 changes: 2 additions & 1 deletion apps/meteor/client/lib/queryKeys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<StatusVisibilityFormValues>({
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 (
<GenericModal
icon={null}
variant='warning'
title={t('Accounts_StatusVisibility_HideStatus')}
onCancel={onClose}
confirmText={t('Save')}
confirmDisabled={!isDirty || isSubmitting}
wrapperFunction={(props: ComponentProps<typeof Box>) => <Box is='form' onSubmit={handleSubmit(handleSave)} {...props} />}
>
<FieldGroup>
<Field>
<FieldLabel>{t('Accounts_StatusVisibility_HideFromUsers')}</FieldLabel>
<FieldRow>
<Controller
control={control}
name='statusVisibilityDenied'
render={({ field: { onChange, value } }) => (
<UserAutoCompleteMultiple
value={value}
onChange={onChange}
exceptions={user?.username ? [user.username] : undefined}
placeholder={t('Select_users')}
/>
)}
/>
</FieldRow>
<FieldHint>{t('Accounts_StatusVisibility_HideFromUsers_Description')}</FieldHint>
</Field>
</FieldGroup>
</GenericModal>
);
};

export default EditStatusVisibilityModal;
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<GenericMenuItemProps[]>(() => {
Expand Down Expand Up @@ -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,
Expand All @@ -170,6 +184,8 @@ export const useStatusItems = (user?: IUser): GenericMenuItemProps[] => {
customStatusExpiration,
statuses,
handleCustomStatus,
handleStatusVisibility,
statusVisibilityEnabled,
setStatusMutation,
]);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { useSetModal } from '@rocket.chat/ui-contexts';

import { EditStatusVisibilityModal } from '../EditStatusVisibilityModal';

export const useStatusVisibilityModalHandler = () => {
const setModal = useSetModal();

return () => setModal(<EditStatusVisibilityModal onClose={() => setModal(null)} />);
};
29 changes: 29 additions & 0 deletions apps/meteor/client/views/account/profile/AccountProfileForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand All @@ -44,6 +46,8 @@ const AccountProfileForm = (props: AllHTMLAttributes<HTMLFormElement>) => {
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');

Expand Down Expand Up @@ -138,6 +142,7 @@ const AccountProfileForm = (props: AllHTMLAttributes<HTMLFormElement>) => {
nickname,
bio,
customFields,
statusVisibilityDenied,
} = values;

const expiresAt = STATUS_DURATION_OPTIONS.find((o) => o.value === statusDuration)?.getExpiresAt?.({
Expand Down Expand Up @@ -165,6 +170,10 @@ const AccountProfileForm = (props: AllHTMLAttributes<HTMLFormElement>) => {
customFields,
});

if (dirtyFields.statusVisibilityDenied) {
await setPreferences({ data: { statusVisibilityDenied } });
}

if (statusDirty) {
await setUserStatus({
status: statusType,
Expand Down Expand Up @@ -338,6 +347,26 @@ const AccountProfileForm = (props: AllHTMLAttributes<HTMLFormElement>) => {
{errors.statusDuration && <FieldError>{errors.statusDuration.message}</FieldError>}
<FieldHint>{t('Status_new_status_warning')}</FieldHint>
</Field>
{statusVisibilityEnabled && (
<Field>
<FieldLabel>{t('Accounts_StatusVisibility_HideStatusFromUsers')}</FieldLabel>
<FieldRow>
<Controller
control={control}
name='statusVisibilityDenied'
render={({ field: { onChange, value } }) => (
<UserAutoCompleteMultiple
value={value}
onChange={onChange}
exceptions={user?.username ? [user.username] : undefined}
placeholder={t('Select_users')}
/>
)}
/>
</FieldRow>
<FieldHint>{t('Accounts_StatusVisibility_HideFromUsers_Description')}</FieldHint>
</Field>
)}
<Divider marginBlockStart={24} marginBlockEnd={0} />
<Field>
<FieldLabel>{t('Nickname')}</FieldLabel>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export type AccountProfileFormValues = {
bio: string;
customFields: Record<string, string>;
nickname: string;
statusVisibilityDenied: string[];
} & UserStatusInitialValues;

export const getProfileInitialValues = (user: IUser | null): AccountProfileFormValues => {
Expand All @@ -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),
};
};
Original file line number Diff line number Diff line change
Expand Up @@ -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'];
Expand All @@ -27,6 +28,10 @@ const SettingsGroupSelector = ({ groupId, onClickBack }: SettingsGroupSelectorPr
return <LDAPGroupPage {...group} onClickBack={onClickBack} />;
}

if (groupId === 'SAML') {
return <SAMLGroupPage {...group} onClickBack={onClickBack} />;
}

if (groupId === 'Assets') {
return <BaseGroupPage {...group} onClickBack={onClickBack} hasReset={false} />;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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(
<SamlMetadataModal
onClose={closeModal}
onFetch={(url) => parseMetadata({ url })}
onApply={handleApply}
showIdentifierFormat={identifierFormatSetting !== undefined}
/>,
);

return (
<BaseGroupPage
_id={_id}
i18nLabel={i18nLabel}
onClickBack={onClickBack}
{...group}
headerButtons={
<Button disabled={changed} onClick={handleImportClick}>
{t('SAML_Import_metadata')}
</Button>
}
/>
);
}

export default memo(SAMLGroupPage);
Loading
Loading