Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
dccd547
feat: map users to exchange servers by domain
cardoso Jun 4, 2025
6928825
wip: open outlook
cardoso Jun 4, 2025
57aa970
Merge branch 'develop' of https://github.com/RocketChat/Rocket.Chat i…
cardoso Jun 5, 2025
935067e
chore: remove dead code
cardoso Jun 5, 2025
18e537b
Merge branch 'develop' of https://github.com/RocketChat/Rocket.Chat i…
cardoso Jun 5, 2025
04ccc98
wip: map all outlook settings to domain
cardoso Jun 5, 2025
3bd6351
Merge branch 'develop' of https://github.com/RocketChat/Rocket.Chat i…
cardoso Jun 5, 2025
772b51f
fix: typechecking
cardoso Jun 5, 2025
3eee286
Merge branch 'develop' of https://github.com/RocketChat/Rocket.Chat i…
cardoso Jun 5, 2025
94d0cde
Merge branch 'develop' into support-multiple-exchange-servers-CONN-674
cardoso Jun 6, 2025
00df1c8
Merge branch 'develop' of https://github.com/RocketChat/Rocket.Chat i…
cardoso Jun 6, 2025
e168a27
wip: send only necessary calendar user info
cardoso Jun 6, 2025
39878af
Merge branch 'develop' of https://github.com/RocketChat/Rocket.Chat i…
cardoso Jun 9, 2025
3d4adb7
Merge branch 'develop' of https://github.com/RocketChat/Rocket.Chat i…
cardoso Jun 9, 2025
4f3fef1
wip: simplify & revert some type definitions
cardoso Jun 9, 2025
57eaafa
fix: user calendar mapping
cardoso Jun 10, 2025
a98cc5d
Merge branch 'develop' of https://github.com/RocketChat/Rocket.Chat i…
cardoso Jun 10, 2025
ba9e63b
Merge branch 'develop' of https://github.com/RocketChat/Rocket.Chat i…
cardoso Jun 10, 2025
5a97f24
Merge branch 'develop' of https://github.com/RocketChat/Rocket.Chat i…
cardoso Jun 13, 2025
32dcfce
test: getUserInfo
cardoso Jun 13, 2025
b388a32
Merge branch 'develop' into support-multiple-exchange-servers-CONN-674
cardoso Jun 13, 2025
2fda15e
Merge branch 'develop' into support-multiple-exchange-servers-CONN-674
cardoso Jun 16, 2025
f25d0eb
chore: add changeset
cardoso Jun 16, 2025
a3d8154
Merge branch 'develop' of https://github.com/RocketChat/Rocket.Chat i…
cardoso Jun 16, 2025
0650d91
Merge branch 'develop' into support-multiple-exchange-servers-CONN-674
kodiakhq[bot] Jun 17, 2025
5356317
Merge branch 'develop' into support-multiple-exchange-servers-CONN-674
cardoso Jun 17, 2025
7328c24
Merge branch 'develop' into support-multiple-exchange-servers-CONN-674
cardoso Jun 18, 2025
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
7 changes: 7 additions & 0 deletions .changeset/thirty-lemons-talk.md
Original file line number Diff line number Diff line change
@@ -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
196 changes: 196 additions & 0 deletions apps/meteor/app/api/server/helpers/getUserInfo.spec.ts
Original file line number Diff line number Diff line change
@@ -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: '[email protected]' }],
}),
},
}));

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: '[email protected]', verified: true }];
const userInfo = await getUserInfo(user);
expect(userInfo.email).toEqual('[email protected]');
});

it('should not include email if one email is present and not verified', async () => {
user.emails = [{ address: '[email protected]', 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: '[email protected]', verified: false },
{ address: '[email protected]', verified: true },
];
const userInfo = await getUserInfo(user);
expect(userInfo.email).toEqual('[email protected]');
});

it('should not include email if multiple emails are present and none are verified', async () => {
user.emails = [
{ address: '[email protected]', verified: false },
{ address: '[email protected]', 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: '[email protected]', 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: '[email protected]', 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: '[email protected]', 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',
},
});
});
});
});
47 changes: 42 additions & 5 deletions apps/meteor/app/api/server/helpers/getUserInfo.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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;
}
> {
Expand All @@ -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),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
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';
import OutlookCalendarEventModal from '../../views/outlookCalendar/OutlookCalendarEventModal';

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) => {
Expand All @@ -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]);
};
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
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';

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;
}

Expand All @@ -21,5 +21,5 @@ export const useOutlookCalenderRoomAction = () => {
tabComponent: OutlookEventsRoute,
order: 999,
};
}, [enabled]);
}, [user?.settings?.calendar?.outlook?.Enabled]);
};
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -22,6 +22,7 @@ const emailNotificationOptionsLabelMap = {

const PreferencesNotificationsSection = () => {
const { t, i18n } = useTranslation();
const user = useUser();

const [notificationsPermission, setNotificationsPermission] = useState<NotificationPermission>();

Expand All @@ -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();

Expand Down Expand Up @@ -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>
Expand Down
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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);
Expand All @@ -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>
Expand Down
Loading
Loading