From 73d9eb2783176954f42aa2cbeda8abf1d49ac260 Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Thu, 18 Dec 2025 06:32:11 -0600 Subject: [PATCH] feat: ABAC (#37091) Co-authored-by: Tasso Co-authored-by: Martin Schoeler Co-authored-by: MartinSchoeler Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .changeset/ninety-dodos-confess.md | 15 + apps/meteor/app/api/server/v1/rooms.ts | 142 +- apps/meteor/app/api/server/v1/teams.ts | 8 + .../server/methods/saveRoomSettings.ts | 38 +- .../server/functions/findOrCreateInvite.ts | 7 + .../server/functions/validateInviteToken.ts | 9 + .../functions/addUserToDefaultChannels.ts | 5 + .../app/lib/server/functions/addUserToRoom.ts | 6 +- .../lib/server/functions/getFullUserData.ts | 1 + .../server/functions/removeUserFromRoom.ts | 35 +- .../app/lib/server/lib/beforeAddUserToRoom.ts | 6 + .../app/slashcommands-invite/server/server.ts | 48 +- .../app/statistics/server/lib/statistics.ts | 22 + .../server/functions/getBaseUserFields.ts | 1 + .../ABACUpsellModal/ABACUpsellModal.spec.tsx | 9 +- .../components/UserInfo/UserInfo.stories.tsx | 12 +- .../client/components/UserInfo/UserInfo.tsx | 6 +- .../UserInfo/UserInfoABACAttributes.tsx | 15 +- .../__snapshots__/UserInfo.spec.tsx.snap | 11 +- .../items/actions/ForwardMessageAction.tsx | 1 - .../message/toolbar/usePermalinkAction.ts | 1 - .../message/toolbar/useReplyInDMAction.ts | 1 - apps/meteor/client/lib/links.ts | 2 + apps/meteor/client/lib/queryKeys.ts | 19 + .../client/lib/rooms/roomTypes/private.ts | 1 - .../lib/utils/mapSubscriptionFromApi.ts | 2 + .../ABAC/ABACAttributesTab/AttributeMenu.tsx | 28 + .../AttributesContextualBar.tsx | 99 + .../AttributesContextualBarWithData.tsx | 28 + .../AttributesForm.spec.tsx} | 49 +- .../AttributesForm.stories.tsx} | 25 +- .../ABAC/ABACAttributesTab/AttributesForm.tsx | 163 ++ .../ABAC/ABACAttributesTab/AttributesPage.tsx | 110 + .../AttributesForm.spec.tsx.snap} | 272 +- .../views/admin/ABAC/ABACLogsTab/LogsPage.tsx | 213 ++ .../ABACRoomsTab/DeleteRoomModal.spec.tsx | 71 + .../ABAC/ABACRoomsTab/DeleteRoomModal.tsx | 48 + .../admin/ABAC/ABACRoomsTab/RoomForm.tsx | 131 + .../RoomFormAttributeField.spec.tsx | 13 + .../RoomFormAttributeField.stories.tsx | 73 + .../ABACRoomsTab/RoomFormAttributeField.tsx | 93 + .../RoomFormAttributeFields.spec.tsx | 136 + .../ABACRoomsTab/RoomFormAttributeFields.tsx | 37 + .../RoomFormAutocomplete.spec.tsx | 60 + .../RoomFormAutocomplete.stories.tsx | 42 + .../ABACRoomsTab/RoomFormAutocomplete.tsx | 58 + .../RoomFormAutocompleteDummy.tsx | 11 + .../admin/ABAC/ABACRoomsTab/RoomMenu.tsx | 28 + .../ABAC/ABACRoomsTab/RoomsContextualBar.tsx | 89 + .../RoomsContextualBarWithData.tsx | 34 + .../admin/ABAC/ABACRoomsTab/RoomsPage.tsx | 137 + .../DeleteRoomModal.spec.tsx.snap | 98 + .../RoomFormAttributeField.spec.tsx.snap | 95 + .../RoomFormAutocomplete.spec.tsx.snap | 31 + .../ABAC/ABACSettingTab/AbacEnabledToggle.tsx | 95 + .../ABAC/ABACSettingTab/SettingField.spec.tsx | 58 + .../ABAC/ABACSettingTab/SettingField.tsx | 145 + .../ABACSettingTab/SettingToggle.spec.tsx | 142 + .../ABACSettingTab/SettingToggle.stories.tsx | 64 + .../ABAC/ABACSettingTab/SettingsPage.tsx | 33 + .../ABAC/ABACSettingTab/WarningModal.tsx | 48 + .../__snapshots__/SettingToggle.spec.tsx.snap | 152 + .../client/views/admin/ABAC/AdminABACPage.tsx | 103 + .../ABAC/AdminABACRoomAttributesForm.tsx | 135 - .../views/admin/ABAC/AdminABACRoute.tsx | 64 + .../client/views/admin/ABAC/AdminABACTabs.tsx | 33 + .../admin/ABAC/hooks/useAttributeList.ts | 39 + .../ABAC/hooks/useAttributeOptions.spec.tsx | 267 ++ .../admin/ABAC/hooks/useAttributeOptions.tsx | 101 + .../ABAC/hooks/useDeleteRoomModal.spec.tsx | 52 + .../admin/ABAC/hooks/useDeleteRoomModal.tsx | 11 + .../admin/ABAC/hooks/useIsABACAvailable.ts | 10 + .../admin/ABAC/hooks/useRoomItems.spec.tsx | 124 + .../views/admin/ABAC/hooks/useRoomItems.tsx | 40 + .../moderation/helpers/DateRangePicker.tsx | 16 +- apps/meteor/client/views/admin/routes.tsx | 9 + .../meteor/client/views/admin/sidebarItems.ts | 6 + .../admin/users/AdminUserInfoWithData.tsx | 2 + .../currentChats/CurrentChatsPage.tsx | 2 +- .../Info/RoomInfo/RoomInfo.stories.tsx | 7 +- .../RoomMembers/RoomMembersWithData.tsx | 1 - apps/meteor/ee/server/api/abac/index.ts | 421 +++ apps/meteor/ee/server/api/abac/schemas.ts | 389 +++ apps/meteor/ee/server/api/index.ts | 1 + apps/meteor/ee/server/configuration/abac.ts | 29 + apps/meteor/ee/server/configuration/index.ts | 1 + apps/meteor/ee/server/configuration/ldap.ts | 17 + .../server/hooks/abac/beforeAddUserToRoom.ts | 22 + apps/meteor/ee/server/hooks/abac/index.ts | 1 + apps/meteor/ee/server/lib/abac/index.ts | 9 + apps/meteor/ee/server/lib/audit/methods.ts | 5 +- apps/meteor/ee/server/lib/ldap/Manager.ts | 131 +- .../ee/server/local-services/ldap/service.ts | 10 + .../ee/server/sdk/types/ILDAPEEService.ts | 5 + apps/meteor/ee/server/settings/abac.ts | 35 + apps/meteor/ee/server/settings/ldap.ts | 24 + apps/meteor/ee/server/startup/services.ts | 2 + apps/meteor/lib/publishFields.ts | 3 + apps/meteor/lib/rooms/adminFields.ts | 1 + apps/meteor/package.json | 1 + .../dataExport/exportRoomMessagesToFile.ts | 3 + .../meteor/server/methods/addAllUserToRoom.ts | 6 + apps/meteor/server/models.ts | 2 + .../services/authorization/canAccessRoom.ts | 28 +- .../server/services/authorization/service.ts | 3 +- apps/meteor/server/services/room/service.ts | 17 +- apps/meteor/tests/end-to-end/api/abac.ts | 2455 +++++++++++++++++ apps/meteor/tests/end-to-end/api/rooms.ts | 104 + apps/meteor/tests/mocks/data.ts | 1 + development/ldap/02-data.ldif | 204 ++ docker-compose-ci.yml | 18 +- ee/apps/authorization-service/Dockerfile | 3 + ee/apps/authorization-service/package.json | 1 + ee/apps/authorization-service/src/service.ts | 6 + ee/packages/abac/.eslintrc.json | 4 + ee/packages/abac/jest.config.ts | 7 + ee/packages/abac/package.json | 42 + ee/packages/abac/src/audit.ts | 148 + .../abac/src/can-access-object.spec.ts | 264 ++ ee/packages/abac/src/errors.ts | 93 + ee/packages/abac/src/helper.spec.ts | 478 ++++ ee/packages/abac/src/helper.ts | 324 +++ ee/packages/abac/src/index.ts | 667 +++++ ee/packages/abac/src/service.spec.ts | 1136 ++++++++ .../subject-attributes-validations.spec.ts | 480 ++++ .../abac/src/user-auto-removal.spec.ts | 477 ++++ ee/packages/abac/tsconfig.json | 10 + .../src/definition/messages/MessageType.ts | 4 +- packages/core-services/src/index.ts | 3 + .../core-services/src/types/IAbacService.ts | 49 + .../core-services/src/types/IAuthorization.ts | 2 +- .../core-services/src/types/IRoomService.ts | 8 +- packages/core-typings/src/Abac.ts | 9 + packages/core-typings/src/IAbacAttribute.ts | 16 + .../core-typings/src/IMessage/IMessage.ts | 1 + packages/core-typings/src/IRoom.ts | 6 +- packages/core-typings/src/IStats.ts | 4 + packages/core-typings/src/ISubscription.ts | 2 + packages/core-typings/src/IUser.ts | 3 + .../src/ServerAudit/IAuditServerAbacAction.ts | 89 + packages/core-typings/src/index.ts | 4 + .../core-typings/src/license/LicenseModule.ts | 1 + packages/i18n/src/locales/en.i18n.json | 90 +- packages/jwt/__tests__/jwt.spec.ts | 1 + .../message-types/src/registrations/common.ts | 6 + packages/model-typings/src/index.ts | 1 + .../src/models/IAbacAttributesModel.ts | 10 + .../model-typings/src/models/IRoomsModel.ts | 13 + .../src/models/ISubscriptionsModel.ts | 1 + .../model-typings/src/models/IUsersModel.ts | 7 + packages/models/src/index.ts | 6 + packages/models/src/modelClasses.ts | 1 + packages/models/src/models/AbacAttributes.ts | 39 + packages/models/src/models/Rooms.ts | 114 + packages/models/src/models/Subscriptions.ts | 15 + packages/models/src/models/Users.ts | 40 + yarn.lock | 747 ++++- 157 files changed, 13369 insertions(+), 465 deletions(-) create mode 100644 .changeset/ninety-dodos-confess.md create mode 100644 apps/meteor/app/lib/server/lib/beforeAddUserToRoom.ts create mode 100644 apps/meteor/client/views/admin/ABAC/ABACAttributesTab/AttributeMenu.tsx create mode 100644 apps/meteor/client/views/admin/ABAC/ABACAttributesTab/AttributesContextualBar.tsx create mode 100644 apps/meteor/client/views/admin/ABAC/ABACAttributesTab/AttributesContextualBarWithData.tsx rename apps/meteor/client/views/admin/ABAC/{AdminABACRoomAttributesForm.spec.tsx => ABACAttributesTab/AttributesForm.spec.tsx} (84%) rename apps/meteor/client/views/admin/ABAC/{AdminABACRoomAttributesForm.stories.tsx => ABACAttributesTab/AttributesForm.stories.tsx} (68%) create mode 100644 apps/meteor/client/views/admin/ABAC/ABACAttributesTab/AttributesForm.tsx create mode 100644 apps/meteor/client/views/admin/ABAC/ABACAttributesTab/AttributesPage.tsx rename apps/meteor/client/views/admin/ABAC/{__snapshots__/AdminABACRoomAttributesForm.spec.tsx.snap => ABACAttributesTab/__snapshots__/AttributesForm.spec.tsx.snap} (60%) create mode 100644 apps/meteor/client/views/admin/ABAC/ABACLogsTab/LogsPage.tsx create mode 100644 apps/meteor/client/views/admin/ABAC/ABACRoomsTab/DeleteRoomModal.spec.tsx create mode 100644 apps/meteor/client/views/admin/ABAC/ABACRoomsTab/DeleteRoomModal.tsx create mode 100644 apps/meteor/client/views/admin/ABAC/ABACRoomsTab/RoomForm.tsx create mode 100644 apps/meteor/client/views/admin/ABAC/ABACRoomsTab/RoomFormAttributeField.spec.tsx create mode 100644 apps/meteor/client/views/admin/ABAC/ABACRoomsTab/RoomFormAttributeField.stories.tsx create mode 100644 apps/meteor/client/views/admin/ABAC/ABACRoomsTab/RoomFormAttributeField.tsx create mode 100644 apps/meteor/client/views/admin/ABAC/ABACRoomsTab/RoomFormAttributeFields.spec.tsx create mode 100644 apps/meteor/client/views/admin/ABAC/ABACRoomsTab/RoomFormAttributeFields.tsx create mode 100644 apps/meteor/client/views/admin/ABAC/ABACRoomsTab/RoomFormAutocomplete.spec.tsx create mode 100644 apps/meteor/client/views/admin/ABAC/ABACRoomsTab/RoomFormAutocomplete.stories.tsx create mode 100644 apps/meteor/client/views/admin/ABAC/ABACRoomsTab/RoomFormAutocomplete.tsx create mode 100644 apps/meteor/client/views/admin/ABAC/ABACRoomsTab/RoomFormAutocompleteDummy.tsx create mode 100644 apps/meteor/client/views/admin/ABAC/ABACRoomsTab/RoomMenu.tsx create mode 100644 apps/meteor/client/views/admin/ABAC/ABACRoomsTab/RoomsContextualBar.tsx create mode 100644 apps/meteor/client/views/admin/ABAC/ABACRoomsTab/RoomsContextualBarWithData.tsx create mode 100644 apps/meteor/client/views/admin/ABAC/ABACRoomsTab/RoomsPage.tsx create mode 100644 apps/meteor/client/views/admin/ABAC/ABACRoomsTab/__snapshots__/DeleteRoomModal.spec.tsx.snap create mode 100644 apps/meteor/client/views/admin/ABAC/ABACRoomsTab/__snapshots__/RoomFormAttributeField.spec.tsx.snap create mode 100644 apps/meteor/client/views/admin/ABAC/ABACRoomsTab/__snapshots__/RoomFormAutocomplete.spec.tsx.snap create mode 100644 apps/meteor/client/views/admin/ABAC/ABACSettingTab/AbacEnabledToggle.tsx create mode 100644 apps/meteor/client/views/admin/ABAC/ABACSettingTab/SettingField.spec.tsx create mode 100644 apps/meteor/client/views/admin/ABAC/ABACSettingTab/SettingField.tsx create mode 100644 apps/meteor/client/views/admin/ABAC/ABACSettingTab/SettingToggle.spec.tsx create mode 100644 apps/meteor/client/views/admin/ABAC/ABACSettingTab/SettingToggle.stories.tsx create mode 100644 apps/meteor/client/views/admin/ABAC/ABACSettingTab/SettingsPage.tsx create mode 100644 apps/meteor/client/views/admin/ABAC/ABACSettingTab/WarningModal.tsx create mode 100644 apps/meteor/client/views/admin/ABAC/ABACSettingTab/__snapshots__/SettingToggle.spec.tsx.snap create mode 100644 apps/meteor/client/views/admin/ABAC/AdminABACPage.tsx delete mode 100644 apps/meteor/client/views/admin/ABAC/AdminABACRoomAttributesForm.tsx create mode 100644 apps/meteor/client/views/admin/ABAC/AdminABACRoute.tsx create mode 100644 apps/meteor/client/views/admin/ABAC/AdminABACTabs.tsx create mode 100644 apps/meteor/client/views/admin/ABAC/hooks/useAttributeList.ts create mode 100644 apps/meteor/client/views/admin/ABAC/hooks/useAttributeOptions.spec.tsx create mode 100644 apps/meteor/client/views/admin/ABAC/hooks/useAttributeOptions.tsx create mode 100644 apps/meteor/client/views/admin/ABAC/hooks/useDeleteRoomModal.spec.tsx create mode 100644 apps/meteor/client/views/admin/ABAC/hooks/useDeleteRoomModal.tsx create mode 100644 apps/meteor/client/views/admin/ABAC/hooks/useIsABACAvailable.ts create mode 100644 apps/meteor/client/views/admin/ABAC/hooks/useRoomItems.spec.tsx create mode 100644 apps/meteor/client/views/admin/ABAC/hooks/useRoomItems.tsx create mode 100644 apps/meteor/ee/server/api/abac/index.ts create mode 100644 apps/meteor/ee/server/api/abac/schemas.ts create mode 100644 apps/meteor/ee/server/configuration/abac.ts create mode 100644 apps/meteor/ee/server/hooks/abac/beforeAddUserToRoom.ts create mode 100644 apps/meteor/ee/server/hooks/abac/index.ts create mode 100644 apps/meteor/ee/server/lib/abac/index.ts create mode 100644 apps/meteor/ee/server/settings/abac.ts create mode 100644 apps/meteor/tests/end-to-end/api/abac.ts create mode 100644 development/ldap/02-data.ldif create mode 100644 ee/packages/abac/.eslintrc.json create mode 100644 ee/packages/abac/jest.config.ts create mode 100644 ee/packages/abac/package.json create mode 100644 ee/packages/abac/src/audit.ts create mode 100644 ee/packages/abac/src/can-access-object.spec.ts create mode 100644 ee/packages/abac/src/errors.ts create mode 100644 ee/packages/abac/src/helper.spec.ts create mode 100644 ee/packages/abac/src/helper.ts create mode 100644 ee/packages/abac/src/index.ts create mode 100644 ee/packages/abac/src/service.spec.ts create mode 100644 ee/packages/abac/src/subject-attributes-validations.spec.ts create mode 100644 ee/packages/abac/src/user-auto-removal.spec.ts create mode 100644 ee/packages/abac/tsconfig.json create mode 100644 packages/core-services/src/types/IAbacService.ts create mode 100644 packages/core-typings/src/Abac.ts create mode 100644 packages/core-typings/src/IAbacAttribute.ts create mode 100644 packages/core-typings/src/ServerAudit/IAuditServerAbacAction.ts create mode 100644 packages/model-typings/src/models/IAbacAttributesModel.ts create mode 100644 packages/models/src/models/AbacAttributes.ts diff --git a/.changeset/ninety-dodos-confess.md b/.changeset/ninety-dodos-confess.md new file mode 100644 index 00000000000..b85badf29d0 --- /dev/null +++ b/.changeset/ninety-dodos-confess.md @@ -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. diff --git a/apps/meteor/app/api/server/v1/rooms.ts b/apps/meteor/app/api/server/v1/rooms.ts index eb9b261d634..2fdc1c071c7 100644 --- a/apps/meteor/app/api/server/v1/rooms.ts +++ b/apps/meteor/app/api/server/v1/rooms.ts @@ -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', diff --git a/apps/meteor/app/api/server/v1/teams.ts b/apps/meteor/app/api/server/v1/teams.ts index 9ce577522a2..f1893def4a7 100644 --- a/apps/meteor/app/api/server/v1/teams.ts +++ b/apps/meteor/app/api/server/v1/teams.ts @@ -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 }); diff --git a/apps/meteor/app/channel-settings/server/methods/saveRoomSettings.ts b/apps/meteor/app/channel-settings/server/methods/saveRoomSettings.ts index 7cbdca852cd..6ca88c1fdab 100644 --- a/apps/meteor/app/channel-settings/server/methods/saveRoomSettings.ts +++ b/apps/meteor/app/channel-settings/server/methods/saveRoomSettings.ts @@ -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('ABAC_Enabled') && Array.isArray(room?.abacAttributes) && room.abacAttributes.length > 0; +}; + +const isAbacManagedTeam = (team: Partial | null, teamRoom: IRoom): boolean => { + return ( + team?.type === TEAM_TYPE.PRIVATE && + settings.get('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) { diff --git a/apps/meteor/app/invites/server/functions/findOrCreateInvite.ts b/apps/meteor/app/invites/server/functions/findOrCreateInvite.ts index 052445a1ebc..fa905cc345c 100644 --- a/apps/meteor/app/invites/server/functions/findOrCreateInvite.ts +++ b/apps/meteor/app/invites/server/functions/findOrCreateInvite.ts @@ -63,6 +63,13 @@ export const findOrCreateInvite = async (userId: string, invite: Pick { 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', diff --git a/apps/meteor/app/lib/server/functions/addUserToDefaultChannels.ts b/apps/meteor/app/lib/server/functions/addUserToDefaultChannels.ts index a74964cc05a..89f4e5e3527 100644 --- a/apps/meteor/app/lib/server/functions/addUserToDefaultChannels.ts +++ b/apps/meteor/app/lib/server/functions/addUserToDefaultChannels.ts @@ -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); diff --git a/apps/meteor/app/lib/server/functions/addUserToRoom.ts b/apps/meteor/app/lib/server/functions/addUserToRoom.ts index 7651b571723..6ebfd848448 100644 --- a/apps/meteor/app/lib/server/functions/addUserToRoom.ts +++ b/apps/meteor/app/lib/server/functions/addUserToRoom.ts @@ -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); } diff --git a/apps/meteor/app/lib/server/functions/getFullUserData.ts b/apps/meteor/app/lib/server/functions/getFullUserData.ts index f66f8ecb49c..9c3eb6a10ef 100644 --- a/apps/meteor/app/lib/server/functions/getFullUserData.ts +++ b/apps/meteor/app/lib/server/functions/getFullUserData.ts @@ -22,6 +22,7 @@ const defaultFields = { extension: 1, federated: 1, statusLivechat: 1, + abacAttributes: 1, } as const; const fullFields = { diff --git a/apps/meteor/app/lib/server/functions/removeUserFromRoom.ts b/apps/meteor/app/lib/server/functions/removeUserFromRoom.ts index 56cdd03a701..0a899a88690 100644 --- a/apps/meteor/app/lib/server/functions/removeUserFromRoom.ts +++ b/apps/meteor/app/lib/server/functions/removeUserFromRoom.ts @@ -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 { +export const performUserRemoval = async function ( + room: IRoom, + user: IUser, + options?: { byUser?: IUser; skipAppPreEvents?: boolean; customSystemMessage?: MessageTypesValues }, +): Promise { 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 { +export const removeUserFromRoom = async function ( + rid: string, + user: IUser, + options?: { byUser?: IUser; skipAppPreEvents?: boolean; customSystemMessage?: MessageTypesValues }, +): Promise { 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); diff --git a/apps/meteor/app/lib/server/lib/beforeAddUserToRoom.ts b/apps/meteor/app/lib/server/lib/beforeAddUserToRoom.ts new file mode 100644 index 00000000000..984dc34a22a --- /dev/null +++ b/apps/meteor/app/lib/server/lib/beforeAddUserToRoom.ts @@ -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 +}); diff --git a/apps/meteor/app/slashcommands-invite/server/server.ts b/apps/meteor/app/slashcommands-invite/server/server.ts index f325f6b2e08..583a70a202b 100644 --- a/apps/meteor/app/slashcommands-invite/server/server.ts +++ b/apps/meteor/app/slashcommands-invite/server/server.ts @@ -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' }), + }); + } } } }), diff --git a/apps/meteor/app/statistics/server/lib/statistics.ts b/apps/meteor/app/statistics/server/lib/statistics.ts index 0fadf701d46..73292480d74 100644 --- a/apps/meteor/app/statistics/server/lib/statistics.ts +++ b/apps/meteor/app/statistics/server/lib/statistics.ts @@ -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; diff --git a/apps/meteor/app/utils/server/functions/getBaseUserFields.ts b/apps/meteor/app/utils/server/functions/getBaseUserFields.ts index a366f95a2fe..12a97d53923 100644 --- a/apps/meteor/app/utils/server/functions/getBaseUserFields.ts +++ b/apps/meteor/app/utils/server/functions/getBaseUserFields.ts @@ -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 }), }); diff --git a/apps/meteor/client/components/ABAC/ABACUpsellModal/ABACUpsellModal.spec.tsx b/apps/meteor/client/components/ABAC/ABACUpsellModal/ABACUpsellModal.spec.tsx index c2fd14fb4fe..584b64aece7 100644 --- a/apps/meteor/client/components/ABAC/ABACUpsellModal/ABACUpsellModal.spec.tsx +++ b/apps/meteor/client/components/ABAC/ABACUpsellModal/ABACUpsellModal.spec.tsx @@ -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', () => { diff --git a/apps/meteor/client/components/UserInfo/UserInfo.stories.tsx b/apps/meteor/client/components/UserInfo/UserInfo.stories.tsx index 815b11a0008..81e32c7f6cd 100644 --- a/apps/meteor/client/components/UserInfo/UserInfo.stories.tsx +++ b/apps/meteor/client/components/UserInfo/UserInfo.stories.tsx @@ -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'], + }, + ], }; diff --git a/apps/meteor/client/components/UserInfo/UserInfo.tsx b/apps/meteor/client/components/UserInfo/UserInfo.tsx index 124fffc2344..28a82c3835d 100644 --- a/apps/meteor/client/components/UserInfo/UserInfo.tsx +++ b/apps/meteor/client/components/UserInfo/UserInfo.tsx @@ -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 = ({ )} - {abacAttributes?.length > 0 && ( + {abacAttributes && abacAttributes.length > 0 && ( {t('ABAC_Attributes')} diff --git a/apps/meteor/client/components/UserInfo/UserInfoABACAttributes.tsx b/apps/meteor/client/components/UserInfo/UserInfoABACAttributes.tsx index b9cf2870d3d..952e7a9afc6 100644 --- a/apps/meteor/client/components/UserInfo/UserInfoABACAttributes.tsx +++ b/apps/meteor/client/components/UserInfo/UserInfoABACAttributes.tsx @@ -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 ( - {abacAttributes.map((attribute, index) => ( - - - - ))} + {abacAttributes.map((attribute, index) => + attribute.values.map((value) => ( + + + + )), + )} ); diff --git a/apps/meteor/client/components/UserInfo/__snapshots__/UserInfo.spec.tsx.snap b/apps/meteor/client/components/UserInfo/__snapshots__/UserInfo.spec.tsx.snap index d7c3831028a..3625392b362 100644 --- a/apps/meteor/client/components/UserInfo/__snapshots__/UserInfo.spec.tsx.snap +++ b/apps/meteor/client/components/UserInfo/__snapshots__/UserInfo.spec.tsx.snap @@ -510,7 +510,16 @@ exports[`renders WithABACAttributes without crashing 1`] = ` - Classified + Top Secret + + + + + Confidential { const { t } = useTranslation(); const encrypted = isE2EEMessage(message); - // @ts-expect-error to be implemented const isABACEnabled = !!room.abacAttributes; const getTitle = useMemo(() => { diff --git a/apps/meteor/client/components/message/toolbar/usePermalinkAction.ts b/apps/meteor/client/components/message/toolbar/usePermalinkAction.ts index f9b59abca1d..593fdb728bc 100644 --- a/apps/meteor/client/components/message/toolbar/usePermalinkAction.ts +++ b/apps/meteor/client/components/message/toolbar/usePermalinkAction.ts @@ -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(() => { diff --git a/apps/meteor/client/components/message/toolbar/useReplyInDMAction.ts b/apps/meteor/client/components/message/toolbar/useReplyInDMAction.ts index 6bebb1d8c17..588ab0a6076 100644 --- a/apps/meteor/client/components/message/toolbar/useReplyInDMAction.ts +++ b/apps/meteor/client/components/message/toolbar/useReplyInDMAction.ts @@ -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(); diff --git a/apps/meteor/client/lib/links.ts b/apps/meteor/client/lib/links.ts index 4c97d4696ff..2a1d06ad9da 100644 --- a/apps/meteor/client/lib/links.ts +++ b/apps/meteor/client/lib/links.ts @@ -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', diff --git a/apps/meteor/client/lib/queryKeys.ts b/apps/meteor/client/lib/queryKeys.ts index e6046a6dd07..d0414b91cd3 100644 --- a/apps/meteor/client/lib/queryKeys.ts +++ b/apps/meteor/client/lib/queryKeys.ts @@ -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, + }, +}; diff --git a/apps/meteor/client/lib/rooms/roomTypes/private.ts b/apps/meteor/client/lib/rooms/roomTypes/private.ts index 2948c48abfb..31b8fa7ca7f 100644 --- a/apps/meteor/client/lib/rooms/roomTypes/private.ts +++ b/apps/meteor/client/lib/rooms/roomTypes/private.ts @@ -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'; diff --git a/apps/meteor/client/lib/utils/mapSubscriptionFromApi.ts b/apps/meteor/client/lib/utils/mapSubscriptionFromApi.ts index d0f75a389ab..358efaef0e9 100644 --- a/apps/meteor/client/lib/utils/mapSubscriptionFromApi.ts +++ b/apps/meteor/client/lib/utils/mapSubscriptionFromApi.ts @@ -7,6 +7,7 @@ export const mapSubscriptionFromApi = ({ _updatedAt, oldRoomKeys, suggestedOldRoomKeys, + abacLastTimeChecked, ...subscription }: Serialized): 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) })) }), }); diff --git a/apps/meteor/client/views/admin/ABAC/ABACAttributesTab/AttributeMenu.tsx b/apps/meteor/client/views/admin/ABAC/ABACAttributesTab/AttributeMenu.tsx new file mode 100644 index 00000000000..84376fa8608 --- /dev/null +++ b/apps/meteor/client/views/admin/ABAC/ABACAttributesTab/AttributeMenu.tsx @@ -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 ( + + ); +}; + +export default AttributeMenu; diff --git a/apps/meteor/client/views/admin/ABAC/ABACAttributesTab/AttributesContextualBar.tsx b/apps/meteor/client/views/admin/ABAC/ABACAttributesTab/AttributesContextualBar.tsx new file mode 100644 index 00000000000..a41e7b6b87c --- /dev/null +++ b/apps/meteor/client/views/admin/ABAC/ABACAttributesTab/AttributesContextualBar.tsx @@ -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 ( + <> + + {t(attributeId ? 'ABAC_Edit_attribute' : 'ABAC_New_attribute')} + + + + saveMutation.mutateAsync(values)} + onCancel={onClose} + description={t(attributeId ? 'ABAC_Edit_attribute_description' : 'ABAC_New_attribute_description')} + /> + + + ); +}; + +export default AttributesContextualBar; diff --git a/apps/meteor/client/views/admin/ABAC/ABACAttributesTab/AttributesContextualBarWithData.tsx b/apps/meteor/client/views/admin/ABAC/ABACAttributesTab/AttributesContextualBarWithData.tsx new file mode 100644 index 00000000000..1efb5599bb8 --- /dev/null +++ b/apps/meteor/client/views/admin/ABAC/ABACAttributesTab/AttributesContextualBarWithData.tsx @@ -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 ; + } + + return ; +}; + +export default AttributesContextualBarWithData; diff --git a/apps/meteor/client/views/admin/ABAC/AdminABACRoomAttributesForm.spec.tsx b/apps/meteor/client/views/admin/ABAC/ABACAttributesTab/AttributesForm.spec.tsx similarity index 84% rename from apps/meteor/client/views/admin/ABAC/AdminABACRoomAttributesForm.spec.tsx rename to apps/meteor/client/views/admin/ABAC/ABACAttributesTab/AttributesForm.spec.tsx index 7ffa9ca665d..5cad3a2b227 100644 --- a/apps/meteor/client/views/admin/ABAC/AdminABACRoomAttributesForm.spec.tsx +++ b/apps/meteor/client/views/admin/ABAC/ABACAttributesTab/AttributesForm.spec.tsx @@ -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; -}) => { - const methods = useForm({ +const FormProviderWrapper = ({ children, defaultValues }: { children: ReactNode; defaultValues?: Partial }) => { + const methods = useForm({ defaultValues: { name: '', attributeValues: [{ value: '' }], @@ -43,7 +37,7 @@ const FormProviderWrapper = ({ return {children}; }; -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( - + , { 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( - + , { wrapper: appRoot }, ); @@ -110,7 +109,7 @@ describe('AdminABACRoomAttributesForm', () => { render( - + , { wrapper: appRoot }, ); @@ -130,12 +129,12 @@ describe('AdminABACRoomAttributesForm', () => { render( - + , { 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( - + , { 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( - + , { wrapper: appRoot }, ); @@ -189,7 +188,7 @@ describe('AdminABACRoomAttributesForm', () => { render( - + , { wrapper: appRoot }, ); @@ -206,12 +205,12 @@ describe('AdminABACRoomAttributesForm', () => { render( - + , { 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( - + , { wrapper: appRoot }, ); @@ -245,7 +244,7 @@ describe('AdminABACRoomAttributesForm', () => { render( - + , { 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); }); }); diff --git a/apps/meteor/client/views/admin/ABAC/AdminABACRoomAttributesForm.stories.tsx b/apps/meteor/client/views/admin/ABAC/ABACAttributesTab/AttributesForm.stories.tsx similarity index 68% rename from apps/meteor/client/views/admin/ABAC/AdminABACRoomAttributesForm.stories.tsx rename to apps/meteor/client/views/admin/ABAC/ABACAttributesTab/AttributesForm.stories.tsx index 283996289c5..2b34b06af71 100644 --- a/apps/meteor/client/views/admin/ABAC/AdminABACRoomAttributesForm.stories.tsx +++ b/apps/meteor/client/views/admin/ABAC/ABACAttributesTab/AttributesForm.stories.tsx @@ -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; + 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; -const Template: StoryFn = (args) => ; +const Template: StoryFn = (args) => ; export const NewAttribute = Template.bind({}); NewAttribute.decorators = [ (fn) => { - const methods = useForm({ + const methods = useForm({ defaultValues: { name: '', attributeValues: [{ value: '' }], @@ -49,7 +60,7 @@ WithLockedAttributes.args = { WithLockedAttributes.decorators = [ (fn) => { - const methods = useForm({ + const methods = useForm({ defaultValues: { name: 'Room Type', lockedAttributes: [{ value: 'Locked Value 1' }, { value: 'Locked Value 2' }, { value: 'Locked Value 3' }], diff --git a/apps/meteor/client/views/admin/ABAC/ABACAttributesTab/AttributesForm.tsx b/apps/meteor/client/views/admin/ABAC/ABACAttributesTab/AttributesForm.tsx new file mode 100644 index 00000000000..7e2cb90fba8 --- /dev/null +++ b/apps/meteor/client/views/admin/ABAC/ABACAttributesTab/AttributesForm.tsx @@ -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(); + + 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 ( + <> + + + {description} + + + {t('Name')} + + + + + {errors.name && {errors.name.message}} + + + + {t('Values')} + + {lockedAttributesFields.map((field, index) => ( + + + validateRepeatedValues(value), + })} + /> + {index !== 0 && ( + removeLockedAttribute(index)} /> + )} + + {errors.lockedAttributes?.[index]?.value && {errors.lockedAttributes?.[index]?.value?.message}} + + ))} + {fields.map((field, index) => ( + + + validateRepeatedValues(value), + })} + /> + {(index !== 0 || lockedAttributesFields.length > 0) && ( + remove(index)} /> + )} + + {errors.attributeValues?.[index]?.value && {errors.attributeValues[index].value.message}} + + ))} + + + + + + + + + + + + ); +}; + +export default AttributesForm; diff --git a/apps/meteor/client/views/admin/ABAC/ABACAttributesTab/AttributesPage.tsx b/apps/meteor/client/views/admin/ABAC/ABACAttributesTab/AttributesPage.tsx new file mode 100644 index 00000000000..a52d6d4a743 --- /dev/null +++ b/apps/meteor/client/views/admin/ABAC/ABACAttributesTab/AttributesPage.tsx @@ -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 ( + <> + + + } + placeholder={t('ABAC_Search_attributes')} + value={text} + onChange={(e) => setText((e.target as HTMLInputElement).value)} + /> + + + + {(!data || data.attributes?.length === 0) && !isLoading ? ( + + + + ) : ( + <> + + + {t('Name')} + {t('Value')} + + + + {data?.attributes?.map((attribute) => ( + + {attribute.key} + {attribute.values.join(', ')} + + + + + ))} + + + + + )} + + ); +}; + +export default AttributesPage; diff --git a/apps/meteor/client/views/admin/ABAC/__snapshots__/AdminABACRoomAttributesForm.spec.tsx.snap b/apps/meteor/client/views/admin/ABAC/ABACAttributesTab/__snapshots__/AttributesForm.spec.tsx.snap similarity index 60% rename from apps/meteor/client/views/admin/ABAC/__snapshots__/AdminABACRoomAttributesForm.spec.tsx.snap rename to apps/meteor/client/views/admin/ABAC/ABACAttributesTab/__snapshots__/AttributesForm.spec.tsx.snap index 46ecfca0c51..3e914b776d6 100644 --- a/apps/meteor/client/views/admin/ABAC/__snapshots__/AdminABACRoomAttributesForm.spec.tsx.snap +++ b/apps/meteor/client/views/admin/ABAC/ABACAttributesTab/__snapshots__/AttributesForm.spec.tsx.snap @@ -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`] = `
-
-
-
+ class="os-size-observer-listener" + /> +
+
-
Create an attribute that can later be assigned to rooms.
-
+
+
+
-
-
+ class="os-scrollbar-handle" + />
+
+
-
-
+ class="os-scrollbar-handle" + />
+
+
-
- - + -
+ Save + +
- +
`; -exports[`AdminABACRoomAttributesForm renders WithLockedAttributes without crashing 1`] = ` +exports[`AttributesForm renders WithLockedAttributes without crashing 1`] = `
-
-
-
+ class="os-size-observer-listener" + /> +
+
-
Attribute values cannot be edited, but can be added or deleted.
-
+
+
+
-
-
+ class="os-scrollbar-handle" + />
+
+
-
-
+ class="os-scrollbar-handle" + />
+
+
-
- - + -
+ Save + +
- +
diff --git a/apps/meteor/client/views/admin/ABAC/ABACLogsTab/LogsPage.tsx b/apps/meteor/client/views/admin/ABAC/ABACLogsTab/LogsPage.tsx new file mode 100644 index 00000000000..50b5d49595f --- /dev/null +++ b/apps/meteor/client/views/admin/ABAC/ABACLogsTab/LogsPage.tsx @@ -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(new Date().toISOString().split('T')[0]); + const [endDate, setEndDate] = useState(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: }), + 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 ( + <> + + + setStartDate((e.target as HTMLInputElement).value)} + /> + + setEndDate((e.target as HTMLInputElement).value)} + /> + + + { + setStartDate(range.start); + setEndDate(range.end); + }} + /> + + + + {(!data || data.events?.length === 0) && !isLoading ? ( + + + + ) : ( + <> + + + {t('User')} + {t('Action')} + {t('Room')} + {t('ABAC_Element')} + {t('ABAC_Element_Name')} + {t('Timestamp')} + + + {data?.events.map((eventInfo) => { + if (!eventInfo) { + return null; + } + return ( + + + {eventInfo.userAvatar && ( + + {eventInfo.userAvatar} + + )} + {eventInfo.user} + + {eventInfo.action} + {eventInfo.room} + {eventInfo.element} + + {eventInfo.name} + + {formatDate(eventInfo.timestamp)} + + ); + })} + + + + + )} + + ); +}; + +export default LogsPage; diff --git a/apps/meteor/client/views/admin/ABAC/ABACRoomsTab/DeleteRoomModal.spec.tsx b/apps/meteor/client/views/admin/ABAC/ABACRoomsTab/DeleteRoomModal.spec.tsx new file mode 100644 index 00000000000..9ff834dcc63 --- /dev/null +++ b/apps/meteor/client/views/admin/ABAC/ABACRoomsTab/DeleteRoomModal.spec.tsx @@ -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 {{roomName}} 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(, { + wrapper: baseAppRoot.build(), + }); + + expect(baseElement).toMatchSnapshot(); + }); + + it('should call delete endpoint when delete is confirmed', async () => { + const deleteEndpointMock = jest.fn().mockResolvedValue(null); + + render(, { + 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(, { + 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', + }); + }); + }); +}); diff --git a/apps/meteor/client/views/admin/ABAC/ABACRoomsTab/DeleteRoomModal.tsx b/apps/meteor/client/views/admin/ABAC/ABACRoomsTab/DeleteRoomModal.tsx new file mode 100644 index 00000000000..653a2d8191a --- /dev/null +++ b/apps/meteor/client/views/admin/ABAC/ABACRoomsTab/DeleteRoomModal.tsx @@ -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 ( + deleteMutation.mutate(undefined)} + onCancel={onClose} + > + }} /> + + ); +}; + +export default DeleteRoomModal; diff --git a/apps/meteor/client/views/admin/ABAC/ABACRoomsTab/RoomForm.tsx b/apps/meteor/client/views/admin/ABAC/ABACRoomsTab/RoomForm.tsx new file mode 100644 index 00000000000..ca427d377ea --- /dev/null +++ b/apps/meteor/client/views/admin/ABAC/ABACRoomsTab/RoomForm.tsx @@ -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>; +}; + +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(); + + 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( + { + action(); + setModal(null); + }} + onCancel={() => setModal(null)} + > + }} + /> + , + ); + }); + + const handleSave = useEffectEvent(() => { + if (roomInfo) { + updateAction(handleSubmit(onSave)); + } else { + handleSubmit(onSave)(); + } + }); + + return ( + <> + + + + + {t('ABAC_Room_to_be_managed')} + + + {roomInfo ? ( + + ) : ( + ( + { + field.onChange(value); + setSelectedRoomLabel(label); + }} + /> + )} + /> + )} + + {errors.room && {errors.room.message}} + + + + + + + + + + + + + ); +}; + +export default RoomForm; diff --git a/apps/meteor/client/views/admin/ABAC/ABACRoomsTab/RoomFormAttributeField.spec.tsx b/apps/meteor/client/views/admin/ABAC/ABACRoomsTab/RoomFormAttributeField.spec.tsx new file mode 100644 index 00000000000..b8c3cd71c17 --- /dev/null +++ b/apps/meteor/client/views/admin/ABAC/ABACRoomsTab/RoomFormAttributeField.spec.tsx @@ -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(); + expect(baseElement).toMatchSnapshot(); + }); +}); diff --git a/apps/meteor/client/views/admin/ABAC/ABACRoomsTab/RoomFormAttributeField.stories.tsx b/apps/meteor/client/views/admin/ABAC/ABACRoomsTab/RoomFormAttributeField.stories.tsx new file mode 100644 index 00000000000..00f42f32320 --- /dev/null +++ b/apps/meteor/client/views/admin/ABAC/ABACRoomsTab/RoomFormAttributeField.stories.tsx @@ -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 = { + component: RoomFormAttributeField, + parameters: { + layout: 'padded', + }, + decorators: [ + (Story) => { + const AppRoot = mockAppRoot().build(); + + const methods = useForm({ + defaultValues: { + room: '', + attributes: [{ key: '', values: [] }], + }, + mode: 'onChange', + }); + + return ( + + + + + + + + ); + }, + ], + 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; + +export const Default: Story = {}; diff --git a/apps/meteor/client/views/admin/ABAC/ABACRoomsTab/RoomFormAttributeField.tsx b/apps/meteor/client/views/admin/ABAC/ABACRoomsTab/RoomFormAttributeField.tsx new file mode 100644 index 00000000000..d9a4247c3db --- /dev/null +++ b/apps/meteor/client/views/admin/ABAC/ABACRoomsTab/RoomFormAttributeField.tsx @@ -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(); + + 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 ( + + + { + keyField.onChange(value); + resetField(`attributes.${index}.values`, { defaultValue: [] }); + }} + /> + + {keyFieldState.error && {keyFieldState.error.message}} + + + + + {valuesFieldState.error && {valuesFieldState.error.message}} + + + + ); +}; + +export default RoomFormAttributeField; diff --git a/apps/meteor/client/views/admin/ABAC/ABACRoomsTab/RoomFormAttributeFields.spec.tsx b/apps/meteor/client/views/admin/ABAC/ABACRoomsTab/RoomFormAttributeFields.spec.tsx new file mode 100644 index 00000000000..5f585e3099e --- /dev/null +++ b/apps/meteor/client/views/admin/ABAC/ABACRoomsTab/RoomFormAttributeFields.spec.tsx @@ -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 }) => { + const methods = useForm({ + defaultValues: { + room: '', + attributes: [{ key: '', values: [] }], + ...defaultValues, + }, + mode: 'onChange', + }); + + return {children}; +}; + +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( + + + , + { wrapper: appRoot }, + ); + + const attributeLabels = screen.getAllByText('Attribute'); + expect(attributeLabels).toHaveLength(3); + }); + + it('should render a single field', () => { + const fields = [{ id: 'field-1' }]; + + render( + + + , + { 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( + + + , + { 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( + + + , + { 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(); + }); +}); diff --git a/apps/meteor/client/views/admin/ABAC/ABACRoomsTab/RoomFormAttributeFields.tsx b/apps/meteor/client/views/admin/ABAC/ABACRoomsTab/RoomFormAttributeFields.tsx new file mode 100644 index 00000000000..bfcdf21464b --- /dev/null +++ b/apps/meteor/client/views/admin/ABAC/ABACRoomsTab/RoomFormAttributeFields.tsx @@ -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 ; + } + + return fields.map((field, index) => ( + + + {t('Attribute')} + + { + remove(index); + }} + index={index} + /> + + )); +}; + +export default RoomFormAttributeFields; diff --git a/apps/meteor/client/views/admin/ABAC/ABACRoomsTab/RoomFormAutocomplete.spec.tsx b/apps/meteor/client/views/admin/ABAC/ABACRoomsTab/RoomFormAutocomplete.spec.tsx new file mode 100644 index 00000000000..2b5c6ad38a3 --- /dev/null +++ b/apps/meteor/client/views/admin/ABAC/ABACRoomsTab/RoomFormAutocomplete.spec.tsx @@ -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(); + 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(); + + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); + + it('should populate select options correctly', async () => { + render(, { + 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(); + }); + }); +}); diff --git a/apps/meteor/client/views/admin/ABAC/ABACRoomsTab/RoomFormAutocomplete.stories.tsx b/apps/meteor/client/views/admin/ABAC/ABACRoomsTab/RoomFormAutocomplete.stories.tsx new file mode 100644 index 00000000000..4345892d3cb --- /dev/null +++ b/apps/meteor/client/views/admin/ABAC/ABACRoomsTab/RoomFormAutocomplete.stories.tsx @@ -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 = { + 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 ( + + + + ); + }, + ], + args: { + value: '', + onSelectedRoom: action('onChange'), + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/apps/meteor/client/views/admin/ABAC/ABACRoomsTab/RoomFormAutocomplete.tsx b/apps/meteor/client/views/admin/ABAC/ABACRoomsTab/RoomFormAutocomplete.tsx new file mode 100644 index 00000000000..506c2e3a1f8 --- /dev/null +++ b/apps/meteor/client/views/admin/ABAC/ABACRoomsTab/RoomFormAutocomplete.tsx @@ -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, '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 ( + { + onSelectedRoom(val as string, result.data?.find(({ value }) => value === val)?.label?.name || ''); + }} + value={value} + filter={filter} + setFilter={setFilter} + renderSelected={({ selected: { label } }) => ( + + {label?.name} + + )} + renderItem={({ label, ...props }) =>
+
+ + +
+
+
+ +
+
+
+ +
+
+
+ +
+
+ + +`; diff --git a/apps/meteor/client/views/admin/ABAC/ABACRoomsTab/__snapshots__/RoomFormAutocomplete.spec.tsx.snap b/apps/meteor/client/views/admin/ABAC/ABACRoomsTab/__snapshots__/RoomFormAutocomplete.spec.tsx.snap new file mode 100644 index 00000000000..e50aec9686e --- /dev/null +++ b/apps/meteor/client/views/admin/ABAC/ABACRoomsTab/__snapshots__/RoomFormAutocomplete.spec.tsx.snap @@ -0,0 +1,31 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`RoomFormAutocomplete renders Default without crashing 1`] = ` + +
+
+
+ +
+
+ +
+
+
+ +`; diff --git a/apps/meteor/client/views/admin/ABAC/ABACSettingTab/AbacEnabledToggle.tsx b/apps/meteor/client/views/admin/ABAC/ABACSettingTab/AbacEnabledToggle.tsx new file mode 100644 index 00000000000..264f0fc07d4 --- /dev/null +++ b/apps/meteor/client/views/admin/ABAC/ABACSettingTab/AbacEnabledToggle.tsx @@ -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(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( + { + 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( + { + setValue(value); + dispatch([{ _id: setting._id, value }]); + setModal(); + }} + onCancel={() => setModal()} + />, + ); + }, [dispatch, setModal, setting]); + + if (!setting) { + return null; + } + + if (hasABAC === 'loading') { + return ; + } + + return ( + onChange(value === true)} + onResetButtonClick={() => onReset()} + /> + ); +}; +export default ABACEnabledToggle; diff --git a/apps/meteor/client/views/admin/ABAC/ABACSettingTab/SettingField.spec.tsx b/apps/meteor/client/views/admin/ABAC/ABACSettingTab/SettingField.spec.tsx new file mode 100644 index 00000000000..77e52759a43 --- /dev/null +++ b/apps/meteor/client/views/admin/ABAC/ABACSettingTab/SettingField.spec.tsx @@ -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; + +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(, { + wrapper: mockAppRoot() + .wrap((children) => {children}) + .withSetting('Test_Setting', false, settingStructure) + .build(), + }); + + const checkbox = screen.getByRole('checkbox'); + await user.click(checkbox); + + await waitFor(() => { + expect(dispatchMock).toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/meteor/client/views/admin/ABAC/ABACSettingTab/SettingField.tsx b/apps/meteor/client/views/admin/ABAC/ABACSettingTab/SettingField.tsx new file mode 100644 index 00000000000..6d743296c73 --- /dev/null +++ b/apps/meteor/client/views/admin/ABAC/ABACSettingTab/SettingField.tsx @@ -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) ? : undefined), + [i18n, i18nDescription, t], + ); + + const callout = useMemo( + () => + 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 ( + + ); +} + +export default SettingField; diff --git a/apps/meteor/client/views/admin/ABAC/ABACSettingTab/SettingToggle.spec.tsx b/apps/meteor/client/views/admin/ABAC/ABACSettingTab/SettingToggle.spec.tsx new file mode 100644 index 00000000000..df5c6d8fe63 --- /dev/null +++ b/apps/meteor/client/views/admin/ABAC/ABACSettingTab/SettingToggle.spec.tsx @@ -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; + +const baseAppRoot = mockAppRoot() + .wrap((children) => {children}) + .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(, { + 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(, { + 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(, { + 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(, { + 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(, { + 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(, { + 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(, { + 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(, { + wrapper: baseAppRoot.withSetting('ABAC_Enabled', true, settingStructure).build(), + }); + expect(baseElement).toMatchSnapshot(); + }); + + it('should show reset button when value differs from package value', () => { + render(, { + 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(, { + wrapper: baseAppRoot.withSetting('ABAC_Enabled', false, settingStructure).build(), + }); + + expect(screen.queryByRole('button', { name: /reset/i })).not.toBeInTheDocument(); + }); +}); diff --git a/apps/meteor/client/views/admin/ABAC/ABACSettingTab/SettingToggle.stories.tsx b/apps/meteor/client/views/admin/ABAC/ABACSettingTab/SettingToggle.stories.tsx new file mode 100644 index 00000000000..7fb4027aafd --- /dev/null +++ b/apps/meteor/client/views/admin/ABAC/ABACSettingTab/SettingToggle.stories.tsx @@ -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 = { + component: AbacEnabledToggle, + parameters: { + layout: 'padded', + }, + decorators: [ + (Story) => { + const AppRoot = mockAppRoot() + .wrap((children) => {children}) + .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 ( + + + + ); + }, + ], + args: { + hasABAC: true, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + hasABAC: true, + }, +}; + +export const Loading: Story = { + args: { + hasABAC: 'loading', + }, +}; + +export const False: Story = { + args: { + hasABAC: false, + }, +}; diff --git a/apps/meteor/client/views/admin/ABAC/ABACSettingTab/SettingsPage.tsx b/apps/meteor/client/views/admin/ABAC/ABACSettingTab/SettingsPage.tsx new file mode 100644 index 00000000000..c6fb3108eb6 --- /dev/null +++ b/apps/meteor/client/views/admin/ABAC/ABACSettingTab/SettingsPage.tsx @@ -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 ( + + + + + + + + + + User attributes are synchronized via LDAP + + Learn more + + + + + + + ); +}; + +export default SettingsPage; diff --git a/apps/meteor/client/views/admin/ABAC/ABACSettingTab/WarningModal.tsx b/apps/meteor/client/views/admin/ABAC/ABACSettingTab/WarningModal.tsx new file mode 100644 index 00000000000..887c5fb4fb7 --- /dev/null +++ b/apps/meteor/client/views/admin/ABAC/ABACSettingTab/WarningModal.tsx @@ -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 ( + + + 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 + + {' '} + ABAC {'>'} Rooms + + . + + + ); +}; + +export default WarningModal; diff --git a/apps/meteor/client/views/admin/ABAC/ABACSettingTab/__snapshots__/SettingToggle.spec.tsx.snap b/apps/meteor/client/views/admin/ABAC/ABACSettingTab/__snapshots__/SettingToggle.spec.tsx.snap new file mode 100644 index 00000000000..7c48a94fb68 --- /dev/null +++ b/apps/meteor/client/views/admin/ABAC/ABACSettingTab/__snapshots__/SettingToggle.spec.tsx.snap @@ -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`] = ` + +
+
+
+
+ + +
+