feat: support mapping users to exchange servers by domain (#36153)

This commit is contained in:
Matheus Cardoso 2025-06-18 01:17:20 -03:00 committed by GitHub
parent 8033bdb1f6
commit 3779de0e8c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 299 additions and 68 deletions

View File

@ -0,0 +1,7 @@
---
'@rocket.chat/core-typings': minor
'@rocket.chat/i18n': minor
'@rocket.chat/meteor': minor
---
Adds a new setting to override outlook calendar settings per user email domain

View File

@ -0,0 +1,196 @@
import { getUserInfo } from './getUserInfo';
import type { CachedSettings } from '../../../settings/server/CachedSettings';
jest.mock('@rocket.chat/models', () => ({
Users: {
findOneById: jest.fn().mockResolvedValue({
id: '123',
name: 'Test User',
emails: [{ address: 'test@example.com' }],
}),
},
}));
const settings = new Map<string, unknown>();
jest.mock('../../../settings/server', () => ({
settings: {
getByRegexp(_id) {
return [...settings].filter(([key]) => key.match(_id)) as any;
},
get(_id) {
return settings.get(_id) as any;
},
set(record) {
settings.set(record._id, record.value);
},
} satisfies Partial<CachedSettings>,
}));
// @ts-expect-error __meteor_runtime_config__ is not defined in the type definitions
global.__meteor_runtime_config__ = {
ROOT_URL: 'http://localhost:3000',
ROOT_URL_PATH_PREFIX: '',
};
describe('getUserInfo', () => {
let user: Parameters<typeof getUserInfo>[0];
beforeEach(() => {
settings.clear();
settings.set('Site_Url', 'http://localhost:3000');
user = {
_id: '123',
createdAt: new Date(),
roles: [],
type: 'user',
active: true,
_updatedAt: new Date(),
};
});
it('should return user info', async () => {
const userInfo = await getUserInfo(user);
expect(userInfo).toEqual(
expect.objectContaining({
_id: '123',
type: 'user',
roles: [],
active: true,
_updatedAt: expect.any(Date),
createdAt: expect.any(Date),
email: undefined,
avatarUrl: 'http://localhost:3000/avatar/undefined',
settings: {
calendar: {},
profile: {},
preferences: {},
},
}),
);
});
describe('email handling', () => {
it('should not include email if no emails are present', async () => {
user.emails = [];
const userInfo = await getUserInfo(user);
expect(userInfo.email).toBe(undefined);
});
it('should include email if one email is present and verified', async () => {
user.emails = [{ address: 'test@example.com', verified: true }];
const userInfo = await getUserInfo(user);
expect(userInfo.email).toEqual('test@example.com');
});
it('should not include email if one email is present and not verified', async () => {
user.emails = [{ address: 'test@example.com', verified: false }];
const userInfo = await getUserInfo(user);
expect(userInfo.email).toBe(undefined);
});
it('should include email if multiple emails are present and one is verified', async () => {
user.emails = [
{ address: 'test@example.com', verified: false },
{ address: 'test2@example.com', verified: true },
];
const userInfo = await getUserInfo(user);
expect(userInfo.email).toEqual('test2@example.com');
});
it('should not include email if multiple emails are present and none are verified', async () => {
user.emails = [
{ address: 'test@example.com', verified: false },
{ address: 'test2@example.com', verified: false },
];
const userInfo = await getUserInfo(user);
expect(userInfo.email).toBe(undefined);
});
});
describe('outlook calendar settings', () => {
beforeEach(() => {
settings.set('Outlook_Calendar_Enabled', true);
settings.set('Outlook_Calendar_Exchange_Url', 'https://outlook.office365.com/');
settings.set('Outlook_Calendar_Outlook_Url', 'https://outlook.office365.com/owa/#calendar/view/month');
settings.set('Outlook_Calendar_Url_Mapping', JSON.stringify({}));
user.emails = [{ address: 'test@example.com', verified: true }];
});
it('should return empty calendar settings if Outlook is disabled', async () => {
settings.set('Outlook_Calendar_Enabled', false);
const userInfo = await getUserInfo(user);
expect(userInfo.settings?.calendar).toEqual({});
});
it('should return calendar settings with Outlook enabled and default URLs', async () => {
const userInfo = await getUserInfo(user);
expect(userInfo.settings?.calendar?.outlook).toEqual({
Enabled: true,
Exchange_Url: 'https://outlook.office365.com/',
Outlook_Url: 'https://outlook.office365.com/owa/#calendar/view/month',
});
});
it('should return calendar settings with Outlook enabled and domain mapping', async () => {
settings.set(
'Outlook_Calendar_Url_Mapping',
JSON.stringify({
'example.com': { Exchange_Url: 'https://custom.exchange.com/', Outlook_Url: 'https://custom.outlook.com/' },
}),
);
const userInfo = await getUserInfo(user);
expect(userInfo.settings?.calendar).toEqual({
outlook: {
Enabled: true,
Exchange_Url: 'https://custom.exchange.com/',
Outlook_Url: 'https://custom.outlook.com/',
},
});
});
it('should return calendar settings with outlook disabled but enabled for specific domain', async () => {
settings.set('Outlook_Calendar_Enabled', false);
settings.set(
'Outlook_Calendar_Url_Mapping',
JSON.stringify({
'specific.com': { Enabled: true, Exchange_Url: 'https://specific.exchange.com/', Outlook_Url: 'https://specific.outlook.com/' },
}),
);
user.emails = [{ address: 'me@specific.com', verified: true }];
const userInfo = await getUserInfo(user);
expect(userInfo.settings?.calendar).toEqual({
outlook: {
Enabled: true,
Exchange_Url: 'https://specific.exchange.com/',
Outlook_Url: 'https://specific.outlook.com/',
},
});
});
it('should return calendar settings with Outlook enabled and default mapping for unknown domain', async () => {
user.emails = [{ address: 'unknown@example.com', verified: true }];
const userInfo = await getUserInfo(user);
expect(userInfo.settings?.calendar).toEqual({
outlook: {
Enabled: true,
Exchange_Url: 'https://outlook.office365.com/',
Outlook_Url: 'https://outlook.office365.com/owa/#calendar/view/month',
},
});
});
it('should handle invalid JSON in Outlook_Calendar_Url_Mapping', async () => {
settings.set('Outlook_Calendar_Url_Mapping', 'invalid json');
const userInfo = await getUserInfo(user);
expect(userInfo.settings?.calendar).toEqual({
outlook: {
Enabled: true,
Exchange_Url: 'https://outlook.office365.com/',
Outlook_Url: 'https://outlook.office365.com/owa/#calendar/view/month',
},
});
});
});
});

View File

@ -1,4 +1,4 @@
import { isOAuthUser, type IUser, type IUserEmail } from '@rocket.chat/core-typings';
import { isOAuthUser, type IUser, type IUserEmail, type IUserCalendar } from '@rocket.chat/core-typings';
import { settings } from '../../../settings/server';
import { getURL } from '../../../utils/server/getURL';
@ -25,13 +25,49 @@ const getUserPreferences = async (me: IUser): Promise<Record<string, unknown>> =
return accumulator;
};
/**
* Returns the user's calendar settings based on their email domain and the configured mapping.
* If the email is not provided or the domain is not found in the mapping,
* it returns the default Outlook calendar settings.
* @param email - The user's email object, which may contain the address and verification status.
* @returns The calendar settings for the user, including Outlook calendar settings if enabled.
*/
const getUserCalendar = (email: false | IUserEmail | undefined): IUserCalendar => {
const calendarSettings: IUserCalendar = {};
const outlook = {
Enabled: settings.get<boolean>('Outlook_Calendar_Enabled'),
Exchange_Url: settings.get<string>('Outlook_Calendar_Exchange_Url'),
Outlook_Url: settings.get<string>('Outlook_Calendar_Outlook_Url'),
};
const domain = email ? email.address.split('@').pop() : undefined;
const outlookCalendarUrlMapping = settings.get<string>('Outlook_Calendar_Url_Mapping');
if (domain && outlookCalendarUrlMapping && outlookCalendarUrlMapping.includes(domain)) {
try {
const mappingObject = JSON.parse(outlookCalendarUrlMapping);
const mappedSettings = mappingObject[domain];
if (mappedSettings) {
outlook.Enabled = mappedSettings.Enabled ?? outlook.Enabled;
outlook.Exchange_Url = mappedSettings.Exchange_Url ?? outlook.Exchange_Url;
outlook.Outlook_Url = mappedSettings.Outlook_Url ?? outlook.Outlook_Url;
}
} catch (error) {
console.error('Invalid Outlook Calendar URL Mapping JSON:', error);
}
}
if (outlook.Enabled) {
calendarSettings.outlook = outlook;
}
return calendarSettings;
};
export async function getUserInfo(me: IUser): Promise<
IUser & {
email?: string;
settings?: {
profile: Record<string, unknown>;
preferences: unknown;
};
avatarUrl: string;
}
> {
@ -48,6 +84,7 @@ export async function getUserInfo(me: IUser): Promise<
...(await getUserPreferences(me)),
...userPreferences,
},
calendar: getUserCalendar(verifiedEmail),
},
avatarUrl: getURL(`/avatar/${me.username}`, { cdn: false, full: true }),
isOAuthUser: isOAuthUser(me),

