Co-authored-by: Tasso <tasso.evangelista@rocket.chat>
Co-authored-by: Martin Schoeler <martin.schoeler@rocket.chat>
Co-authored-by: MartinSchoeler <martinschoeler8@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
pull/37860/head^2
Kevin Aleman 3 weeks ago committed by GitHub
parent cb7c338d94
commit 73d9eb2783
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 15
      .changeset/ninety-dodos-confess.md
  2. 142
      apps/meteor/app/api/server/v1/rooms.ts
  3. 8
      apps/meteor/app/api/server/v1/teams.ts
  4. 38
      apps/meteor/app/channel-settings/server/methods/saveRoomSettings.ts
  5. 7
      apps/meteor/app/invites/server/functions/findOrCreateInvite.ts
  6. 9
      apps/meteor/app/invites/server/functions/validateInviteToken.ts
  7. 5
      apps/meteor/app/lib/server/functions/addUserToDefaultChannels.ts
  8. 6
      apps/meteor/app/lib/server/functions/addUserToRoom.ts
  9. 1
      apps/meteor/app/lib/server/functions/getFullUserData.ts
  10. 35
      apps/meteor/app/lib/server/functions/removeUserFromRoom.ts
  11. 6
      apps/meteor/app/lib/server/lib/beforeAddUserToRoom.ts
  12. 48
      apps/meteor/app/slashcommands-invite/server/server.ts
  13. 22
      apps/meteor/app/statistics/server/lib/statistics.ts
  14. 1
      apps/meteor/app/utils/server/functions/getBaseUserFields.ts
  15. 9
      apps/meteor/client/components/ABAC/ABACUpsellModal/ABACUpsellModal.spec.tsx
  16. 12
      apps/meteor/client/components/UserInfo/UserInfo.stories.tsx
  17. 6
      apps/meteor/client/components/UserInfo/UserInfo.tsx
  18. 15
      apps/meteor/client/components/UserInfo/UserInfoABACAttributes.tsx
  19. 11
      apps/meteor/client/components/UserInfo/__snapshots__/UserInfo.spec.tsx.snap
  20. 1
      apps/meteor/client/components/message/toolbar/items/actions/ForwardMessageAction.tsx
  21. 1
      apps/meteor/client/components/message/toolbar/usePermalinkAction.ts
  22. 1
      apps/meteor/client/components/message/toolbar/useReplyInDMAction.ts
  23. 2
      apps/meteor/client/lib/links.ts
  24. 19
      apps/meteor/client/lib/queryKeys.ts
  25. 1
      apps/meteor/client/lib/rooms/roomTypes/private.ts
  26. 2
      apps/meteor/client/lib/utils/mapSubscriptionFromApi.ts
  27. 28
      apps/meteor/client/views/admin/ABAC/ABACAttributesTab/AttributeMenu.tsx
  28. 99
      apps/meteor/client/views/admin/ABAC/ABACAttributesTab/AttributesContextualBar.tsx
  29. 28
      apps/meteor/client/views/admin/ABAC/ABACAttributesTab/AttributesContextualBarWithData.tsx
  30. 49
      apps/meteor/client/views/admin/ABAC/ABACAttributesTab/AttributesForm.spec.tsx
  31. 25
      apps/meteor/client/views/admin/ABAC/ABACAttributesTab/AttributesForm.stories.tsx
  32. 163
      apps/meteor/client/views/admin/ABAC/ABACAttributesTab/AttributesForm.tsx
  33. 110
      apps/meteor/client/views/admin/ABAC/ABACAttributesTab/AttributesPage.tsx
  34. 272
      apps/meteor/client/views/admin/ABAC/ABACAttributesTab/__snapshots__/AttributesForm.spec.tsx.snap
  35. 213
      apps/meteor/client/views/admin/ABAC/ABACLogsTab/LogsPage.tsx
  36. 71
      apps/meteor/client/views/admin/ABAC/ABACRoomsTab/DeleteRoomModal.spec.tsx
  37. 48
      apps/meteor/client/views/admin/ABAC/ABACRoomsTab/DeleteRoomModal.tsx
  38. 131
      apps/meteor/client/views/admin/ABAC/ABACRoomsTab/RoomForm.tsx
  39. 13
      apps/meteor/client/views/admin/ABAC/ABACRoomsTab/RoomFormAttributeField.spec.tsx
  40. 73
      apps/meteor/client/views/admin/ABAC/ABACRoomsTab/RoomFormAttributeField.stories.tsx
  41. 93
      apps/meteor/client/views/admin/ABAC/ABACRoomsTab/RoomFormAttributeField.tsx
  42. 136
      apps/meteor/client/views/admin/ABAC/ABACRoomsTab/RoomFormAttributeFields.spec.tsx
  43. 37
      apps/meteor/client/views/admin/ABAC/ABACRoomsTab/RoomFormAttributeFields.tsx
  44. 60
      apps/meteor/client/views/admin/ABAC/ABACRoomsTab/RoomFormAutocomplete.spec.tsx
  45. 42
      apps/meteor/client/views/admin/ABAC/ABACRoomsTab/RoomFormAutocomplete.stories.tsx
  46. 58
      apps/meteor/client/views/admin/ABAC/ABACRoomsTab/RoomFormAutocomplete.tsx
  47. 11
      apps/meteor/client/views/admin/ABAC/ABACRoomsTab/RoomFormAutocompleteDummy.tsx
  48. 28
      apps/meteor/client/views/admin/ABAC/ABACRoomsTab/RoomMenu.tsx
  49. 89
      apps/meteor/client/views/admin/ABAC/ABACRoomsTab/RoomsContextualBar.tsx
  50. 34
      apps/meteor/client/views/admin/ABAC/ABACRoomsTab/RoomsContextualBarWithData.tsx
  51. 137
      apps/meteor/client/views/admin/ABAC/ABACRoomsTab/RoomsPage.tsx
  52. 98
      apps/meteor/client/views/admin/ABAC/ABACRoomsTab/__snapshots__/DeleteRoomModal.spec.tsx.snap
  53. 95
      apps/meteor/client/views/admin/ABAC/ABACRoomsTab/__snapshots__/RoomFormAttributeField.spec.tsx.snap
  54. 31
      apps/meteor/client/views/admin/ABAC/ABACRoomsTab/__snapshots__/RoomFormAutocomplete.spec.tsx.snap
  55. 95
      apps/meteor/client/views/admin/ABAC/ABACSettingTab/AbacEnabledToggle.tsx
  56. 58
      apps/meteor/client/views/admin/ABAC/ABACSettingTab/SettingField.spec.tsx
  57. 145
      apps/meteor/client/views/admin/ABAC/ABACSettingTab/SettingField.tsx
  58. 142
      apps/meteor/client/views/admin/ABAC/ABACSettingTab/SettingToggle.spec.tsx
  59. 64
      apps/meteor/client/views/admin/ABAC/ABACSettingTab/SettingToggle.stories.tsx
  60. 33
      apps/meteor/client/views/admin/ABAC/ABACSettingTab/SettingsPage.tsx
  61. 48
      apps/meteor/client/views/admin/ABAC/ABACSettingTab/WarningModal.tsx
  62. 152
      apps/meteor/client/views/admin/ABAC/ABACSettingTab/__snapshots__/SettingToggle.spec.tsx.snap
  63. 103
      apps/meteor/client/views/admin/ABAC/AdminABACPage.tsx
  64. 135
      apps/meteor/client/views/admin/ABAC/AdminABACRoomAttributesForm.tsx
  65. 64
      apps/meteor/client/views/admin/ABAC/AdminABACRoute.tsx
  66. 33
      apps/meteor/client/views/admin/ABAC/AdminABACTabs.tsx
  67. 39
      apps/meteor/client/views/admin/ABAC/hooks/useAttributeList.ts
  68. 267
      apps/meteor/client/views/admin/ABAC/hooks/useAttributeOptions.spec.tsx
  69. 101
      apps/meteor/client/views/admin/ABAC/hooks/useAttributeOptions.tsx
  70. 52
      apps/meteor/client/views/admin/ABAC/hooks/useDeleteRoomModal.spec.tsx
  71. 11
      apps/meteor/client/views/admin/ABAC/hooks/useDeleteRoomModal.tsx
  72. 10
      apps/meteor/client/views/admin/ABAC/hooks/useIsABACAvailable.ts
  73. 124
      apps/meteor/client/views/admin/ABAC/hooks/useRoomItems.spec.tsx
  74. 40
      apps/meteor/client/views/admin/ABAC/hooks/useRoomItems.tsx
  75. 16
      apps/meteor/client/views/admin/moderation/helpers/DateRangePicker.tsx
  76. 9
      apps/meteor/client/views/admin/routes.tsx
  77. 6
      apps/meteor/client/views/admin/sidebarItems.ts
  78. 2
      apps/meteor/client/views/admin/users/AdminUserInfoWithData.tsx
  79. 2
      apps/meteor/client/views/omnichannel/currentChats/CurrentChatsPage.tsx
  80. 7
      apps/meteor/client/views/room/contextualBar/Info/RoomInfo/RoomInfo.stories.tsx
  81. 1
      apps/meteor/client/views/room/contextualBar/RoomMembers/RoomMembersWithData.tsx
  82. 421
      apps/meteor/ee/server/api/abac/index.ts
  83. 389
      apps/meteor/ee/server/api/abac/schemas.ts
  84. 1
      apps/meteor/ee/server/api/index.ts
  85. 29
      apps/meteor/ee/server/configuration/abac.ts
  86. 1
      apps/meteor/ee/server/configuration/index.ts
  87. 17
      apps/meteor/ee/server/configuration/ldap.ts
  88. 22
      apps/meteor/ee/server/hooks/abac/beforeAddUserToRoom.ts
  89. 1
      apps/meteor/ee/server/hooks/abac/index.ts
  90. 9
      apps/meteor/ee/server/lib/abac/index.ts
  91. 5
      apps/meteor/ee/server/lib/audit/methods.ts
  92. 131
      apps/meteor/ee/server/lib/ldap/Manager.ts
  93. 10
      apps/meteor/ee/server/local-services/ldap/service.ts
  94. 5
      apps/meteor/ee/server/sdk/types/ILDAPEEService.ts
  95. 35
      apps/meteor/ee/server/settings/abac.ts
  96. 24
      apps/meteor/ee/server/settings/ldap.ts
  97. 2
      apps/meteor/ee/server/startup/services.ts
  98. 3
      apps/meteor/lib/publishFields.ts
  99. 1
      apps/meteor/lib/rooms/adminFields.ts
  100. 1
      apps/meteor/package.json
  101. Some files were not shown because too many files have changed in this diff Show More

@ -0,0 +1,15 @@
---
'@rocket.chat/authorization-service': minor
'@rocket.chat/core-services': minor
'@rocket.chat/message-types': minor
'@rocket.chat/model-typings': minor
'@rocket.chat/core-typings': minor
'@rocket.chat/apps-engine': minor
'@rocket.chat/abac': minor
'@rocket.chat/models': minor
'@rocket.chat/i18n': minor
'@rocket.chat/jwt': minor
'@rocket.chat/meteor': minor
---
Adds Attribute Based Access Control (ABAC) for private channels & private teams.