View File

@ -1,6 +1,6 @@
import type { ICalendarNotification, IUser } from '@rocket.chat/core-typings';
import { useEffectEvent } from '@rocket.chat/fuselage-hooks';
import { useSetting, useStream, useUserPreference } from '@rocket.chat/ui-contexts';
import { useStream, useUserPreference } from '@rocket.chat/ui-contexts';
import { useEffect } from 'react';
import { imperativeModal } from '../../lib/imperativeModal';
@ -8,7 +8,6 @@ import OutlookCalendarEventModal from '../../views/outlookCalendar/OutlookCalend
export const useNotificationUserCalendar = (user: IUser) => {
const requireInteraction = useUserPreference('desktopNotificationRequireInteraction');
const outLookEnabled = useSetting('Outlook_Calendar_Enabled');
const notifyUserStream = useStream('notify-user');
const notifyUserCalendar = useEffectEvent(async (notification: ICalendarNotification) => {
@ -34,10 +33,10 @@ export const useNotificationUserCalendar = (user: IUser) => {
});
useEffect(() => {
if (!user?._id || !outLookEnabled) {
if (!user?._id || !user.settings?.calendar?.outlook?.Enabled) {
return;
}
return notifyUserStream(`${user._id}/calendar`, notifyUserCalendar);
}, [notifyUserCalendar, notifyUserStream, outLookEnabled, user?._id]);
}, [notifyUserCalendar, notifyUserStream, user.settings?.calendar?.outlook?.Enabled, user?._id]);
};

View File

@ -1,4 +1,4 @@
import { useSetting } from '@rocket.chat/ui-contexts';
import { useUser } from '@rocket.chat/ui-contexts';
import { lazy, useMemo } from 'react';
import type { RoomToolboxActionConfig } from '../../views/room/contexts/RoomToolboxContext';
@ -6,10 +6,10 @@ import type { RoomToolboxActionConfig } from '../../views/room/contexts/RoomTool
const OutlookEventsRoute = lazy(() => import('../../views/outlookCalendar/OutlookEventsRoute'));
export const useOutlookCalenderRoomAction = () => {
const enabled = useSetting('Outlook_Calendar_Enabled', false);
const user = useUser();
return useMemo((): RoomToolboxActionConfig | undefined => {
if (!enabled) {
if (!user?.settings?.calendar?.outlook?.Enabled) {
return undefined;
}
@ -21,5 +21,5 @@ export const useOutlookCalenderRoomAction = () => {
tabComponent: OutlookEventsRoute,
order: 999,
};
}, [enabled]);
}, [user?.settings?.calendar?.outlook?.Enabled]);
};

View File

@ -2,7 +2,7 @@ import type { INotificationDesktop } from '@rocket.chat/core-typings';
import type { SelectOption } from '@rocket.chat/fuselage';
import { AccordionItem, Button, Field, FieldGroup, FieldHint, FieldLabel, FieldRow, Select, ToggleSwitch } from '@rocket.chat/fuselage';
import type { TranslationKey } from '@rocket.chat/ui-contexts';
import { useSetting, useUserPreference } from '@rocket.chat/ui-contexts';
import { useSetting, useUserPreference, useUser } from '@rocket.chat/ui-contexts';
import { useCallback, useEffect, useId, useMemo, useState } from 'react';
import { Controller, useFormContext } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
@ -22,6 +22,7 @@ const emailNotificationOptionsLabelMap = {
const PreferencesNotificationsSection = () => {
const { t, i18n } = useTranslation();
const user = useUser();
const [notificationsPermission, setNotificationsPermission] = useState<NotificationPermission>();
@ -36,7 +37,6 @@ const PreferencesNotificationsSection = () => {
const loginEmailEnabled = useSetting('Device_Management_Enable_Login_Emails');
const allowLoginEmailPreference = useSetting('Device_Management_Allow_Login_Email_preference');
const showNewLoginEmailPreference = loginEmailEnabled && allowLoginEmailPreference;
const showCalendarPreference = useSetting('Outlook_Calendar_Enabled');
const showMobileRinging = useSetting('VideoConf_Mobile_Ringing');
const notify = useNotification();
@ -95,6 +95,8 @@ const PreferencesNotificationsSection = () => {
const enableMobileRingingId = useId();
const desktopNotificationsLabelId = useId();
const showCalendarPreference = user?.settings?.calendar?.outlook?.Enabled;
return (
<AccordionItem title={t('Notifications')}>
<FieldGroup>

View File

@ -1,6 +1,6 @@
import { Box, States, StatesIcon, StatesTitle, StatesSubtitle, ButtonGroup, Button, Throbber } from '@rocket.chat/fuselage';
import { useResizeObserver } from '@rocket.chat/fuselage-hooks';
import { useTranslation, useSetting } from '@rocket.chat/ui-contexts';
import { useTranslation, useUser } from '@rocket.chat/ui-contexts';
import type { ReactElement } from 'react';
import { Virtuoso } from 'react-virtuoso';
@ -27,7 +27,7 @@ type OutlookEventsListProps = {
const OutlookEventsList = ({ onClose, changeRoute }: OutlookEventsListProps): ReactElement => {
const t = useTranslation();
const outlookUrl = useSetting('Outlook_Calendar_Outlook_Url', '');
const user = useUser();
const { authEnabled, isError, error } = useOutlookAuthentication();
const hasOutlookMethods = !(isError && error instanceof NotOnDesktopError);
@ -43,6 +43,8 @@ const OutlookEventsList = ({ onClose, changeRoute }: OutlookEventsListProps): Re
const calendarEvents = calendarListResult.data;
const total = calendarEvents?.length || 0;
const outlookUrl = user?.settings?.calendar?.outlook?.Outlook_Url;
return (
<ContextualbarDialog>
<ContextualbarHeader>

View File

@ -18,12 +18,14 @@ export function addSettings(): void {
type: 'string',
public: true,
invalidValue: '',
placeholder: 'https://exchange.example.com/',
});
await this.add('Outlook_Calendar_Outlook_Url', '', {
type: 'string',
public: true,
invalidValue: '',
placeholder: 'https://exchange.example.com/owa/#path=/calendar/view/Month',
});
await this.add(
@ -41,6 +43,23 @@ export function addSettings(): void {
public: true,
invalidValue: false,
});
/**
* const defaultMapping = {
* 'rocket.chat': {
* Enabled: true,
* Exchange_Url: 'https://owa.dev.rocket.chat/',
* Outlook_Url: 'https://owa.dev.rocket.chat/owa/#path=/calendar'
* },
* };
*/
await this.add('Outlook_Calendar_Url_Mapping', '{}', {
type: 'code',
multiline: true,
public: true,
code: 'application/json',
invalidValue: '{}',
});
},
);
});

View File

@ -41,6 +41,7 @@ export default {
'<rootDir>/server/lib/auditServerEvents/**.spec.ts',
'<rootDir>/server/cron/**.spec.ts',
'<rootDir>/app/api/server/**.spec.ts',
'<rootDir>/app/api/server/helpers/**.spec.ts',
'<rootDir>/app/api/server/middlewares/**.spec.ts',
],
coveragePathIgnorePatterns: ['/node_modules/'],

View File

@ -1,45 +0,0 @@
import type { ICalendarEvent, IUser, UserStatus } from '@rocket.chat/core-typings';
import { cronJobs } from '@rocket.chat/cron';
import { applyStatusChange } from './applyStatusChange';
import { generateCronJobId } from './generateCronJobId';
import { handleOverlappingEvents } from './handleOverlappingEvents';
import { settings } from '../../../../app/settings/server';
export async function setupAppointmentStatusChange(
eventId: ICalendarEvent['_id'],
uid: IUser['_id'],
startTime: Date,
endTime?: Date,
status?: UserStatus,
shouldScheduleRemoval?: boolean,
): Promise<void> {
const hasBusyStatusSetting = settings.get<boolean>('Calendar_BusyStatus_Enabled');
if (!endTime || !hasBusyStatusSetting) {
return;
}
if (shouldScheduleRemoval) {
const { shouldProceed } = await handleOverlappingEvents(eventId, uid, startTime, endTime, status);
if (!shouldProceed) {
return;
}
}
const scheduledTime = shouldScheduleRemoval ? startTime : endTime;
const cronJobId = generateCronJobId(eventId, uid, 'status');
if (await cronJobs.has(cronJobId)) {
await cronJobs.remove(cronJobId);
}
await cronJobs.addAtTimestamp(cronJobId, scheduledTime, async () => {
await applyStatusChange({ eventId, uid, startTime, endTime, status, shouldScheduleRemoval });
if (!shouldScheduleRemoval) {
if (await cronJobs.has('calendar-next-status-change')) {
await cronJobs.remove('calendar-next-status-change');
}
}
});
}

View File

@ -157,11 +157,20 @@ export interface IUserEmail {
verified?: boolean;
}
export interface IOutlook {
Enabled: boolean;
Exchange_Url: string;
Outlook_Url: string;
}
export interface IUserCalendar {
outlook?: IOutlook;
}
export interface IUserSettings {
profile?: any;
preferences?: {
[key: string]: any;
};
profile?: Record<string, unknown>;
preferences?: Record<string, any>;
calendar?: IUserCalendar;
}
export interface IGetRoomRoles {

View File

@ -883,6 +883,8 @@
"CROWD_Allow_Custom_Username": "Allow custom username in Rocket.Chat",
"CROWD_Reject_Unauthorized": "Reject Unauthorized",
"CSV": "CSV",
"Calendar_BusyStatus_Enabled": "Outlook calendar status sync",
"Calendar_BusyStatus_Enabled_Description": "Automatically sets user status to busy during scheduled Outlook meetings and returns it to the previous status afterward.",
"Calendar_MeetingUrl_Regex": "Meeting url Regular Expression",
"Calendar_MeetingUrl_Regex_Description": "Expression used to detect meeting URLs in event descriptions. The first matching group with a valid url will be used. HTML encoded urls will be decoded automatically.",
"Calendar_settings": "Calendar settings",
@ -3836,9 +3838,11 @@
"Outlook_Calendar": "Outlook Calendar",
"Outlook_Calendar_Enabled": "Enabled",
"Outlook_Calendar_Exchange_Url": "Exchange URL",
"Outlook_Calendar_Exchange_Url_Description": "Host URL for the EWS api.",
"Outlook_Calendar_Exchange_Url_Description": "Default Host URL for the EWS api.",
"Outlook_Calendar_Outlook_Url": "Outlook URL",
"Outlook_Calendar_Outlook_Url_Description": "URL used to launch the Outlook web app.",
"Outlook_Calendar_Outlook_Url_Description": "Default URL used to launch the Outlook web app.",
"Outlook_Calendar_Url_Mapping": "Domain to Outlook URL mapping",
"Outlook_Calendar_Url_Mapping_Description": "Map the domain of the calendar to the Outlook URL. This is useful if you have multiple domains and want to use different Outlook URLs for each.",
"Outlook_Sync_Failed": "Failed to load outlook events.",
"Outlook_Sync_Success": "Outlook events synchronized.",
"Outlook_authentication": "Outlook authentication",