@ -22,6 +22,7 @@ import {
import { Meteor } from 'meteor/meteor';
import { isTruthy } from '../../../../lib/isTruthy';
import { adminFields } from '../../../../lib/rooms/adminFields';
import { omit } from '../../../../lib/utils/omit';
import * as dataExport from '../../../../server/lib/dataExport';
import { eraseRoom } from '../../../../server/lib/eraseRoom';
@ -1032,49 +1033,120 @@ const isRoomGetRolesPropsSchema = {
additionalProperties: false,
required: ['rid'],
};
export const roomEndpoints = API.v1.get(
'rooms.roles',
{
authRequired: true,
query: ajv.compile<{
rid: string;
}>(isRoomGetRolesPropsSchema),
response: {
200: ajv.compile<{
roles: RoomRoles[];
}>({
type: 'object',
properties: {
roles: {
type: 'array',
items: {
type: 'object',
properties: {
rid: { type: 'string' },
u: {
type: 'object',
properties: { _id: { type: 'string' }, username: { type: 'string' } },
required: ['_id', 'username'],
export const roomEndpoints = API.v1
.get(
'rooms.roles',
{
authRequired: true,
query: ajv.compile<{
rid: string;
}>(isRoomGetRolesPropsSchema),
response: {
200: ajv.compile<{
roles: RoomRoles[];
}>({
type: 'object',
properties: {
roles: {
type: 'array',
items: {
type: 'object',
properties: {
rid: { type: 'string' },
u: {
type: 'object',
properties: { _id: { type: 'string' }, username: { type: 'string' } },
required: ['_id', 'username'],
},
roles: { type: 'array', items: { type: 'string' } },
},
roles: { type: 'array', items: { type: 'string' } },
required: ['rid', 'u', 'roles'],
},
required: ['rid', 'u', 'roles'],
},
},
required: ['roles'],
}),
},
},
async function () {
const { rid } = this.queryParams;
const roles = await executeGetRoomRoles(rid, this.userId);
return API.v1.success({
roles,
});
},
)
.get(
'rooms.adminRooms.privateRooms',
{
authRequired: true,
permissionsRequired: ['view-room-administration'],
query: ajv.compile<{
filter?: string;
offset?: number;
count?: number;
sort?: string;
}>({
type: 'object',
properties: {
filter: { type: 'string' },
offset: { type: 'number' },
count: { type: 'number' },
sort: { type: 'string' },
},
required: ['roles'],
additionalProperties: true,
}),
response: {
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
403: validateUnauthorizedErrorResponse,
200: ajv.compile<{
rooms: IRoom[];
count: number;
offset: number;
total: number;
}>({
type: 'object',
properties: {
rooms: {
type: 'array',
items: { type: 'object' },
},
count: { type: 'number' },
offset: { type: 'number' },
total: { type: 'number' },
success: { type: 'boolean', enum: [true] },
},
required: ['rooms', 'count', 'offset', 'total', 'success'],
additionalProperties: false,
}),
},
},
},
async function () {
const { rid } = this.queryParams;
const roles = await executeGetRoomRoles(rid, this.userId);
async function action() {
const { offset, count } = await getPaginationItems(this.queryParams);
const { sort } = await this.parseJsonQuery();
const { filter } = this.queryParams;
return API.v1.success({
roles,
});
},
);
const name = (filter || '').trim();
const { cursor, totalCount } = Rooms.findPrivateRoomsAndTeamsPaginated(name, {
skip: offset,
limit: count,
sort: sort || { default: -1, name: 1 },
projection: adminFields,
});
const [rooms, total] = await Promise.all([cursor.toArray(), totalCount]);
return API.v1.success({
rooms,
count: rooms.length,
offset,
total,
});
},
);
const roomInviteEndpoints = API.v1.post(
'rooms.invite',

@ -20,6 +20,7 @@ import { eraseRoom } from '../../../../server/lib/eraseRoom';
import { canAccessRoomAsync } from '../../../authorization/server';
import { hasPermissionAsync, hasAtLeastOnePermissionAsync } from '../../../authorization/server/functions/hasPermission';
import { removeUserFromRoom } from '../../../lib/server/functions/removeUserFromRoom';
import { settings } from '../../../settings/server';
import { API } from '../api';
import { getPaginationItems } from '../helpers/getPaginationItems';
import { eraseTeam } from '../lib/eraseTeam';
@ -235,6 +236,13 @@ API.v1.addRoute(
}
const canUpdateAny = !!(await hasPermissionAsync(this.userId, 'view-all-team-channels', team.roomId));
if (settings.get('ABAC_Enabled') && isDefault) {
const room = await Rooms.findOneByIdAndType(roomId, 'p', { projection: { abacAttributes: 1 } });
if (room?.abacAttributes?.length) {
return API.v1.failure('error-room-is-abac-managed');
}
}
const room = await Team.updateRoom(this.userId, roomId, isDefault, canUpdateAny);
return API.v1.success({ room });

@ -1,5 +1,5 @@
import { Team } from '@rocket.chat/core-services';
import type { IRoom, IRoomWithRetentionPolicy, IUser, MessageTypesValues } from '@rocket.chat/core-typings';
import type { IRoom, IRoomWithRetentionPolicy, IUser, MessageTypesValues, ITeam } from '@rocket.chat/core-typings';
import { TEAM_TYPE } from '@rocket.chat/core-typings';
import type { ServerMethods } from '@rocket.chat/ddp-client';
import { Rooms, Users } from '@rocket.chat/models';
@ -11,6 +11,7 @@ import { roomCoordinator } from '../../../../server/lib/rooms/roomCoordinator';
import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission';
import { setRoomAvatar } from '../../../lib/server/functions/setRoomAvatar';
import { notifyOnRoomChangedById } from '../../../lib/server/lib/notifyListener';
import { settings } from '../../../settings/server';
import { saveReactWhenReadOnly } from '../functions/saveReactWhenReadOnly';
import { saveRoomAnnouncement } from '../functions/saveRoomAnnouncement';
import { saveRoomCustomFields } from '../functions/saveRoomCustomFields';
@ -61,14 +62,33 @@ type RoomSettingsValidators = {
const hasRetentionPolicy = (room: IRoom & { retention?: any }): room is IRoomWithRetentionPolicy =>
'retention' in room && room.retention !== undefined;
const isAbacManagedRoom = (room: IRoom): boolean => {
return room.t === 'p' && settings.get<boolean>('ABAC_Enabled') && Array.isArray(room?.abacAttributes) && room.abacAttributes.length > 0;
};
const isAbacManagedTeam = (team: Partial<ITeam> | null, teamRoom: IRoom): boolean => {
return (
team?.type === TEAM_TYPE.PRIVATE &&
settings.get<boolean>('ABAC_Enabled') &&
Array.isArray(teamRoom?.abacAttributes) &&
teamRoom.abacAttributes.length > 0
);
};
const validators: RoomSettingsValidators = {
async default({ userId }) {
async default({ userId, room, value }) {
if (!(await hasPermissionAsync(userId, 'view-room-administration'))) {
throw new Meteor.Error('error-action-not-allowed', 'Viewing room administration is not allowed', {
method: 'saveRoomSettings',
action: 'Viewing_room_administration',
});
}
if (isAbacManagedRoom(room) && value) {
throw new Meteor.Error('error-action-not-allowed', 'Setting an ABAC managed room as default is not allowed', {
method: 'saveRoomSettings',
action: 'Viewing_room_administration',
});
}
},
async featured({ userId }) {
if (!(await hasPermissionAsync(userId, 'view-room-administration'))) {
@ -98,6 +118,13 @@ const validators: RoomSettingsValidators = {
});
}
if (isAbacManagedRoom(room) && value !== 'p') {
throw new Meteor.Error('error-action-not-allowed', 'Changing an ABAC managed private room to public is not allowed', {
method: 'saveRoomSettings',
action: 'Change_Room_Type',
});
}
if (!room.teamId) {
return;
}
@ -116,6 +143,13 @@ const validators: RoomSettingsValidators = {
action: 'Change_Room_Type',
});
}
if (isAbacManagedTeam(team, room) && value !== 'p') {
throw new Meteor.Error('error-action-not-allowed', 'Changing an ABAC managed private team room to public is not allowed', {
method: 'saveRoomSettings',
action: 'Change_Room_Type',
});
}
},
async encrypted({ userId, value, room, rid }) {
if (value !== room.encrypted) {

@ -63,6 +63,13 @@ export const findOrCreateInvite = async (userId: string, invite: Pick<IInvite, '
});
}
if (settings.get('ABAC_Enabled') && room?.abacAttributes?.length) {
throw new Meteor.Error('error-invalid-room', 'Room is ABAC managed', {
method: 'findOrCreateInvite',
field: 'rid',
});
}
if (!(await roomCoordinator.getRoomDirectives(room.t).allowMemberAction(room, RoomMemberActions.INVITE, userId))) {
throw new Meteor.Error('error-room-type-not-allowed', 'Cannot create invite links for this room type', {
method: 'findOrCreateInvite',

@ -1,6 +1,8 @@
import { Invites, Rooms } from '@rocket.chat/models';
import { Meteor } from 'meteor/meteor';
import { settings } from '../../../settings/server';
export const validateInviteToken = async (token: string) => {
if (!token || typeof token !== 'string') {
throw new Meteor.Error('error-invalid-token', 'The invite token is invalid.', {
@ -25,6 +27,13 @@ export const validateInviteToken = async (token: string) => {
});
}
if (settings.get('ABAC_Enabled') && room?.abacAttributes?.length) {
throw new Meteor.Error('error-invalid-room', 'Room is ABAC managed', {
method: 'validateInviteToken',
field: 'rid',
});
}
if (inviteData.expires && new Date(inviteData.expires).getTime() <= Date.now()) {
throw new Meteor.Error('error-invite-expired', 'The invite token has expired.', {
method: 'validateInviteToken',

@ -5,6 +5,7 @@ import { Subscriptions } from '@rocket.chat/models';
import { getDefaultChannels } from './getDefaultChannels';
import { callbacks } from '../../../../server/lib/callbacks';
import { getSubscriptionAutotranslateDefaultConfig } from '../../../../server/lib/getSubscriptionAutotranslateDefaultConfig';
import { settings } from '../../../settings/server';
import { getDefaultSubscriptionPref } from '../../../utils/lib/getDefaultSubscriptionPref';
import { notifyOnSubscriptionChangedById } from '../lib/notifyListener';
@ -13,6 +14,10 @@ export const addUserToDefaultChannels = async function (user: IUser, silenced?:
const defaultRooms = await getDefaultChannels();
for await (const room of defaultRooms) {
if (settings.get('ABAC_Enabled') && room?.abacAttributes?.length) {
continue;
}
if (!(await Subscriptions.findOneByRoomIdAndUserId(room._id, user._id, { projection: { _id: 1 } }))) {
const autoTranslateConfig = getSubscriptionAutotranslateDefaultConfig(user);

@ -10,6 +10,7 @@ import { callbacks } from '../../../../server/lib/callbacks';
import { beforeAddUserToRoom } from '../../../../server/lib/callbacks/beforeAddUserToRoom';
import { roomCoordinator } from '../../../../server/lib/rooms/roomCoordinator';
import { settings } from '../../../settings/server';
import { beforeAddUserToRoom as beforeAddUserToRoomPatch } from '../lib/beforeAddUserToRoom';
import { notifyOnRoomChangedById } from '../lib/notifyListener';
/**
@ -61,7 +62,10 @@ export const addUserToRoom = async (
}
try {
await beforeAddUserToRoom.run({ user: userToBeAdded, inviter: (inviter && (await Users.findOneById(inviter._id))) || undefined }, room);
const inviterUser = inviter && ((await Users.findOneById(inviter._id)) || undefined);
// Not "duplicated": we're moving away from callbacks so this is a patch function. We should migrate the next one to be a patch or use this same patch, instead of calling both
await beforeAddUserToRoomPatch([userToBeAdded.username!], room, inviterUser);
await beforeAddUserToRoom.run({ user: userToBeAdded, inviter: inviterUser }, room);
} catch (error) {
throw new Meteor.Error((error as any)?.message);
}

@ -22,6 +22,7 @@ const defaultFields = {
extension: 1,
federated: 1,
statusLivechat: 1,
abacAttributes: 1,
} as const;
const fullFields = {

@ -1,7 +1,7 @@
import { Apps, AppEvents } from '@rocket.chat/apps';
import { AppsEngineException } from '@rocket.chat/apps-engine/definition/exceptions';
import { Message, Team, Room } from '@rocket.chat/core-services';
import type { IRoom, IUser } from '@rocket.chat/core-typings';
import type { IRoom, IUser, MessageTypesValues } from '@rocket.chat/core-typings';
import { Subscriptions, Rooms } from '@rocket.chat/models';
import { Meteor } from 'meteor/meteor';
@ -15,7 +15,11 @@ import { notifyOnRoomChangedById, notifyOnSubscriptionChanged } from '../lib/not
* Executes only the necessary database operations, with no callbacks, to prevent
* propagation loops during external event processing.
*/
export const performUserRemoval = async function (room: IRoom, user: IUser, options?: { byUser?: IUser }): Promise<void> {
export const performUserRemoval = async function (
room: IRoom,
user: IUser,
options?: { byUser?: IUser; skipAppPreEvents?: boolean; customSystemMessage?: MessageTypesValues },
): Promise<void> {
const subscription = await Subscriptions.findOneByRoomIdAndUserId(room._id, user._id, {
projection: { _id: 1, status: 1 },
});
@ -28,7 +32,9 @@ export const performUserRemoval = async function (room: IRoom, user: IUser, opti
if (subscription) {
const removedUser = user;
if (options?.byUser) {
if (options?.customSystemMessage) {
await Message.saveSystemMessage(options?.customSystemMessage, room._id, user.username || '', user);
} else if (options?.byUser) {
const extraData = {
u: options.byUser,
};
@ -72,20 +78,27 @@ export const performUserRemoval = async function (room: IRoom, user: IUser, opti
* and triggering all standard callbacks. Used for local actions (UI or API)
* that should propagate normally to federation and other subscribers.
*/
export const removeUserFromRoom = async function (rid: string, user: IUser, options?: { byUser: IUser }): Promise<void> {
export const removeUserFromRoom = async function (
rid: string,
user: IUser,
options?: { byUser?: IUser; skipAppPreEvents?: boolean; customSystemMessage?: MessageTypesValues },
): Promise<void> {
const room = await Rooms.findOneById(rid);
if (!room) {
return;
}
try {
await Apps.self?.triggerEvent(AppEvents.IPreRoomUserLeave, room, user, options?.byUser);
} catch (error: any) {
if (error.name === AppsEngineException.name) {
throw new Meteor.Error('error-app-prevented', error.message);
}
// Rationale: for an abac room, we don't want apps to be able to prevent a user from leaving
if (!options?.skipAppPreEvents) {
try {
await Apps.self?.triggerEvent(AppEvents.IPreRoomUserLeave, room, user, options?.byUser);
} catch (error: any) {
if (error.name === AppsEngineException.name) {
throw new Meteor.Error('error-app-prevented', error.message);
}
throw error;
throw error;
}
}
await Room.beforeLeave(room);

@ -0,0 +1,6 @@
import type { IRoom, IUser } from '@rocket.chat/core-typings';
import { makeFunction } from '@rocket.chat/patch-injection';
export const beforeAddUserToRoom = makeFunction(async (_users: IUser['username'][], _room: IRoom, _actor?: IUser) => {
// no op on CE
});

@ -1,4 +1,4 @@
import { api, FederationMatrix } from '@rocket.chat/core-services';
import { api, FederationMatrix, isMeteorError } from '@rocket.chat/core-services';
import type { IUser, SlashCommandCallbackParams } from '@rocket.chat/core-typings';
import { validateFederatedUsername } from '@rocket.chat/federation-matrix';
import { Subscriptions, Users, Rooms } from '@rocket.chat/models';
@ -10,6 +10,11 @@ import { addUsersToRoomMethod, sanitizeUsername } from '../../lib/server/methods
import { settings } from '../../settings/server';
import { slashCommands } from '../../utils/server/slashCommand';
// Type guards for the error
function isStringError(error: unknown): error is { error: string } {
return typeof (error as any)?.error === 'string';
}
/*
* Invite is a named function that will replace /invite commands
* @param {Object} message - The message object
@ -105,23 +110,34 @@ slashCommands.add({
},
inviter,
);
} catch ({ error }: any) {
if (typeof error !== 'string') {
return;
} catch (e: unknown) {
if (isMeteorError(e)) {
if (e.error === 'error-only-compliant-users-can-be-added-to-abac-rooms') {
void api.broadcast('notify.ephemeralMessage', userId, message.rid, {
msg: i18n.t(e.error, { lng: settings.get('Language') || 'en' }),
});
} else {
void api.broadcast('notify.ephemeralMessage', userId, message.rid, {
msg: i18n.t(e.message, { lng: settings.get('Language') || 'en' }),
});
}
}
if (error === 'error-federated-users-in-non-federated-rooms') {
void api.broadcast('notify.ephemeralMessage', userId, message.rid, {
msg: i18n.t('You_cannot_add_external_users_to_non_federated_room', { lng: settings.get('Language') || 'en' }),
});
} else if (error === 'cant-invite-for-direct-room') {
void api.broadcast('notify.ephemeralMessage', userId, message.rid, {
msg: i18n.t('Cannot_invite_users_to_direct_rooms', { lng: settings.get('Language') || 'en' }),
});
} else {
void api.broadcast('notify.ephemeralMessage', userId, message.rid, {
msg: i18n.t(error, { lng: settings.get('Language') || 'en' }),
});
if (isStringError(e)) {
const { error } = e;
if (error === 'error-federated-users-in-non-federated-rooms') {
void api.broadcast('notify.ephemeralMessage', userId, message.rid, {
msg: i18n.t('You_cannot_add_external_users_to_non_federated_room', { lng: settings.get('Language') || 'en' }),
});
} else if (error === 'cant-invite-for-direct-room') {
void api.broadcast('notify.ephemeralMessage', userId, message.rid, {
msg: i18n.t('Cannot_invite_users_to_direct_rooms', { lng: settings.get('Language') || 'en' }),
});
} else {
void api.broadcast('notify.ephemeralMessage', userId, message.rid, {
msg: i18n.t(error, { lng: settings.get('Language') || 'en' }),
});
}
}
}
}),

@ -4,6 +4,7 @@ import os from 'os';
import { Analytics, Team, VideoConf, Presence } from '@rocket.chat/core-services';
import type { IRoom, IStats, ISetting } from '@rocket.chat/core-typings';
import { UserStatus } from '@rocket.chat/core-typings';
import { License } from '@rocket.chat/license';
import {
NotificationQueue,
Rooms,
@ -25,6 +26,7 @@ import {
Subscriptions,
Users,
LivechatRooms,
AbacAttributes,
} from '@rocket.chat/models';
import { MongoInternals } from 'meteor/mongo';
import moment from 'moment';
@ -609,6 +611,26 @@ export const statistics = {
statistics.webRTCEnabledForOmnichannel = settings.get('Omnichannel_call_provider') === 'WebRTC';
statistics.omnichannelWebRTCCalls = await Rooms.findCountOfRoomsWithActiveCalls();
// ABAC stats
if (License.hasModule('abac')) {
statistics.abacEnabled = settings.get('ABAC_Enabled');
statsPms.push(
AbacAttributes.estimatedDocumentCount().then((result) => {
statistics.abacTotalAttributes = result;
}),
);
statsPms.push(
AbacAttributes.countTotalValues().then((result) => {
statistics.abacTotalAttributeValues = result;
}),
);
statsPms.push(
Rooms.countAbacEnabled().then((result) => {
statistics.abacRoomsEnrolled = result;
}),
);
}
await Promise.all(statsPms).catch(log);
return statistics;

@ -31,5 +31,6 @@ export const getBaseUserFields = (allowServiceKeys = false): UserFields => ({
'avatarETag': 1,
'extension': 1,
'openBusinessHours': 1,
'abacAttributes': 1,
...(allowServiceKeys && { 'services.totp.enabled': 1, 'services.email2fa.enabled': 1 }),
});

@ -4,11 +4,7 @@ import userEvent from '@testing-library/user-event';
import { axe } from 'jest-axe';
import ABACUpsellModal from './ABACUpsellModal';
// Mock the hooks used by ABACUpsellModal
jest.mock('../../../hooks/useHasLicenseModule', () => ({
useHasLicenseModule: jest.fn(() => false),
}));
import { createFakeLicenseInfo } from '../../../../tests/mocks/data';
jest.mock('../../GenericUpsellModal/hooks', () => ({
useUpsellActions: jest.fn(() => ({
@ -34,6 +30,9 @@ const appRoot = mockAppRoot()
Upgrade: 'Upgrade',
Cancel: 'Cancel',
})
.withEndpoint('GET', '/v1/licenses.info', async () => ({
license: createFakeLicenseInfo(),
}))
.build();
describe('ABACUpsellModal', () => {

@ -42,6 +42,14 @@ WithVoiceCallExtension.args = {
export const WithABACAttributes = Template.bind({});
WithABACAttributes.args = {
// @ts-expect-error - abacAttributes is not yet implemented in Users properties
abacAttributes: ['Classified', 'Top Secret', 'Confidential'],
abacAttributes: [
{
key: 'Classified',
values: ['Top Secret', 'Confidential'],
},
{
key: 'Security_Clearance',
values: ['Top Secret', 'Confidential'],
},
],
};

@ -41,6 +41,7 @@ type UserInfoDataProps = Serialized<
| 'canViewAllInfo'
| 'customFields'
| 'freeSwitchExtension'
| 'abacAttributes'
>
>;
@ -73,8 +74,7 @@ const UserInfo = ({
actions,
reason,
freeSwitchExtension,
// @ts-expect-error - abacAttributes is not yet implemented in Users properties
abacAttributes = null,
abacAttributes,
...props
}: UserInfoProps): ReactElement => {
const { t } = useTranslation();
@ -189,7 +189,7 @@ const UserInfo = ({
</InfoPanelField>
)}
{abacAttributes?.length > 0 && (
{abacAttributes && abacAttributes.length > 0 && (
<InfoPanelField>
<InfoPanelLabel title={t('ABAC_Attributes_description')}>{t('ABAC_Attributes')}</InfoPanelLabel>
<UserInfoABACAttributes abacAttributes={abacAttributes} />

@ -1,20 +1,23 @@
import type { IAbacAttributeDefinition } from '@rocket.chat/core-typings';
import { Box, Margins } from '@rocket.chat/fuselage';
import UserInfoABACAttribute from './UserInfoABACAttribute';
type UserInfoABACAttributesProps = {
abacAttributes: string[];
abacAttributes: IAbacAttributeDefinition[];
};
const UserInfoABACAttributes = ({ abacAttributes }: UserInfoABACAttributesProps) => {
return (
<Box m='neg-x2'>
<Box flexWrap='wrap' display='flex' flexShrink={0} mb={8}>
{abacAttributes.map((attribute, index) => (
<Margins inline={2} blockEnd={4} key={`${attribute}-${index}`}>
<UserInfoABACAttribute attribute={attribute} />
</Margins>
))}
{abacAttributes.map((attribute, index) =>
attribute.values.map((value) => (
<Margins inline={2} blockEnd={4} key={`${attribute.key}-${value}-${index}`}>
<UserInfoABACAttribute attribute={value} />
</Margins>
)),
)}
</Box>
</Box>
);

@ -510,7 +510,16 @@ exports[`renders WithABACAttributes without crashing 1`] = `
<span
class="rcx-tag__inner"
>
Classified
Top Secret
</span>
</span>
<span
class="rcx-box rcx-box--full rcx-tag rcx-tag--secondary-warning rcx-css-1drnhgd"
>
<span
class="rcx-tag__inner"
>
Confidential
</span>
</span>
<span

@ -18,7 +18,6 @@ const ForwardMessageAction = ({ message, room }: ForwardMessageActionProps) => {
const { t } = useTranslation();
const encrypted = isE2EEMessage(message);
// @ts-expect-error to be implemented
const isABACEnabled = !!room.abacAttributes;
const getTitle = useMemo(() => {

@ -16,7 +16,6 @@ export const usePermalinkAction = (
const dispatchToastMessage = useToastMessageDispatch();
// @ts-expect-error - to be implemented
const isABACEnabled = !!room.abacAttributes;
const encrypted = isE2EEMessage(message);
const tooltip = useMemo(() => {

@ -16,7 +16,6 @@ export const useReplyInDMAction = (
const user = useUser();
const router = useRouter();
const encrypted = isE2EEMessage(message);
// @ts-expect-error - abacAttributes is not yet implemented in IRoom type
const isABACEnabled = !!room.abacAttributes;
const canCreateDM = usePermission('create-d');
const isLayoutEmbedded = useEmbeddedLayout();

@ -31,6 +31,8 @@ export const links = {
trial: `${GO_ROCKET_CHAT_PREFIX}/i/docs-trial`,
versionSupport: `${GO_ROCKET_CHAT_PREFIX}/i/version-support`,
updateProduct: `${GO_ROCKET_CHAT_PREFIX}/i/update-product`,
abacDocs: `${GO_ROCKET_CHAT_PREFIX}/i/abac`,
abacLDAPDocs: `${GO_ROCKET_CHAT_PREFIX}/i/abac-ldap`,
},
/** @deprecated use `go.rocket.chat` links */
desktopAppDownload: 'https://rocket.chat/download',

@ -138,3 +138,22 @@ export const appsQueryKeys = {
all: ['apps'] as const,
slashCommands: () => [...appsQueryKeys.all, 'slashCommands'] as const,
};
export const ABACQueryKeys = {
all: ['abac'] as const,
logs: {
all: () => [...ABACQueryKeys.all, 'logs'] as const,
list: (query?: PaginatedRequest) => [...ABACQueryKeys.logs.all(), 'list', query] as const,
},
roomAttributes: {
all: () => [...ABACQueryKeys.all, 'room-attributes'] as const,
list: (query?: PaginatedRequest) => [...ABACQueryKeys.roomAttributes.all(), query] as const,
attribute: (attributeId: string) => [...ABACQueryKeys.roomAttributes.all(), attributeId] as const,
},
rooms: {
all: () => [...ABACQueryKeys.all, 'rooms'] as const,
list: (query?: PaginatedRequest) => [...ABACQueryKeys.rooms.all(), query] as const,
autocomplete: (query?: PaginatedRequest) => [...ABACQueryKeys.rooms.all(), 'autocomplete', query] as const,
room: (roomId: string) => [...ABACQueryKeys.rooms.all(), roomId] as const,
},
};

@ -82,7 +82,6 @@ roomCoordinator.add(
},
getIcon(room) {
// @ts-expect-error TODO: Implement ABAC attributes in rooms
if (room.abacAttributes) {
if (room.teamMain) {
return 'team-shield';

@ -7,6 +7,7 @@ export const mapSubscriptionFromApi = ({
_updatedAt,
oldRoomKeys,
suggestedOldRoomKeys,
abacLastTimeChecked,
...subscription
}: Serialized<ISubscription>): ISubscription => ({
...subscription,
@ -14,6 +15,7 @@ export const mapSubscriptionFromApi = ({
ls: new Date(ls),
lr: new Date(lr),
_updatedAt: new Date(_updatedAt),
...(abacLastTimeChecked && { abacLastTimeChecked: new Date(abacLastTimeChecked) }),
...(oldRoomKeys && { oldRoomKeys: oldRoomKeys.map(({ ts, ...key }) => ({ ...key, ts: new Date(ts) })) }),
...(suggestedOldRoomKeys && { suggestedOldRoomKeys: suggestedOldRoomKeys.map(({ ts, ...key }) => ({ ...key, ts: new Date(ts) })) }),
});

@ -0,0 +1,28 @@
import { GenericMenu } from '@rocket.chat/ui-client';
import { useTranslation } from 'react-i18next';
import { useAttributeOptions } from '../hooks/useAttributeOptions';
type AttributeMenuProps = {
attribute: { _id: string; key: string };
};
const AttributeMenu = ({ attribute }: AttributeMenuProps) => {
const { t } = useTranslation();
const items = useAttributeOptions(attribute);
return (
<GenericMenu
title={t('Options')}
icon='kebab'
sections={[
{
items,
},
]}
/>
);
};
export default AttributeMenu;

@ -0,0 +1,99 @@
import { ContextualbarTitle } from '@rocket.chat/fuselage';
import { ContextualbarClose, ContextualbarHeader } from '@rocket.chat/ui-client';
import { useEndpoint, useRouteParameter, useToastMessageDispatch } from '@rocket.chat/ui-contexts';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { FormProvider, useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import type { AttributesFormFormData } from './AttributesForm';
import AttributesForm from './AttributesForm';
import { ABACQueryKeys } from '../../../../lib/queryKeys';
type AttributesContextualBarProps = {
attributeId?: string;
attributeData?: {
key: string;
values: string[];
};
onClose: () => void;
};
const AttributesContextualBar = ({ attributeData, onClose }: AttributesContextualBarProps) => {
const { t } = useTranslation();
const queryClient = useQueryClient();
const methods = useForm<{
name: string;
attributeValues: { value: string }[];
lockedAttributes: { value: string }[];
}>({
defaultValues: attributeData
? {
name: attributeData.key,
attributeValues: [{ value: '' }],
lockedAttributes: attributeData.values.map((value) => ({ value })),
}
: {
name: '',
attributeValues: [{ value: '' }],
lockedAttributes: [],
},
mode: 'onChange',
});
const { getValues } = methods;
const attributeId = useRouteParameter('id');
const createAttribute = useEndpoint('POST', '/v1/abac/attributes');
const updateAttribute = useEndpoint('PUT', '/v1/abac/attributes/:_id', {
_id: attributeId ?? '',
});
const dispatchToastMessage = useToastMessageDispatch();
const saveMutation = useMutation({
mutationFn: async (data: AttributesFormFormData) => {
const payload = {
key: data.name,
values: [...data.lockedAttributes.map((attribute) => attribute.value), ...data.attributeValues.map((attribute) => attribute.value)],
};
if (attributeId) {
await updateAttribute(payload);
} else {
await createAttribute(payload);
}
},
onSuccess: () => {
if (attributeId) {
dispatchToastMessage({ type: 'success', message: t('ABAC_Attribute_updated', { attributeName: getValues('name') }) });
} else {
dispatchToastMessage({ type: 'success', message: t('ABAC_Attribute_created', { attributeName: getValues('name') }) });
}
onClose();
},
onError: (error) => {
dispatchToastMessage({ type: 'error', message: error });
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ABACQueryKeys.roomAttributes.list() });
},
});
return (
<>
<ContextualbarHeader>
<ContextualbarTitle>{t(attributeId ? 'ABAC_Edit_attribute' : 'ABAC_New_attribute')}</ContextualbarTitle>
<ContextualbarClose onClick={onClose} />
</ContextualbarHeader>
<FormProvider {...methods}>
<AttributesForm
onSave={(values) => saveMutation.mutateAsync(values)}
onCancel={onClose}
description={t(attributeId ? 'ABAC_Edit_attribute_description' : 'ABAC_New_attribute_description')}
/>
</FormProvider>
</>
);
};
export default AttributesContextualBar;

@ -0,0 +1,28 @@
import { ContextualbarSkeletonBody } from '@rocket.chat/ui-client';
import { useEndpoint } from '@rocket.chat/ui-contexts';
import { useQuery } from '@tanstack/react-query';
import AttributesContextualBar from './AttributesContextualBar';
import { ABACQueryKeys } from '../../../../lib/queryKeys';
type AttributesContextualBarWithDataProps = {
id: string;
onClose: () => void;
};
const AttributesContextualBarWithData = ({ id, onClose }: AttributesContextualBarWithDataProps) => {
const getAttributes = useEndpoint('GET', '/v1/abac/attributes/:_id', { _id: id });
const { data, isLoading, isFetching } = useQuery({
queryKey: ABACQueryKeys.roomAttributes.attribute(id),
queryFn: () => getAttributes(),
staleTime: 0,
});
if (isLoading || isFetching) {
return <ContextualbarSkeletonBody />;
}
return <AttributesContextualBar attributeData={data} onClose={onClose} />;
};
export default AttributesContextualBarWithData;

@ -7,8 +7,8 @@ import { axe } from 'jest-axe';
import type { ReactNode } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
import AdminABACRoomAttributesForm, { type AdminABACRoomAttributesFormFormData } from './AdminABACRoomAttributesForm';
import * as stories from './AdminABACRoomAttributesForm.stories';
import AttributesForm, { type AttributesFormFormData } from './AttributesForm';
import * as stories from './AttributesForm.stories';
const testCases = Object.values(composeStories(stories)).map((Story) => [Story.storyName || 'Story', Story]);
@ -23,14 +23,8 @@ const appRoot = mockAppRoot()
})
.build();
const FormProviderWrapper = ({
children,
defaultValues,
}: {
children: ReactNode;
defaultValues?: Partial<AdminABACRoomAttributesFormFormData>;
}) => {
const methods = useForm<AdminABACRoomAttributesFormFormData>({
const FormProviderWrapper = ({ children, defaultValues }: { children: ReactNode; defaultValues?: Partial<AttributesFormFormData> }) => {
const methods = useForm<AttributesFormFormData>({
defaultValues: {
name: '',
attributeValues: [{ value: '' }],
@ -43,7 +37,7 @@ const FormProviderWrapper = ({
return <FormProvider {...methods}>{children}</FormProvider>;
};
describe('AdminABACRoomAttributesForm', () => {
describe('AttributesForm', () => {
const defaultProps = {
onSave: jest.fn(),
onCancel: jest.fn(),
@ -70,11 +64,16 @@ describe('AdminABACRoomAttributesForm', () => {
it('should show validation errors for required fields', async () => {
render(
<FormProviderWrapper>
<AdminABACRoomAttributesForm {...defaultProps} />
<AttributesForm {...defaultProps} />
</FormProviderWrapper>,
{ wrapper: appRoot },
);
// Making the form "touched"
const nameInput = screen.getByLabelText('Name*');
await userEvent.type(nameInput, 'Test Attribute');
await userEvent.clear(nameInput);
const saveButton = screen.getByRole('button', { name: 'Save' });
await userEvent.click(saveButton);
@ -86,7 +85,7 @@ describe('AdminABACRoomAttributesForm', () => {
it('should show validation error for empty attribute values', async () => {
render(
<FormProviderWrapper>
<AdminABACRoomAttributesForm {...defaultProps} />
<AttributesForm {...defaultProps} />
</FormProviderWrapper>,
{ wrapper: appRoot },
);
@ -110,7 +109,7 @@ describe('AdminABACRoomAttributesForm', () => {
render(
<FormProviderWrapper defaultValues={defaultValues}>
<AdminABACRoomAttributesForm {...defaultProps} />
<AttributesForm {...defaultProps} />
</FormProviderWrapper>,
{ wrapper: appRoot },
);
@ -130,12 +129,12 @@ describe('AdminABACRoomAttributesForm', () => {
render(
<FormProviderWrapper defaultValues={defaultValues}>
<AdminABACRoomAttributesForm {...defaultProps} />
<AttributesForm {...defaultProps} />
</FormProviderWrapper>,
{ wrapper: appRoot },
);
const trashButtons = screen.getAllByRole('button', { name: 'Remove' });
const trashButtons = screen.getAllByRole('button', { name: 'ABAC_Remove_attribute' });
expect(screen.getByDisplayValue('Value 1')).toBeInTheDocument();
expect(screen.getByDisplayValue('Value 2')).toBeInTheDocument();
@ -153,12 +152,12 @@ describe('AdminABACRoomAttributesForm', () => {
render(
<FormProviderWrapper defaultValues={defaultValues}>
<AdminABACRoomAttributesForm {...defaultProps} />
<AttributesForm {...defaultProps} />
</FormProviderWrapper>,
{ wrapper: appRoot },
);
const trashButtons = screen.queryAllByRole('button', { name: 'Remove' });
const trashButtons = screen.queryAllByRole('button', { name: 'ABAC_Remove_attribute' });
expect(screen.getByDisplayValue('Locked Value 1')).toBeInTheDocument();
expect(screen.getByDisplayValue('Locked Value 2')).toBeInTheDocument();
@ -172,7 +171,7 @@ describe('AdminABACRoomAttributesForm', () => {
it('should disable Add Value button when there are empty values', async () => {
render(
<FormProviderWrapper>
<AdminABACRoomAttributesForm {...defaultProps} />
<AttributesForm {...defaultProps} />
</FormProviderWrapper>,
{ wrapper: appRoot },
);
@ -189,7 +188,7 @@ describe('AdminABACRoomAttributesForm', () => {
render(
<FormProviderWrapper defaultValues={defaultValues}>
<AdminABACRoomAttributesForm {...defaultProps} />
<AttributesForm {...defaultProps} />
</FormProviderWrapper>,
{ wrapper: appRoot },
);
@ -206,12 +205,12 @@ describe('AdminABACRoomAttributesForm', () => {
render(
<FormProviderWrapper defaultValues={defaultValues}>
<AdminABACRoomAttributesForm {...defaultProps} />
<AttributesForm {...defaultProps} />
</FormProviderWrapper>,
{ wrapper: appRoot },
);
const trashButtons = screen.queryAllByRole('button', { name: 'Remove' });
const trashButtons = screen.queryAllByRole('button', { name: 'ABAC_Remove_attribute' });
await userEvent.click(trashButtons[0]);
const saveButton = screen.getByRole('button', { name: 'Save' });
@ -225,7 +224,7 @@ describe('AdminABACRoomAttributesForm', () => {
it('should call onCancel when Cancel button is clicked', async () => {
render(
<FormProviderWrapper>
<AdminABACRoomAttributesForm {...defaultProps} />
<AttributesForm {...defaultProps} />
</FormProviderWrapper>,
{ wrapper: appRoot },
);
@ -245,7 +244,7 @@ describe('AdminABACRoomAttributesForm', () => {
render(
<FormProviderWrapper defaultValues={defaultValues}>
<AdminABACRoomAttributesForm {...defaultProps} />
<AttributesForm {...defaultProps} />
</FormProviderWrapper>,
{ wrapper: appRoot },
);
@ -253,7 +252,7 @@ describe('AdminABACRoomAttributesForm', () => {
expect(screen.getByDisplayValue('Locked Value')).toBeDisabled();
expect(screen.getByDisplayValue('Regular Value')).not.toBeDisabled();
const trashButtons = screen.getAllByRole('button', { name: 'Remove' });
const trashButtons = screen.getAllByRole('button', { name: 'ABAC_Remove_attribute' });
expect(trashButtons).toHaveLength(1);
});
});

@ -3,26 +3,37 @@ import { mockAppRoot } from '@rocket.chat/mock-providers';
import type { Meta, StoryFn } from '@storybook/react';
import { FormProvider, useForm } from 'react-hook-form';
import AdminABACRoomAttributesForm, { type AdminABACRoomAttributesFormFormData } from './AdminABACRoomAttributesForm';
import AttributesForm, { type AttributesFormFormData } from './AttributesForm';
export default {
component: AdminABACRoomAttributesForm,
component: AttributesForm,
parameters: {
layout: 'padded',
},
args: {
description: 'Create an attribute that can later be assigned to rooms.',
},
decorators: [mockAppRoot().buildStoryDecorator()],
} satisfies Meta<typeof AdminABACRoomAttributesForm>;
decorators: [
mockAppRoot()
.withTranslations('en', 'core', {
Name: 'Name',
Values: 'Values',
Add_Value: 'Add Value',
Cancel: 'Cancel',
Save: 'Save',
Required_field: '{{field}} is required',
})
.buildStoryDecorator(),
],
} satisfies Meta<typeof AttributesForm>;
const Template: StoryFn<typeof AdminABACRoomAttributesForm> = (args) => <AdminABACRoomAttributesForm {...args} />;
const Template: StoryFn<typeof AttributesForm> = (args) => <AttributesForm {...args} />;
export const NewAttribute = Template.bind({});
NewAttribute.decorators = [
(fn) => {
const methods = useForm<AdminABACRoomAttributesFormFormData>({
const methods = useForm<AttributesFormFormData>({
defaultValues: {
name: '',
attributeValues: [{ value: '' }],
@ -49,7 +60,7 @@ WithLockedAttributes.args = {
WithLockedAttributes.decorators = [
(fn) => {
const methods = useForm<AdminABACRoomAttributesFormFormData>({
const methods = useForm<AttributesFormFormData>({
defaultValues: {
name: 'Room Type',
lockedAttributes: [{ value: 'Locked Value 1' }, { value: 'Locked Value 2' }, { value: 'Locked Value 3' }],

@ -0,0 +1,163 @@
import {
Box,
Button,
ButtonGroup,
ContextualbarFooter,
Field,
FieldError,
FieldLabel,
FieldRow,
IconButton,
TextInput,
} from '@rocket.chat/fuselage';
import { ContextualbarScrollableContent } from '@rocket.chat/ui-client';
import { useCallback, useId, useMemo, Fragment } from 'react';
import { useFieldArray, useFormContext } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
export type AttributesFormFormData = {
name: string;
attributeValues: { value: string }[];
lockedAttributes: { value: string }[];
};
type AttributesFormProps = {
onSave: (data: AttributesFormFormData) => void;
onCancel: () => void;
description: string;
};
const AttributesForm = ({ onSave, onCancel, description }: AttributesFormProps) => {
const {
handleSubmit,
register,
formState: { errors, isDirty },
watch,
} = useFormContext<AttributesFormFormData>();
const { t } = useTranslation();
const attributeValues = watch('attributeValues');
const lockedAttributes = watch('lockedAttributes');
const { fields: lockedAttributesFields, remove: removeLockedAttribute } = useFieldArray({
name: 'lockedAttributes',
});
const validateRepeatedValues = useCallback(
(value: string) => {
// Only one instance of the same attribute value is allowed to be in the form at a time
const repeatedAttributes = [...lockedAttributes, ...attributeValues].filter((attribute) => attribute.value === value).length > 1;
return repeatedAttributes ? t('ABAC_No_repeated_values') : undefined;
},
[lockedAttributes, attributeValues, t],
);
const { fields, append, remove } = useFieldArray({
name: 'attributeValues',
rules: {
minLength: 1,
},
});
const formId = useId();
const nameField = useId();
const valuesField = useId();
const getAttributeValuesError = useCallback(() => {
if (errors.attributeValues?.length && errors.attributeValues?.length > 0) {
return errors.attributeValues[0]?.value?.message;
}
return '';
}, [errors.attributeValues]);
const hasValuesErrors = useMemo(() => {
const attributeValuesErrors = Array.isArray(errors?.attributeValues) && errors.attributeValues.some((error) => !!error?.value?.message);
const lockedAttributesErrors =
Array.isArray(errors?.lockedAttributes) && errors.lockedAttributes.some((error) => !!error?.value?.message);
return attributeValuesErrors || lockedAttributesErrors;
}, [errors.attributeValues, errors.lockedAttributes]);
return (
<>
<ContextualbarScrollableContent>
<Box is='form' onSubmit={handleSubmit(onSave)} id={formId}>
<Box>{description}</Box>
<Field mb={16}>
<FieldLabel htmlFor={nameField} required>
{t('Name')}
</FieldLabel>
<FieldRow>
<TextInput
error={errors.name?.message}
id={nameField}
{...register('name', { required: t('Required_field', { field: t('Name') }) })}
/>
</FieldRow>
{errors.name && <FieldError>{errors.name.message}</FieldError>}
</Field>
<Field mb={16}>
<FieldLabel required id={valuesField}>
{t('Values')}
</FieldLabel>
{lockedAttributesFields.map((field, index) => (
<Fragment key={field.id}>
<FieldRow key={field.id}>
<TextInput
disabled
aria-labelledby={valuesField}
error={errors.lockedAttributes?.[index]?.value?.message || ''}
{...register(`lockedAttributes.${index}.value`, {
required: t('Required_field', { field: t('Values') }),
validate: (value: string) => validateRepeatedValues(value),
})}
/>
{index !== 0 && (
<IconButton title={t('ABAC_Remove_attribute')} icon='trash' onClick={() => removeLockedAttribute(index)} />
)}
</FieldRow>
{errors.lockedAttributes?.[index]?.value && <FieldError>{errors.lockedAttributes?.[index]?.value?.message}</FieldError>}
</Fragment>
))}
{fields.map((field, index) => (
<Fragment key={field.id}>
<FieldRow>
<TextInput
aria-labelledby={valuesField}
error={errors.attributeValues?.[index]?.value?.message || ''}
{...register(`attributeValues.${index}.value`, {
required: t('Required_field', { field: t('Values') }),
validate: (value: string) => validateRepeatedValues(value),
})}
/>
{(index !== 0 || lockedAttributesFields.length > 0) && (
<IconButton title={t('ABAC_Remove_attribute')} icon='trash' onClick={() => remove(index)} />
)}
</FieldRow>
{errors.attributeValues?.[index]?.value && <FieldError>{errors.attributeValues[index].value.message}</FieldError>}
</Fragment>
))}
<Button
onClick={() => append({ value: '' })}
// Checking for values since rhf does consider the newly added field as dirty after an append() call
disabled={!!getAttributeValuesError() || attributeValues?.some((value: { value: string }) => value?.value === '')}
>
{t('Add_Value')}
</Button>
</Field>
</Box>
</ContextualbarScrollableContent>
<ContextualbarFooter>
<ButtonGroup stretch>
<Button onClick={() => onCancel()}>{t('Cancel')}</Button>
<Button type='submit' form={formId} disabled={hasValuesErrors || !!errors.name || !isDirty} primary>
{t('Save')}
</Button>
</ButtonGroup>
</ContextualbarFooter>
</>
);
};
export default AttributesForm;

@ -0,0 +1,110 @@
import { Box, Button, Icon, Margins, Pagination, TextInput } from '@rocket.chat/fuselage';
import { useDebouncedValue, useEffectEvent } from '@rocket.chat/fuselage-hooks';
import {
GenericTable,
GenericTableBody,
GenericTableCell,
GenericTableHeader,
GenericTableHeaderCell,
GenericTableRow,
usePagination,
} from '@rocket.chat/ui-client';
import { useEndpoint, useRouter } from '@rocket.chat/ui-contexts';
import { useQuery } from '@tanstack/react-query';
import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import AttributeMenu from './AttributeMenu';
import GenericNoResults from '../../../../components/GenericNoResults';
import { ABACQueryKeys } from '../../../../lib/queryKeys';
import { useIsABACAvailable } from '../hooks/useIsABACAvailable';
const AttributesPage = () => {
const { t } = useTranslation();
const [text, setText] = useState('');
const debouncedText = useDebouncedValue(text, 200);
const { current, itemsPerPage, setItemsPerPage, setCurrent, ...paginationProps } = usePagination();
const getAttributes = useEndpoint('GET', '/v1/abac/attributes');
const isABACAvailable = useIsABACAvailable();
const router = useRouter();
const handleNewAttribute = useEffectEvent(() => {
router.navigate({
name: 'admin-ABAC',
params: {
tab: 'room-attributes',
context: 'new',
},
});
});
const query = useMemo(
() => ({
...(debouncedText ? { key: debouncedText, values: debouncedText } : {}),
offset: current,
count: itemsPerPage,
}),
[debouncedText, current, itemsPerPage],
);
const { data, isLoading } = useQuery({
queryKey: ABACQueryKeys.roomAttributes.list(query),
queryFn: () => getAttributes(query),
});
return (
<>
<Margins block={24}>
<Box display='flex'>
<TextInput
addon={<Icon name='magnifier' size='x20' />}
placeholder={t('ABAC_Search_attributes')}
value={text}
onChange={(e) => setText((e.target as HTMLInputElement).value)}
/>
<Button onClick={handleNewAttribute} primary mis={8} disabled={!isABACAvailable}>
{t('ABAC_New_attribute')}
</Button>
</Box>
</Margins>
{(!data || data.attributes?.length === 0) && !isLoading ? (
<Box display='flex' justifyContent='center' height='full'>
<GenericNoResults icon='list-alt' title={t('ABAC_No_attributes')} description={t('ABAC_No_attributes_description')} />
</Box>
) : (
<>
<GenericTable>
<GenericTableHeader>
<GenericTableHeaderCell>{t('Name')}</GenericTableHeaderCell>
<GenericTableHeaderCell>{t('Value')}</GenericTableHeaderCell>
<GenericTableHeaderCell key='spacer' w={40} />
</GenericTableHeader>
<GenericTableBody>
{data?.attributes?.map((attribute) => (
<GenericTableRow key={attribute._id}>
<GenericTableCell withTruncatedText>{attribute.key}</GenericTableCell>
<GenericTableCell withTruncatedText>{attribute.values.join(', ')}</GenericTableCell>
<GenericTableCell>
<AttributeMenu attribute={attribute} />
</GenericTableCell>
</GenericTableRow>
))}
</GenericTableBody>
</GenericTable>
<Pagination
divider
current={current}
itemsPerPage={itemsPerPage}
count={data?.total || 0}
onSetItemsPerPage={setItemsPerPage}
onSetCurrent={setCurrent}
{...paginationProps}
/>
</>
)}
</>
);
};
export default AttributesPage;

@ -1,45 +1,45 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`AdminABACRoomAttributesForm renders NewAttribute without crashing 1`] = `
exports[`AttributesForm renders NewAttribute without crashing 1`] = `
<body>
<div>
<div
class="rcx-box rcx-box--full rcx-vertical-bar rcx-css-1ph8q3"
>
<form
class="rcx-box rcx-box--full"
id=":r0:"
<div
class="rcx-box rcx-box--full rcx-css-1svuzur"
>
<div
class="rcx-box rcx-box--full rcx-css-1svuzur"
class="rcx-box rcx-box--full rcx-css-vlo1oi rcx-css-1cb6i7s"
data-overlayscrollbars="host"
>
<div
class="rcx-box rcx-box--full rcx-css-vlo1oi rcx-css-1cb6i7s"
data-overlayscrollbars="host"
class="os-size-observer"
>
<div
class="os-size-observer"
>
<div
class="os-size-observer-listener"
/>
</div>
class="os-size-observer-listener"
/>
</div>
<div
class=""
data-overlayscrollbars-viewport="scrollbarHidden overflowXHidden overflowYHidden"
style="margin-right: 0px; margin-bottom: 0px; margin-left: 0px; top: 0px; left: 0px; width: calc(100% + 0px); padding: 0px 0px 0px 0px;"
tabindex="-1"
>
<div
class=""
data-overlayscrollbars-viewport="scrollbarHidden overflowXHidden overflowYHidden"
style="margin-right: 0px; margin-bottom: 0px; margin-left: 0px; top: 0px; left: 0px; width: calc(100% + 0px); padding: 0px 0px 0px 0px;"
tabindex="-1"
class="rcx-box rcx-box--full rcx-css-iag4sp"
>
<div
class="rcx-box rcx-box--full rcx-css-iag4sp"
<form
class="rcx-box rcx-box--full rcx-css-1jggbrp rcx-css-1fuhgt0"
id=":r0:"
>
<div
class="rcx-box rcx-box--full rcx-css-1jggbrp rcx-css-1fuhgt0"
class="rcx-box rcx-box--full"
>
Create an attribute that can later be assigned to rooms.
</div>
<div
class="rcx-box rcx-box--full rcx-field rcx-css-1jggbrp rcx-css-1dd6ilo"
class="rcx-box rcx-box--full rcx-field rcx-css-1jagyun"
>
<label
class="rcx-box rcx-box--full rcx-field__label rcx-label"
@ -64,12 +64,9 @@ exports[`AdminABACRoomAttributesForm renders NewAttribute without crashing 1`] =
type="text"
/>
</span>
<span
class="rcx-box rcx-box--full rcx-field__error"
/>
</div>
<div
class="rcx-box rcx-box--full rcx-field rcx-css-1jggbrp rcx-css-1dd6ilo"
class="rcx-box rcx-box--full rcx-field rcx-css-1jagyun"
>
<label
class="rcx-box rcx-box--full rcx-field__label rcx-label"
@ -94,9 +91,6 @@ exports[`AdminABACRoomAttributesForm renders NewAttribute without crashing 1`] =
type="text"
/>
</span>
<span
class="rcx-box rcx-box--full rcx-field__error"
/>
<button
class="rcx-box rcx-box--full rcx-button"
disabled=""
@ -109,109 +103,111 @@ exports[`AdminABACRoomAttributesForm renders NewAttribute without crashing 1`] =
</span>
</button>
</div>
</div>
</form>
</div>
</div>
<div
class="os-scrollbar os-scrollbar-horizontal os-theme-dark os-scrollbar-auto-hide os-scrollbar-auto-hide-hidden os-scrollbar-handle-interactive os-scrollbar-cornerless os-scrollbar-unusable"
style="--os-scroll-percent: 0; --os-viewport-percent: 0; --os-scroll-direction: 0;"
>
<div
class="os-scrollbar os-scrollbar-horizontal os-theme-dark os-scrollbar-auto-hide os-scrollbar-auto-hide-hidden os-scrollbar-handle-interactive os-scrollbar-cornerless os-scrollbar-unusable"
style="--os-scroll-percent: 0; --os-viewport-percent: 0; --os-scroll-direction: 0;"
class="os-scrollbar-track"
>
<div
class="os-scrollbar-track"
>
<div
class="os-scrollbar-handle"
/>
</div>
class="os-scrollbar-handle"
/>
</div>
</div>
<div
class="os-scrollbar os-scrollbar-vertical os-theme-dark os-scrollbar-auto-hide os-scrollbar-auto-hide-hidden os-scrollbar-handle-interactive os-scrollbar-cornerless os-scrollbar-unusable"
style="--os-scroll-percent: 0; --os-viewport-percent: 0; --os-scroll-direction: 0;"
>
<div
class="os-scrollbar os-scrollbar-vertical os-theme-dark os-scrollbar-auto-hide os-scrollbar-auto-hide-hidden os-scrollbar-handle-interactive os-scrollbar-cornerless os-scrollbar-unusable"
style="--os-scroll-percent: 0; --os-viewport-percent: 0; --os-scroll-direction: 0;"
class="os-scrollbar-track"
>
<div
class="os-scrollbar-track"
>
<div
class="os-scrollbar-handle"
/>
</div>
class="os-scrollbar-handle"
/>
</div>
</div>
</div>
</div>
<div
class="rcx-box rcx-box--full rcx-css-m843eh"
>
<div
class="rcx-box rcx-box--full rcx-css-m843eh"
class="rcx-button-group rcx-button-group--stretch rcx-button-group--align-start"
role="group"
>
<div
class="rcx-button-group rcx-button-group--stretch rcx-button-group--align-start"
role="group"
<button
class="rcx-box rcx-box--full rcx-button rcx-button-group__item"
type="button"
>
<button
class="rcx-box rcx-box--full rcx-button rcx-button-group__item"
type="button"
<span
class="rcx-button--content"
>
<span
class="rcx-button--content"
>
Cancel
</span>
</button>
<button
class="rcx-box rcx-box--full rcx-button--primary rcx-button rcx-button-group__item"
type="submit"
Cancel
</span>
</button>
<button
class="rcx-box rcx-box--full rcx-button--primary rcx-button rcx-button-group__item"
disabled=""
form=":r0:"
type="submit"
>
<span
class="rcx-button--content"
>
<span
class="rcx-button--content"
>
Save
</span>
</button>
</div>
Save
</span>
</button>
</div>
</form>
</div>
</div>
</div>
</body>
`;
exports[`AdminABACRoomAttributesForm renders WithLockedAttributes without crashing 1`] = `
exports[`AttributesForm renders WithLockedAttributes without crashing 1`] = `
<body>
<div>
<div
class="rcx-box rcx-box--full rcx-vertical-bar rcx-css-1ph8q3"
>
<form
class="rcx-box rcx-box--full"
id=":r3:"
<div
class="rcx-box rcx-box--full rcx-css-1svuzur"
>
<div
class="rcx-box rcx-box--full rcx-css-1svuzur"
class="rcx-box rcx-box--full rcx-css-vlo1oi rcx-css-1cb6i7s"
data-overlayscrollbars="host"
>
<div
class="rcx-box rcx-box--full rcx-css-vlo1oi rcx-css-1cb6i7s"
data-overlayscrollbars="host"
class="os-size-observer"
>
<div
class="os-size-observer"
>
<div
class="os-size-observer-listener"
/>
</div>
class="os-size-observer-listener"
/>
</div>
<div
class=""
data-overlayscrollbars-viewport="scrollbarHidden overflowXHidden overflowYHidden"
style="margin-right: 0px; margin-bottom: 0px; margin-left: 0px; top: 0px; left: 0px; width: calc(100% + 0px); padding: 0px 0px 0px 0px;"
tabindex="-1"
>
<div
class=""
data-overlayscrollbars-viewport="scrollbarHidden overflowXHidden overflowYHidden"
style="margin-right: 0px; margin-bottom: 0px; margin-left: 0px; top: 0px; left: 0px; width: calc(100% + 0px); padding: 0px 0px 0px 0px;"
tabindex="-1"
class="rcx-box rcx-box--full rcx-css-iag4sp"
>
<div
class="rcx-box rcx-box--full rcx-css-iag4sp"
<form
class="rcx-box rcx-box--full rcx-css-1jggbrp rcx-css-1fuhgt0"
id=":r3:"
>
<div
class="rcx-box rcx-box--full rcx-css-1jggbrp rcx-css-1fuhgt0"
class="rcx-box rcx-box--full"
>
Attribute values cannot be edited, but can be added or deleted.
</div>
<div
class="rcx-box rcx-box--full rcx-field rcx-css-1jggbrp rcx-css-1dd6ilo"
class="rcx-box rcx-box--full rcx-field rcx-css-1jagyun"
>
<label
class="rcx-box rcx-box--full rcx-field__label rcx-label"
@ -236,12 +232,9 @@ exports[`AdminABACRoomAttributesForm renders WithLockedAttributes without crashi
type="text"
/>
</span>
<span
class="rcx-box rcx-box--full rcx-field__error"
/>
</div>
<div
class="rcx-box rcx-box--full rcx-field rcx-css-1jggbrp rcx-css-1dd6ilo"
class="rcx-box rcx-box--full rcx-field rcx-css-1jagyun"
>
<label
class="rcx-box rcx-box--full rcx-field__label rcx-label"
@ -279,8 +272,8 @@ exports[`AdminABACRoomAttributesForm renders WithLockedAttributes without crashi
type="text"
/>
<button
aria-label="Remove"
class="rcx-box rcx-box--full rcx-button--large-square rcx-button--square rcx-button--icon rcx-button"
title="ABAC_Remove_attribute"
type="button"
>
<i
@ -303,8 +296,8 @@ exports[`AdminABACRoomAttributesForm renders WithLockedAttributes without crashi
type="text"
/>
<button
aria-label="Remove"
class="rcx-box rcx-box--full rcx-button--large-square rcx-button--square rcx-button--icon rcx-button"
title="ABAC_Remove_attribute"
type="button"
>
<i
@ -315,9 +308,6 @@ exports[`AdminABACRoomAttributesForm renders WithLockedAttributes without crashi
</i>
</button>
</span>
<span
class="rcx-box rcx-box--full rcx-field__error"
/>
<button
class="rcx-box rcx-box--full rcx-button"
type="button"
@ -329,64 +319,66 @@ exports[`AdminABACRoomAttributesForm renders WithLockedAttributes without crashi
</span>
</button>
</div>
</div>
</form>
</div>
</div>
<div
class="os-scrollbar os-scrollbar-horizontal os-theme-dark os-scrollbar-auto-hide os-scrollbar-auto-hide-hidden os-scrollbar-handle-interactive os-scrollbar-cornerless os-scrollbar-unusable"
style="--os-scroll-percent: 0; --os-viewport-percent: 0; --os-scroll-direction: 0;"
>
<div
class="os-scrollbar os-scrollbar-horizontal os-theme-dark os-scrollbar-auto-hide os-scrollbar-auto-hide-hidden os-scrollbar-handle-interactive os-scrollbar-cornerless os-scrollbar-unusable"
style="--os-scroll-percent: 0; --os-viewport-percent: 0; --os-scroll-direction: 0;"
class="os-scrollbar-track"
>
<div
class="os-scrollbar-track"
>
<div
class="os-scrollbar-handle"
/>
</div>
class="os-scrollbar-handle"
/>
</div>
</div>
<div
class="os-scrollbar os-scrollbar-vertical os-theme-dark os-scrollbar-auto-hide os-scrollbar-auto-hide-hidden os-scrollbar-handle-interactive os-scrollbar-cornerless os-scrollbar-unusable"
style="--os-scroll-percent: 0; --os-viewport-percent: 0; --os-scroll-direction: 0;"
>
<div
class="os-scrollbar os-scrollbar-vertical os-theme-dark os-scrollbar-auto-hide os-scrollbar-auto-hide-hidden os-scrollbar-handle-interactive os-scrollbar-cornerless os-scrollbar-unusable"
style="--os-scroll-percent: 0; --os-viewport-percent: 0; --os-scroll-direction: 0;"
class="os-scrollbar-track"
>
<div
class="os-scrollbar-track"
>
<div
class="os-scrollbar-handle"
/>
</div>
class="os-scrollbar-handle"
/>
</div>
</div>
</div>
</div>
<div
class="rcx-box rcx-box--full rcx-css-m843eh"
>
<div
class="rcx-box rcx-box--full rcx-css-m843eh"
class="rcx-button-group rcx-button-group--stretch rcx-button-group--align-start"
role="group"
>
<div
class="rcx-button-group rcx-button-group--stretch rcx-button-group--align-start"
role="group"
<button
class="rcx-box rcx-box--full rcx-button rcx-button-group__item"
type="button"
>
<button
class="rcx-box rcx-box--full rcx-button rcx-button-group__item"
type="button"
<span
class="rcx-button--content"
>
<span
class="rcx-button--content"
>
Cancel
</span>
</button>
<button
class="rcx-box rcx-box--full rcx-button--primary rcx-button rcx-button-group__item"
type="submit"
Cancel
</span>
</button>
<button
class="rcx-box rcx-box--full rcx-button--primary rcx-button rcx-button-group__item"
disabled=""
form=":r3:"
type="submit"
>
<span
class="rcx-button--content"
>
<span
class="rcx-button--content"
>
Save
</span>
</button>
</div>
Save
</span>
</button>
</div>
</form>
</div>
</div>
</div>
</body>

@ -0,0 +1,213 @@
import type { AbacAttributeDefinitionChangeType, AbacActionPerformed } from '@rocket.chat/core-typings';
import { Box, InputBox, Margins, Pagination } from '@rocket.chat/fuselage';
import { UserAvatar } from '@rocket.chat/ui-avatar';
import {
GenericTable,
GenericTableBody,
GenericTableCell,
GenericTableHeader,
GenericTableHeaderCell,
GenericTableRow,
usePagination,
} from '@rocket.chat/ui-client';
import { useEndpoint } from '@rocket.chat/ui-contexts';
import { useQuery } from '@tanstack/react-query';
import { useMemo, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import GenericNoResults from '../../../../components/GenericNoResults';
import { useFormatDateAndTime } from '../../../../hooks/useFormatDateAndTime';
import { ABACQueryKeys } from '../../../../lib/queryKeys';
import DateRangePicker from '../../moderation/helpers/DateRangePicker';
const LogsPage = () => {
const { t } = useTranslation();
const [startDate, setStartDate] = useState<string>(new Date().toISOString().split('T')[0]);
const [endDate, setEndDate] = useState<string>(new Date().toISOString().split('T')[0]);
const formatDate = useFormatDateAndTime();
const { current, itemsPerPage, setItemsPerPage, setCurrent, ...paginationProps } = usePagination();
const getLogs = useEndpoint('GET', '/v1/abac/audit');
const query = useMemo(
() => ({
...(startDate && { start: new Date(`${startDate}T00:00:00.000Z`).toISOString() }),
...(endDate && { end: new Date(`${endDate}T23:59:59.999Z`).toISOString() }),
offset: current,
count: itemsPerPage,
}),
[current, itemsPerPage, startDate, endDate],
);
// Whenever the user changes the filter or the text, reset the pagination to the first page
useEffect(() => {
setCurrent(0);
}, [startDate, endDate, setCurrent]);
const getActionLabel = (action?: AbacAttributeDefinitionChangeType | AbacActionPerformed | null) => {
switch (action) {
case 'created':
return t('Created');
case 'updated':
return t('Updated');
case 'deleted':
return t('Deleted');
case 'all-deleted':
return t('ABAC_All_Attributes_deleted');
case 'key-removed':
return t('ABAC_Key_removed');
case 'key-renamed':
return t('ABAC_Key_renamed');
case 'value-removed':
return t('ABAC_Value_removed');
case 'key-added':
return t('ABAC_Key_added');
case 'key-updated':
return t('ABAC_Key_updated');
case 'revoked-object-access':
return t('ABAC_Revoked_Object_Access');
case 'granted-object-access':
return t('ABAC_Granted_Object_Access');
default:
return '';
}
};
const { data, isLoading } = useQuery({
queryKey: ABACQueryKeys.logs.list(query),
queryFn: () => getLogs(query),
select: (data) => ({
events: data.events.map((event) => {
const eventInfo = {
id: event._id,
user: event.actor?.type === 'user' ? event.actor.username : t('System'),
...(event.actor?.type === 'user' && { userAvatar: <UserAvatar size='x28' userId={event.actor._id} /> }),
timestamp: new Date(event.ts),
element: t('ABAC_Room'),
action: getActionLabel(event.data?.find((item) => item.key === 'change')?.value),
room: undefined,
};
switch (event.t) {
case 'abac.attribute.changed':
return {
...eventInfo,
element: t('ABAC_Room_Attribute'),
name: event.data?.find((item) => item.key === 'attributeKey')?.value ?? '',
};
case 'abac.action.performed':
return {
...eventInfo,
name: event.data?.find((item) => item.key === 'subject')?.value?.username ?? '',
action: getActionLabel(event.data?.find((item) => item.key === 'action')?.value),
room: event.data?.find((item) => item.key === 'object')?.value?.name ?? '',
element: t('ABAC_room_membership'),
};
case 'abac.object.attribute.changed':
case 'abac.object.attributes.removed':
return {
...eventInfo,
name:
event.data
?.find((item) => item.key === 'current')
?.value?.map((item) => item.key)
.join(', ') ?? t('Empty'),
room: event.data?.find((item) => item.key === 'room')?.value?.name ?? '',
};
default:
return null;
}
}),
count: data.count,
offset: data.offset,
total: data.total,
}),
});
return (
<>
<Margins block={24}>
<Box display='flex'>
<InputBox
type='date'
placeholder={t('Start_date')}
value={startDate}
onChange={(e) => setStartDate((e.target as HTMLInputElement).value)}
/>
<Margins inlineStart={8}>
<InputBox
type='date'
placeholder={t('End_date')}
value={endDate}
onChange={(e) => setEndDate((e.target as HTMLInputElement).value)}
/>
</Margins>
<Margins inlineStart={8}>
<DateRangePicker
defaultSelectedKey='today'
onChange={(range) => {
setStartDate(range.start);
setEndDate(range.end);
}}
/>
</Margins>
</Box>
</Margins>
{(!data || data.events?.length === 0) && !isLoading ? (
<Box display='flex' justifyContent='center' height='full'>
<GenericNoResults icon='extended-view' title={t('ABAC_No_logs')} description={t('ABAC_No_logs_description')} />
</Box>
) : (
<>
<GenericTable>
<GenericTableHeader>
<GenericTableHeaderCell>{t('User')}</GenericTableHeaderCell>
<GenericTableHeaderCell>{t('Action')}</GenericTableHeaderCell>
<GenericTableHeaderCell>{t('Room')}</GenericTableHeaderCell>
<GenericTableHeaderCell>{t('ABAC_Element')}</GenericTableHeaderCell>
<GenericTableHeaderCell>{t('ABAC_Element_Name')}</GenericTableHeaderCell>
<GenericTableHeaderCell>{t('Timestamp')}</GenericTableHeaderCell>
</GenericTableHeader>
<GenericTableBody>
{data?.events.map((eventInfo) => {
if (!eventInfo) {
return null;
}
return (
<GenericTableRow key={eventInfo.id}>
<GenericTableCell withTruncatedText>
{eventInfo.userAvatar && (
<Box is='span' mie={4}>
{eventInfo.userAvatar}
</Box>
)}
{eventInfo.user}
</GenericTableCell>
<GenericTableCell withTruncatedText>{eventInfo.action}</GenericTableCell>
<GenericTableCell withTruncatedText>{eventInfo.room}</GenericTableCell>
<GenericTableCell withTruncatedText>{eventInfo.element}</GenericTableCell>
<GenericTableCell withTruncatedText title={eventInfo.name}>
{eventInfo.name}
</GenericTableCell>
<GenericTableCell withTruncatedText>{formatDate(eventInfo.timestamp)}</GenericTableCell>
</GenericTableRow>
);
})}
</GenericTableBody>
</GenericTable>
<Pagination
divider
current={current}
itemsPerPage={itemsPerPage}
count={data?.total || 0}
onSetItemsPerPage={setItemsPerPage}
onSetCurrent={setCurrent}
{...paginationProps}
/>
</>
)}
</>
);
};
export default LogsPage;

@ -0,0 +1,71 @@
import { faker } from '@faker-js/faker';
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import DeleteRoomModal from './DeleteRoomModal';
const mockDispatchToastMessage = jest.fn();
jest.mock('@rocket.chat/ui-contexts', () => ({
...jest.requireActual('@rocket.chat/ui-contexts'),
useToastMessageDispatch: () => mockDispatchToastMessage,
}));
const baseAppRoot = mockAppRoot().withTranslations('en', 'core', {
Edit: 'Edit',
Remove: 'Remove',
ABAC_Room_removed: 'Room {{roomName}} removed from ABAC management',
ABAC_Delete_room: 'Remove room from ABAC management',
ABAC_Delete_room_annotation: 'Proceed with caution',
ABAC_Delete_room_content: 'Removing <bold>{{roomName}}</bold> from ABAC management may result in unintended users gaining access.',
Cancel: 'Cancel',
});
describe('DeleteRoomModal', () => {
beforeEach(() => {
jest.clearAllMocks();
});
const rid = faker.database.mongodbObjectId();
const roomName = 'Test Room';
it('should render without crashing', () => {
const { baseElement } = render(<DeleteRoomModal rid={rid} roomName={roomName} onClose={jest.fn()} />, {
wrapper: baseAppRoot.build(),
});
expect(baseElement).toMatchSnapshot();
});
it('should call delete endpoint when delete is confirmed', async () => {
const deleteEndpointMock = jest.fn().mockResolvedValue(null);
render(<DeleteRoomModal rid={rid} roomName={roomName} onClose={jest.fn()} />, {
wrapper: baseAppRoot.withEndpoint('DELETE', '/v1/abac/rooms/:rid/attributes', deleteEndpointMock).build(),
});
await userEvent.click(screen.getByRole('button', { name: 'Remove' }));
await waitFor(() => {
expect(deleteEndpointMock).toHaveBeenCalled();
});
});
it('should show success toast when delete succeeds', async () => {
const deleteEndpointMock = jest.fn().mockResolvedValue(null);
render(<DeleteRoomModal rid={rid} roomName={roomName} onClose={jest.fn()} />, {
wrapper: baseAppRoot.withEndpoint('DELETE', '/v1/abac/rooms/:rid/attributes', deleteEndpointMock).build(),
});
await userEvent.click(screen.getByRole('button', { name: 'Remove' }));
await waitFor(() => {
expect(mockDispatchToastMessage).toHaveBeenCalledWith({
type: 'success',
message: 'Room Test Room removed from ABAC management',
});
});
});
});

@ -0,0 +1,48 @@
import type { IRoom } from '@rocket.chat/core-typings';
import { Box } from '@rocket.chat/fuselage';
import { GenericModal } from '@rocket.chat/ui-client';
import { useToastMessageDispatch } from '@rocket.chat/ui-contexts';
import { useQueryClient } from '@tanstack/react-query';
import { Trans, useTranslation } from 'react-i18next';
import { useEndpointMutation } from '../../../../hooks/useEndpointMutation';
import { ABACQueryKeys } from '../../../../lib/queryKeys';
type DeleteRoomModalProps = {
rid: IRoom['_id'];
roomName: string;
onClose: () => void;
};
const DeleteRoomModal = ({ rid, roomName, onClose }: DeleteRoomModalProps) => {
const { t } = useTranslation();
const queryClient = useQueryClient();
const dispatchToastMessage = useToastMessageDispatch();
const deleteMutation = useEndpointMutation('DELETE', '/v1/abac/rooms/:rid/attributes', {
keys: { rid },
onSuccess: () => {
dispatchToastMessage({ type: 'success', message: t('ABAC_Room_removed', { roomName }) });
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ABACQueryKeys.rooms.all() });
onClose();
},
});
return (
<GenericModal
variant='danger'
icon={null}
title={t('ABAC_Delete_room')}
annotation={t('ABAC_Delete_room_annotation')}
confirmText={t('Remove')}
onConfirm={() => deleteMutation.mutate(undefined)}
onCancel={onClose}
>
<Trans i18nKey='ABAC_Delete_room_content' values={{ roomName }} components={{ bold: <Box is='span' fontWeight='bold' /> }} />
</GenericModal>
);
};
export default DeleteRoomModal;

@ -0,0 +1,131 @@
import { Box, Field, FieldLabel, FieldRow, FieldError, ButtonGroup, Button, ContextualbarFooter } from '@rocket.chat/fuselage';
import { useEffectEvent } from '@rocket.chat/fuselage-hooks';
import { GenericModal, ContextualbarScrollableContent } from '@rocket.chat/ui-client';
import { useSetModal } from '@rocket.chat/ui-contexts';
import type { Dispatch, SetStateAction } from 'react';
import { useId } from 'react';
import { Controller, useFieldArray, useFormContext } from 'react-hook-form';
import { Trans, useTranslation } from 'react-i18next';
import RoomFormAttributeFields from './RoomFormAttributeFields';
import RoomFormAutocomplete from './RoomFormAutocomplete';
import RoomFormAutocompleteDummy from './RoomFormAutocompleteDummy';
type RoomFormProps = {
onClose: () => void;
onSave: (data: RoomFormData) => void;
roomInfo?: { rid: string; name: string };
setSelectedRoomLabel: Dispatch<SetStateAction<string>>;
};
export type RoomFormData = {
room: string;
attributes: { key: string; values: string[] }[];
};
const RoomForm = ({ onClose, onSave, roomInfo, setSelectedRoomLabel }: RoomFormProps) => {
const {
control,
handleSubmit,
formState: { isValid, errors, isDirty },
} = useFormContext<RoomFormData>();
const { t } = useTranslation();
const formId = useId();
const nameField = useId();
const { fields, append, remove } = useFieldArray({
name: 'attributes',
control,
});
const setModal = useSetModal();
const updateAction = useEffectEvent(async (action: () => void) => {
setModal(
<GenericModal
variant='info'
icon={null}
title={t('ABAC_Update_room_confirmation_modal_title')}
annotation={t('ABAC_Update_room_confirmation_modal_annotation')}
confirmText={t('Save_changes')}
onConfirm={() => {
action();
setModal(null);
}}
onCancel={() => setModal(null)}
>
<Trans
i18nKey='ABAC_Update_room_content'
values={{ roomName: roomInfo?.name }}
components={{ bold: <Box is='span' fontWeight='bold' /> }}
/>
</GenericModal>,
);
});
const handleSave = useEffectEvent(() => {
if (roomInfo) {
updateAction(handleSubmit(onSave));
} else {
handleSubmit(onSave)();
}
});
return (
<>
<ContextualbarScrollableContent>
<Box is='form' onSubmit={handleSubmit(handleSave)} id={formId}>
<Field mb={16}>
<FieldLabel id={nameField} required>
{t('ABAC_Room_to_be_managed')}
</FieldLabel>
<FieldRow>
{roomInfo ? (
<RoomFormAutocompleteDummy roomInfo={roomInfo} />
) : (
<Controller
name='room'
control={control}
rules={{ required: t('Required_field', { field: t('ABAC_Room_to_be_managed') }) }}
render={({ field }) => (
<RoomFormAutocomplete
{...field}
error={!!errors.room?.message}
aria-labelledby={nameField}
onSelectedRoom={(value: string, label: string) => {
field.onChange(value);
setSelectedRoomLabel(label);
}}
/>
)}
/>
)}
</FieldRow>
{errors.room && <FieldError>{errors.room.message}</FieldError>}
</Field>
<RoomFormAttributeFields fields={fields} remove={remove} />
<Button
w='full'
disabled={fields.length >= 10}
onClick={() => {
append({ key: '', values: [] });
}}
>
{t('ABAC_Add_Attribute')}
</Button>
</Box>
</ContextualbarScrollableContent>
<ContextualbarFooter>
<ButtonGroup stretch>
<Button onClick={onClose}>{t('Cancel')}</Button>
<Button type='submit' form={formId} disabled={!isValid || !isDirty} primary>
{t('Save')}
</Button>
</ButtonGroup>
</ContextualbarFooter>
</>
);
};
export default RoomForm;

@ -0,0 +1,13 @@
import { composeStories } from '@storybook/react';
import { render } from '@testing-library/react';
import * as stories from './RoomFormAttributeField.stories';
describe('RoomFormAttributeField', () => {
const testCases = Object.values(composeStories(stories)).map((Story) => [Story.storyName || 'Story', Story]);
test.each(testCases)(`renders %s without crashing`, async (_storyname, Story) => {
const { baseElement } = render(<Story />);
expect(baseElement).toMatchSnapshot();
});
});

@ -0,0 +1,73 @@
import { Field } from '@rocket.chat/fuselage';
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { action } from '@storybook/addon-actions';
import type { Meta, StoryObj } from '@storybook/react';
import { FormProvider, useForm } from 'react-hook-form';
import type { RoomFormData } from './RoomForm';
import RoomFormAttributeField from './RoomFormAttributeField';
const mockAttribute1 = {
_id: 'attr1',
_updatedAt: new Date().toISOString(),
key: 'Department',
values: ['Engineering', 'Sales', 'Marketing'],
};
const mockAttribute2 = {
_id: 'attr2',
_updatedAt: new Date().toISOString(),
key: 'Security-Level',
values: ['Public', 'Internal', 'Confidential'],
};
const mockAttribute3 = {
_id: 'attr3',
_updatedAt: new Date().toISOString(),
key: 'Location',
values: ['US', 'EU', 'APAC'],
};
const meta: Meta<typeof RoomFormAttributeField> = {
component: RoomFormAttributeField,
parameters: {
layout: 'padded',
},
decorators: [
(Story) => {
const AppRoot = mockAppRoot().build();
const methods = useForm<RoomFormData>({
defaultValues: {
room: '',
attributes: [{ key: '', values: [] }],
},
mode: 'onChange',
});
return (
<AppRoot>
<FormProvider {...methods}>
<Field>
<Story />
</Field>
</FormProvider>
</AppRoot>
);
},
],
args: {
onRemove: action('onRemove'),
attributeList: [
{ value: mockAttribute1.key, label: mockAttribute1.key, attributeValues: mockAttribute1.values },
{ value: mockAttribute2.key, label: mockAttribute2.key, attributeValues: mockAttribute2.values },
{ value: mockAttribute3.key, label: mockAttribute3.key, attributeValues: mockAttribute3.values },
],
index: 0,
},
};
export default meta;
type Story = StoryObj<typeof RoomFormAttributeField>;
export const Default: Story = {};

@ -0,0 +1,93 @@
import type { SelectOption } from '@rocket.chat/fuselage';
import { Box, Button, FieldError, FieldRow, MultiSelect, SelectFiltered } from '@rocket.chat/fuselage';
import { useCallback, useMemo } from 'react';
import { useController, useFormContext } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import type { RoomFormData } from './RoomForm';
type ABACAttributeAutocompleteProps = {
onRemove: () => void;
index: number;
attributeList: { value: string; label: string; attributeValues: string[] }[];
};
const RoomFormAttributeField = ({ onRemove, index, attributeList }: ABACAttributeAutocompleteProps) => {
const { t } = useTranslation();
const { control, getValues, resetField } = useFormContext<RoomFormData>();
const options: SelectOption[] = useMemo(() => attributeList.map((attribute) => [attribute.value, attribute.label]), [attributeList]);
const validateRepeatedAttributes = useCallback(
(value: string) => {
const attributes = getValues('attributes');
// Only one instance of the same attribute is allowed to be in the form at a time
const repeatedAttributes = attributes.filter((attribute) => attribute.key === value).length > 1;
return repeatedAttributes ? t('ABAC_No_repeated_attributes') : undefined;
},
[getValues, t],
);
const { field: keyField, fieldState: keyFieldState } = useController({
name: `attributes.${index}.key`,
control,
rules: {
required: t('Required_field', { field: t('Attribute') }),
validate: validateRepeatedAttributes,
},
});
const { field: valuesField, fieldState: valuesFieldState } = useController({
name: `attributes.${index}.values`,
control,
rules: { required: t('Required_field', { field: t('Attribute_Values') }) },
});
const valueOptions: [string, string][] = useMemo(() => {
if (!keyField.value) {
return [];
}
const selectedAttributeData = attributeList.find((option) => option.value === keyField.value);
return selectedAttributeData?.attributeValues.map((value) => [value, value]) || [];
}, [attributeList, keyField.value]);
return (
<Box display='flex' flexDirection='column' w='full'>
<FieldRow>
<SelectFiltered
{...keyField}
options={options}
placeholder={t('ABAC_Search_Attribute')}
mbe={4}
error={keyFieldState.error?.message}
withTruncatedText
onChange={(value) => {
keyField.onChange(value);
resetField(`attributes.${index}.values`, { defaultValue: [] });
}}
/>
</FieldRow>
{keyFieldState.error && <FieldError>{keyFieldState.error.message}</FieldError>}
<FieldRow>
<MultiSelect
withTruncatedText
{...valuesField}
options={valueOptions}
placeholder={t('ABAC_Select_Attribute_Values')}
error={valuesFieldState.error?.message}
/>
</FieldRow>
{valuesFieldState.error && <FieldError>{valuesFieldState.error.message}</FieldError>}
<Button onClick={onRemove} title={t('Remove')} mbs={4}>
{t('Remove')}
</Button>
</Box>
);
};
export default RoomFormAttributeField;

@ -0,0 +1,136 @@
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { render, screen } from '@testing-library/react';
import type { ReactNode } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
import type { RoomFormData } from './RoomForm';
import RoomFormAttributeFields from './RoomFormAttributeFields';
const mockAttribute1 = {
_id: 'attr1',
key: 'Department',
values: ['Engineering', 'Sales', 'Marketing'],
};
const mockAttribute2 = {
_id: 'attr2',
key: 'Security-Level',
values: ['Public', 'Internal', 'Confidential'],
};
const mockAttribute3 = {
_id: 'attr3',
key: 'Location',
values: ['US', 'EU', 'APAC'],
};
jest.mock('../hooks/useAttributeList', () => ({
useAttributeList: jest.fn(() => ({
data: {
attributes: [
{ value: mockAttribute1.key, label: mockAttribute1.key, attributeValues: mockAttribute1.values },
{ value: mockAttribute2.key, label: mockAttribute2.key, attributeValues: mockAttribute2.values },
{ value: mockAttribute3.key, label: mockAttribute3.key, attributeValues: mockAttribute3.values },
],
},
isLoading: false,
})),
}));
const appRoot = mockAppRoot()
.withTranslations('en', 'core', {
Attribute: 'Attribute',
ABAC_Search_Attribute: 'Search attribute',
ABAC_Select_Attribute_Values: 'Select attribute values',
Remove: 'Remove',
})
.build();
const FormProviderWrapper = ({ children, defaultValues }: { children: ReactNode; defaultValues?: Partial<RoomFormData> }) => {
const methods = useForm<RoomFormData>({
defaultValues: {
room: '',
attributes: [{ key: '', values: [] }],
...defaultValues,
},
mode: 'onChange',
});
return <FormProvider {...methods}>{children}</FormProvider>;
};
describe('RoomFormAttributeFields', () => {
const mockRemove = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
});
it('should render the correct number of fields', () => {
const fields = [{ id: 'field-1' }, { id: 'field-2' }, { id: 'field-3' }];
render(
<FormProviderWrapper>
<RoomFormAttributeFields fields={fields} remove={mockRemove} />
</FormProviderWrapper>,
{ wrapper: appRoot },
);
const attributeLabels = screen.getAllByText('Attribute');
expect(attributeLabels).toHaveLength(3);
});
it('should render a single field', () => {
const fields = [{ id: 'field-1' }];
render(
<FormProviderWrapper>
<RoomFormAttributeFields fields={fields} remove={mockRemove} />
</FormProviderWrapper>,
{ wrapper: appRoot },
);
const attributeLabels = screen.getAllByText('Attribute');
expect(attributeLabels).toHaveLength(1);
});
it('should render multiple fields', () => {
const fields = [{ id: 'field-1' }, { id: 'field-2' }, { id: 'field-3' }, { id: 'field-4' }, { id: 'field-5' }];
render(
<FormProviderWrapper>
<RoomFormAttributeFields fields={fields} remove={mockRemove} />
</FormProviderWrapper>,
{ wrapper: appRoot },
);
const attributeLabels = screen.getAllByText('Attribute');
expect(attributeLabels).toHaveLength(5);
});
it('should render fields with provided default values', () => {
const fields = [{ id: 'field-1' }, { id: 'field-2' }];
render(
<FormProviderWrapper
defaultValues={{
attributes: [
{ key: 'Department', values: ['Engineering'] },
{ key: 'Security-Level', values: ['Public', 'Internal'] },
],
}}
>
<RoomFormAttributeFields fields={fields} remove={mockRemove} />
</FormProviderWrapper>,
{ wrapper: appRoot },
);
const attributeLabels = screen.getAllByText('Attribute');
expect(attributeLabels).toHaveLength(2);
expect(screen.getByText('Department')).toBeInTheDocument();
expect(screen.getByText('Engineering')).toBeInTheDocument();
expect(screen.getByText('Security-Level')).toBeInTheDocument();
expect(screen.getByText('Public')).toBeInTheDocument();
expect(screen.getByText('Internal')).toBeInTheDocument();
});
});

@ -0,0 +1,37 @@
import { Field, FieldLabel, InputBoxSkeleton } from '@rocket.chat/fuselage';
import { useTranslation } from 'react-i18next';
import RoomFormAttributeField from './RoomFormAttributeField';
import { useAttributeList } from '../hooks/useAttributeList';
type RoomFormAttributeFieldsProps = {
fields: { id: string }[];
remove: (index: number) => void;
};
const RoomFormAttributeFields = ({ fields, remove }: RoomFormAttributeFieldsProps) => {
const { t } = useTranslation();
const { data: attributeList, isLoading } = useAttributeList();
if (isLoading || !attributeList) {
return <InputBoxSkeleton />;
}
return fields.map((field, index) => (
<Field key={field.id} mb={16}>
<FieldLabel htmlFor={field.id} required>
{t('Attribute')}
</FieldLabel>
<RoomFormAttributeField
attributeList={attributeList.attributes}
onRemove={() => {
remove(index);
}}
index={index}
/>
</Field>
));
};
export default RoomFormAttributeFields;

@ -0,0 +1,60 @@
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { composeStories } from '@storybook/react';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { axe } from 'jest-axe';
import RoomFormAutocomplete from './RoomFormAutocomplete';
import * as stories from './RoomFormAutocomplete.stories';
import { createFakeRoom } from '../../../../../tests/mocks/data';
const mockRoom1 = createFakeRoom({ t: 'p', name: 'Room 1', fname: 'Room 1' });
const mockRoom2 = createFakeRoom({ t: 'p', name: 'Room 2', fname: 'Room 2' });
const mockRoom3 = createFakeRoom({ t: 'p', name: 'Room 3', fname: 'Room 3', abacAttributes: [] });
const appRoot = mockAppRoot()
.withEndpoint('GET', '/v1/rooms.adminRooms.privateRooms', () => ({
rooms: [mockRoom1 as any, mockRoom2 as any, mockRoom3 as any],
count: 3,
offset: 0,
total: 3,
}))
.build();
describe('RoomFormAutocomplete', () => {
const testCases = Object.values(composeStories(stories)).map((Story) => [Story.storyName || 'Story', Story]);
test.each(testCases)(`renders %s without crashing`, async (_storyname, Story) => {
const { baseElement } = render(<Story />);
expect(baseElement).toMatchSnapshot();
});
test.each(testCases)('%s should have no a11y violations', async (_storyname, Story) => {
// Aria label added in a higher level
const { container } = render(<Story aria-label='ABAC Room Autocomplete' />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('should populate select options correctly', async () => {
render(<RoomFormAutocomplete value='' onSelectedRoom={jest.fn()} />, {
wrapper: appRoot,
});
const input = screen.getByRole('textbox');
await userEvent.click(input);
await waitFor(() => {
expect(screen.getByText('Room 1')).toBeInTheDocument();
});
await waitFor(() => {
expect(screen.getByText('Room 2')).toBeInTheDocument();
});
await waitFor(() => {
expect(screen.getByText('Room 3')).toBeInTheDocument();
});
});
});

@ -0,0 +1,42 @@
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { action } from '@storybook/addon-actions';
import type { Meta, StoryObj } from '@storybook/react';
import RoomFormAutocomplete from './RoomFormAutocomplete';
import { createFakeRoom } from '../../../../../tests/mocks/data';
const mockRoom1 = createFakeRoom({ t: 'p', name: 'Room 1' });
const mockRoom2 = createFakeRoom({ t: 'p', name: 'Room 2' });
const meta: Meta<typeof RoomFormAutocomplete> = {
component: RoomFormAutocomplete,
parameters: {
layout: 'padded',
},
decorators: [
(Story) => {
const AppRoot = mockAppRoot()
.withEndpoint('GET', '/v1/rooms.adminRooms', () => ({
rooms: [mockRoom1 as any, mockRoom2 as any],
count: 2,
offset: 0,
total: 2,
}))
.build();
return (
<AppRoot>
<Story />
</AppRoot>
);
},
],
args: {
value: '',
onSelectedRoom: action('onChange'),
},
};
export default meta;
type Story = StoryObj<typeof RoomFormAutocomplete>;
export const Default: Story = {};

@ -0,0 +1,58 @@
import { AutoComplete, Option, Box } from '@rocket.chat/fuselage';
import { useDebouncedValue } from '@rocket.chat/fuselage-hooks';
import { useEndpoint } from '@rocket.chat/ui-contexts';
import { keepPreviousData, useQuery } from '@tanstack/react-query';
import type { ComponentProps } from 'react';
import { memo, useState } from 'react';
import { ABACQueryKeys } from '../../../../lib/queryKeys';
const generateQuery = (
term = '',
): {
filter: string;
} => ({ filter: term });
type RoomFormAutocompleteProps = Omit<ComponentProps<typeof AutoComplete>, 'filter' | 'onChange'> & {
onSelectedRoom: (value: string, label: string) => void;
};
const RoomFormAutocomplete = ({ value, onSelectedRoom, ...props }: RoomFormAutocompleteProps) => {
const [filter, setFilter] = useState('');
const filterDebounced = useDebouncedValue(filter, 300);
const roomsAutoCompleteEndpoint = useEndpoint('GET', '/v1/rooms.adminRooms.privateRooms');
const result = useQuery({
queryKey: ABACQueryKeys.rooms.autocomplete(generateQuery(filterDebounced)),
queryFn: () => roomsAutoCompleteEndpoint(generateQuery(filterDebounced)),
placeholderData: keepPreviousData,
select: (data) =>
data.rooms
.filter((room) => !room.abacAttributes || room.abacAttributes.length === 0)
.map((room) => ({
value: room._id,
label: { name: room.fname || room.name },
})),
});
return (
<AutoComplete
{...props}
onChange={(val) => {
onSelectedRoom(val as string, result.data?.find(({ value }) => value === val)?.label?.name || '');
}}
value={value}
filter={filter}
setFilter={setFilter}
renderSelected={({ selected: { label } }) => (
<Box margin='none' mi={2}>
{label?.name}
</Box>
)}
renderItem={({ label, ...props }) => <Option {...props} label={label.name} />}
options={result.data}
/>
);
};
export default memo(RoomFormAutocomplete);

@ -0,0 +1,11 @@
import { Input } from '@rocket.chat/fuselage';
type RoomFormAutocompleteDummyProps = {
roomInfo: { rid: string; name: string };
};
const RoomFormAutocompleteDummy = ({ roomInfo }: RoomFormAutocompleteDummyProps) => {
return <Input value={roomInfo.name} disabled />;
};
export default RoomFormAutocompleteDummy;

@ -0,0 +1,28 @@
import { GenericMenu } from '@rocket.chat/ui-client';
import { useTranslation } from 'react-i18next';
import { useRoomItems } from '../hooks/useRoomItems';
type RoomMenuProps = {
room: { rid: string; name: string };
};
const RoomMenu = ({ room }: RoomMenuProps) => {
const { t } = useTranslation();
const items = useRoomItems(room);
return (
<GenericMenu
title={t('Options')}
icon='kebab'
sections={[
{
items,
},
]}
/>
);
};
export default RoomMenu;

@ -0,0 +1,89 @@
import { ContextualbarTitle } from '@rocket.chat/fuselage';
import { ContextualbarClose, ContextualbarHeader } from '@rocket.chat/ui-client';
import { useEndpoint, useRouteParameter, useToastMessageDispatch } from '@rocket.chat/ui-contexts';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import RoomForm from './RoomForm';
import { ABACQueryKeys } from '../../../../lib/queryKeys';
type RoomsContextualBarProps = {
attributeId?: string;
roomInfo?: { rid: string; name: string };
attributesData?: { key: string; values: string[] }[];
onClose: () => void;
};
const RoomsContextualBar = ({ roomInfo, attributesData, onClose }: RoomsContextualBarProps) => {
const { t } = useTranslation();
const queryClient = useQueryClient();
const methods = useForm<{
room: string;
attributes: { key: string; values: string[] }[];
}>({
defaultValues: {
room: roomInfo?.rid || '',
attributes: attributesData ?? [{ key: '', values: [] }],
},
mode: 'onChange',
});
const { watch } = methods;
const [selectedRoomLabel, setSelectedRoomLabel] = useState<string>('');
const attributeId = useRouteParameter('id');
const createOrUpdateABACRoom = useEndpoint('POST', '/v1/abac/rooms/:rid/attributes', { rid: watch('room') });
const dispatchToastMessage = useToastMessageDispatch();
const saveMutation = useMutation({
mutationFn: async (data: { room: string; attributes: { key: string; values: string[] }[] }) => {
const payload = {
attributes: data.attributes.reduce((acc: Record<string, string[]>, attribute) => {
acc[attribute.key] = attribute.values;
return acc;
}, {}),
};
await createOrUpdateABACRoom(payload);
},
onSuccess: () => {
if (attributeId) {
dispatchToastMessage({ type: 'success', message: t('ABAC_Room_updated', { roomName: selectedRoomLabel }) });
} else {
dispatchToastMessage({ type: 'success', message: t('ABAC_Room_created', { roomName: selectedRoomLabel }) });
}
onClose();
},
onError: (error) => {
dispatchToastMessage({ type: 'error', message: error });
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ABACQueryKeys.rooms.list() });
},
});
return (
<>
<ContextualbarHeader>
<ContextualbarTitle>{t(attributeId ? 'ABAC_Edit_Room' : 'ABAC_Add_room')}</ContextualbarTitle>
<ContextualbarClose onClick={onClose} />
</ContextualbarHeader>
<FormProvider {...methods}>
<RoomForm
roomInfo={roomInfo}
onSave={(values) => saveMutation.mutateAsync(values)}
onClose={onClose}
setSelectedRoomLabel={setSelectedRoomLabel}
/>
</FormProvider>
</>
);
};
export default RoomsContextualBar;

@ -0,0 +1,34 @@
import { ContextualbarSkeletonBody } from '@rocket.chat/ui-client';
import { useEndpoint } from '@rocket.chat/ui-contexts';
import { useQuery } from '@tanstack/react-query';
import RoomsContextualBar from './RoomsContextualBar';
import { ABACQueryKeys } from '../../../../lib/queryKeys';
type RoomsContextualBarWithDataProps = {
id: string;
onClose: () => void;
};
const RoomsContextualBarWithData = ({ id, onClose }: RoomsContextualBarWithDataProps) => {
const getRoomAttributes = useEndpoint('GET', '/v1/rooms.adminRooms.getRoom');
const { data, isLoading, isFetching } = useQuery({
queryKey: ABACQueryKeys.rooms.room(id),
queryFn: () => getRoomAttributes({ rid: id }),
staleTime: 0,
});
if (isLoading || isFetching) {
return <ContextualbarSkeletonBody />;
}
return (
<RoomsContextualBar
roomInfo={{ rid: id, name: data?.fname || data?.name || id }}
attributesData={data?.abacAttributes}
onClose={onClose}
/>
);
};
export default RoomsContextualBarWithData;

@ -0,0 +1,137 @@
import { Box, Button, Icon, Margins, Pagination, Select, TextInput } from '@rocket.chat/fuselage';
import { useDebouncedValue, useEffectEvent } from '@rocket.chat/fuselage-hooks';
import {
GenericTable,
GenericTableBody,
GenericTableCell,
GenericTableHeader,
GenericTableHeaderCell,
GenericTableRow,
usePagination,
} from '@rocket.chat/ui-client';
import { useEndpoint, useRouter } from '@rocket.chat/ui-contexts';
import { useQuery } from '@tanstack/react-query';
import { useMemo, useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import RoomMenu from './RoomMenu';
import GenericNoResults from '../../../../components/GenericNoResults';
import { ABACQueryKeys } from '../../../../lib/queryKeys';
import { useIsABACAvailable } from '../hooks/useIsABACAvailable';
const RoomsPage = () => {
const { t } = useTranslation();
const [text, setText] = useState('');
const [filterType, setFilterType] = useState<'all' | 'roomName' | 'attribute' | 'value'>('all');
const debouncedText = useDebouncedValue(text, 200);
const { current, itemsPerPage, setItemsPerPage, setCurrent, ...paginationProps } = usePagination();
const getRooms = useEndpoint('GET', '/v1/abac/rooms');
const isABACAvailable = useIsABACAvailable();
const router = useRouter();
const handleNewAttribute = useEffectEvent(() => {
router.navigate({
name: 'admin-ABAC',
params: {
tab: 'rooms',
context: 'new',
},
});
});
const query = useMemo(
() => ({
...(debouncedText ? { filter: debouncedText } : {}),
...(filterType !== 'all' ? { filterType } : {}),
offset: current,
count: itemsPerPage,
}),
[debouncedText, current, itemsPerPage, filterType],
);
// Whenever the user changes the filter or the text, reset the pagination to the first page
useEffect(() => {
setCurrent(0);
}, [debouncedText, filterType, setCurrent]);
const { data, isLoading } = useQuery({
queryKey: ABACQueryKeys.rooms.list(query),
queryFn: () => getRooms(query),
});
return (
<>
<Margins block={24}>
<Box display='flex'>
<TextInput
addon={<Icon name='magnifier' size='x20' />}
placeholder={t('ABAC_Search_rooms')}
value={text}
onChange={(e) => setText((e.target as HTMLInputElement).value)}
/>
<Box pis={8} maxWidth={200}>
<Select
options={[
['all', t('All'), true],
['roomName', t('Rooms'), false],
['attribute', t('Attributes'), false],
['value', t('Values'), false],
]}
value={filterType}
onChange={(value) => setFilterType(value as 'all' | 'roomName' | 'attribute' | 'value')}
/>
</Box>
<Button onClick={handleNewAttribute} primary mis={8} disabled={isABACAvailable !== true}>
{t('Add_room')}
</Button>
</Box>
</Margins>
{(!data || data.rooms?.length === 0) && !isLoading ? (
<Box display='flex' justifyContent='center' height='full'>
<GenericNoResults icon='list-alt' title={t('ABAC_No_rooms')} description={t('ABAC_No_rooms_description')} />
</Box>
) : (
<>
<GenericTable>
<GenericTableHeader>
<GenericTableHeaderCell>{t('Room')}</GenericTableHeaderCell>
<GenericTableHeaderCell>{t('Members')}</GenericTableHeaderCell>
<GenericTableHeaderCell>{t('ABAC_Attributes')}</GenericTableHeaderCell>
<GenericTableHeaderCell>{t('ABAC_Attribute_Values')}</GenericTableHeaderCell>
<GenericTableHeaderCell key='spacer' w={40} />
</GenericTableHeader>
<GenericTableBody>
{data?.rooms?.map((room) => (
<GenericTableRow key={room._id}>
<GenericTableCell>{room.fname || room.name}</GenericTableCell>
<GenericTableCell>{room.usersCount}</GenericTableCell>
<GenericTableCell withTruncatedText>
{room.abacAttributes?.flatMap((attribute) => attribute.key ?? []).join(', ')}
</GenericTableCell>
<GenericTableCell withTruncatedText>
{room.abacAttributes?.flatMap((attribute) => attribute.values ?? []).join(', ')}
</GenericTableCell>
<GenericTableCell>
<RoomMenu room={{ rid: room._id, name: room.fname || room.name || room._id }} />
</GenericTableCell>
</GenericTableRow>
))}
</GenericTableBody>
</GenericTable>
<Pagination
divider
current={current}
itemsPerPage={itemsPerPage}
count={data?.total || 0}
onSetItemsPerPage={setItemsPerPage}
onSetCurrent={setCurrent}
{...paginationProps}
/>
</>
)}
</>
);
};
export default RoomsPage;

@ -0,0 +1,98 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`DeleteRoomModal should render without crashing 1`] = `
<body>
<div>
<dialog
aria-labelledby=":r0:-title"
aria-modal="true"
class="rcx-box rcx-box--full rcx-modal"
open=""
>
<div
class="rcx-box rcx-box--full rcx-modal__inner rcx-css-1euli2f"
>
<header
class="rcx-box rcx-box--full rcx-modal__header"
>
<div
class="rcx-box rcx-box--full rcx-modal__header-inner"
>
<div
class="rcx-box rcx-box--full rcx-modal__header-text rcx-css-trljwa rcx-css-lma364"
>
<h2
class="rcx-box rcx-box--full rcx-modal__title"
id=":r0:-title"
>
Remove room from ABAC management
</h2>
</div>
<button
aria-label="Close"
class="rcx-box rcx-box--full rcx-button--small-square rcx-button--square rcx-button--icon rcx-button rcx-css-trljwa rcx-css-lma364"
type="button"
>
<i
aria-hidden="true"
class="rcx-box rcx-box--full rcx-icon--name-cross rcx-icon rcx-css-4pvxx3"
>
</i>
</button>
</div>
</header>
<div
class="rcx-box rcx-box--full rcx-modal__content rcx-css-1vw7itl"
>
<div
class="rcx-box rcx-box--full rcx-modal__content-wrapper rcx-css-r1bpeb"
>
Removing
<span
class="rcx-box rcx-box--full rcx-css-skyl9j"
>
Test Room
</span>
from ABAC management may result in unintended users gaining access.
</div>
</div>
<div
class="rcx-box rcx-box--full rcx-modal__footer rcx-css-rzpqmp"
>
<div
class="rcx-box rcx-box--full rcx-modal__footer-annotation"
>
Proceed with caution
</div>
<div
class="rcx-button-group rcx-button-group--align-end"
role="group"
>
<button
class="rcx-box rcx-box--full rcx-button--secondary rcx-button rcx-button-group__item"
type="button"
>
<span
class="rcx-button--content"
>
Cancel
</span>
</button>
<button
class="rcx-box rcx-box--full rcx-button--danger rcx-button rcx-button-group__item"
type="button"
>
<span
class="rcx-button--content"
>
Remove
</span>
</button>
</div>
</div>
</div>
</dialog>
</div>
</body>
`;

@ -0,0 +1,95 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`RoomFormAttributeField renders Default without crashing 1`] = `
<body>
<div>
<div
class="rcx-box rcx-box--full rcx-field"
>
<div
class="rcx-box rcx-box--full rcx-css-1g16yxh"
>
<span
class="rcx-box rcx-box--full rcx-field__row"
>
<div
class="rcx-box rcx-box--full rcx-select rcx-css-1hx4au7"
name="attributes.0.key"
>
<div
class="rcx-box rcx-box--full rcx-select__wrapper--hidden rcx-select__wrapper rcx-css-bpb89k"
>
<span
class="rcx-box rcx-box--full rcx-select__item rcx-css-4wu0oz"
>
ABAC_Search_Attribute
</span>
<input
class="rcx-box rcx-box--full rcx-box--animated rcx-input-box--undecorated rcx-input-box rcx-select__focus rcx-css-1l0s473"
placeholder="ABAC_Search_Attribute"
value=""
/>
<div
class="rcx-box rcx-box--full rcx-select__addon rcx-css-x7bl3q rcx-css-6bg1ps"
>
<i
aria-hidden="true"
class="rcx-box rcx-box--full rcx-icon--name-chevron-down rcx-icon rcx-css-4pvxx3"
>
</i>
</div>
</div>
</div>
</span>
<span
class="rcx-box rcx-box--full rcx-field__row"
>
<div
class="rcx-box rcx-box--full rcx-select rcx-css-1te28na"
name="attributes.0.values"
>
<div
class="rcx-box rcx-box--full rcx-css-1sr8su7"
>
<div
class="rcx-box rcx-box--full rcx-css-w398ts"
role="listbox"
>
<button
aria-haspopup="listbox"
class="rcx-box rcx-box--full rcx-input-box--undecorated rcx-select__focus rcx-css-trljwa rcx-css-3fq6z9"
type="button"
>
ABAC_Select_Attribute_Values
</button>
</div>
</div>
<div
class="rcx-box rcx-box--full rcx-select__addon rcx-css-x7bl3q rcx-css-1bprq55"
>
<i
aria-hidden="true"
class="rcx-box rcx-box--full rcx-icon--name-chevron-down rcx-icon rcx-css-4pvxx3"
>
</i>
</div>
</div>
</span>
<button
class="rcx-box rcx-box--full rcx-button rcx-css-1qsx7et"
title="Remove"
type="button"
>
<span
class="rcx-button--content"
>
Remove
</span>
</button>
</div>
</div>
</div>
</body>
`;

@ -0,0 +1,31 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`RoomFormAutocomplete renders Default without crashing 1`] = `
<body>
<div>
<div
class="rcx-box rcx-box--full rcx-autocomplete rcx-css-t3n91h"
>
<div
class="rcx-box rcx-box--full rcx-css-6d871f"
role="group"
>
<input
class="rcx-box rcx-box--full rcx-box--animated rcx-input-box--undecorated rcx-input-box rcx-css-trljwa rcx-css-rcil7k"
value=""
/>
</div>
<div
class="rcx-box rcx-box--full rcx-autocomplete__addon"
>
<i
aria-hidden="true"
class="rcx-box rcx-box--full rcx-icon--name-magnifier rcx-icon rcx-css-1wz6xj9"
>
</i>
</div>
</div>
</div>
</body>
`;

@ -0,0 +1,95 @@
import type { SettingValue } from '@rocket.chat/core-typings';
import { useSetModal, useSettingsDispatch } from '@rocket.chat/ui-contexts';
import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import WarningModal from './WarningModal';
import type { EditableSetting } from '../../EditableSettingsContext';
import { useEditableSetting } from '../../EditableSettingsContext';
import MemoizedSetting from '../../settings/Setting/MemoizedSetting';
import SettingSkeleton from '../../settings/Setting/SettingSkeleton';
type ABACEnabledToggleProps = {
hasABAC: 'loading' | boolean;
};
const ABACEnabledToggle = ({ hasABAC }: ABACEnabledToggleProps) => {
const setting = useEditableSetting('ABAC_Enabled');
const setModal = useSetModal();
const dispatch = useSettingsDispatch();
const { t } = useTranslation();
const [value, setValue] = useState<boolean>(setting?.value === true);
useEffect(() => {
setValue(setting?.value === true);
}, [setting]);
const onChange = useCallback(
(value: boolean) => {
if (!setting) {
return;
}
const handleChange = (value: boolean, setting: EditableSetting) => {
setValue(value);
dispatch([{ _id: setting._id, value }]);
};
if (value === false) {
return setModal(
<WarningModal
onConfirm={() => {
handleChange(value, setting);
setModal();
}}
onCancel={() => setModal()}
/>,
);
}
handleChange(value, setting);
},
[dispatch, setModal, setting],
);
const onReset = useCallback(() => {
if (!setting) {
return;
}
const value = setting.packageValue as boolean;
setModal(
<WarningModal
onConfirm={() => {
setValue(value);
dispatch([{ _id: setting._id, value }]);
setModal();
}}
onCancel={() => setModal()}
/>,
);
}, [dispatch, setModal, setting]);
if (!setting) {
return null;
}
if (hasABAC === 'loading') {
return <SettingSkeleton />;
}
return (
<MemoizedSetting
type='boolean'
_id={setting._id}
label={t(setting.i18nLabel)}
value={value}
packageValue={setting.packageValue === true}
hint={t(setting.i18nDescription || '')}
disabled={!hasABAC || setting.blocked}
hasResetButton={hasABAC && setting.packageValue !== setting.value}
onChangeValue={(value: SettingValue) => onChange(value === true)}
onResetButtonClick={() => onReset()}
/>
);
};
export default ABACEnabledToggle;

@ -0,0 +1,58 @@
import type { ISetting } from '@rocket.chat/core-typings';
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import SettingField from './SettingField';
import EditableSettingsProvider from '../../settings/EditableSettingsProvider';
const settingStructure = {
packageValue: false,
blocked: false,
public: true,
type: 'boolean',
i18nLabel: 'Test_Setting',
i18nDescription: 'Test_Setting_Description',
enableQuery: undefined,
displayQuery: undefined,
} as Partial<ISetting>;
const dispatchMock = jest.fn();
jest.mock('@rocket.chat/ui-contexts', () => ({
...jest.requireActual('@rocket.chat/ui-contexts'),
useSettingsDispatch: () => dispatchMock,
}));
jest.mock('@rocket.chat/core-typings', () => ({
...jest.requireActual('@rocket.chat/core-typings'),
isSetting: jest.fn().mockReturnValue(true),
}));
describe('SettingField', () => {
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.runOnlyPendingTimers();
jest.useRealTimers();
});
it('should call dispatch when setting value is changed', async () => {
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
render(<SettingField settingId='Test_Setting' />, {
wrapper: mockAppRoot()
.wrap((children) => <EditableSettingsProvider>{children}</EditableSettingsProvider>)
.withSetting('Test_Setting', false, settingStructure)
.build(),
});
const checkbox = screen.getByRole('checkbox');
await user.click(checkbox);
await waitFor(() => {
expect(dispatchMock).toHaveBeenCalled();
});
});
});

@ -0,0 +1,145 @@
import type { ISettingColor, SettingEditor, SettingValue } from '@rocket.chat/core-typings';
import { isSettingColor, isSetting } from '@rocket.chat/core-typings';
import { useDebouncedCallback } from '@rocket.chat/fuselage-hooks';
import { useSettingsDispatch, useSettingStructure } from '@rocket.chat/ui-contexts';
import DOMPurify from 'dompurify';
import type { ReactElement } from 'react';
import { useEffect, useMemo, useState, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import MarkdownText from '../../../../components/MarkdownText';
import { useEditableSetting, useEditableSettingVisibilityQuery } from '../../EditableSettingsContext';
import MemoizedSetting from '../../settings/Setting/MemoizedSetting';
import { useHasSettingModule } from '../../settings/hooks/useHasSettingModule';
type SettingFieldProps = {
className?: string;
settingId: string;
sectionChanged?: boolean;
};
function SettingField({ className = undefined, settingId, sectionChanged }: SettingFieldProps): ReactElement {
const setting = useEditableSetting(settingId);
const persistedSetting = useSettingStructure(settingId);
const hasSettingModule = useHasSettingModule(setting);
if (!setting || !persistedSetting) {
throw new Error(`Setting ${settingId} not found`);
}
// Checks if setting has at least required fields before doing anything
if (!isSetting(setting)) {
throw new Error(`Setting ${settingId} is not valid`);
}
const dispatch = useSettingsDispatch();
const update = useDebouncedCallback(
({ value, editor }: { value?: SettingValue; editor?: SettingEditor }) => {
if (!persistedSetting) {
return;
}
dispatch([
{
_id: persistedSetting._id,
...(value !== undefined && { value }),
...(editor !== undefined && { editor }),
},
]);
},
230,
[persistedSetting, dispatch],
);
const { t, i18n } = useTranslation();
const [value, setValue] = useState(setting.value);
const [editor, setEditor] = useState(isSettingColor(setting) ? setting.editor : undefined);
useEffect(() => {
setValue(setting.value);
}, [setting.value]);
useEffect(() => {
setEditor(isSettingColor(setting) ? setting.editor : undefined);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [(setting as ISettingColor).editor]);
const onChangeValue = useCallback(
(value: SettingValue) => {
setValue(value);
update({ value });
},
[update],
);
const onChangeEditor = useCallback(
(editor: SettingEditor) => {
setEditor(editor);
update({ editor });
},
[update],
);
const onResetButtonClick = useCallback(() => {
setValue(setting.value);
setEditor(isSettingColor(setting) ? setting.editor : undefined);
update({
value: persistedSetting.packageValue,
...(isSettingColor(persistedSetting) && { editor: persistedSetting.packageEditor }),
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [setting.value, (setting as ISettingColor).editor, update, persistedSetting]);
const { _id, readonly, type, packageValue, i18nLabel, i18nDescription, alert } = setting;
const disabled = !useEditableSettingVisibilityQuery(persistedSetting.enableQuery);
const invisible = !useEditableSettingVisibilityQuery(persistedSetting.displayQuery);
const labelText = (i18n.exists(i18nLabel) && t(i18nLabel)) || (i18n.exists(_id) && t(_id)) || i18nLabel || _id;
const hint = useMemo(
() => (i18nDescription && i18n.exists(i18nDescription) ? <MarkdownText variant='inline' content={t(i18nDescription)} /> : undefined),
[i18n, i18nDescription, t],
);
const callout = useMemo(
() =>
alert && <span dangerouslySetInnerHTML={{ __html: i18n.exists(alert) ? DOMPurify.sanitize(t(alert)) : DOMPurify.sanitize(alert) }} />,
[alert, i18n, t],
);
const shouldDisableEnterprise = setting.enterprise && !hasSettingModule;
const hasResetButton =
!shouldDisableEnterprise &&
!readonly &&
type !== 'asset' &&
((isSettingColor(setting) && JSON.stringify(setting.packageEditor) !== JSON.stringify(editor)) ||
JSON.stringify(value) !== JSON.stringify(packageValue)) &&
!disabled;
// @todo: type check props based on setting type
return (
<MemoizedSetting
className={className}
label={labelText}
hint={hint}
callout={callout}
sectionChanged={sectionChanged}
{...setting}
disabled={disabled || shouldDisableEnterprise}
value={value}
editor={editor}
hasResetButton={hasResetButton}
onChangeValue={onChangeValue}
onChangeEditor={onChangeEditor}
onResetButtonClick={onResetButtonClick}
invisible={invisible}
/>
);
}
export default SettingField;

@ -0,0 +1,142 @@
import type { ISetting } from '@rocket.chat/core-typings';
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { axe } from 'jest-axe';
import AbacEnabledToggle from './AbacEnabledToggle';
import EditableSettingsProvider from '../../settings/EditableSettingsProvider';
const settingStructure = {
packageValue: false,
blocked: false,
public: true,
type: 'boolean',
i18nLabel: 'ABAC_Enabled',
i18nDescription: 'ABAC_Enabled_Description',
} as Partial<ISetting>;
const baseAppRoot = mockAppRoot()
.wrap((children) => <EditableSettingsProvider>{children}</EditableSettingsProvider>)
.withTranslations('en', 'core', {
ABAC_Enabled: 'Enable ABAC',
ABAC_Enabled_Description: 'Enable Attribute-Based Access Control',
ABAC_Warning_Modal_Title: 'Disable ABAC',
ABAC_Warning_Modal_Confirm_Text: 'Disable',
Cancel: 'Cancel',
});
describe('AbacEnabledToggle', () => {
it('should render the setting toggle when setting exists', () => {
const { baseElement } = render(<AbacEnabledToggle hasABAC={true} />, {
wrapper: baseAppRoot.withSetting('ABAC_Enabled', true, settingStructure).build(),
});
expect(baseElement).toMatchSnapshot();
});
it('should show warning modal when disabling ABAC', async () => {
const user = userEvent.setup();
render(<AbacEnabledToggle hasABAC={true} />, {
wrapper: baseAppRoot.withSetting('ABAC_Enabled', true, settingStructure).build(),
});
const toggle = screen.getByRole('checkbox');
await waitFor(() => {
expect(toggle).not.toBeDisabled();
});
await user.click(toggle);
await waitFor(() => {
expect(screen.getByText('Disable ABAC')).toBeInTheDocument();
});
// TODO: discover how to automatically unmount all modals after each test
const cancelButton = screen.getByRole('button', { name: /cancel/i });
await user.click(cancelButton);
});
it('should not show warning modal when enabling ABAC', async () => {
const user = userEvent.setup();
render(<AbacEnabledToggle hasABAC={true} />, {
wrapper: baseAppRoot.withSetting('ABAC_Enabled', false, settingStructure).build(),
});
const toggle = screen.getByRole('checkbox');
await user.click(toggle);
// The modal should not appear when enabling ABAC
expect(screen.queryByText('Disable ABAC')).not.toBeInTheDocument();
});
it('should show warning modal when resetting setting', async () => {
const user = userEvent.setup();
render(<AbacEnabledToggle hasABAC={true} />, {
wrapper: baseAppRoot.withSetting('ABAC_Enabled', true, settingStructure).build(),
});
const resetButton = screen.getByRole('button', { name: /reset/i });
await user.click(resetButton);
await waitFor(() => {
expect(screen.getByText('Disable ABAC')).toBeInTheDocument();
});
// TODO: discover how to automatically unmount all modals after each test
const cancelButton = screen.getByRole('button', { name: /cancel/i });
await user.click(cancelButton);
});
it('should have no accessibility violations', async () => {
const { container } = render(<AbacEnabledToggle hasABAC={true} />, {
wrapper: baseAppRoot.withSetting('ABAC_Enabled', true, settingStructure).build(),
});
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('should handle setting change correctly', async () => {
const user = userEvent.setup();
render(<AbacEnabledToggle hasABAC={true} />, {
wrapper: baseAppRoot.withSetting('ABAC_Enabled', false, settingStructure).build(),
});
const toggle = await screen.findByRole('checkbox', { busy: false });
expect(toggle).not.toBeChecked();
await user.click(toggle);
expect(toggle).toBeChecked();
});
it('should be disabled when abac license is not installed', () => {
const { baseElement } = render(<AbacEnabledToggle hasABAC={false} />, {
wrapper: baseAppRoot.withSetting('ABAC_Enabled', true, settingStructure).build(),
});
const toggle = screen.getByRole('checkbox');
expect(toggle).toBeDisabled();
expect(baseElement).toMatchSnapshot();
});
it('should show skeleton when loading', () => {
const { baseElement } = render(<AbacEnabledToggle hasABAC='loading' />, {
wrapper: baseAppRoot.withSetting('ABAC_Enabled', true, settingStructure).build(),
});
expect(baseElement).toMatchSnapshot();
});
it('should show reset button when value differs from package value', () => {
render(<AbacEnabledToggle hasABAC={true} />, {
wrapper: baseAppRoot.withSetting('ABAC_Enabled', true, settingStructure).build(),
});
expect(screen.getByRole('button', { name: /reset/i })).toBeInTheDocument();
});
it('should not show reset button when value matches package value', () => {
render(<AbacEnabledToggle hasABAC={true} />, {
wrapper: baseAppRoot.withSetting('ABAC_Enabled', false, settingStructure).build(),
});
expect(screen.queryByRole('button', { name: /reset/i })).not.toBeInTheDocument();
});
});

@ -0,0 +1,64 @@
import { mockAppRoot } from '@rocket.chat/mock-providers';
import type { Meta, StoryObj } from '@storybook/react';
import AbacEnabledToggle from './AbacEnabledToggle';
import EditableSettingsProvider from '../../settings/EditableSettingsProvider';
const meta: Meta<typeof AbacEnabledToggle> = {
component: AbacEnabledToggle,
parameters: {
layout: 'padded',
},
decorators: [
(Story) => {
const AppRoot = mockAppRoot()
.wrap((children) => <EditableSettingsProvider>{children}</EditableSettingsProvider>)
.withTranslations('en', 'core', {
ABAC_Enabled: 'Enable ABAC',
ABAC_Enabled_Description: 'Enable Attribute-Based Access Control',
ABAC_Warning_Modal_Title: 'Disable ABAC',
ABAC_Warning_Modal_Confirm_Text: 'Disable',
Cancel: 'Cancel',
})
.withSetting('ABAC_Enabled', true, {
packageValue: false,
blocked: false,
public: true,
type: 'boolean',
i18nLabel: 'ABAC_Enabled',
i18nDescription: 'ABAC_Enabled_Description',
})
.build();
return (
<AppRoot>
<Story />
</AppRoot>
);
},
],
args: {
hasABAC: true,
},
};
export default meta;
type Story = StoryObj<typeof AbacEnabledToggle>;
export const Default: Story = {
args: {
hasABAC: true,
},
};
export const Loading: Story = {
args: {
hasABAC: 'loading',
},
};
export const False: Story = {
args: {
hasABAC: false,
},
};

@ -0,0 +1,33 @@
import { Box, Callout, Margins } from '@rocket.chat/fuselage';
import { Trans } from 'react-i18next';
import AbacEnabledToggle from './AbacEnabledToggle';
import SettingField from './SettingField';
import { useHasLicenseModule } from '../../../../hooks/useHasLicenseModule';
import { links } from '../../../../lib/links';
const SettingsPage = () => {
const { data: hasABAC = false } = useHasLicenseModule('abac');
return (
<Box maxWidth='x600' w='full' alignSelf='center'>
<Box>
<Margins block={24}>
<AbacEnabledToggle hasABAC={hasABAC} />
<SettingField settingId='ABAC_ShowAttributesInRooms' />
<SettingField settingId='Abac_Cache_Decision_Time_Seconds' />
<Callout>
<Trans i18nKey='ABAC_Enabled_callout'>
User attributes are synchronized via LDAP
<a href={links.go.abacLDAPDocs} rel='noopener noreferrer' target='_blank'>
Learn more
</a>
</Trans>
</Callout>
</Margins>
</Box>
</Box>
);
};
export default SettingsPage;

@ -0,0 +1,48 @@
import { Box } from '@rocket.chat/fuselage';
import { GenericModal } from '@rocket.chat/ui-client';
import { useRouter } from '@rocket.chat/ui-contexts';
import { Trans, useTranslation } from 'react-i18next';
type WarningModalProps = {
onConfirm: () => void;
onCancel: () => void;
};
const WarningModal = ({ onConfirm, onCancel }: WarningModalProps) => {
const { t } = useTranslation();
const router = useRouter();
const handleNavigate = () => {
onCancel();
router.navigate({
name: 'admin-ABAC',
params: {
tab: 'rooms',
},
});
};
return (
<GenericModal
title={t('ABAC_Warning_Modal_Title')}
variant='secondary-danger'
confirmText={t('ABAC_Warning_Modal_Confirm_Text')}
cancelText={t('Cancel')}
onConfirm={onConfirm}
onCancel={onCancel}
onClose={onCancel}
onDismiss={onCancel}
>
<Trans i18nKey='ABAC_Warning_Modal_Content'>
You will not be able to automatically or manually manage users in existing ABAC-managed rooms. To restore a room's default access
control, it must be removed from ABAC management in
<Box is='a' onClick={handleNavigate}>
{' '}
ABAC {'>'} Rooms
</Box>
.
</Trans>
</GenericModal>
);
};
export default WarningModal;

@ -0,0 +1,152 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`AbacEnabledToggle should be disabled when abac license is not installed 1`] = `
<body>
<div>
<div
class="rcx-box rcx-box--full rcx-field rcx-css-1gfu76s"
>
<div
class="rcx-box rcx-box--full rcx-css-1u2ihfm"
>
<div
class="rcx-box rcx-box--full rcx-field"
>
<span
class="rcx-box rcx-box--full rcx-field__row rcx-css-ctk2ij"
>
<label
class="rcx-box rcx-box--full rcx-field__label rcx-label"
for="ABAC_Enabled"
title="ABAC_Enabled"
>
Enable ABAC
</label>
<div
class="rcx-box rcx-box--full rcx-css-127j9mz"
>
<label
class="rcx-box rcx-box--full rcx-toggle-switch"
>
<input
checked=""
class="rcx-box rcx-box--full rcx-toggle-switch__input"
data-qa-setting-id="ABAC_Enabled"
disabled=""
id="ABAC_Enabled"
type="checkbox"
/>
<i
aria-hidden="true"
class="rcx-box rcx-box--full rcx-toggle-switch__fake"
/>
</label>
</div>
</span>
<span
class="rcx-box rcx-box--full rcx-field__hint"
>
Enable Attribute-Based Access Control
</span>
</div>
</div>
</div>
</div>
</body>
`;
exports[`AbacEnabledToggle should render the setting toggle when setting exists 1`] = `
<body>
<div>
<div
class="rcx-box rcx-box--full rcx-field rcx-css-1gfu76s"
>
<div
class="rcx-box rcx-box--full rcx-css-1u2ihfm"
>
<div
class="rcx-box rcx-box--full rcx-field"
>
<span
class="rcx-box rcx-box--full rcx-field__row rcx-css-ctk2ij"
>
<label
class="rcx-box rcx-box--full rcx-field__label rcx-label"
for="ABAC_Enabled"
title="ABAC_Enabled"
>
Enable ABAC
</label>
<div
class="rcx-box rcx-box--full rcx-css-127j9mz"
>
<button
class="rcx-box rcx-box--full rcx-button--small-square rcx-button--icon-danger rcx-button--square rcx-button--icon rcx-button rcx-css-1rtu0k9"
data-qa-reset-setting-id="ABAC_Enabled"
title="Reset"
type="button"
>
<i
aria-hidden="true"
class="rcx-box rcx-box--full rcx-icon--name-undo rcx-icon rcx-css-4pvxx3"
>
</i>
</button>
<label
class="rcx-box rcx-box--full rcx-toggle-switch"
>
<input
checked=""
class="rcx-box rcx-box--full rcx-toggle-switch__input"
data-qa-setting-id="ABAC_Enabled"
id="ABAC_Enabled"
type="checkbox"
/>
<i
aria-hidden="true"
class="rcx-box rcx-box--full rcx-toggle-switch__fake"
/>
</label>
</div>
</span>
<span
class="rcx-box rcx-box--full rcx-field__hint"
>
Enable Attribute-Based Access Control
</span>
</div>
</div>
</div>
</div>
</body>
`;
exports[`AbacEnabledToggle should show skeleton when loading 1`] = `
<body>
<div>
<div
class="rcx-box rcx-box--full rcx-field"
>
<label
class="rcx-box rcx-box--full rcx-field__label rcx-label rcx-css-1exa5vl"
>
<span
class="rcx-skeleton rcx-skeleton--text rcx-css-1v1eapn"
/>
</label>
<span
class="rcx-box rcx-box--full rcx-field__row"
>
<div
class="rcx-box rcx-box--full rcx-skeleton__input"
>
<span
class="rcx-skeleton rcx-skeleton--text rcx-css-1qcz93u"
/>
</div>
</span>
</div>
</div>
</body>
`;

@ -0,0 +1,103 @@
import { Box, Button, Callout } from '@rocket.chat/fuselage';
import { useEffectEvent } from '@rocket.chat/fuselage-hooks';
import { ContextualbarDialog, Page, PageContent, PageHeader } from '@rocket.chat/ui-client';
import { useRouteParameter, useRouter } from '@rocket.chat/ui-contexts';
import { Trans, useTranslation } from 'react-i18next';
import AttributesContextualBar from './ABACAttributesTab/AttributesContextualBar';
import AttributesContextualBarWithData from './ABACAttributesTab/AttributesContextualBarWithData';
import AttributesPage from './ABACAttributesTab/AttributesPage';
import LogsPage from './ABACLogsTab/LogsPage';
import RoomsContextualBar from './ABACRoomsTab/RoomsContextualBar';
import RoomsContextualBarWithData from './ABACRoomsTab/RoomsContextualBarWithData';
import RoomsPage from './ABACRoomsTab/RoomsPage';
import SettingsPage from './ABACSettingTab/SettingsPage';
import AdminABACTabs from './AdminABACTabs';
import { useIsABACAvailable } from './hooks/useIsABACAvailable';
import { useExternalLink } from '../../../hooks/useExternalLink';
import { links } from '../../../lib/links';
type AdminABACPageProps = {
shouldShowWarning: boolean;
};
const AdminABACPage = ({ shouldShowWarning }: AdminABACPageProps) => {
const { t } = useTranslation();
const router = useRouter();
const tab = useRouteParameter('tab');
const _id = useRouteParameter('id');
const context = useRouteParameter('context');
const learnMore = useExternalLink();
const isABACAvailable = useIsABACAvailable();
const handleCloseContextualbar = useEffectEvent((): void => {
if (!context) {
return;
}
router.navigate(
{
name: 'admin-ABAC',
params: { ...router.getRouteParameters(), context: '', id: '' },
},
{ replace: true },
);
});
return (
<Page flexDirection='row'>
<Page>
<PageHeader title={t('ABAC')}>
<Button icon='new-window' secondary onClick={() => learnMore(links.go.abacDocs)}>
{t('ABAC_Learn_More')}
</Button>
</PageHeader>
{shouldShowWarning && (
<Box mi={24} mb={16}>
<Callout type='warning' title={t('ABAC_automatically_disabled_callout')}>
<Trans
i18nKey='ABAC_automatically_disabled_callout_description'
components={{
1: (
<a href={links.go.abacDocs} rel='noopener noreferrer' target='_blank'>
ABAC capabilities without restriction.
</a>
),
}}
/>
</Callout>
</Box>
)}
<AdminABACTabs />
<PageContent>
{tab === 'settings' && <SettingsPage />}
{tab === 'room-attributes' && <AttributesPage />}
{tab === 'rooms' && <RoomsPage />}
{tab === 'logs' && <LogsPage />}
</PageContent>
</Page>
{tab !== undefined && context !== undefined && (
<ContextualbarDialog onClose={() => handleCloseContextualbar()}>
{tab === 'room-attributes' && (
<>
{context === 'new' && isABACAvailable === true && <AttributesContextualBar onClose={() => handleCloseContextualbar()} />}
{context === 'edit' && _id && isABACAvailable === true && (
<AttributesContextualBarWithData id={_id} onClose={() => handleCloseContextualbar()} />
)}
</>
)}
{tab === 'rooms' && (
<>
{context === 'new' && isABACAvailable === true && <RoomsContextualBar onClose={() => handleCloseContextualbar()} />}
{context === 'edit' && _id && isABACAvailable === true && (
<RoomsContextualBarWithData id={_id} onClose={() => handleCloseContextualbar()} />
)}
</>
)}
</ContextualbarDialog>
)}
</Page>
);
};
export default AdminABACPage;

@ -1,135 +0,0 @@
import {
Box,
Button,
ButtonGroup,
ContextualbarFooter,
Field,
FieldError,
FieldLabel,
FieldRow,
IconButton,
TextInput,
} from '@rocket.chat/fuselage';
import { ContextualbarScrollableContent } from '@rocket.chat/ui-client';
import { useCallback, useId, useMemo } from 'react';
import { useFieldArray, useFormContext } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
export type AdminABACRoomAttributesFormFormData = {
name: string;
attributeValues: { value: string }[];
lockedAttributes: { value: string }[];
};
type AdminABACRoomAttributesFormProps = {
onSave: (data: unknown) => void;
onCancel: () => void;
description: string;
};
const AdminABACRoomAttributesForm = ({ onSave, onCancel, description }: AdminABACRoomAttributesFormProps) => {
const {
handleSubmit,
register,
formState: { errors },
watch,
} = useFormContext<AdminABACRoomAttributesFormFormData>();
const { fields: lockedAttributesFields, remove: removeLockedAttribute } = useFieldArray({
name: 'lockedAttributes',
});
const { fields, append, remove } = useFieldArray({
name: 'attributeValues',
rules: {
minLength: 1,
},
});
const { t } = useTranslation();
const formId = useId();
const nameField = useId();
const valuesField = useId();
const attributeValues = watch('attributeValues');
const getAttributeValuesError = useCallback(() => {
if (errors.attributeValues?.length && errors.attributeValues?.length > 0) {
return t('Required_field', { field: t('Values') });
}
return '';
}, [errors.attributeValues, t]);
const hasValuesErrors = useMemo(() => {
const attributeValuesErrors = Array.isArray(errors?.attributeValues) && errors.attributeValues.some((error) => !!error?.value?.message);
const lockedAttributesErrors =
Array.isArray(errors?.lockedAttributes) && errors.lockedAttributes.some((error) => !!error?.value?.message);
return attributeValuesErrors || lockedAttributesErrors;
}, [errors.attributeValues, errors.lockedAttributes]);
return (
<Box is='form' onSubmit={handleSubmit(onSave)} id={formId}>
<ContextualbarScrollableContent>
<Box>{description}</Box>
<Field mb={16}>
<FieldLabel htmlFor={nameField} required>
{t('Name')}
</FieldLabel>
<FieldRow>
<TextInput
error={errors.name?.message}
id={nameField}
{...register('name', { required: t('Required_field', { field: t('Name') }) })}
/>
</FieldRow>
<FieldError>{errors.name?.message || ''}</FieldError>
</Field>
<Field mb={16}>
<FieldLabel required id={valuesField}>
{t('Values')}
</FieldLabel>
{lockedAttributesFields.map((field, index) => (
<FieldRow key={field.id}>
<TextInput
disabled
aria-labelledby={valuesField}
error={errors.lockedAttributes?.[index]?.value?.message || ''}
{...register(`lockedAttributes.${index}.value`, { required: t('Required_field', { field: t('Values') }) })}
/>
{index !== 0 && <IconButton aria-label={t('Remove')} icon='trash' onClick={() => removeLockedAttribute(index)} />}
</FieldRow>
))}
{fields.map((field, index) => (
<FieldRow key={field.id}>
<TextInput
aria-labelledby={valuesField}
error={errors.attributeValues?.[index]?.value?.message || ''}
{...register(`attributeValues.${index}.value`, { required: t('Required_field', { field: t('Values') }) })}
/>
{(index !== 0 || lockedAttributesFields.length > 0) && (
<IconButton aria-label={t('Remove')} icon='trash' onClick={() => remove(index)} />
)}
</FieldRow>
))}
<FieldError>{getAttributeValuesError()}</FieldError>
<Button
onClick={() => append({ value: '' })}
// Checking for values since rhf does consider the newly added field as dirty after an append() call
disabled={!!getAttributeValuesError() || attributeValues?.some((value: { value: string }) => value?.value === '')}
>
{t('Add Value')}
</Button>
</Field>
</ContextualbarScrollableContent>
<ContextualbarFooter>
<ButtonGroup stretch>
<Button onClick={() => onCancel()}>{t('Cancel')}</Button>
<Button type='submit' disabled={hasValuesErrors || !!errors.name} primary>
{t('Save')}
</Button>
</ButtonGroup>
</ContextualbarFooter>
</Box>
);
};
export default AdminABACRoomAttributesForm;

@ -0,0 +1,64 @@
import { usePermission, useSetModal, useCurrentModal, useRouter, useRouteParameter, useSettingStructure } from '@rocket.chat/ui-contexts';
import type { ReactElement } from 'react';
import { memo, useEffect, useLayoutEffect } from 'react';
import { useTranslation } from 'react-i18next';
import AdminABACPage from './AdminABACPage';
import ABACUpsellModal from '../../../components/ABAC/ABACUpsellModal/ABACUpsellModal';
import { useUpsellActions } from '../../../components/GenericUpsellModal/hooks';
import PageSkeleton from '../../../components/PageSkeleton';
import { useHasLicenseModule } from '../../../hooks/useHasLicenseModule';
import SettingsProvider from '../../../providers/SettingsProvider';
import NotAuthorizedPage from '../../notAuthorized/NotAuthorizedPage';
import EditableSettingsProvider from '../settings/EditableSettingsProvider';
const AdminABACRoute = (): ReactElement => {
const { t } = useTranslation();
// TODO: Check what permission is needed to view the ABAC page
const canViewABACPage = usePermission('abac-management');
const { data: hasABAC = false } = useHasLicenseModule('abac');
const isModalOpen = !!useCurrentModal();
const tab = useRouteParameter('tab');
const router = useRouter();
// Check if setting exists in the DB to decide if we show warning or upsell
const ABACEnabledSetting = useSettingStructure('ABAC_Enabled');
useLayoutEffect(() => {
if (!tab) {
router.navigate({
name: 'admin-ABAC',
params: { tab: 'settings' },
});
}
}, [tab, router]);
const { shouldShowUpsell, handleManageSubscription } = useUpsellActions(hasABAC);
const setModal = useSetModal();
useEffect(() => {
// WS has never activated ABAC
if (shouldShowUpsell && ABACEnabledSetting === undefined) {
setModal(<ABACUpsellModal onClose={() => setModal(null)} onConfirm={handleManageSubscription} />);
}
}, [shouldShowUpsell, setModal, t, handleManageSubscription, ABACEnabledSetting]);
if (isModalOpen) {
return <PageSkeleton />;
}
if (!canViewABACPage || (ABACEnabledSetting === undefined && !hasABAC)) {
return <NotAuthorizedPage />;
}
return (
<SettingsProvider>
<EditableSettingsProvider>
<AdminABACPage shouldShowWarning={ABACEnabledSetting !== undefined && !hasABAC} />
</EditableSettingsProvider>
</SettingsProvider>
);
};
export default memo(AdminABACRoute);

@ -0,0 +1,33 @@
import { Tabs, TabsItem } from '@rocket.chat/fuselage';
import { useRouteParameter, useRouter } from '@rocket.chat/ui-contexts';
import { useTranslation } from 'react-i18next';
const AdminABACTabs = () => {
const { t } = useTranslation();
const router = useRouter();
const tab = useRouteParameter('tab');
const handleTabClick = (tab: string) => {
router.navigate({
name: 'admin-ABAC',
params: { tab },
});
};
return (
<Tabs>
<TabsItem selected={tab === 'settings'} onClick={() => handleTabClick('settings')}>
{t('Settings')}
</TabsItem>
<TabsItem selected={tab === 'room-attributes'} onClick={() => handleTabClick('room-attributes')}>
{t('ABAC_Room_Attributes')}
</TabsItem>
<TabsItem selected={tab === 'rooms'} onClick={() => handleTabClick('rooms')}>
{t('Rooms')}
</TabsItem>
<TabsItem selected={tab === 'logs'} onClick={() => handleTabClick('logs')}>
{t('ABAC_Logs')}
</TabsItem>
</Tabs>
);
};
export default AdminABACTabs;

@ -0,0 +1,39 @@
import { useEndpoint } from '@rocket.chat/ui-contexts';
import { useQuery } from '@tanstack/react-query';
import { useIsABACAvailable } from './useIsABACAvailable';
import { ABACQueryKeys } from '../../../../lib/queryKeys';
const COUNT = 150;
export const useAttributeList = () => {
const attributesAutoCompleteEndpoint = useEndpoint('GET', '/v1/abac/attributes');
const isABACAvailable = useIsABACAvailable();
return useQuery({
enabled: isABACAvailable,
queryKey: ABACQueryKeys.roomAttributes.list(),
queryFn: async () => {
const firstPage = await attributesAutoCompleteEndpoint({ offset: 0, count: COUNT });
const { attributes: firstPageAttributes, total } = firstPage;
let currentPage = COUNT;
const pages = [];
while (currentPage < total) {
pages.push(attributesAutoCompleteEndpoint({ offset: currentPage, count: COUNT }));
currentPage += COUNT;
}
const remainingPages = await Promise.all(pages);
return {
attributes: [...firstPageAttributes, ...remainingPages.flatMap((page) => page.attributes)].map((attribute) => ({
_id: attribute._id,
label: attribute.key,
value: attribute.key,
attributeValues: attribute.values,
})),
};
},
});
};

@ -0,0 +1,267 @@
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { renderHook, waitFor } from '@testing-library/react';
import { useAttributeOptions } from './useAttributeOptions';
import { createFakeLicenseInfo } from '../../../../../tests/mocks/data';
const mockNavigate = jest.fn();
const mockSetModal = jest.fn();
const mockDispatchToastMessage = jest.fn();
const useIsABACAvailableMock = jest.fn(() => true);
jest.mock('./useIsABACAvailable', () => ({
useIsABACAvailable: () => useIsABACAvailableMock(),
}));
jest.mock('@rocket.chat/ui-contexts', () => ({
...jest.requireActual('@rocket.chat/ui-contexts'),
useRouter: () => ({
navigate: mockNavigate,
}),
useSetModal: () => mockSetModal,
useToastMessageDispatch: () => mockDispatchToastMessage,
}));
const mockAttribute = {
_id: 'attribute-1',
key: 'Room Type',
};
const baseAppRoot = mockAppRoot()
.withTranslations('en', 'core', {
Edit: 'Edit',
Delete: 'Delete',
ABAC_Attribute_deleted: 'Attribute {{attributeName}} deleted',
ABAC_Cannot_delete_attribute: 'Cannot delete attribute',
ABAC_Cannot_delete_attribute_content:
'The attribute <bold>{{attributeName}}</bold> is currently in use and cannot be deleted. Please remove it from all rooms before deleting.',
ABAC_Delete_room_attribute: 'Delete room attribute',
ABAC_Delete_room_attribute_content:
'Are you sure you want to delete the attribute <bold>{{attributeName}}</bold>? This action cannot be undone.',
View_rooms: 'View rooms',
Cancel: 'Cancel',
})
.withSetting('ABAC_Enabled', true, {
packageValue: false,
blocked: false,
public: true,
type: 'boolean',
i18nLabel: 'ABAC_Enabled',
i18nDescription: 'ABAC_Enabled_Description',
})
.withEndpoint('GET', '/v1/licenses.info', async () => ({
license: createFakeLicenseInfo({ activeModules: ['abac'] }),
}));
describe('useAttributeOptions', () => {
beforeEach(() => {
jest.clearAllMocks();
mockNavigate.mockClear();
mockSetModal.mockClear();
mockDispatchToastMessage.mockClear();
});
it('should return menu items with correct structure', () => {
const { result } = renderHook(() => useAttributeOptions(mockAttribute), {
wrapper: baseAppRoot
.withEndpoint('DELETE', '/v1/abac/attributes/:_id', async () => null)
.withEndpoint('GET', '/v1/abac/attributes/:key/is-in-use', async () => ({ inUse: false }))
.build(),
});
expect(result.current).toHaveLength(2);
expect(result.current[0]).toMatchObject({
id: 'edit',
icon: 'edit',
content: 'Edit',
});
expect(result.current[1]).toMatchObject({
id: 'delete',
icon: 'trash',
iconColor: 'danger',
});
});
it('should enable edit when ABAC is available', async () => {
const { result } = renderHook(() => useAttributeOptions(mockAttribute), {
wrapper: baseAppRoot
.withEndpoint('DELETE', '/v1/abac/attributes/:_id', async () => null)
.withEndpoint('GET', '/v1/abac/attributes/:key/is-in-use', async () => ({ inUse: false }))
.build(),
});
await waitFor(() => {
expect(result.current[0].disabled).toBe(false);
});
});
it('should navigate to edit page when edit action is clicked', async () => {
const { result } = renderHook(() => useAttributeOptions(mockAttribute), {
wrapper: baseAppRoot
.withEndpoint('DELETE', '/v1/abac/attributes/:_id', async () => null)
.withEndpoint('GET', '/v1/abac/attributes/:key/is-in-use', async () => ({ inUse: false }))
.build(),
});
const editAction = result.current[0].onClick;
if (editAction) {
editAction();
}
expect(mockNavigate).toHaveBeenCalledWith(
{
name: 'admin-ABAC',
params: {
tab: 'room-attributes',
context: 'edit',
id: mockAttribute._id,
},
},
{ replace: true },
);
});
it('should disable edit when ABAC is not available', () => {
useIsABACAvailableMock.mockReturnValue(false);
const { result } = renderHook(() => useAttributeOptions(mockAttribute), {
wrapper: baseAppRoot
.withSetting('ABAC_Enabled', false, {
packageValue: false,
blocked: false,
public: true,
type: 'boolean',
i18nLabel: 'ABAC_Enabled',
i18nDescription: 'ABAC_Enabled_Description',
})
.withEndpoint('GET', '/v1/licenses.info', async () => ({
license: createFakeLicenseInfo({ activeModules: [] }),
}))
.withEndpoint('DELETE', '/v1/abac/attributes/:_id', async () => null)
.withEndpoint('GET', '/v1/abac/attributes/:key/is-in-use', async () => ({ inUse: false }))
.build(),
});
expect(result.current[0].disabled).toBe(true);
});
it('should show warning modal when delete is clicked and attribute is in use', async () => {
const { result } = renderHook(() => useAttributeOptions(mockAttribute), {
wrapper: baseAppRoot
.withEndpoint('DELETE', '/v1/abac/attributes/:_id', async () => null)
.withEndpoint('GET', '/v1/abac/attributes/:key/is-in-use', async () => ({ inUse: true }))
.build(),
});
const deleteAction = result.current[1].onClick;
if (deleteAction) {
deleteAction();
}
await waitFor(() => {
expect(mockSetModal).toHaveBeenCalled();
});
const modalCall = mockSetModal.mock.calls[0][0];
expect(modalCall.props.variant).toBe('warning');
expect(modalCall.props.title).toBe('Cannot delete attribute');
});
it('should show delete confirmation modal when delete is clicked and attribute is not in use', async () => {
const deleteEndpointMock = jest.fn().mockResolvedValue(null);
const { result } = renderHook(() => useAttributeOptions(mockAttribute), {
wrapper: baseAppRoot
.withEndpoint('DELETE', '/v1/abac/attributes/:_id', deleteEndpointMock)
.withEndpoint('GET', '/v1/abac/attributes/:key/is-in-use', async () => ({ inUse: false }))
.build(),
});
const deleteAction = result.current[1].onClick;
if (deleteAction) {
deleteAction();
}
await waitFor(() => {
expect(mockSetModal).toHaveBeenCalled();
});
const modalCall = mockSetModal.mock.calls[0][0];
expect(modalCall.props.variant).toBe('danger');
expect(modalCall.props.title).toBe('Delete room attribute');
expect(modalCall.props.confirmText).toBe('Delete');
});
it('should call delete endpoint when delete is confirmed', async () => {
const deleteEndpointMock = jest.fn().mockResolvedValue(null);
let confirmHandler: (() => void) | undefined;
mockSetModal.mockImplementation((modal) => {
if (modal?.props?.onConfirm) {
confirmHandler = modal.props.onConfirm;
}
});
const { result } = renderHook(() => useAttributeOptions(mockAttribute), {
wrapper: baseAppRoot
.withEndpoint('DELETE', '/v1/abac/attributes/:_id', deleteEndpointMock)
.withEndpoint('GET', '/v1/abac/attributes/:key/is-in-use', async () => ({ inUse: false }))
.build(),
});
const deleteAction = result.current[1].onClick;
if (deleteAction) {
deleteAction();
}
await waitFor(() => {
expect(mockSetModal).toHaveBeenCalled();
});
if (confirmHandler) {
confirmHandler();
}
await waitFor(() => {
expect(deleteEndpointMock).toHaveBeenCalled();
});
});
it('should show success toast when delete succeeds', async () => {
const deleteEndpointMock = jest.fn().mockResolvedValue(null);
let confirmHandler: (() => void) | undefined;
mockSetModal.mockImplementation((modal) => {
if (modal?.props?.onConfirm) {
confirmHandler = modal.props.onConfirm;
}
});
const { result } = renderHook(() => useAttributeOptions(mockAttribute), {
wrapper: baseAppRoot
.withEndpoint('DELETE', '/v1/abac/attributes/:_id', deleteEndpointMock)
.withEndpoint('GET', '/v1/abac/attributes/:key/is-in-use', async () => ({ inUse: false }))
.build(),
});
const deleteAction = result.current[1].onClick;
if (deleteAction) {
deleteAction();
}
await waitFor(() => {
expect(mockSetModal).toHaveBeenCalled();
});
if (confirmHandler) {
confirmHandler();
}
await waitFor(() => {
expect(mockDispatchToastMessage).toHaveBeenCalledWith({
type: 'success',
message: 'Attribute Room Type deleted',
});
});
});
});

@ -0,0 +1,101 @@
import { Box } from '@rocket.chat/fuselage';
import { useEffectEvent } from '@rocket.chat/fuselage-hooks';
import type { GenericMenuItemProps } from '@rocket.chat/ui-client';
import { GenericModal } from '@rocket.chat/ui-client';
import { useRouter, useSetModal, useEndpoint, useToastMessageDispatch } from '@rocket.chat/ui-contexts';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Trans, useTranslation } from 'react-i18next';
import { useIsABACAvailable } from './useIsABACAvailable';
import { ABACQueryKeys } from '../../../../lib/queryKeys';
export const useAttributeOptions = (attribute: { _id: string; key: string }): GenericMenuItemProps[] => {
const { t } = useTranslation();
const router = useRouter();
const setModal = useSetModal();
const queryClient = useQueryClient();
const deleteAttribute = useEndpoint('DELETE', '/v1/abac/attributes/:_id', { _id: attribute._id });
const isAttributeUsed = useEndpoint('GET', '/v1/abac/attributes/:key/is-in-use', { key: attribute.key });
const dispatchToastMessage = useToastMessageDispatch();
const isABACAvailable = useIsABACAvailable();
const editAction = useEffectEvent(() => {
return router.navigate(
{
name: 'admin-ABAC',
params: {
tab: 'room-attributes',
context: 'edit',
id: attribute._id,
},
},
{ replace: true },
);
});
const deleteMutation = useMutation({
mutationFn: deleteAttribute,
onSuccess: () => {
dispatchToastMessage({ type: 'success', message: t('ABAC_Attribute_deleted', { attributeName: attribute.key }) });
},
onError: (error) => {
dispatchToastMessage({ type: 'error', message: error });
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ABACQueryKeys.roomAttributes.all() });
setModal(null);
},
});
const deleteAction = useEffectEvent(async () => {
const isUsed = await isAttributeUsed();
if (isUsed.inUse) {
return setModal(
<GenericModal
variant='warning'
icon={null}
title={t('ABAC_Cannot_delete_attribute')}
confirmText={t('View_rooms')}
// TODO Route to rooms tab once implemented
onConfirm={() => setModal(null)}
onCancel={() => setModal(null)}
>
<Trans
i18nKey='ABAC_Cannot_delete_attribute_content'
values={{ attributeName: attribute.key }}
components={{ bold: <Box is='span' fontWeight='bold' /> }}
/>
</GenericModal>,
);
}
setModal(
<GenericModal
variant='danger'
icon={null}
title={t('ABAC_Delete_room_attribute')}
confirmText={t('Delete')}
onConfirm={() => {
deleteMutation.mutateAsync(undefined);
}}
onCancel={() => setModal(null)}
>
<Trans
i18nKey='ABAC_Delete_room_attribute_content'
values={{ attributeName: attribute.key }}
components={{ bold: <Box is='span' fontWeight='bold' /> }}
/>
</GenericModal>,
);
});
return [
{ id: 'edit', icon: 'edit' as const, content: t('Edit'), onClick: () => editAction(), disabled: !isABACAvailable },
{
id: 'delete',
iconColor: 'danger',
icon: 'trash' as const,
content: <Box color='danger'>{t('Delete')}</Box>,
onClick: () => deleteAction(),
},
];
};

@ -0,0 +1,52 @@
import { faker } from '@faker-js/faker';
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { renderHook, waitFor } from '@testing-library/react';
import { useDeleteRoomModal } from './useDeleteRoomModal';
const mockSetModal = jest.fn();
jest.mock('@rocket.chat/ui-contexts', () => {
const originalModule = jest.requireActual('@rocket.chat/ui-contexts');
return {
...originalModule,
useSetModal: () => mockSetModal,
};
});
describe('useDeleteRoomModal', () => {
beforeEach(() => {
jest.clearAllMocks();
mockSetModal.mockClear();
});
it('should show delete confirmation modal when hook is called', async () => {
const { result } = renderHook(
() =>
useDeleteRoomModal({
rid: faker.database.mongodbObjectId(),
name: faker.lorem.words(3),
}),
{
wrapper: mockAppRoot()
.withTranslations('en', 'core', {
Edit: 'Edit',
Remove: 'Remove',
ABAC_Room_removed: 'Room {{roomName}} removed from ABAC management',
ABAC_Delete_room: 'Remove room from ABAC management',
ABAC_Delete_room_annotation: 'Proceed with caution',
ABAC_Delete_room_content:
'Removing <bold>{{roomName}}</bold> from ABAC management may result in unintended users gaining access.',
Cancel: 'Cancel',
})
.build(),
},
);
result.current();
await waitFor(() => {
expect(mockSetModal).toHaveBeenCalled();
});
});
});

@ -0,0 +1,11 @@
import { useSetModal } from '@rocket.chat/ui-contexts';
import DeleteRoomModal from '../ABACRoomsTab/DeleteRoomModal';
export const useDeleteRoomModal = (room: { rid: string; name: string }) => {
const setModal = useSetModal();
return () => {
setModal(<DeleteRoomModal rid={room.rid} roomName={room.name} onClose={() => setModal(null)} />);
};
};

@ -0,0 +1,10 @@
import { useSetting } from '@rocket.chat/ui-contexts';
import { useHasLicenseModule } from '../../../../hooks/useHasLicenseModule';
export const useIsABACAvailable = () => {
const { data: hasABAC = false } = useHasLicenseModule('abac');
const isABACSettingEnabled = useSetting('ABAC_Enabled', false);
return hasABAC && isABACSettingEnabled;
};

@ -0,0 +1,124 @@
import { faker } from '@faker-js/faker';
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { renderHook, waitFor } from '@testing-library/react';
import { useRoomItems } from './useRoomItems';
const navigateMock = jest.fn();
const setDeleteRoomModalMock = jest.fn();
const useIsABACAvailableMock = jest.fn(() => true);
jest.mock('./useIsABACAvailable', () => ({
useIsABACAvailable: () => useIsABACAvailableMock(),
}));
jest.mock('./useDeleteRoomModal', () => ({
useDeleteRoomModal: () => setDeleteRoomModalMock,
}));
jest.mock('@rocket.chat/ui-contexts', () => ({
...jest.requireActual('@rocket.chat/ui-contexts'),
useRouter: () => ({
navigate: navigateMock,
}),
}));
const mockRoom = {
rid: faker.database.mongodbObjectId(),
name: 'Test Room',
};
const createAppRoot = () =>
mockAppRoot()
.withTranslations('en', 'core', {
Edit: 'Edit',
Remove: 'Remove',
ABAC_Room_removed: 'Room {{roomName}} removed from ABAC management',
ABAC_Delete_room: 'Remove room from ABAC management',
ABAC_Delete_room_annotation: 'Proceed with caution',
ABAC_Delete_room_content: 'Removing <bold>{{roomName}}</bold> from ABAC management may result in unintended users gaining access.',
Cancel: 'Cancel',
})
.withEndpoint('DELETE', '/v1/abac/rooms/:rid/attributes', async () => null);
describe('useRoomItems', () => {
beforeEach(() => {
jest.clearAllMocks();
navigateMock.mockClear();
useIsABACAvailableMock.mockReturnValue(true);
});
it('should return menu items with correct structure', () => {
const { result } = renderHook(() => useRoomItems(mockRoom), {
wrapper: createAppRoot().build(),
});
expect(result.current).toHaveLength(2);
expect(result.current[0]).toMatchObject({
id: 'edit',
icon: 'edit',
content: 'Edit',
});
expect(result.current[1]).toMatchObject({
id: 'delete',
icon: 'cross',
iconColor: 'danger',
});
});
it('should enable edit when ABAC is available', async () => {
const { result } = renderHook(() => useRoomItems(mockRoom), {
wrapper: createAppRoot().build(),
});
await waitFor(() => {
expect(result.current[0].disabled).toBe(false);
});
});
it('should navigate to edit page when edit action is clicked', async () => {
const { result } = renderHook(() => useRoomItems(mockRoom), {
wrapper: createAppRoot().build(),
});
const editAction = result.current[0].onClick;
if (editAction) {
editAction();
}
expect(navigateMock).toHaveBeenCalledWith(
{
name: 'admin-ABAC',
params: {
tab: 'rooms',
context: 'edit',
id: mockRoom.rid,
},
},
{ replace: true },
);
});
it('should disable edit when ABAC is not available', () => {
useIsABACAvailableMock.mockReturnValue(false);
const { result } = renderHook(() => useRoomItems(mockRoom), {
wrapper: createAppRoot().build(),
});
expect(result.current[0].disabled).toBe(true);
});
it('should show delete modal when delete is clicked', async () => {
const { result } = renderHook(() => useRoomItems(mockRoom), {
wrapper: createAppRoot().build(),
});
const deleteAction = result.current[1].onClick;
if (deleteAction) {
deleteAction();
}
await waitFor(() => {
expect(setDeleteRoomModalMock).toHaveBeenCalled();
});
});
});

@ -0,0 +1,40 @@
import { Box } from '@rocket.chat/fuselage';
import { useEffectEvent } from '@rocket.chat/fuselage-hooks';
import type { GenericMenuItemProps } from '@rocket.chat/ui-client';
import { useRouter } from '@rocket.chat/ui-contexts';
import { useTranslation } from 'react-i18next';
import { useDeleteRoomModal } from './useDeleteRoomModal';
import { useIsABACAvailable } from './useIsABACAvailable';
export const useRoomItems = (room: { rid: string; name: string }): GenericMenuItemProps[] => {
const { t } = useTranslation();
const router = useRouter();
const setDeleteRoomModal = useDeleteRoomModal(room);
const isABACAvailable = useIsABACAvailable();
const editAction = useEffectEvent(() => {
return router.navigate(
{
name: 'admin-ABAC',
params: {
tab: 'rooms',
context: 'edit',
id: room.rid,
},
},
{ replace: true },
);
});
return [
{ id: 'edit', icon: 'edit' as const, content: t('Edit'), onClick: () => editAction(), disabled: isABACAvailable !== true },
{
id: 'delete',
iconColor: 'danger',
icon: 'cross' as const,
content: <Box color='danger'>{t('Remove')}</Box>,
onClick: setDeleteRoomModal,
},
];
};

@ -7,6 +7,7 @@ import { useTranslation } from 'react-i18next';
type DateRangePickerProps = {
onChange(range: { start: string; end: string }): void;
defaultSelectedKey?: 'today' | 'yesterday' | 'thisWeek' | 'previousWeek' | 'thisMonth' | 'alldates';
};
const formatToDateInput = (date: Moment) => date.locale('en').format('YYYY-MM-DD');
@ -23,7 +24,7 @@ const getWeekRange = (daysToSubtractFromStart: number, daysToSubtractFromEnd: nu
end: formatToDateInput(moment().subtract(daysToSubtractFromEnd, 'day')),
});
const DateRangePicker = ({ onChange }: DateRangePickerProps) => {
const DateRangePicker = ({ onChange, defaultSelectedKey = 'alldates' }: DateRangePickerProps) => {
const { t } = useTranslation();
const handleRange = useEffectEvent((range: { start: string; end: string }) => {
@ -41,13 +42,6 @@ const DateRangePicker = ({ onChange }: DateRangePickerProps) => {
].map(([value, label]) => [value, label] as SelectOption);
}, [t]);
useEffect(() => {
handleRange({
start: formatToDateInput(moment(0)),
end: todayDate,
});
}, [handleRange]);
const handleOptionClick = useEffectEvent((action: Key) => {
switch (action) {
case 'today':
@ -76,9 +70,13 @@ const DateRangePicker = ({ onChange }: DateRangePickerProps) => {
}
});
useEffect(() => {
handleOptionClick(defaultSelectedKey);
}, []);
return (
<Box flexGrow={0}>
<Select defaultSelectedKey='alldates' width='100%' options={timeOptions} onChange={handleOptionClick} />
<Select defaultSelectedKey={defaultSelectedKey} width='100%' options={timeOptions} onChange={handleOptionClick} />
</Box>
);
};

@ -104,6 +104,10 @@ declare module '@rocket.chat/ui-contexts' {
pathname: '/admin/feature-preview';
pattern: '/admin/feature-preview';
};
'admin-ABAC': {
pathname: '/admin/ABAC';
pattern: '/admin/ABAC/:tab?/:context?/:id?';
};
}
}
@ -237,3 +241,8 @@ registerAdminRoute('/feature-preview', {
name: 'admin-feature-preview',
component: lazy(() => import('./featurePreview/AdminFeaturePreviewRoute')),
});
registerAdminRoute('/ABAC/:tab?/:context?/:id?', {
name: 'admin-ABAC',
component: lazy(() => import('./ABAC/AdminABACRoute')),
});

@ -64,6 +64,12 @@ export const {
icon: 'user-lock',
permissionGranted: (): boolean => hasAtLeastOnePermission(['access-permissions', 'access-setting-permissions']),
},
{
href: '/admin/ABAC',
i18nLabel: 'ABAC',
icon: 'team-lock',
permissionGranted: (): boolean => hasPermission('abac-management'),
},
{
href: '/admin/device-management',
i18nLabel: 'Device_Management',

@ -68,6 +68,7 @@ const AdminUserInfoWithData = ({ uid, onReload, tab }: AdminUserInfoWithDataProp
canViewAllInfo,
reason,
freeSwitchExtension,
abacAttributes,
} = data.user;
return {
@ -92,6 +93,7 @@ const AdminUserInfoWithData = ({ uid, onReload, tab }: AdminUserInfoWithDataProp
nickname,
reason,
freeSwitchExtension,
abacAttributes,
};
}, [approveManuallyUsers, data, getRoles]);

@ -350,7 +350,7 @@ const CurrentChatsPage = ({ id, onRowClick }: { id?: string; onRowClick: (_id: s
</GenericTableBody>
</GenericTable>
)}
{isSuccess && data?.rooms.length > 0 && (
{isSuccess && data?.rooms?.length > 0 && (
<>
<GenericTable>
<GenericTableHeader>{headers}</GenericTableHeader>

@ -83,11 +83,10 @@ ABAC.args = {
...Default.args,
room: {
...roomArgs,
// @ts-expect-error - abacAttributes is not yet implemented in Rooms properties
abacAttributes: [
{ name: 'Chat-sensitivity', values: ['Classified', 'Top-Secret'] },
{ name: 'Country', values: ['US-only'] },
{ name: 'Project', values: ['Ruminator-2000'] },
{ key: 'Chat-sensitivity', values: ['Classified', 'Top-Secret'] },
{ key: 'Country', values: ['US-only'] },
{ key: 'Project', values: ['Ruminator-2000'] },
],
},
};

@ -123,7 +123,6 @@ const RoomMembersWithData = ({ rid }: { rid: IRoom['_id'] }): ReactElement => {
reload={refetch}
onClickInvite={canCreateInviteLinks && canAddUsers ? openInvite : undefined}
onClickAdd={canAddUsers ? openAddUser : undefined}
// @ts-expect-error to be implemented in ABAC Feature branch
isABACRoom={Boolean(room?.abacAttributes)}
/>
);

@ -0,0 +1,421 @@
import { Abac } from '@rocket.chat/core-services';
import type { AbacActor } from '@rocket.chat/core-services';
import type { IServerEvents, IUser } from '@rocket.chat/core-typings';
import { ServerEvents, Users } from '@rocket.chat/models';
import { validateUnauthorizedErrorResponse } from '@rocket.chat/rest-typings/src/v1/Ajv';
import { convertSubObjectsIntoPaths } from '@rocket.chat/tools';
import {
GenericSuccessSchema,
PUTAbacAttributeUpdateBodySchema,
GETAbacAttributesQuerySchema,
GETAbacAttributesResponseSchema,
GETAbacAttributeByIdResponseSchema,
POSTAbacAttributeDefinitionSchema,
GETAbacAttributeIsInUseResponseSchema,
POSTRoomAbacAttributesBodySchema,
POSTSingleRoomAbacAttributeBodySchema,
PUTRoomAbacAttributeValuesBodySchema,
POSTAbacUsersSyncBodySchema,
GenericErrorSchema,
GETAbacRoomsListQueryValidator,
GETAbacRoomsResponseValidator,
GETAbacAuditEventsQuerySchema,
GETAbacAuditEventsResponseSchema,
} from './schemas';
import { API } from '../../../../app/api/server';
import type { ExtractRoutesFromAPI } from '../../../../app/api/server/ApiClass';
import { getPaginationItems } from '../../../../app/api/server/helpers/getPaginationItems';
import { settings } from '../../../../app/settings/server';
import { LDAPEE } from '../../sdk';
const getActorFromUser = (user?: IUser | null): AbacActor | undefined =>
user?._id
? {
_id: user._id,
username: user.username,
name: user.name,
}
: undefined;
const abacEndpoints = API.v1
.post(
'abac/rooms/:rid/attributes',
{
authRequired: true,
permissionsRequired: ['abac-management'],
body: POSTRoomAbacAttributesBodySchema,
response: {
200: GenericSuccessSchema,
401: validateUnauthorizedErrorResponse,
400: GenericErrorSchema,
403: validateUnauthorizedErrorResponse,
},
license: ['abac'],
},
async function action() {
const { rid } = this.urlParams;
const { attributes } = this.bodyParams;
if (!settings.get('ABAC_Enabled')) {
throw new Error('error-abac-not-enabled');
}
// This is a replace-all operation
// IF you need fine grained, use the other endpoints for removing, editing & adding single attributes
await Abac.setRoomAbacAttributes(rid, attributes, getActorFromUser(this.user));
return API.v1.success();
},
)
.delete(
'abac/rooms/:rid/attributes',
{
authRequired: true,
permissionsRequired: ['abac-management'],
response: {
200: GenericSuccessSchema,
401: validateUnauthorizedErrorResponse,
400: GenericErrorSchema,
403: validateUnauthorizedErrorResponse,
},
},
async function action() {
const { rid } = this.urlParams;
// We don't need to check if ABAC is enabled to clear attributes
// Since we're always allowing this operation
// license check is also not required
await Abac.setRoomAbacAttributes(rid, {}, getActorFromUser(this.user));
return API.v1.success();
},
)
// add an abac attribute by key
.post(
'abac/rooms/:rid/attributes/:key',
{
authRequired: true,
permissionsRequired: ['abac-management'],
license: ['abac'],
body: POSTSingleRoomAbacAttributeBodySchema,
response: {
200: GenericSuccessSchema,
401: validateUnauthorizedErrorResponse,
400: GenericErrorSchema,
403: validateUnauthorizedErrorResponse,
},
},
async function action() {
const { rid, key } = this.urlParams;
const { values } = this.bodyParams;
if (!settings.get('ABAC_Enabled')) {
throw new Error('error-abac-not-enabled');
}
await Abac.addRoomAbacAttributeByKey(rid, key, values, getActorFromUser(this.user));
return API.v1.success();
},
)
// edit a room attribute
.put(
'abac/rooms/:rid/attributes/:key',
{
authRequired: true,
permissionsRequired: ['abac-management'],
body: PUTRoomAbacAttributeValuesBodySchema,
response: {
200: GenericSuccessSchema,
401: validateUnauthorizedErrorResponse,
400: GenericErrorSchema,
403: validateUnauthorizedErrorResponse,
},
license: ['abac'],
},
async function action() {
const { rid, key } = this.urlParams;
const { values } = this.bodyParams;
if (!settings.get('ABAC_Enabled')) {
throw new Error('error-abac-not-enabled');
}
await Abac.replaceRoomAbacAttributeByKey(rid, key, values, getActorFromUser(this.user));
return API.v1.success();
},
)
// delete a room attribute
.delete(
'abac/rooms/:rid/attributes/:key',
{
authRequired: true,
permissionsRequired: ['abac-management'],
response: {
200: GenericSuccessSchema,
401: validateUnauthorizedErrorResponse,
400: GenericErrorSchema,
403: validateUnauthorizedErrorResponse,
},
},
async function action() {
const { rid, key } = this.urlParams;
await Abac.removeRoomAbacAttribute(rid, key, getActorFromUser(this.user));
return API.v1.success();
},
)
// attribute endpoints
// list attributes
.get(
'abac/attributes',
{
authRequired: true,
permissionsRequired: ['abac-management'],
query: GETAbacAttributesQuerySchema,
response: {
200: GETAbacAttributesResponseSchema,
401: validateUnauthorizedErrorResponse,
400: GenericErrorSchema,
403: validateUnauthorizedErrorResponse,
},
},
async function action() {
const { offset, count } = await getPaginationItems(this.queryParams as Record<string, string | string[] | number | null | undefined>);
const { key, values } = this.queryParams;
return API.v1.success(
await Abac.listAbacAttributes(
{
key,
values,
offset,
count,
},
getActorFromUser(this.user),
),
);
},
)
.post(
'abac/users/sync',
{
authRequired: true,
permissionsRequired: ['abac-management'],
license: ['abac', 'ldap-enterprise'],
body: POSTAbacUsersSyncBodySchema,
response: {
200: GenericSuccessSchema,
401: validateUnauthorizedErrorResponse,
400: GenericErrorSchema,
403: validateUnauthorizedErrorResponse,
},
},
async function action() {
if (!settings.get('ABAC_Enabled')) {
throw new Error('error-abac-not-enabled');
}
const { usernames, ids, emails, ldapIds } = this.bodyParams;
await LDAPEE.syncUsersAbacAttributes(Users.findUsersByIdentifiers({ usernames, ids, emails, ldapIds }));
return API.v1.success();
},
)
.post(
'abac/attributes',
{
authRequired: true,
permissionsRequired: ['abac-management'],
license: ['abac'],
body: POSTAbacAttributeDefinitionSchema,
response: {
200: GenericSuccessSchema,
401: validateUnauthorizedErrorResponse,
400: GenericErrorSchema,
403: validateUnauthorizedErrorResponse,
},
},
async function action() {
if (!settings.get('ABAC_Enabled')) {
throw new Error('error-abac-not-enabled');
}
await Abac.addAbacAttribute(this.bodyParams, getActorFromUser(this.user));
return API.v1.success();
},
)
// update attribute definition (key and/or values)
.put(
'abac/attributes/:_id',
{
authRequired: true,
permissionsRequired: ['abac-management'],
license: ['abac'],
body: PUTAbacAttributeUpdateBodySchema,
response: {
200: GenericSuccessSchema,
401: validateUnauthorizedErrorResponse,
400: GenericErrorSchema,
403: validateUnauthorizedErrorResponse,
},
},
async function action() {
const { _id } = this.urlParams;
if (!settings.get('ABAC_Enabled')) {
throw new Error('error-abac-not-enabled');
}
await Abac.updateAbacAttributeById(_id, this.bodyParams, getActorFromUser(this.user));
return API.v1.success();
},
)
// get single attribute with usage
.get(
'abac/attributes/:_id',
{
authRequired: true,
permissionsRequired: ['abac-management'],
response: {
200: GETAbacAttributeByIdResponseSchema,
401: validateUnauthorizedErrorResponse,
400: GenericErrorSchema,
403: validateUnauthorizedErrorResponse,
},
},
async function action() {
const { _id } = this.urlParams;
const result = await Abac.getAbacAttributeById(_id, getActorFromUser(this.user));
return API.v1.success(result);
},
)
// delete attribute (only if not in use)
.delete(
'abac/attributes/:_id',
{
authRequired: true,
permissionsRequired: ['abac-management'],
response: {
200: GenericSuccessSchema,
401: validateUnauthorizedErrorResponse,
400: GenericErrorSchema,
403: validateUnauthorizedErrorResponse,
},
},
async function action() {
const { _id } = this.urlParams;
await Abac.deleteAbacAttributeById(_id, getActorFromUser(this.user));
return API.v1.success();
},
)
// check if attribute is in use
.get(
'abac/attributes/:key/is-in-use',
{
authRequired: true,
permissionsRequired: ['abac-management'],
response: {
200: GETAbacAttributeIsInUseResponseSchema,
401: validateUnauthorizedErrorResponse,
400: GenericErrorSchema,
403: validateUnauthorizedErrorResponse,
},
},
async function action() {
const { key } = this.urlParams;
const inUse = await Abac.isAbacAttributeInUseByKey(key);
return API.v1.success({ inUse });
},
)
.get(
'abac/rooms',
{
authRequired: true,
permissionsRequired: ['abac-management'],
response: {
200: GETAbacRoomsResponseValidator,
401: validateUnauthorizedErrorResponse,
400: GenericErrorSchema,
403: validateUnauthorizedErrorResponse,
},
query: GETAbacRoomsListQueryValidator,
},
async function action() {
const { offset, count } = await getPaginationItems(this.queryParams as Record<string, string | string[] | number | null | undefined>);
const { filter, filterType } = this.queryParams;
const result = await Abac.listAbacRooms(
{
offset,
count,
filter,
filterType,
},
getActorFromUser(this.user),
);
return API.v1.success(result);
},
)
.get(
'abac/audit',
{
response: {
200: GETAbacAuditEventsResponseSchema,
400: GenericErrorSchema,
401: validateUnauthorizedErrorResponse,
403: validateUnauthorizedErrorResponse,
},
query: GETAbacAuditEventsQuerySchema,
authRequired: true,
permissionsRequired: ['abac-management'],
license: ['abac', 'auditing'],
},
async function action() {
const { start, end, actor } = this.queryParams;
const { offset, count } = await getPaginationItems(this.queryParams as Record<string, string | number | null | undefined>);
const { sort } = await this.parseJsonQuery();
const _sort = { ts: sort?.ts ? sort?.ts : -1 };
const { cursor, totalCount } = ServerEvents.findPaginated(
{
...(actor && convertSubObjectsIntoPaths({ actor })),
ts: {
$gte: start ? new Date(start) : new Date(0),
$lte: end ? new Date(end) : new Date(),
},
t: {
$in: ['abac.attribute.changed', 'abac.object.attribute.changed', 'abac.object.attributes.removed', 'abac.action.performed'],
},
},
{
sort: _sort,
skip: offset,
limit: count,
allowDiskUse: true,
},
);
const [events, total] = await Promise.all([cursor.toArray(), totalCount]);
return API.v1.success({
events: events as (
| IServerEvents['abac.action.performed']
| IServerEvents['abac.attribute.changed']
| IServerEvents['abac.object.attribute.changed']
| IServerEvents['abac.object.attributes.removed']
)[],
count: events.length,
offset,
total,
});
},
);
export type AbacEndpoints = ExtractRoutesFromAPI<typeof abacEndpoints>;
declare module '@rocket.chat/rest-typings' {
// eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface
interface Endpoints extends AbacEndpoints {}
}

@ -0,0 +1,389 @@
import type { IAbacAttribute, IAbacAttributeDefinition, IAuditServerActor, IRoom, IServerEvents } from '@rocket.chat/core-typings';
import type { PaginatedResult, PaginatedRequest } from '@rocket.chat/rest-typings';
import { ajv } from '@rocket.chat/rest-typings';
const ATTRIBUTE_KEY_PATTERN = '^[A-Za-z0-9_-]+$';
const MAX_ROOM_ATTRIBUTE_VALUES = 10;
const MAX_USERS_SYNC_ITEMS = 100;
const MAX_ROOM_ATTRIBUTE_KEYS = 10;
const GenericSuccess = {
type: 'object',
properties: {
success: { type: 'boolean', enum: [true] },
},
additionalProperties: false,
};
export const GenericSuccessSchema = ajv.compile<void>(GenericSuccess);
// Update ABAC attribute (request body)
const UpdateAbacAttributeBody = {
type: 'object',
properties: {
key: { type: 'string', minLength: 1, pattern: ATTRIBUTE_KEY_PATTERN },
values: {
type: 'array',
items: { type: 'string', minLength: 1, pattern: ATTRIBUTE_KEY_PATTERN },
minItems: 1,
uniqueItems: true,
},
},
additionalProperties: false,
anyOf: [{ required: ['key'] }, { required: ['values'] }],
};
export const PUTAbacAttributeUpdateBodySchema = ajv.compile<IAbacAttributeDefinition>(UpdateAbacAttributeBody);
const AbacAttributeDefinition = {
type: 'object',
properties: {
key: { type: 'string', minLength: 1, pattern: ATTRIBUTE_KEY_PATTERN },
values: {
type: 'array',
items: { type: 'string', minLength: 1, pattern: ATTRIBUTE_KEY_PATTERN },
minItems: 1,
uniqueItems: true,
},
},
required: ['key', 'values'],
additionalProperties: false,
};
export const POSTAbacAttributeDefinitionSchema = ajv.compile<IAbacAttributeDefinition>(AbacAttributeDefinition);
const GetAbacAttributesQuery = {
type: 'object',
properties: {
key: { type: 'string', minLength: 1, pattern: ATTRIBUTE_KEY_PATTERN },
values: { type: 'string', minLength: 1, pattern: ATTRIBUTE_KEY_PATTERN },
offset: { type: 'number' },
count: { type: 'number' },
},
additionalProperties: false,
};
export const GETAbacAttributesQuerySchema = ajv.compile<{ key?: string; values?: string; offset: number; count?: number }>(
GetAbacAttributesQuery,
);
const AbacAttributeRecord = {
type: 'object',
properties: {
_id: { type: 'string', minLength: 1 },
key: { type: 'string', minLength: 1, pattern: ATTRIBUTE_KEY_PATTERN },
values: {
type: 'array',
items: { type: 'string', minLength: 1, pattern: ATTRIBUTE_KEY_PATTERN },
minItems: 1,
uniqueItems: true,
},
},
required: ['_id', 'key', 'values'],
additionalProperties: false,
};
const GetAbacAttributesResponse = {
type: 'object',
properties: {
success: { type: 'boolean', enum: [true] },
attributes: {
type: 'array',
items: AbacAttributeRecord,
},
offset: { type: 'number' },
count: { type: 'number' },
total: { type: 'number' },
},
required: ['attributes', 'offset', 'count', 'total'],
additionalProperties: false,
};
export const GETAbacAttributesResponseSchema = ajv.compile<{
attributes: IAbacAttribute[];
offset: number;
count: number;
total: number;
}>(GetAbacAttributesResponse);
const GetAbacAttributeByIdResponse = {
type: 'object',
properties: {
success: { type: 'boolean', enum: [true] },
_id: { type: 'string', minLength: 1 },
key: { type: 'string', minLength: 1, pattern: ATTRIBUTE_KEY_PATTERN },
values: {
type: 'array',
items: { type: 'string', minLength: 1, pattern: ATTRIBUTE_KEY_PATTERN },
minItems: 1,
uniqueItems: true,
},
},
required: ['key', 'values'],
additionalProperties: false,
};
export const GETAbacAttributeByIdResponseSchema = ajv.compile<{
key: string;
values: string[];
}>(GetAbacAttributeByIdResponse);
const GetAbacAttributeIsInUseResponse = {
type: 'object',
properties: {
success: { type: 'boolean', enum: [true] },
inUse: { type: 'boolean' },
},
required: ['inUse'],
additionalProperties: false,
};
export const GETAbacAttributeIsInUseResponseSchema = ajv.compile<{ inUse: boolean }>(GetAbacAttributeIsInUseResponse);
const GetAbacAuditEventsQuerySchemaObject = {
type: 'object',
properties: {
start: { type: 'string', format: 'date-time', nullable: true },
end: { type: 'string', format: 'date-time', nullable: true },
offset: { type: 'number', nullable: true },
count: { type: 'number', nullable: true },
actor: {
type: 'object',
nullable: true,
properties: {
type: {
type: 'string',
nullable: true,
},
_id: {
type: 'string',
nullable: true,
},
username: {
type: 'string',
nullable: true,
},
ip: {
type: 'string',
nullable: true,
},
useragent: {
type: 'string',
nullable: true,
},
reason: {
type: 'string',
nullable: true,
},
},
},
},
additionalProperties: false,
};
export const GETAbacAuditEventsQuerySchema = ajv.compile<
PaginatedRequest<{
start?: string;
end?: string;
actor?: IAuditServerActor;
}>
>(GetAbacAuditEventsQuerySchemaObject);
const GetAbacAuditEventsResponseSchemaObject = {
type: 'object',
properties: {
success: { type: 'boolean', enum: [true] },
events: {
type: 'array',
items: {
type: 'object',
},
},
count: {
type: 'number',
description: 'The number of events returned in this response.',
},
offset: {
type: 'number',
description: 'The number of events that were skipped in this response.',
},
total: {
type: 'number',
description: 'The total number of events that match the query.',
},
},
required: ['events', 'count', 'offset', 'total'],
additionalProperties: false,
};
export const GETAbacAuditEventsResponseSchema = ajv.compile<{
events: (
| IServerEvents['abac.action.performed']
| IServerEvents['abac.attribute.changed']
| IServerEvents['abac.object.attribute.changed']
| IServerEvents['abac.object.attributes.removed']
)[];
count: number;
offset: number;
total: number;
}>(GetAbacAuditEventsResponseSchemaObject);
const PostRoomAbacAttributesBody = {
type: 'object',
properties: {
attributes: {
type: 'object',
propertyNames: { type: 'string', pattern: ATTRIBUTE_KEY_PATTERN },
minProperties: 1,
maxProperties: MAX_ROOM_ATTRIBUTE_KEYS,
additionalProperties: {
type: 'array',
items: { type: 'string', minLength: 1, pattern: ATTRIBUTE_KEY_PATTERN },
maxItems: MAX_ROOM_ATTRIBUTE_VALUES,
uniqueItems: true,
},
},
},
required: ['attributes'],
additionalProperties: false,
};
export const POSTRoomAbacAttributesBodySchema = ajv.compile<{ attributes: Record<string, string[]> }>(PostRoomAbacAttributesBody);
const PostSingleRoomAbacAttributeBody = {
type: 'object',
properties: {
values: {
type: 'array',
items: { type: 'string', minLength: 1, pattern: ATTRIBUTE_KEY_PATTERN },
minItems: 1,
maxItems: MAX_ROOM_ATTRIBUTE_VALUES,
uniqueItems: true,
},
},
required: ['values'],
additionalProperties: false,
};
export const POSTSingleRoomAbacAttributeBodySchema = ajv.compile<{ values: string[] }>(PostSingleRoomAbacAttributeBody);
const PutRoomAbacAttributeValuesBody = {
type: 'object',
properties: {
values: {
type: 'array',
items: { type: 'string', minLength: 1, pattern: ATTRIBUTE_KEY_PATTERN },
minItems: 1,
maxItems: MAX_ROOM_ATTRIBUTE_VALUES,
uniqueItems: true,
},
},
required: ['values'],
additionalProperties: false,
};
export const PUTRoomAbacAttributeValuesBodySchema = ajv.compile<{ values: string[] }>(PutRoomAbacAttributeValuesBody);
const GenericError = {
type: 'object',
properties: {
success: {
type: 'boolean',
},
message: {
type: 'string',
},
},
};
const PostAbacUsersSyncBody = {
type: 'object',
properties: {
usernames: {
type: 'array',
items: { type: 'string', minLength: 1 },
minItems: 1,
maxItems: MAX_USERS_SYNC_ITEMS,
uniqueItems: true,
},
ids: {
type: 'array',
items: { type: 'string', minLength: 1 },
minItems: 1,
maxItems: MAX_USERS_SYNC_ITEMS,
uniqueItems: true,
},
emails: {
type: 'array',
items: { type: 'string', minLength: 1 },
minItems: 1,
maxItems: MAX_USERS_SYNC_ITEMS,
uniqueItems: true,
},
ldapIds: {
type: 'array',
items: { type: 'string', minLength: 1 },
minItems: 1,
maxItems: MAX_USERS_SYNC_ITEMS,
uniqueItems: true,
},
},
additionalProperties: false,
anyOf: [{ required: ['usernames'] }, { required: ['ids'] }, { required: ['emails'] }, { required: ['ldapIds'] }],
};
export const POSTAbacUsersSyncBodySchema = ajv.compile<{
usernames?: string[];
ids?: string[];
emails?: string[];
ldapIds?: string[];
}>(PostAbacUsersSyncBody);
export const GenericErrorSchema = ajv.compile<{ success: boolean; message: string }>(GenericError);
const GETAbacRoomsListQuerySchema = {
type: 'object',
properties: {
filter: { type: 'string', minLength: 1 },
filterType: { type: 'string', enum: ['all', 'roomName', 'attribute', 'value'] },
offset: { type: 'number' },
count: { type: 'number' },
},
additionalProperties: false,
};
type GETAbacRoomsListQuery = PaginatedRequest<{ filter?: string; filterType?: 'all' | 'roomName' | 'attribute' | 'value' }>;
export const GETAbacRoomsListQueryValidator = ajv.compile<GETAbacRoomsListQuery>(GETAbacRoomsListQuerySchema);
export const GETAbacRoomsResponseSchema = {
type: 'object',
properties: {
success: {
type: 'boolean',
enum: [true],
},
rooms: {
type: 'array',
items: { type: 'object' },
},
offset: {
type: 'number',
},
count: {
type: 'number',
},
total: {
type: 'number',
},
},
required: ['rooms', 'offset', 'count', 'total'],
additionalProperties: false,
};
type GETAbacRoomsResponse = PaginatedResult<{
rooms: IRoom[];
}>;
export const GETAbacRoomsResponseValidator = ajv.compile<GETAbacRoomsResponse>(GETAbacRoomsResponseSchema);

@ -7,3 +7,4 @@ import './roles';
import '../apps/communication/uikit';
import './engagementDashboard';
import './audit';
import './abac';

@ -0,0 +1,29 @@
import { License } from '@rocket.chat/license';
import { Users } from '@rocket.chat/models';
import { settings } from '../../../app/settings/server';
import { LDAPEE } from '../sdk';
Meteor.startup(async () => {
let stopWatcher: () => void;
License.onToggledFeature('abac', {
up: async () => {
const { addSettings } = await import('../settings/abac');
const { createPermissions } = await import('../lib/abac');
await addSettings();
await createPermissions();
await import('../hooks/abac');
stopWatcher = settings.watch('ABAC_Enabled', async (value) => {
if (value) {
await LDAPEE.syncUsersAbacAttributes(Users.findLDAPUsers());
}
});
},
down: () => {
stopWatcher?.();
},
});
});

@ -5,3 +5,4 @@ import './outlookCalendar';
import './saml';
import './videoConference';
import './voip';
import './abac';

@ -60,14 +60,23 @@ Meteor.startup(async () => {
() => LDAPEE.syncLogout(),
);
const addAbacCronJob = configureBackgroundSync(
'LDAP_AbacSync',
'LDAP_Background_Sync_ABAC_Attributes',
'LDAP_Background_Sync_ABAC_Attributes_Interval',
() => LDAPEE.syncAbacAttributes(),
);
settings.watchMultiple(['LDAP_Background_Sync', 'LDAP_Background_Sync_Interval'], addCronJob);
settings.watchMultiple(['LDAP_Background_Sync_Avatars', 'LDAP_Background_Sync_Avatars_Interval'], addAvatarCronJob);
settings.watchMultiple(['LDAP_Sync_AutoLogout_Enabled', 'LDAP_Sync_AutoLogout_Interval'], addLogoutCronJob);
settings.watchMultiple(['LDAP_Background_Sync_ABAC_Attributes', 'LDAP_Background_Sync_ABAC_Attributes_Interval'], addAbacCronJob);
settings.watch('LDAP_Enable', async () => {
await addCronJob();
await addAvatarCronJob();
await addLogoutCronJob();
await addAbacCronJob();
});
settings.watch<string>('LDAP_Groups_To_Rocket_Chat_Teams', (value) => {
@ -78,6 +87,14 @@ Meteor.startup(async () => {
}
});
settings.watch<string>('LDAP_ABAC_AttributeMap', (value) => {
try {
LDAPEEManager.validateLDAPABACAttributeMap(value);
} catch (error) {
logger.error(error);
}
});
callbacks.add(
'mapLDAPUserData',
(userData: IImportUser, ldapUser?: ILDAPEntry) => {

@ -0,0 +1,22 @@
import { Abac } from '@rocket.chat/core-services';
import { License } from '@rocket.chat/license';
import { beforeAddUserToRoom } from '../../../../app/lib/server/lib/beforeAddUserToRoom';
import { settings } from '../../../../app/settings/server';
beforeAddUserToRoom.patch(async (prev, users, room, actor) => {
await prev(users, room, actor);
const validUsers = users.filter(Boolean);
// No need to check ABAC when theres no users or when room is not private or when room is not ABAC managed
if (!validUsers.length || room.t !== 'p' || !room?.abacAttributes?.length) {
return;
}
// Throw error (prevent add) if ABAC is disabled (setting, license) but room is ABAC managed
if (!settings.get('ABAC_Enabled') || !License.hasModule('abac')) {
throw new Error('error-room-is-abac-managed');
}
await Abac.checkUsernamesMatchAttributes(validUsers as string[], room.abacAttributes, room);
});

@ -0,0 +1 @@
import './beforeAddUserToRoom';

@ -0,0 +1,9 @@
import { Permissions } from '@rocket.chat/models';
export const createPermissions = async () => {
const permissions = [{ _id: 'abac-management', roles: ['admin'] }];
for (const permission of permissions) {
void Permissions.create(permission._id, permission.roles);
}
};

@ -37,7 +37,8 @@ const getRoomInfoByAuditParams = async ({
userId: string;
}) => {
if (rid) {
return getValue(await Rooms.findOne({ _id: rid }));
// When ABAC is enabled, only rooms without ABAC attributes are considered for auditing by room ID.
return getValue(await Rooms.findOne({ _id: rid, abacAttributes: { $exists: false } }));
}
if (type === 'd') {
@ -165,7 +166,7 @@ Meteor.methods<ServerMethods>({
} else {
const roomInfo = await getRoomInfoByAuditParams({ type, roomId: rid, users: usernames, visitor, agent, userId: user._id });
if (!roomInfo) {
throw new Meteor.Error('Room doesn`t exist');
throw new Meteor.Error(`Room doesn't exist`);
}
rids = roomInfo.rids;

@ -1,7 +1,9 @@
import { Team } from '@rocket.chat/core-services';
import { Abac, Team } from '@rocket.chat/core-services';
import type { ILDAPEntry, IUser, IRoom, IRole, IImportUser, IImportRecord } from '@rocket.chat/core-typings';
import { License } from '@rocket.chat/license';
import { Users, Roles, Subscriptions as SubscriptionsRaw, Rooms } from '@rocket.chat/models';
import type ldapjs from 'ldapjs';
import type { FindCursor } from 'mongodb';
import type {
ImporterAfterImportCallback,
@ -102,6 +104,52 @@ export class LDAPEEManager extends LDAPManager {
}
}
public static async syncAbacAttributes(): Promise<void> {
if (
!settings.get('LDAP_Enable') ||
!settings.get('LDAP_Background_Sync_ABAC_Attributes') ||
!License.hasModule('abac') ||
!settings.get('ABAC_Enabled')
) {
return;
}
try {
const ldap = new LDAPConnection();
await ldap.connect();
try {
await this.updateUserAbacAttributes(ldap);
} finally {
ldap.disconnect();
}
} catch (error) {
logger.error(error);
}
}
public static async syncUsersAbacAttributes(users: FindCursor<IUser>): Promise<void> {
if (!settings.get('LDAP_Enable') || !License.hasModule('abac') || !settings.get('ABAC_Enabled')) {
return;
}
try {
const ldap = new LDAPConnection();
await ldap.connect();
try {
logger.debug({ msg: 'Starting ABAC attributes sync for LDAP users' });
for await (const user of users) {
await this.syncUserAbacAttribute(ldap, user);
}
} finally {
ldap.disconnect();
}
} catch (error) {
logger.error(error);
}
}
public static validateLDAPTeamsMappingChanges(json: string): void {
if (!json) {
return;
@ -123,6 +171,32 @@ export class LDAPEEManager extends LDAPManager {
}
}
public static validateLDAPABACAttributeMap(json: string): void {
if (!json) {
return;
}
const mappedAttributes = this.parseJson(json);
// attributes are { key: value } with key being the ldap attribute and value being the abac attribute in rocketchat
// both strings
// There's no need for the attribute to exist in rocketchat, we just add whatever the admin wants to map
if (!mappedAttributes || Object.keys(mappedAttributes).length === 0) {
return;
}
const validStructureMapping = Object.entries(mappedAttributes).every(
([key, value]) => typeof key === 'string' && typeof value === 'string',
);
if (!validStructureMapping) {
throw new Error(
'Please verify your mapping for LDAP X RocketChat ABAC Attributes. The structure is invalid, the structure should be an object like: {key: LdapAttribute, value: RocketChatAbacAttribute}',
);
}
}
public static async syncLogout(): Promise<void> {
if (settings.get('LDAP_Enable') !== true || settings.get('LDAP_Sync_AutoLogout_Enabled') !== true) {
return;
@ -358,6 +432,11 @@ export class LDAPEEManager extends LDAPManager {
return;
}
if (settings.get('ABAC_Enabled') && room?.abacAttributes?.length) {
logger.error({ msg: 'Cannot add user to channel. Channel is ABAC managed', userChannelName });
continue;
}
if (room.teamMain) {
logger.error(`Can't add user to channel ${userChannelName} because it is a team.`);
} else {
@ -430,7 +509,23 @@ export class LDAPEEManager extends LDAPManager {
});
const currentTeamIds = currentTeams?.map(({ teamId }) => teamId);
const teamsToRemove = currentTeamIds?.filter((teamId) => notInTeamIds.includes(teamId));
const teamsToAdd = inTeamIds.filter((teamId) => !currentTeamIds?.includes(teamId));
let teamsToAdd = inTeamIds.filter((teamId) => !currentTeamIds?.includes(teamId));
if (settings.get('ABAC_Enabled')) {
const roomsWithAbacAttributes = await Rooms.findPrivateRoomsByIdsWithAbacAttributes(
allTeams.filter((t) => teamsToAdd.includes(t._id)).map((t) => t.roomId),
{ projection: { teamId: 1 } },
)
.map((r) => r.teamId)
.toArray();
logger.debug({ msg: 'Some teams will be ignored from sync because they are abac managed', roomsWithAbacAttributes });
teamsToAdd = teamsToAdd.filter((teamId) => !roomsWithAbacAttributes.includes(teamId));
if (!teamsToAdd.length) {
return;
}
}
await Team.insertMemberOnTeams(user._id, teamsToAdd);
if (teamsToRemove) {
@ -644,6 +739,38 @@ export class LDAPEEManager extends LDAPManager {
}
}
private static async updateUserAbacAttributes(ldap: LDAPConnection): Promise<void> {
const mapping = this.parseJson(settings.get('LDAP_ABAC_AttributeMap'));
if (!mapping) {
logger.error('LDAP to ABAC attribute mapping is not valid JSON');
return;
}
for await (const user of Users.findLDAPUsers()) {
const ldapUser = await this.findLDAPUser(ldap, user);
if (!ldapUser) {
continue;
}
await Abac.addSubjectAttributes(user, ldapUser, mapping, undefined);
}
}
private static async syncUserAbacAttribute(ldap: LDAPConnection, user: IUser): Promise<void> {
const mapping = this.parseJson(settings.get('LDAP_ABAC_AttributeMap'));
if (!mapping) {
logger.error('LDAP to ABAC attribute mapping is not valid JSON');
return;
}
const ldapUser = await this.findLDAPUser(ldap, user);
if (!ldapUser) {
return;
}
await Abac.addSubjectAttributes(user, ldapUser, mapping, undefined);
}
private static async findLDAPUser(ldap: LDAPConnection, user: IUser): Promise<ILDAPEntry | undefined> {
if (user.services?.ldap?.id) {
return ldap.findOneById(user.services.ldap.id, user.services.ldap.idAttribute);

@ -1,4 +1,6 @@
import { ServiceClassInternal } from '@rocket.chat/core-services';
import type { IUser } from '@rocket.chat/core-typings';
import type { FindCursor } from 'mongodb';
import { LDAPEEManager } from '../../lib/ldap/Manager';
import type { ILDAPEEService } from '../../sdk/types/ILDAPEEService';
@ -17,4 +19,12 @@ export class LDAPEEService extends ServiceClassInternal implements ILDAPEEServic
async syncLogout(): Promise<void> {
return LDAPEEManager.syncLogout();
}
async syncAbacAttributes(): Promise<void> {
return LDAPEEManager.syncAbacAttributes();
}
async syncUsersAbacAttributes(users: FindCursor<IUser>): Promise<void> {
return LDAPEEManager.syncUsersAbacAttributes(users);
}
}

@ -1,5 +1,10 @@
import type { IUser } from '@rocket.chat/core-typings';
import type { FindCursor } from 'mongodb';
export interface ILDAPEEService {
sync(): Promise<void>;
syncAvatars(): Promise<void>;
syncLogout(): Promise<void>;
syncAbacAttributes(): Promise<void>;
syncUsersAbacAttributes(users: FindCursor<IUser>): Promise<void>;
}

@ -0,0 +1,35 @@
import { settingsRegistry } from '../../../app/settings/server';
export function addSettings(): Promise<void> {
return settingsRegistry.addGroup('General', async function () {
await this.with(
{
enterprise: true,
modules: ['abac'],
},
async function () {
await this.add('ABAC_Enabled', false, {
type: 'boolean',
public: true,
invalidValue: false,
section: 'ABAC',
i18nDescription: 'ABAC_Enabled_Description',
});
await this.add('ABAC_ShowAttributesInRooms', false, {
type: 'boolean',
public: true,
invalidValue: false,
section: 'ABAC',
enableQuery: { _id: 'ABAC_Enabled', value: true },
});
await this.add('Abac_Cache_Decision_Time_Seconds', 300, {
type: 'int',
public: true,
section: 'ABAC',
invalidValue: 0,
enableQuery: { _id: 'ABAC_Enabled', value: true },
});
},
);
});
}

@ -282,6 +282,30 @@ export function addSettings(): Promise<void> {
invalidValue: '',
});
});
await this.section('LDAP_DataSync_ABAC', async function () {
await this.add('LDAP_Background_Sync_ABAC_Attributes', false, {
type: 'boolean',
enableQuery,
invalidValue: false,
modules: ['abac', 'ldap-enterprise'],
});
await this.add('LDAP_Background_Sync_ABAC_Attributes_Interval', '0 0 * * *', {
type: 'string',
enableQuery: [enableQuery, { _id: 'LDAP_Background_Sync_ABAC_Attributes', value: true }],
invalidValue: '0 0 * * *',
modules: ['abac', 'ldap-enterprise'],
});
await this.add('LDAP_ABAC_AttributeMap', '{}', {
type: 'code',
multiline: true,
enableQuery: [enableQuery, { _id: 'LDAP_Background_Sync_ABAC_Attributes', value: true }],
invalidValue: '{}',
modules: ['abac', 'ldap-enterprise'],
});
});
},
);
});

@ -1,3 +1,4 @@
import { AbacService } from '@rocket.chat/abac';
import { api } from '@rocket.chat/core-services';
import { isRunningMs } from '../../../server/lib/isRunningMs';
@ -19,5 +20,6 @@ api.registerService(new VoipFreeSwitchService());
// when not running micro services we want to start up the instance intercom
if (!isRunningMs()) {
api.registerService(new AbacService());
api.registerService(new InstanceService());
}

@ -127,4 +127,7 @@ export const roomFields = {
callTotalHoldTime: 1,
callWaitingTime: 1,
usersWaitingForE2EKeys: 1,
// ABAC fields
abacAttributes: 1,
} as const;

@ -29,4 +29,5 @@ export const adminFields: Partial<Record<keyof IRoom, 1>> = {
uids: 1,
avatarETag: 1,
federated: 1,
abacAttributes: 1,
} as const;

@ -84,6 +84,7 @@
"@parse/node-apn": "^6.3.0",
"@react-aria/toolbar": "^3.0.0-nightly.5042",
"@react-pdf/renderer": "^3.4.5",
"@rocket.chat/abac": "workspace:^",
"@rocket.chat/account-utils": "workspace:^",
"@rocket.chat/agenda": "workspace:^",
"@rocket.chat/api-client": "workspace:^",

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save