feat: ABAC (#37091)
Co-authored-by: Tasso <tasso.evangelista@rocket.chat> Co-authored-by: Martin Schoeler <martin.schoeler@rocket.chat> Co-authored-by: MartinSchoeler <martinschoeler8@gmail.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>pull/37860/head^2
parent
cb7c338d94
commit
73d9eb2783
@ -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. |
||||
@ -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
|
||||
}); |
||||
@ -0,0 +1,28 @@ |
||||
import { GenericMenu } from '@rocket.chat/ui-client'; |
||||
import { useTranslation } from 'react-i18next'; |
||||
|
||||
import { useAttributeOptions } from '../hooks/useAttributeOptions'; |
||||
|
||||
type AttributeMenuProps = { |
||||
attribute: { _id: string; key: string }; |
||||
}; |
||||
|
||||
const AttributeMenu = ({ attribute }: AttributeMenuProps) => { |
||||
const { t } = useTranslation(); |
||||
|
||||
const items = useAttributeOptions(attribute); |
||||
|
||||
return ( |
||||
<GenericMenu |
||||
title={t('Options')} |
||||
icon='kebab' |
||||
sections={[ |
||||
{ |
||||
items, |
||||
}, |
||||
]} |
||||
/> |
||||
); |
||||
}; |
||||
|
||||
export default AttributeMenu; |
||||
@ -0,0 +1,99 @@ |
||||
import { ContextualbarTitle } from '@rocket.chat/fuselage'; |
||||
import { ContextualbarClose, ContextualbarHeader } from '@rocket.chat/ui-client'; |
||||
import { useEndpoint, useRouteParameter, useToastMessageDispatch } from '@rocket.chat/ui-contexts'; |
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'; |
||||
import { FormProvider, useForm } from 'react-hook-form'; |
||||
import { useTranslation } from 'react-i18next'; |
||||
|
||||
import type { AttributesFormFormData } from './AttributesForm'; |
||||
import AttributesForm from './AttributesForm'; |
||||
import { ABACQueryKeys } from '../../../../lib/queryKeys'; |
||||
|
||||
type AttributesContextualBarProps = { |
||||
attributeId?: string; |
||||
attributeData?: { |
||||
key: string; |
||||
values: string[]; |
||||
}; |
||||
onClose: () => void; |
||||
}; |
||||
|
||||
const AttributesContextualBar = ({ attributeData, onClose }: AttributesContextualBarProps) => { |
||||
const { t } = useTranslation(); |
||||
const queryClient = useQueryClient(); |
||||
|
||||
const methods = useForm<{ |
||||
name: string; |
||||
attributeValues: { value: string }[]; |
||||
lockedAttributes: { value: string }[]; |
||||
}>({ |
||||
defaultValues: attributeData |
||||
? { |
||||
name: attributeData.key, |
||||
attributeValues: [{ value: '' }], |
||||
lockedAttributes: attributeData.values.map((value) => ({ value })), |
||||
} |
||||
: { |
||||
name: '', |
||||
attributeValues: [{ value: '' }], |
||||
lockedAttributes: [], |
||||
}, |
||||
mode: 'onChange', |
||||
}); |
||||
|
||||
const { getValues } = methods; |
||||
|
||||
const attributeId = useRouteParameter('id'); |
||||
const createAttribute = useEndpoint('POST', '/v1/abac/attributes'); |
||||
const updateAttribute = useEndpoint('PUT', '/v1/abac/attributes/:_id', { |
||||
_id: attributeId ?? '', |
||||
}); |
||||
|
||||
const dispatchToastMessage = useToastMessageDispatch(); |
||||
|
||||
const saveMutation = useMutation({ |
||||
mutationFn: async (data: AttributesFormFormData) => { |
||||
const payload = { |
||||
key: data.name, |
||||
values: [...data.lockedAttributes.map((attribute) => attribute.value), ...data.attributeValues.map((attribute) => attribute.value)], |
||||
}; |
||||
if (attributeId) { |
||||
await updateAttribute(payload); |
||||
} else { |
||||
await createAttribute(payload); |
||||
} |
||||
}, |
||||
onSuccess: () => { |
||||
if (attributeId) { |
||||
dispatchToastMessage({ type: 'success', message: t('ABAC_Attribute_updated', { attributeName: getValues('name') }) }); |
||||
} else { |
||||
dispatchToastMessage({ type: 'success', message: t('ABAC_Attribute_created', { attributeName: getValues('name') }) }); |
||||
} |
||||
onClose(); |
||||
}, |
||||
onError: (error) => { |
||||
dispatchToastMessage({ type: 'error', message: error }); |
||||
}, |
||||
onSettled: () => { |
||||
queryClient.invalidateQueries({ queryKey: ABACQueryKeys.roomAttributes.list() }); |
||||
}, |
||||
}); |
||||
|
||||
return ( |
||||
<> |
||||
<ContextualbarHeader> |
||||
<ContextualbarTitle>{t(attributeId ? 'ABAC_Edit_attribute' : 'ABAC_New_attribute')}</ContextualbarTitle> |
||||
<ContextualbarClose onClick={onClose} /> |
||||
</ContextualbarHeader> |
||||
<FormProvider {...methods}> |
||||
<AttributesForm |
||||
onSave={(values) => saveMutation.mutateAsync(values)} |
||||
onCancel={onClose} |
||||
description={t(attributeId ? 'ABAC_Edit_attribute_description' : 'ABAC_New_attribute_description')} |
||||
/> |
||||
</FormProvider> |
||||
</> |
||||
); |
||||
}; |
||||
|
||||
export default AttributesContextualBar; |
||||
@ -0,0 +1,28 @@ |
||||
import { ContextualbarSkeletonBody } from '@rocket.chat/ui-client'; |
||||
import { useEndpoint } from '@rocket.chat/ui-contexts'; |
||||
import { useQuery } from '@tanstack/react-query'; |
||||
|
||||
import AttributesContextualBar from './AttributesContextualBar'; |
||||
import { ABACQueryKeys } from '../../../../lib/queryKeys'; |
||||
|
||||
type AttributesContextualBarWithDataProps = { |
||||
id: string; |
||||
onClose: () => void; |
||||
}; |
||||
|
||||
const AttributesContextualBarWithData = ({ id, onClose }: AttributesContextualBarWithDataProps) => { |
||||
const getAttributes = useEndpoint('GET', '/v1/abac/attributes/:_id', { _id: id }); |
||||
const { data, isLoading, isFetching } = useQuery({ |
||||
queryKey: ABACQueryKeys.roomAttributes.attribute(id), |
||||
queryFn: () => getAttributes(), |
||||
staleTime: 0, |
||||
}); |
||||
|
||||
if (isLoading || isFetching) { |
||||
return <ContextualbarSkeletonBody />; |
||||
} |
||||
|
||||
return <AttributesContextualBar attributeData={data} onClose={onClose} />; |
||||
}; |
||||
|
||||
export default AttributesContextualBarWithData; |
||||
@ -0,0 +1,163 @@ |
||||
import { |
||||
Box, |
||||
Button, |
||||
ButtonGroup, |
||||
ContextualbarFooter, |
||||
Field, |
||||
FieldError, |
||||
FieldLabel, |
||||
FieldRow, |
||||
IconButton, |
||||
TextInput, |
||||
} from '@rocket.chat/fuselage'; |
||||
import { ContextualbarScrollableContent } from '@rocket.chat/ui-client'; |
||||
import { useCallback, useId, useMemo, Fragment } from 'react'; |
||||
import { useFieldArray, useFormContext } from 'react-hook-form'; |
||||
import { useTranslation } from 'react-i18next'; |
||||
|
||||
export type AttributesFormFormData = { |
||||
name: string; |
||||
attributeValues: { value: string }[]; |
||||
lockedAttributes: { value: string }[]; |
||||
}; |
||||
|
||||
type AttributesFormProps = { |
||||
onSave: (data: AttributesFormFormData) => void; |
||||
onCancel: () => void; |
||||
description: string; |
||||
}; |
||||
|
||||
const AttributesForm = ({ onSave, onCancel, description }: AttributesFormProps) => { |
||||
const { |
||||
handleSubmit, |
||||
register, |
||||
formState: { errors, isDirty }, |
||||
watch, |
||||
} = useFormContext<AttributesFormFormData>(); |
||||
|
||||
const { t } = useTranslation(); |
||||
|
||||
const attributeValues = watch('attributeValues'); |
||||
const lockedAttributes = watch('lockedAttributes'); |
||||
|
||||
const { fields: lockedAttributesFields, remove: removeLockedAttribute } = useFieldArray({ |
||||
name: 'lockedAttributes', |
||||
}); |
||||
|
||||
const validateRepeatedValues = useCallback( |
||||
(value: string) => { |
||||
// Only one instance of the same attribute value is allowed to be in the form at a time
|
||||
const repeatedAttributes = [...lockedAttributes, ...attributeValues].filter((attribute) => attribute.value === value).length > 1; |
||||
return repeatedAttributes ? t('ABAC_No_repeated_values') : undefined; |
||||
}, |
||||
[lockedAttributes, attributeValues, t], |
||||
); |
||||
|
||||
const { fields, append, remove } = useFieldArray({ |
||||
name: 'attributeValues', |
||||
rules: { |
||||
minLength: 1, |
||||
}, |
||||
}); |
||||
|
||||
const formId = useId(); |
||||
const nameField = useId(); |
||||
const valuesField = useId(); |
||||
|
||||
const getAttributeValuesError = useCallback(() => { |
||||
if (errors.attributeValues?.length && errors.attributeValues?.length > 0) { |
||||
return errors.attributeValues[0]?.value?.message; |
||||
} |
||||
|
||||
return ''; |
||||
}, [errors.attributeValues]); |
||||
|
||||
const hasValuesErrors = useMemo(() => { |
||||
const attributeValuesErrors = Array.isArray(errors?.attributeValues) && errors.attributeValues.some((error) => !!error?.value?.message); |
||||
const lockedAttributesErrors = |
||||
Array.isArray(errors?.lockedAttributes) && errors.lockedAttributes.some((error) => !!error?.value?.message); |
||||
return attributeValuesErrors || lockedAttributesErrors; |
||||
}, [errors.attributeValues, errors.lockedAttributes]); |
||||
|
||||
return ( |
||||
<> |
||||
<ContextualbarScrollableContent> |
||||
<Box is='form' onSubmit={handleSubmit(onSave)} id={formId}> |
||||
<Box>{description}</Box> |
||||
<Field mb={16}> |
||||
<FieldLabel htmlFor={nameField} required> |
||||
{t('Name')} |
||||
</FieldLabel> |
||||
<FieldRow> |
||||
<TextInput |
||||
error={errors.name?.message} |
||||
id={nameField} |
||||
{...register('name', { required: t('Required_field', { field: t('Name') }) })} |
||||
/> |
||||
</FieldRow> |
||||
{errors.name && <FieldError>{errors.name.message}</FieldError>} |
||||
</Field> |
||||
<Field mb={16}> |
||||
<FieldLabel required id={valuesField}> |
||||
{t('Values')} |
||||
</FieldLabel> |
||||
{lockedAttributesFields.map((field, index) => ( |
||||
<Fragment key={field.id}> |
||||
<FieldRow key={field.id}> |
||||
<TextInput |
||||
disabled |
||||
aria-labelledby={valuesField} |
||||
error={errors.lockedAttributes?.[index]?.value?.message || ''} |
||||
{...register(`lockedAttributes.${index}.value`, { |
||||
required: t('Required_field', { field: t('Values') }), |
||||
validate: (value: string) => validateRepeatedValues(value), |
||||
})} |
||||
/> |
||||
{index !== 0 && ( |
||||
<IconButton title={t('ABAC_Remove_attribute')} icon='trash' onClick={() => removeLockedAttribute(index)} /> |
||||
)} |
||||
</FieldRow> |
||||
{errors.lockedAttributes?.[index]?.value && <FieldError>{errors.lockedAttributes?.[index]?.value?.message}</FieldError>} |
||||
</Fragment> |
||||
))} |
||||
{fields.map((field, index) => ( |
||||
<Fragment key={field.id}> |
||||
<FieldRow> |
||||
<TextInput |
||||
aria-labelledby={valuesField} |
||||
error={errors.attributeValues?.[index]?.value?.message || ''} |
||||
{...register(`attributeValues.${index}.value`, { |
||||
required: t('Required_field', { field: t('Values') }), |
||||
validate: (value: string) => validateRepeatedValues(value), |
||||
})} |
||||
/> |
||||
{(index !== 0 || lockedAttributesFields.length > 0) && ( |
||||
<IconButton title={t('ABAC_Remove_attribute')} icon='trash' onClick={() => remove(index)} /> |
||||
)} |
||||
</FieldRow> |
||||
{errors.attributeValues?.[index]?.value && <FieldError>{errors.attributeValues[index].value.message}</FieldError>} |
||||
</Fragment> |
||||
))} |
||||
<Button |
||||
onClick={() => append({ value: '' })} |
||||
// Checking for values since rhf does consider the newly added field as dirty after an append() call
|
||||
disabled={!!getAttributeValuesError() || attributeValues?.some((value: { value: string }) => value?.value === '')} |
||||
> |
||||
{t('Add_Value')} |
||||
</Button> |
||||
</Field> |
||||
</Box> |
||||
</ContextualbarScrollableContent> |
||||
<ContextualbarFooter> |
||||
<ButtonGroup stretch> |
||||
<Button onClick={() => onCancel()}>{t('Cancel')}</Button> |
||||
<Button type='submit' form={formId} disabled={hasValuesErrors || !!errors.name || !isDirty} primary> |
||||
{t('Save')} |
||||
</Button> |
||||
</ButtonGroup> |
||||
</ContextualbarFooter> |
||||
</> |
||||
); |
||||
}; |
||||
|
||||
export default AttributesForm; |
||||
@ -0,0 +1,110 @@ |
||||
import { Box, Button, Icon, Margins, Pagination, TextInput } from '@rocket.chat/fuselage'; |
||||
import { useDebouncedValue, useEffectEvent } from '@rocket.chat/fuselage-hooks'; |
||||
import { |
||||
GenericTable, |
||||
GenericTableBody, |
||||
GenericTableCell, |
||||
GenericTableHeader, |
||||
GenericTableHeaderCell, |
||||
GenericTableRow, |
||||
usePagination, |
||||
} from '@rocket.chat/ui-client'; |
||||
import { useEndpoint, useRouter } from '@rocket.chat/ui-contexts'; |
||||
import { useQuery } from '@tanstack/react-query'; |
||||
import { useMemo, useState } from 'react'; |
||||
import { useTranslation } from 'react-i18next'; |
||||
|
||||
import AttributeMenu from './AttributeMenu'; |
||||
import GenericNoResults from '../../../../components/GenericNoResults'; |
||||
import { ABACQueryKeys } from '../../../../lib/queryKeys'; |
||||
import { useIsABACAvailable } from '../hooks/useIsABACAvailable'; |
||||
|
||||
const AttributesPage = () => { |
||||
const { t } = useTranslation(); |
||||
|
||||
const [text, setText] = useState(''); |
||||
const debouncedText = useDebouncedValue(text, 200); |
||||
const { current, itemsPerPage, setItemsPerPage, setCurrent, ...paginationProps } = usePagination(); |
||||
const getAttributes = useEndpoint('GET', '/v1/abac/attributes'); |
||||
const isABACAvailable = useIsABACAvailable(); |
||||
|
||||
const router = useRouter(); |
||||
const handleNewAttribute = useEffectEvent(() => { |
||||
router.navigate({ |
||||
name: 'admin-ABAC', |
||||
params: { |
||||
tab: 'room-attributes', |
||||
context: 'new', |
||||
}, |
||||
}); |
||||
}); |
||||
|
||||
const query = useMemo( |
||||
() => ({ |
||||
...(debouncedText ? { key: debouncedText, values: debouncedText } : {}), |
||||
offset: current, |
||||
count: itemsPerPage, |
||||
}), |
||||
[debouncedText, current, itemsPerPage], |
||||
); |
||||
|
||||
const { data, isLoading } = useQuery({ |
||||
queryKey: ABACQueryKeys.roomAttributes.list(query), |
||||
queryFn: () => getAttributes(query), |
||||
}); |
||||
|
||||
return ( |
||||
<> |
||||
<Margins block={24}> |
||||
<Box display='flex'> |
||||
<TextInput |
||||
addon={<Icon name='magnifier' size='x20' />} |
||||
placeholder={t('ABAC_Search_attributes')} |
||||
value={text} |
||||
onChange={(e) => setText((e.target as HTMLInputElement).value)} |
||||
/> |
||||
<Button onClick={handleNewAttribute} primary mis={8} disabled={!isABACAvailable}> |
||||
{t('ABAC_New_attribute')} |
||||
</Button> |
||||
</Box> |
||||
</Margins> |
||||
{(!data || data.attributes?.length === 0) && !isLoading ? ( |
||||
<Box display='flex' justifyContent='center' height='full'> |
||||
<GenericNoResults icon='list-alt' title={t('ABAC_No_attributes')} description={t('ABAC_No_attributes_description')} /> |
||||
</Box> |
||||
) : ( |
||||
<> |
||||
<GenericTable> |
||||
<GenericTableHeader> |
||||
<GenericTableHeaderCell>{t('Name')}</GenericTableHeaderCell> |
||||
<GenericTableHeaderCell>{t('Value')}</GenericTableHeaderCell> |
||||
<GenericTableHeaderCell key='spacer' w={40} /> |
||||
</GenericTableHeader> |
||||
<GenericTableBody> |
||||
{data?.attributes?.map((attribute) => ( |
||||
<GenericTableRow key={attribute._id}> |
||||
<GenericTableCell withTruncatedText>{attribute.key}</GenericTableCell> |
||||
<GenericTableCell withTruncatedText>{attribute.values.join(', ')}</GenericTableCell> |
||||
<GenericTableCell> |
||||
<AttributeMenu attribute={attribute} /> |
||||
</GenericTableCell> |
||||
</GenericTableRow> |
||||
))} |
||||
</GenericTableBody> |
||||
</GenericTable> |
||||
<Pagination |
||||
divider |
||||
current={current} |
||||
itemsPerPage={itemsPerPage} |
||||
count={data?.total || 0} |
||||
onSetItemsPerPage={setItemsPerPage} |
||||
onSetCurrent={setCurrent} |
||||
{...paginationProps} |
||||
/> |
||||
</> |
||||
)} |
||||
</> |
||||
); |
||||
}; |
||||
|
||||
export default AttributesPage; |
||||
@ -0,0 +1,213 @@ |
||||
import type { AbacAttributeDefinitionChangeType, AbacActionPerformed } from '@rocket.chat/core-typings'; |
||||
import { Box, InputBox, Margins, Pagination } from '@rocket.chat/fuselage'; |
||||
import { UserAvatar } from '@rocket.chat/ui-avatar'; |
||||
import { |
||||
GenericTable, |
||||
GenericTableBody, |
||||
GenericTableCell, |
||||
GenericTableHeader, |
||||
GenericTableHeaderCell, |
||||
GenericTableRow, |
||||
usePagination, |
||||
} from '@rocket.chat/ui-client'; |
||||
import { useEndpoint } from '@rocket.chat/ui-contexts'; |
||||
import { useQuery } from '@tanstack/react-query'; |
||||
import { useMemo, useEffect, useState } from 'react'; |
||||
import { useTranslation } from 'react-i18next'; |
||||
|
||||
import GenericNoResults from '../../../../components/GenericNoResults'; |
||||
import { useFormatDateAndTime } from '../../../../hooks/useFormatDateAndTime'; |
||||
import { ABACQueryKeys } from '../../../../lib/queryKeys'; |
||||
import DateRangePicker from '../../moderation/helpers/DateRangePicker'; |
||||
|
||||
const LogsPage = () => { |
||||
const { t } = useTranslation(); |
||||
|
||||
const [startDate, setStartDate] = useState<string>(new Date().toISOString().split('T')[0]); |
||||
const [endDate, setEndDate] = useState<string>(new Date().toISOString().split('T')[0]); |
||||
|
||||
const formatDate = useFormatDateAndTime(); |
||||
|
||||
const { current, itemsPerPage, setItemsPerPage, setCurrent, ...paginationProps } = usePagination(); |
||||
const getLogs = useEndpoint('GET', '/v1/abac/audit'); |
||||
const query = useMemo( |
||||
() => ({ |
||||
...(startDate && { start: new Date(`${startDate}T00:00:00.000Z`).toISOString() }), |
||||
...(endDate && { end: new Date(`${endDate}T23:59:59.999Z`).toISOString() }), |
||||
offset: current, |
||||
count: itemsPerPage, |
||||
}), |
||||
[current, itemsPerPage, startDate, endDate], |
||||
); |
||||
|
||||
// Whenever the user changes the filter or the text, reset the pagination to the first page
|
||||
useEffect(() => { |
||||
setCurrent(0); |
||||
}, [startDate, endDate, setCurrent]); |
||||
|
||||
const getActionLabel = (action?: AbacAttributeDefinitionChangeType | AbacActionPerformed | null) => { |
||||
switch (action) { |
||||
case 'created': |
||||
return t('Created'); |
||||
case 'updated': |
||||
return t('Updated'); |
||||
case 'deleted': |
||||
return t('Deleted'); |
||||
case 'all-deleted': |
||||
return t('ABAC_All_Attributes_deleted'); |
||||
case 'key-removed': |
||||
return t('ABAC_Key_removed'); |
||||
case 'key-renamed': |
||||
return t('ABAC_Key_renamed'); |
||||
case 'value-removed': |
||||
return t('ABAC_Value_removed'); |
||||
case 'key-added': |
||||
return t('ABAC_Key_added'); |
||||
case 'key-updated': |
||||
return t('ABAC_Key_updated'); |
||||
case 'revoked-object-access': |
||||
return t('ABAC_Revoked_Object_Access'); |
||||
case 'granted-object-access': |
||||
return t('ABAC_Granted_Object_Access'); |
||||
default: |
||||
return ''; |
||||
} |
||||
}; |
||||
|
||||
const { data, isLoading } = useQuery({ |
||||
queryKey: ABACQueryKeys.logs.list(query), |
||||
queryFn: () => getLogs(query), |
||||
select: (data) => ({ |
||||
events: data.events.map((event) => { |
||||
const eventInfo = { |
||||
id: event._id, |
||||
user: event.actor?.type === 'user' ? event.actor.username : t('System'), |
||||
...(event.actor?.type === 'user' && { userAvatar: <UserAvatar size='x28' userId={event.actor._id} /> }), |
||||
timestamp: new Date(event.ts), |
||||
element: t('ABAC_Room'), |
||||
action: getActionLabel(event.data?.find((item) => item.key === 'change')?.value), |
||||
room: undefined, |
||||
}; |
||||
switch (event.t) { |
||||
case 'abac.attribute.changed': |
||||
return { |
||||
...eventInfo, |
||||
element: t('ABAC_Room_Attribute'), |
||||
name: event.data?.find((item) => item.key === 'attributeKey')?.value ?? '', |
||||
}; |
||||
case 'abac.action.performed': |
||||
return { |
||||
...eventInfo, |
||||
name: event.data?.find((item) => item.key === 'subject')?.value?.username ?? '', |
||||
action: getActionLabel(event.data?.find((item) => item.key === 'action')?.value), |
||||
room: event.data?.find((item) => item.key === 'object')?.value?.name ?? '', |
||||
element: t('ABAC_room_membership'), |
||||
}; |
||||
case 'abac.object.attribute.changed': |
||||
case 'abac.object.attributes.removed': |
||||
return { |
||||
...eventInfo, |
||||
name: |
||||
event.data |
||||
?.find((item) => item.key === 'current') |
||||
?.value?.map((item) => item.key) |
||||
.join(', ') ?? t('Empty'), |
||||
room: event.data?.find((item) => item.key === 'room')?.value?.name ?? '', |
||||
}; |
||||
default: |
||||
return null; |
||||
} |
||||
}), |
||||
count: data.count, |
||||
offset: data.offset, |
||||
total: data.total, |
||||
}), |
||||
}); |
||||
|
||||
return ( |
||||
<> |
||||
<Margins block={24}> |
||||
<Box display='flex'> |
||||
<InputBox |
||||
type='date' |
||||
placeholder={t('Start_date')} |
||||
value={startDate} |
||||
onChange={(e) => setStartDate((e.target as HTMLInputElement).value)} |
||||
/> |
||||
<Margins inlineStart={8}> |
||||
<InputBox |
||||
type='date' |
||||
placeholder={t('End_date')} |
||||
value={endDate} |
||||
onChange={(e) => setEndDate((e.target as HTMLInputElement).value)} |
||||
/> |
||||
</Margins> |
||||
<Margins inlineStart={8}> |
||||
<DateRangePicker |
||||
defaultSelectedKey='today' |
||||
onChange={(range) => { |
||||
setStartDate(range.start); |
||||
setEndDate(range.end); |
||||
}} |
||||
/> |
||||
</Margins> |
||||
</Box> |
||||
</Margins> |
||||
{(!data || data.events?.length === 0) && !isLoading ? ( |
||||
<Box display='flex' justifyContent='center' height='full'> |
||||
<GenericNoResults icon='extended-view' title={t('ABAC_No_logs')} description={t('ABAC_No_logs_description')} /> |
||||
</Box> |
||||
) : ( |
||||
<> |
||||
<GenericTable> |
||||
<GenericTableHeader> |
||||
<GenericTableHeaderCell>{t('User')}</GenericTableHeaderCell> |
||||
<GenericTableHeaderCell>{t('Action')}</GenericTableHeaderCell> |
||||
<GenericTableHeaderCell>{t('Room')}</GenericTableHeaderCell> |
||||
<GenericTableHeaderCell>{t('ABAC_Element')}</GenericTableHeaderCell> |
||||
<GenericTableHeaderCell>{t('ABAC_Element_Name')}</GenericTableHeaderCell> |
||||
<GenericTableHeaderCell>{t('Timestamp')}</GenericTableHeaderCell> |
||||
</GenericTableHeader> |
||||
<GenericTableBody> |
||||
{data?.events.map((eventInfo) => { |
||||
if (!eventInfo) { |
||||
return null; |
||||
} |
||||
return ( |
||||
<GenericTableRow key={eventInfo.id}> |
||||
<GenericTableCell withTruncatedText> |
||||
{eventInfo.userAvatar && ( |
||||
<Box is='span' mie={4}> |
||||
{eventInfo.userAvatar} |
||||
</Box> |
||||
)} |
||||
{eventInfo.user} |
||||
</GenericTableCell> |
||||
<GenericTableCell withTruncatedText>{eventInfo.action}</GenericTableCell> |
||||
<GenericTableCell withTruncatedText>{eventInfo.room}</GenericTableCell> |
||||
<GenericTableCell withTruncatedText>{eventInfo.element}</GenericTableCell> |
||||
<GenericTableCell withTruncatedText title={eventInfo.name}> |
||||
{eventInfo.name} |
||||
</GenericTableCell> |
||||
<GenericTableCell withTruncatedText>{formatDate(eventInfo.timestamp)}</GenericTableCell> |
||||
</GenericTableRow> |
||||
); |
||||
})} |
||||
</GenericTableBody> |
||||
</GenericTable> |
||||
<Pagination |
||||
divider |
||||
current={current} |
||||
itemsPerPage={itemsPerPage} |
||||
count={data?.total || 0} |
||||
onSetItemsPerPage={setItemsPerPage} |
||||
onSetCurrent={setCurrent} |
||||
{...paginationProps} |
||||
/> |
||||
</> |
||||
)} |
||||
</> |
||||
); |
||||
}; |
||||
|
||||
export default LogsPage; |
||||
@ -0,0 +1,71 @@ |
||||
import { faker } from '@faker-js/faker'; |
||||
import { mockAppRoot } from '@rocket.chat/mock-providers'; |
||||
import { render, screen, waitFor } from '@testing-library/react'; |
||||
import userEvent from '@testing-library/user-event'; |
||||
|
||||
import DeleteRoomModal from './DeleteRoomModal'; |
||||
|
||||
const mockDispatchToastMessage = jest.fn(); |
||||
|
||||
jest.mock('@rocket.chat/ui-contexts', () => ({ |
||||
...jest.requireActual('@rocket.chat/ui-contexts'), |
||||
useToastMessageDispatch: () => mockDispatchToastMessage, |
||||
})); |
||||
|
||||
const baseAppRoot = mockAppRoot().withTranslations('en', 'core', { |
||||
Edit: 'Edit', |
||||
Remove: 'Remove', |
||||
ABAC_Room_removed: 'Room {{roomName}} removed from ABAC management', |
||||
ABAC_Delete_room: 'Remove room from ABAC management', |
||||
ABAC_Delete_room_annotation: 'Proceed with caution', |
||||
ABAC_Delete_room_content: 'Removing <bold>{{roomName}}</bold> from ABAC management may result in unintended users gaining access.', |
||||
Cancel: 'Cancel', |
||||
}); |
||||
|
||||
describe('DeleteRoomModal', () => { |
||||
beforeEach(() => { |
||||
jest.clearAllMocks(); |
||||
}); |
||||
|
||||
const rid = faker.database.mongodbObjectId(); |
||||
const roomName = 'Test Room'; |
||||
|
||||
it('should render without crashing', () => { |
||||
const { baseElement } = render(<DeleteRoomModal rid={rid} roomName={roomName} onClose={jest.fn()} />, { |
||||
wrapper: baseAppRoot.build(), |
||||
}); |
||||
|
||||
expect(baseElement).toMatchSnapshot(); |
||||
}); |
||||
|
||||
it('should call delete endpoint when delete is confirmed', async () => { |
||||
const deleteEndpointMock = jest.fn().mockResolvedValue(null); |
||||
|
||||
render(<DeleteRoomModal rid={rid} roomName={roomName} onClose={jest.fn()} />, { |
||||
wrapper: baseAppRoot.withEndpoint('DELETE', '/v1/abac/rooms/:rid/attributes', deleteEndpointMock).build(), |
||||
}); |
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Remove' })); |
||||
|
||||
await waitFor(() => { |
||||
expect(deleteEndpointMock).toHaveBeenCalled(); |
||||
}); |
||||
}); |
||||
|
||||
it('should show success toast when delete succeeds', async () => { |
||||
const deleteEndpointMock = jest.fn().mockResolvedValue(null); |
||||
|
||||
render(<DeleteRoomModal rid={rid} roomName={roomName} onClose={jest.fn()} />, { |
||||
wrapper: baseAppRoot.withEndpoint('DELETE', '/v1/abac/rooms/:rid/attributes', deleteEndpointMock).build(), |
||||
}); |
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Remove' })); |
||||
|
||||
await waitFor(() => { |
||||
expect(mockDispatchToastMessage).toHaveBeenCalledWith({ |
||||
type: 'success', |
||||
message: 'Room Test Room removed from ABAC management', |
||||
}); |
||||
}); |
||||
}); |
||||
}); |
||||
@ -0,0 +1,48 @@ |
||||
import type { IRoom } from '@rocket.chat/core-typings'; |
||||
import { Box } from '@rocket.chat/fuselage'; |
||||
import { GenericModal } from '@rocket.chat/ui-client'; |
||||
import { useToastMessageDispatch } from '@rocket.chat/ui-contexts'; |
||||
import { useQueryClient } from '@tanstack/react-query'; |
||||
import { Trans, useTranslation } from 'react-i18next'; |
||||
|
||||
import { useEndpointMutation } from '../../../../hooks/useEndpointMutation'; |
||||
import { ABACQueryKeys } from '../../../../lib/queryKeys'; |
||||
|
||||
type DeleteRoomModalProps = { |
||||
rid: IRoom['_id']; |
||||
roomName: string; |
||||
onClose: () => void; |
||||
}; |
||||
|
||||
const DeleteRoomModal = ({ rid, roomName, onClose }: DeleteRoomModalProps) => { |
||||
const { t } = useTranslation(); |
||||
|
||||
const queryClient = useQueryClient(); |
||||
const dispatchToastMessage = useToastMessageDispatch(); |
||||
const deleteMutation = useEndpointMutation('DELETE', '/v1/abac/rooms/:rid/attributes', { |
||||
keys: { rid }, |
||||
onSuccess: () => { |
||||
dispatchToastMessage({ type: 'success', message: t('ABAC_Room_removed', { roomName }) }); |
||||
}, |
||||
onSettled: () => { |
||||
queryClient.invalidateQueries({ queryKey: ABACQueryKeys.rooms.all() }); |
||||
onClose(); |
||||
}, |
||||
}); |
||||
|
||||
return ( |
||||
<GenericModal |
||||
variant='danger' |
||||
icon={null} |
||||
title={t('ABAC_Delete_room')} |
||||
annotation={t('ABAC_Delete_room_annotation')} |
||||
confirmText={t('Remove')} |
||||
onConfirm={() => deleteMutation.mutate(undefined)} |
||||
onCancel={onClose} |
||||
> |
||||
<Trans i18nKey='ABAC_Delete_room_content' values={{ roomName }} components={{ bold: <Box is='span' fontWeight='bold' /> }} /> |
||||
</GenericModal> |
||||
); |
||||
}; |
||||
|
||||
export default DeleteRoomModal; |
||||
@ -0,0 +1,131 @@ |
||||
import { Box, Field, FieldLabel, FieldRow, FieldError, ButtonGroup, Button, ContextualbarFooter } from '@rocket.chat/fuselage'; |
||||
import { useEffectEvent } from '@rocket.chat/fuselage-hooks'; |
||||
import { GenericModal, ContextualbarScrollableContent } from '@rocket.chat/ui-client'; |
||||
import { useSetModal } from '@rocket.chat/ui-contexts'; |
||||
import type { Dispatch, SetStateAction } from 'react'; |
||||
import { useId } from 'react'; |
||||
import { Controller, useFieldArray, useFormContext } from 'react-hook-form'; |
||||
import { Trans, useTranslation } from 'react-i18next'; |
||||
|
||||
import RoomFormAttributeFields from './RoomFormAttributeFields'; |
||||
import RoomFormAutocomplete from './RoomFormAutocomplete'; |
||||
import RoomFormAutocompleteDummy from './RoomFormAutocompleteDummy'; |
||||
|
||||
type RoomFormProps = { |
||||
onClose: () => void; |
||||
onSave: (data: RoomFormData) => void; |
||||
roomInfo?: { rid: string; name: string }; |
||||
setSelectedRoomLabel: Dispatch<SetStateAction<string>>; |
||||
}; |
||||
|
||||
export type RoomFormData = { |
||||
room: string; |
||||
attributes: { key: string; values: string[] }[]; |
||||
}; |
||||
|
||||
const RoomForm = ({ onClose, onSave, roomInfo, setSelectedRoomLabel }: RoomFormProps) => { |
||||
const { |
||||
control, |
||||
handleSubmit, |
||||
formState: { isValid, errors, isDirty }, |
||||
} = useFormContext<RoomFormData>(); |
||||
|
||||
const { t } = useTranslation(); |
||||
const formId = useId(); |
||||
const nameField = useId(); |
||||
|
||||
const { fields, append, remove } = useFieldArray({ |
||||
name: 'attributes', |
||||
control, |
||||
}); |
||||
|
||||
const setModal = useSetModal(); |
||||
|
||||
const updateAction = useEffectEvent(async (action: () => void) => { |
||||
setModal( |
||||
<GenericModal |
||||
variant='info' |
||||
icon={null} |
||||
title={t('ABAC_Update_room_confirmation_modal_title')} |
||||
annotation={t('ABAC_Update_room_confirmation_modal_annotation')} |
||||
confirmText={t('Save_changes')} |
||||
onConfirm={() => { |
||||
action(); |
||||
setModal(null); |
||||
}} |
||||
onCancel={() => setModal(null)} |
||||
> |
||||
<Trans |
||||
i18nKey='ABAC_Update_room_content' |
||||
values={{ roomName: roomInfo?.name }} |
||||
components={{ bold: <Box is='span' fontWeight='bold' /> }} |
||||
/> |
||||
</GenericModal>, |
||||
); |
||||
}); |
||||
|
||||
const handleSave = useEffectEvent(() => { |
||||
if (roomInfo) { |
||||
updateAction(handleSubmit(onSave)); |
||||
} else { |
||||
handleSubmit(onSave)(); |
||||
} |
||||
}); |
||||
|
||||
return ( |
||||
<> |
||||
<ContextualbarScrollableContent> |
||||
<Box is='form' onSubmit={handleSubmit(handleSave)} id={formId}> |
||||
<Field mb={16}> |
||||
<FieldLabel id={nameField} required> |
||||
{t('ABAC_Room_to_be_managed')} |
||||
</FieldLabel> |
||||
<FieldRow> |
||||
{roomInfo ? ( |
||||
<RoomFormAutocompleteDummy roomInfo={roomInfo} /> |
||||
) : ( |
||||
<Controller |
||||
name='room' |
||||
control={control} |
||||
rules={{ required: t('Required_field', { field: t('ABAC_Room_to_be_managed') }) }} |
||||
render={({ field }) => ( |
||||
<RoomFormAutocomplete |
||||
{...field} |
||||
error={!!errors.room?.message} |
||||
aria-labelledby={nameField} |
||||
onSelectedRoom={(value: string, label: string) => { |
||||
field.onChange(value); |
||||
setSelectedRoomLabel(label); |
||||
}} |
||||
/> |
||||
)} |
||||
/> |
||||
)} |
||||
</FieldRow> |
||||
{errors.room && <FieldError>{errors.room.message}</FieldError>} |
||||
</Field> |
||||
<RoomFormAttributeFields fields={fields} remove={remove} /> |
||||
<Button |
||||
w='full' |
||||
disabled={fields.length >= 10} |
||||
onClick={() => { |
||||
append({ key: '', values: [] }); |
||||
}} |
||||
> |
||||
{t('ABAC_Add_Attribute')} |
||||
</Button> |
||||
</Box> |
||||
</ContextualbarScrollableContent> |
||||
<ContextualbarFooter> |
||||
<ButtonGroup stretch> |
||||
<Button onClick={onClose}>{t('Cancel')}</Button> |
||||
<Button type='submit' form={formId} disabled={!isValid || !isDirty} primary> |
||||
{t('Save')} |
||||
</Button> |
||||
</ButtonGroup> |
||||
</ContextualbarFooter> |
||||
</> |
||||
); |
||||
}; |
||||
|
||||
export default RoomForm; |
||||
@ -0,0 +1,13 @@ |
||||
import { composeStories } from '@storybook/react'; |
||||
import { render } from '@testing-library/react'; |
||||
|
||||
import * as stories from './RoomFormAttributeField.stories'; |
||||
|
||||
describe('RoomFormAttributeField', () => { |
||||
const testCases = Object.values(composeStories(stories)).map((Story) => [Story.storyName || 'Story', Story]); |
||||
|
||||
test.each(testCases)(`renders %s without crashing`, async (_storyname, Story) => { |
||||
const { baseElement } = render(<Story />); |
||||
expect(baseElement).toMatchSnapshot(); |
||||
}); |
||||
}); |
||||
@ -0,0 +1,73 @@ |
||||
import { Field } from '@rocket.chat/fuselage'; |
||||
import { mockAppRoot } from '@rocket.chat/mock-providers'; |
||||
import { action } from '@storybook/addon-actions'; |
||||
import type { Meta, StoryObj } from '@storybook/react'; |
||||
import { FormProvider, useForm } from 'react-hook-form'; |
||||
|
||||
import type { RoomFormData } from './RoomForm'; |
||||
import RoomFormAttributeField from './RoomFormAttributeField'; |
||||
|
||||
const mockAttribute1 = { |
||||
_id: 'attr1', |
||||
_updatedAt: new Date().toISOString(), |
||||
key: 'Department', |
||||
values: ['Engineering', 'Sales', 'Marketing'], |
||||
}; |
||||
|
||||
const mockAttribute2 = { |
||||
_id: 'attr2', |
||||
_updatedAt: new Date().toISOString(), |
||||
key: 'Security-Level', |
||||
values: ['Public', 'Internal', 'Confidential'], |
||||
}; |
||||
|
||||
const mockAttribute3 = { |
||||
_id: 'attr3', |
||||
_updatedAt: new Date().toISOString(), |
||||
key: 'Location', |
||||
values: ['US', 'EU', 'APAC'], |
||||
}; |
||||
|
||||
const meta: Meta<typeof RoomFormAttributeField> = { |
||||
component: RoomFormAttributeField, |
||||
parameters: { |
||||
layout: 'padded', |
||||
}, |
||||
decorators: [ |
||||
(Story) => { |
||||
const AppRoot = mockAppRoot().build(); |
||||
|
||||
const methods = useForm<RoomFormData>({ |
||||
defaultValues: { |
||||
room: '', |
||||
attributes: [{ key: '', values: [] }], |
||||
}, |
||||
mode: 'onChange', |
||||
}); |
||||
|
||||
return ( |
||||
<AppRoot> |
||||
<FormProvider {...methods}> |
||||
<Field> |
||||
<Story /> |
||||
</Field> |
||||
</FormProvider> |
||||
</AppRoot> |
||||
); |
||||
}, |
||||
], |
||||
args: { |
||||
onRemove: action('onRemove'), |
||||
attributeList: [ |
||||
{ value: mockAttribute1.key, label: mockAttribute1.key, attributeValues: mockAttribute1.values }, |
||||
{ value: mockAttribute2.key, label: mockAttribute2.key, attributeValues: mockAttribute2.values }, |
||||
{ value: mockAttribute3.key, label: mockAttribute3.key, attributeValues: mockAttribute3.values }, |
||||
], |
||||
index: 0, |
||||
}, |
||||
}; |
||||
|
||||
export default meta; |
||||
type Story = StoryObj<typeof RoomFormAttributeField>; |
||||
|
||||
export const Default: Story = {}; |
||||
@ -0,0 +1,93 @@ |
||||
import type { SelectOption } from '@rocket.chat/fuselage'; |
||||
import { Box, Button, FieldError, FieldRow, MultiSelect, SelectFiltered } from '@rocket.chat/fuselage'; |
||||
import { useCallback, useMemo } from 'react'; |
||||
import { useController, useFormContext } from 'react-hook-form'; |
||||
import { useTranslation } from 'react-i18next'; |
||||
|
||||
import type { RoomFormData } from './RoomForm'; |
||||
|
||||
type ABACAttributeAutocompleteProps = { |
||||
onRemove: () => void; |
||||
index: number; |
||||
attributeList: { value: string; label: string; attributeValues: string[] }[]; |
||||
}; |
||||
|
||||
const RoomFormAttributeField = ({ onRemove, index, attributeList }: ABACAttributeAutocompleteProps) => { |
||||
const { t } = useTranslation(); |
||||
|
||||
const { control, getValues, resetField } = useFormContext<RoomFormData>(); |
||||
|
||||
const options: SelectOption[] = useMemo(() => attributeList.map((attribute) => [attribute.value, attribute.label]), [attributeList]); |
||||
|
||||
const validateRepeatedAttributes = useCallback( |
||||
(value: string) => { |
||||
const attributes = getValues('attributes'); |
||||
// Only one instance of the same attribute is allowed to be in the form at a time
|
||||
const repeatedAttributes = attributes.filter((attribute) => attribute.key === value).length > 1; |
||||
return repeatedAttributes ? t('ABAC_No_repeated_attributes') : undefined; |
||||
}, |
||||
[getValues, t], |
||||
); |
||||
|
||||
const { field: keyField, fieldState: keyFieldState } = useController({ |
||||
name: `attributes.${index}.key`, |
||||
control, |
||||
rules: { |
||||
required: t('Required_field', { field: t('Attribute') }), |
||||
validate: validateRepeatedAttributes, |
||||
}, |
||||
}); |
||||
|
||||
const { field: valuesField, fieldState: valuesFieldState } = useController({ |
||||
name: `attributes.${index}.values`, |
||||
control, |
||||
rules: { required: t('Required_field', { field: t('Attribute_Values') }) }, |
||||
}); |
||||
|
||||
const valueOptions: [string, string][] = useMemo(() => { |
||||
if (!keyField.value) { |
||||
return []; |
||||
} |
||||
|
||||
const selectedAttributeData = attributeList.find((option) => option.value === keyField.value); |
||||
|
||||
return selectedAttributeData?.attributeValues.map((value) => [value, value]) || []; |
||||
}, [attributeList, keyField.value]); |
||||
|
||||
return ( |
||||
<Box display='flex' flexDirection='column' w='full'> |
||||
<FieldRow> |
||||
<SelectFiltered |
||||
{...keyField} |
||||
options={options} |
||||
placeholder={t('ABAC_Search_Attribute')} |
||||
mbe={4} |
||||
error={keyFieldState.error?.message} |
||||
withTruncatedText |
||||
onChange={(value) => { |
||||
keyField.onChange(value); |
||||
resetField(`attributes.${index}.values`, { defaultValue: [] }); |
||||
}} |
||||
/> |
||||
</FieldRow> |
||||
{keyFieldState.error && <FieldError>{keyFieldState.error.message}</FieldError>} |
||||
|
||||
<FieldRow> |
||||
<MultiSelect |
||||
withTruncatedText |
||||
{...valuesField} |
||||
options={valueOptions} |
||||
placeholder={t('ABAC_Select_Attribute_Values')} |
||||
error={valuesFieldState.error?.message} |
||||
/> |
||||
</FieldRow> |
||||
{valuesFieldState.error && <FieldError>{valuesFieldState.error.message}</FieldError>} |
||||
|
||||
<Button onClick={onRemove} title={t('Remove')} mbs={4}> |
||||
{t('Remove')} |
||||
</Button> |
||||
</Box> |
||||
); |
||||
}; |
||||
|
||||
export default RoomFormAttributeField; |
||||
@ -0,0 +1,136 @@ |
||||
import { mockAppRoot } from '@rocket.chat/mock-providers'; |
||||
import { render, screen } from '@testing-library/react'; |
||||
import type { ReactNode } from 'react'; |
||||
import { FormProvider, useForm } from 'react-hook-form'; |
||||
|
||||
import type { RoomFormData } from './RoomForm'; |
||||
import RoomFormAttributeFields from './RoomFormAttributeFields'; |
||||
|
||||
const mockAttribute1 = { |
||||
_id: 'attr1', |
||||
key: 'Department', |
||||
values: ['Engineering', 'Sales', 'Marketing'], |
||||
}; |
||||
|
||||
const mockAttribute2 = { |
||||
_id: 'attr2', |
||||
key: 'Security-Level', |
||||
values: ['Public', 'Internal', 'Confidential'], |
||||
}; |
||||
|
||||
const mockAttribute3 = { |
||||
_id: 'attr3', |
||||
key: 'Location', |
||||
values: ['US', 'EU', 'APAC'], |
||||
}; |
||||
|
||||
jest.mock('../hooks/useAttributeList', () => ({ |
||||
useAttributeList: jest.fn(() => ({ |
||||
data: { |
||||
attributes: [ |
||||
{ value: mockAttribute1.key, label: mockAttribute1.key, attributeValues: mockAttribute1.values }, |
||||
{ value: mockAttribute2.key, label: mockAttribute2.key, attributeValues: mockAttribute2.values }, |
||||
{ value: mockAttribute3.key, label: mockAttribute3.key, attributeValues: mockAttribute3.values }, |
||||
], |
||||
}, |
||||
isLoading: false, |
||||
})), |
||||
})); |
||||
|
||||
const appRoot = mockAppRoot() |
||||
.withTranslations('en', 'core', { |
||||
Attribute: 'Attribute', |
||||
ABAC_Search_Attribute: 'Search attribute', |
||||
ABAC_Select_Attribute_Values: 'Select attribute values', |
||||
Remove: 'Remove', |
||||
}) |
||||
.build(); |
||||
|
||||
const FormProviderWrapper = ({ children, defaultValues }: { children: ReactNode; defaultValues?: Partial<RoomFormData> }) => { |
||||
const methods = useForm<RoomFormData>({ |
||||
defaultValues: { |
||||
room: '', |
||||
attributes: [{ key: '', values: [] }], |
||||
...defaultValues, |
||||
}, |
||||
mode: 'onChange', |
||||
}); |
||||
|
||||
return <FormProvider {...methods}>{children}</FormProvider>; |
||||
}; |
||||
|
||||
describe('RoomFormAttributeFields', () => { |
||||
const mockRemove = jest.fn(); |
||||
|
||||
beforeEach(() => { |
||||
jest.clearAllMocks(); |
||||
}); |
||||
|
||||
it('should render the correct number of fields', () => { |
||||
const fields = [{ id: 'field-1' }, { id: 'field-2' }, { id: 'field-3' }]; |
||||
|
||||
render( |
||||
<FormProviderWrapper> |
||||
<RoomFormAttributeFields fields={fields} remove={mockRemove} /> |
||||
</FormProviderWrapper>, |
||||
{ wrapper: appRoot }, |
||||
); |
||||
|
||||
const attributeLabels = screen.getAllByText('Attribute'); |
||||
expect(attributeLabels).toHaveLength(3); |
||||
}); |
||||
|
||||
it('should render a single field', () => { |
||||
const fields = [{ id: 'field-1' }]; |
||||
|
||||
render( |
||||
<FormProviderWrapper> |
||||
<RoomFormAttributeFields fields={fields} remove={mockRemove} /> |
||||
</FormProviderWrapper>, |
||||
{ wrapper: appRoot }, |
||||
); |
||||
|
||||
const attributeLabels = screen.getAllByText('Attribute'); |
||||
expect(attributeLabels).toHaveLength(1); |
||||
}); |
||||
|
||||
it('should render multiple fields', () => { |
||||
const fields = [{ id: 'field-1' }, { id: 'field-2' }, { id: 'field-3' }, { id: 'field-4' }, { id: 'field-5' }]; |
||||
|
||||
render( |
||||
<FormProviderWrapper> |
||||
<RoomFormAttributeFields fields={fields} remove={mockRemove} /> |
||||
</FormProviderWrapper>, |
||||
{ wrapper: appRoot }, |
||||
); |
||||
|
||||
const attributeLabels = screen.getAllByText('Attribute'); |
||||
expect(attributeLabels).toHaveLength(5); |
||||
}); |
||||
|
||||
it('should render fields with provided default values', () => { |
||||
const fields = [{ id: 'field-1' }, { id: 'field-2' }]; |
||||
|
||||
render( |
||||
<FormProviderWrapper |
||||
defaultValues={{ |
||||
attributes: [ |
||||
{ key: 'Department', values: ['Engineering'] }, |
||||
{ key: 'Security-Level', values: ['Public', 'Internal'] }, |
||||
], |
||||
}} |
||||
> |
||||
<RoomFormAttributeFields fields={fields} remove={mockRemove} /> |
||||
</FormProviderWrapper>, |
||||
{ wrapper: appRoot }, |
||||
); |
||||
|
||||
const attributeLabels = screen.getAllByText('Attribute'); |
||||
expect(attributeLabels).toHaveLength(2); |
||||
expect(screen.getByText('Department')).toBeInTheDocument(); |
||||
expect(screen.getByText('Engineering')).toBeInTheDocument(); |
||||
expect(screen.getByText('Security-Level')).toBeInTheDocument(); |
||||
expect(screen.getByText('Public')).toBeInTheDocument(); |
||||
expect(screen.getByText('Internal')).toBeInTheDocument(); |
||||
}); |
||||
}); |
||||
@ -0,0 +1,37 @@ |
||||
import { Field, FieldLabel, InputBoxSkeleton } from '@rocket.chat/fuselage'; |
||||
import { useTranslation } from 'react-i18next'; |
||||
|
||||
import RoomFormAttributeField from './RoomFormAttributeField'; |
||||
import { useAttributeList } from '../hooks/useAttributeList'; |
||||
|
||||
type RoomFormAttributeFieldsProps = { |
||||
fields: { id: string }[]; |
||||
remove: (index: number) => void; |
||||
}; |
||||
|
||||
const RoomFormAttributeFields = ({ fields, remove }: RoomFormAttributeFieldsProps) => { |
||||
const { t } = useTranslation(); |
||||
|
||||
const { data: attributeList, isLoading } = useAttributeList(); |
||||
|
||||
if (isLoading || !attributeList) { |
||||
return <InputBoxSkeleton />; |
||||
} |
||||
|
||||
return fields.map((field, index) => ( |
||||
<Field key={field.id} mb={16}> |
||||
<FieldLabel htmlFor={field.id} required> |
||||
{t('Attribute')} |
||||
</FieldLabel> |
||||
<RoomFormAttributeField |
||||
attributeList={attributeList.attributes} |
||||
onRemove={() => { |
||||
remove(index); |
||||
}} |
||||
index={index} |
||||
/> |
||||
</Field> |
||||
)); |
||||
}; |
||||
|
||||
export default RoomFormAttributeFields; |
||||
@ -0,0 +1,60 @@ |
||||
import { mockAppRoot } from '@rocket.chat/mock-providers'; |
||||
import { composeStories } from '@storybook/react'; |
||||
import { render, screen, waitFor } from '@testing-library/react'; |
||||
import userEvent from '@testing-library/user-event'; |
||||
import { axe } from 'jest-axe'; |
||||
|
||||
import RoomFormAutocomplete from './RoomFormAutocomplete'; |
||||
import * as stories from './RoomFormAutocomplete.stories'; |
||||
import { createFakeRoom } from '../../../../../tests/mocks/data'; |
||||
|
||||
const mockRoom1 = createFakeRoom({ t: 'p', name: 'Room 1', fname: 'Room 1' }); |
||||
const mockRoom2 = createFakeRoom({ t: 'p', name: 'Room 2', fname: 'Room 2' }); |
||||
const mockRoom3 = createFakeRoom({ t: 'p', name: 'Room 3', fname: 'Room 3', abacAttributes: [] }); |
||||
|
||||
const appRoot = mockAppRoot() |
||||
.withEndpoint('GET', '/v1/rooms.adminRooms.privateRooms', () => ({ |
||||
rooms: [mockRoom1 as any, mockRoom2 as any, mockRoom3 as any], |
||||
count: 3, |
||||
offset: 0, |
||||
total: 3, |
||||
})) |
||||
.build(); |
||||
|
||||
describe('RoomFormAutocomplete', () => { |
||||
const testCases = Object.values(composeStories(stories)).map((Story) => [Story.storyName || 'Story', Story]); |
||||
|
||||
test.each(testCases)(`renders %s without crashing`, async (_storyname, Story) => { |
||||
const { baseElement } = render(<Story />); |
||||
expect(baseElement).toMatchSnapshot(); |
||||
}); |
||||
|
||||
test.each(testCases)('%s should have no a11y violations', async (_storyname, Story) => { |
||||
// Aria label added in a higher level
|
||||
const { container } = render(<Story aria-label='ABAC Room Autocomplete' />); |
||||
|
||||
const results = await axe(container); |
||||
expect(results).toHaveNoViolations(); |
||||
}); |
||||
|
||||
it('should populate select options correctly', async () => { |
||||
render(<RoomFormAutocomplete value='' onSelectedRoom={jest.fn()} />, { |
||||
wrapper: appRoot, |
||||
}); |
||||
|
||||
const input = screen.getByRole('textbox'); |
||||
await userEvent.click(input); |
||||
|
||||
await waitFor(() => { |
||||
expect(screen.getByText('Room 1')).toBeInTheDocument(); |
||||
}); |
||||
|
||||
await waitFor(() => { |
||||
expect(screen.getByText('Room 2')).toBeInTheDocument(); |
||||
}); |
||||
|
||||
await waitFor(() => { |
||||
expect(screen.getByText('Room 3')).toBeInTheDocument(); |
||||
}); |
||||
}); |
||||
}); |
||||
@ -0,0 +1,42 @@ |
||||
import { mockAppRoot } from '@rocket.chat/mock-providers'; |
||||
import { action } from '@storybook/addon-actions'; |
||||
import type { Meta, StoryObj } from '@storybook/react'; |
||||
|
||||
import RoomFormAutocomplete from './RoomFormAutocomplete'; |
||||
import { createFakeRoom } from '../../../../../tests/mocks/data'; |
||||
|
||||
const mockRoom1 = createFakeRoom({ t: 'p', name: 'Room 1' }); |
||||
const mockRoom2 = createFakeRoom({ t: 'p', name: 'Room 2' }); |
||||
|
||||
const meta: Meta<typeof RoomFormAutocomplete> = { |
||||
component: RoomFormAutocomplete, |
||||
parameters: { |
||||
layout: 'padded', |
||||
}, |
||||
decorators: [ |
||||
(Story) => { |
||||
const AppRoot = mockAppRoot() |
||||
.withEndpoint('GET', '/v1/rooms.adminRooms', () => ({ |
||||
rooms: [mockRoom1 as any, mockRoom2 as any], |
||||
count: 2, |
||||
offset: 0, |
||||
total: 2, |
||||
})) |
||||
.build(); |
||||
return ( |
||||
<AppRoot> |
||||
<Story /> |
||||
</AppRoot> |
||||
); |
||||
}, |
||||
], |
||||
args: { |
||||
value: '', |
||||
onSelectedRoom: action('onChange'), |
||||
}, |
||||
}; |
||||
|
||||
export default meta; |
||||
type Story = StoryObj<typeof RoomFormAutocomplete>; |
||||
|
||||
export const Default: Story = {}; |
||||
@ -0,0 +1,58 @@ |
||||
import { AutoComplete, Option, Box } from '@rocket.chat/fuselage'; |
||||
import { useDebouncedValue } from '@rocket.chat/fuselage-hooks'; |
||||
import { useEndpoint } from '@rocket.chat/ui-contexts'; |
||||
import { keepPreviousData, useQuery } from '@tanstack/react-query'; |
||||
import type { ComponentProps } from 'react'; |
||||
import { memo, useState } from 'react'; |
||||
|
||||
import { ABACQueryKeys } from '../../../../lib/queryKeys'; |
||||
|
||||
const generateQuery = ( |
||||
term = '', |
||||
): { |
||||
filter: string; |
||||
} => ({ filter: term }); |
||||
|
||||
type RoomFormAutocompleteProps = Omit<ComponentProps<typeof AutoComplete>, 'filter' | 'onChange'> & { |
||||
onSelectedRoom: (value: string, label: string) => void; |
||||
}; |
||||
|
||||
const RoomFormAutocomplete = ({ value, onSelectedRoom, ...props }: RoomFormAutocompleteProps) => { |
||||
const [filter, setFilter] = useState(''); |
||||
const filterDebounced = useDebouncedValue(filter, 300); |
||||
const roomsAutoCompleteEndpoint = useEndpoint('GET', '/v1/rooms.adminRooms.privateRooms'); |
||||
|
||||
const result = useQuery({ |
||||
queryKey: ABACQueryKeys.rooms.autocomplete(generateQuery(filterDebounced)), |
||||
queryFn: () => roomsAutoCompleteEndpoint(generateQuery(filterDebounced)), |
||||
placeholderData: keepPreviousData, |
||||
select: (data) => |
||||
data.rooms |
||||
.filter((room) => !room.abacAttributes || room.abacAttributes.length === 0) |
||||
.map((room) => ({ |
||||
value: room._id, |
||||
label: { name: room.fname || room.name }, |
||||
})), |
||||
}); |
||||
|
||||
return ( |
||||
<AutoComplete |
||||
{...props} |
||||
onChange={(val) => { |
||||
onSelectedRoom(val as string, result.data?.find(({ value }) => value === val)?.label?.name || ''); |
||||
}} |
||||
value={value} |
||||
filter={filter} |
||||
setFilter={setFilter} |
||||
renderSelected={({ selected: { label } }) => ( |
||||
<Box margin='none' mi={2}> |
||||
{label?.name} |
||||
</Box> |
||||
)} |
||||
renderItem={({ label, ...props }) => <Option {...props} label={label.name} />} |
||||
options={result.data} |
||||
/> |
||||
); |
||||
}; |
||||
|
||||
export default memo(RoomFormAutocomplete); |
||||
@ -0,0 +1,11 @@ |
||||
import { Input } from '@rocket.chat/fuselage'; |
||||
|
||||
type RoomFormAutocompleteDummyProps = { |
||||
roomInfo: { rid: string; name: string }; |
||||
}; |
||||
|
||||
const RoomFormAutocompleteDummy = ({ roomInfo }: RoomFormAutocompleteDummyProps) => { |
||||
return <Input value={roomInfo.name} disabled />; |
||||
}; |
||||
|
||||
export default RoomFormAutocompleteDummy; |
||||
@ -0,0 +1,28 @@ |
||||
import { GenericMenu } from '@rocket.chat/ui-client'; |
||||
import { useTranslation } from 'react-i18next'; |
||||
|
||||
import { useRoomItems } from '../hooks/useRoomItems'; |
||||
|
||||
type RoomMenuProps = { |
||||
room: { rid: string; name: string }; |
||||
}; |
||||
|
||||
const RoomMenu = ({ room }: RoomMenuProps) => { |
||||
const { t } = useTranslation(); |
||||
|
||||
const items = useRoomItems(room); |
||||
|
||||
return ( |
||||
<GenericMenu |
||||
title={t('Options')} |
||||
icon='kebab' |
||||
sections={[ |
||||
{ |
||||
items, |
||||
}, |
||||
]} |
||||
/> |
||||
); |
||||
}; |
||||
|
||||
export default RoomMenu; |
||||
@ -0,0 +1,89 @@ |
||||
import { ContextualbarTitle } from '@rocket.chat/fuselage'; |
||||
import { ContextualbarClose, ContextualbarHeader } from '@rocket.chat/ui-client'; |
||||
import { useEndpoint, useRouteParameter, useToastMessageDispatch } from '@rocket.chat/ui-contexts'; |
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'; |
||||
import { useState } from 'react'; |
||||
import { FormProvider, useForm } from 'react-hook-form'; |
||||
import { useTranslation } from 'react-i18next'; |
||||
|
||||
import RoomForm from './RoomForm'; |
||||
import { ABACQueryKeys } from '../../../../lib/queryKeys'; |
||||
|
||||
type RoomsContextualBarProps = { |
||||
attributeId?: string; |
||||
roomInfo?: { rid: string; name: string }; |
||||
attributesData?: { key: string; values: string[] }[]; |
||||
|
||||
onClose: () => void; |
||||
}; |
||||
|
||||
const RoomsContextualBar = ({ roomInfo, attributesData, onClose }: RoomsContextualBarProps) => { |
||||
const { t } = useTranslation(); |
||||
const queryClient = useQueryClient(); |
||||
|
||||
const methods = useForm<{ |
||||
room: string; |
||||
attributes: { key: string; values: string[] }[]; |
||||
}>({ |
||||
defaultValues: { |
||||
room: roomInfo?.rid || '', |
||||
attributes: attributesData ?? [{ key: '', values: [] }], |
||||
}, |
||||
mode: 'onChange', |
||||
}); |
||||
|
||||
const { watch } = methods; |
||||
|
||||
const [selectedRoomLabel, setSelectedRoomLabel] = useState<string>(''); |
||||
|
||||
const attributeId = useRouteParameter('id'); |
||||
const createOrUpdateABACRoom = useEndpoint('POST', '/v1/abac/rooms/:rid/attributes', { rid: watch('room') }); |
||||
|
||||
const dispatchToastMessage = useToastMessageDispatch(); |
||||
|
||||
const saveMutation = useMutation({ |
||||
mutationFn: async (data: { room: string; attributes: { key: string; values: string[] }[] }) => { |
||||
const payload = { |
||||
attributes: data.attributes.reduce((acc: Record<string, string[]>, attribute) => { |
||||
acc[attribute.key] = attribute.values; |
||||
return acc; |
||||
}, {}), |
||||
}; |
||||
|
||||
await createOrUpdateABACRoom(payload); |
||||
}, |
||||
onSuccess: () => { |
||||
if (attributeId) { |
||||
dispatchToastMessage({ type: 'success', message: t('ABAC_Room_updated', { roomName: selectedRoomLabel }) }); |
||||
} else { |
||||
dispatchToastMessage({ type: 'success', message: t('ABAC_Room_created', { roomName: selectedRoomLabel }) }); |
||||
} |
||||
onClose(); |
||||
}, |
||||
onError: (error) => { |
||||
dispatchToastMessage({ type: 'error', message: error }); |
||||
}, |
||||
onSettled: () => { |
||||
queryClient.invalidateQueries({ queryKey: ABACQueryKeys.rooms.list() }); |
||||
}, |
||||
}); |
||||
|
||||
return ( |
||||
<> |
||||
<ContextualbarHeader> |
||||
<ContextualbarTitle>{t(attributeId ? 'ABAC_Edit_Room' : 'ABAC_Add_room')}</ContextualbarTitle> |
||||
<ContextualbarClose onClick={onClose} /> |
||||
</ContextualbarHeader> |
||||
<FormProvider {...methods}> |
||||
<RoomForm |
||||
roomInfo={roomInfo} |
||||
onSave={(values) => saveMutation.mutateAsync(values)} |
||||
onClose={onClose} |
||||
setSelectedRoomLabel={setSelectedRoomLabel} |
||||
/> |
||||
</FormProvider> |
||||
</> |
||||
); |
||||
}; |
||||
|
||||
export default RoomsContextualBar; |
||||
@ -0,0 +1,34 @@ |
||||
import { ContextualbarSkeletonBody } from '@rocket.chat/ui-client'; |
||||
import { useEndpoint } from '@rocket.chat/ui-contexts'; |
||||
import { useQuery } from '@tanstack/react-query'; |
||||
|
||||
import RoomsContextualBar from './RoomsContextualBar'; |
||||
import { ABACQueryKeys } from '../../../../lib/queryKeys'; |
||||
|
||||
type RoomsContextualBarWithDataProps = { |
||||
id: string; |
||||
onClose: () => void; |
||||
}; |
||||
|
||||
const RoomsContextualBarWithData = ({ id, onClose }: RoomsContextualBarWithDataProps) => { |
||||
const getRoomAttributes = useEndpoint('GET', '/v1/rooms.adminRooms.getRoom'); |
||||
const { data, isLoading, isFetching } = useQuery({ |
||||
queryKey: ABACQueryKeys.rooms.room(id), |
||||
queryFn: () => getRoomAttributes({ rid: id }), |
||||
staleTime: 0, |
||||
}); |
||||
|
||||
if (isLoading || isFetching) { |
||||
return <ContextualbarSkeletonBody />; |
||||
} |
||||
|
||||
return ( |
||||
<RoomsContextualBar |
||||
roomInfo={{ rid: id, name: data?.fname || data?.name || id }} |
||||
attributesData={data?.abacAttributes} |
||||
onClose={onClose} |
||||
/> |
||||
); |
||||
}; |
||||
|
||||
export default RoomsContextualBarWithData; |
||||
@ -0,0 +1,137 @@ |
||||
import { Box, Button, Icon, Margins, Pagination, Select, TextInput } from '@rocket.chat/fuselage'; |
||||
import { useDebouncedValue, useEffectEvent } from '@rocket.chat/fuselage-hooks'; |
||||
import { |
||||
GenericTable, |
||||
GenericTableBody, |
||||
GenericTableCell, |
||||
GenericTableHeader, |
||||
GenericTableHeaderCell, |
||||
GenericTableRow, |
||||
usePagination, |
||||
} from '@rocket.chat/ui-client'; |
||||
import { useEndpoint, useRouter } from '@rocket.chat/ui-contexts'; |
||||
import { useQuery } from '@tanstack/react-query'; |
||||
import { useMemo, useState, useEffect } from 'react'; |
||||
import { useTranslation } from 'react-i18next'; |
||||
|
||||
import RoomMenu from './RoomMenu'; |
||||
import GenericNoResults from '../../../../components/GenericNoResults'; |
||||
import { ABACQueryKeys } from '../../../../lib/queryKeys'; |
||||
import { useIsABACAvailable } from '../hooks/useIsABACAvailable'; |
||||
|
||||
const RoomsPage = () => { |
||||
const { t } = useTranslation(); |
||||
|
||||
const [text, setText] = useState(''); |
||||
const [filterType, setFilterType] = useState<'all' | 'roomName' | 'attribute' | 'value'>('all'); |
||||
const debouncedText = useDebouncedValue(text, 200); |
||||
const { current, itemsPerPage, setItemsPerPage, setCurrent, ...paginationProps } = usePagination(); |
||||
const getRooms = useEndpoint('GET', '/v1/abac/rooms'); |
||||
const isABACAvailable = useIsABACAvailable(); |
||||
|
||||
const router = useRouter(); |
||||
const handleNewAttribute = useEffectEvent(() => { |
||||
router.navigate({ |
||||
name: 'admin-ABAC', |
||||
params: { |
||||
tab: 'rooms', |
||||
context: 'new', |
||||
}, |
||||
}); |
||||
}); |
||||
|
||||
const query = useMemo( |
||||
() => ({ |
||||
...(debouncedText ? { filter: debouncedText } : {}), |
||||
...(filterType !== 'all' ? { filterType } : {}), |
||||
offset: current, |
||||
count: itemsPerPage, |
||||
}), |
||||
[debouncedText, current, itemsPerPage, filterType], |
||||
); |
||||
|
||||
// Whenever the user changes the filter or the text, reset the pagination to the first page
|
||||
useEffect(() => { |
||||
setCurrent(0); |
||||
}, [debouncedText, filterType, setCurrent]); |
||||
|
||||
const { data, isLoading } = useQuery({ |
||||
queryKey: ABACQueryKeys.rooms.list(query), |
||||
queryFn: () => getRooms(query), |
||||
}); |
||||
|
||||
return ( |
||||
<> |
||||
<Margins block={24}> |
||||
<Box display='flex'> |
||||
<TextInput |
||||
addon={<Icon name='magnifier' size='x20' />} |
||||
placeholder={t('ABAC_Search_rooms')} |
||||
value={text} |
||||
onChange={(e) => setText((e.target as HTMLInputElement).value)} |
||||
/> |
||||
<Box pis={8} maxWidth={200}> |
||||
<Select |
||||
options={[ |
||||
['all', t('All'), true], |
||||
['roomName', t('Rooms'), false], |
||||
['attribute', t('Attributes'), false], |
||||
['value', t('Values'), false], |
||||
]} |
||||
value={filterType} |
||||
onChange={(value) => setFilterType(value as 'all' | 'roomName' | 'attribute' | 'value')} |
||||
/> |
||||
</Box> |
||||
<Button onClick={handleNewAttribute} primary mis={8} disabled={isABACAvailable !== true}> |
||||
{t('Add_room')} |
||||
</Button> |
||||
</Box> |
||||
</Margins> |
||||
{(!data || data.rooms?.length === 0) && !isLoading ? ( |
||||
<Box display='flex' justifyContent='center' height='full'> |
||||
<GenericNoResults icon='list-alt' title={t('ABAC_No_rooms')} description={t('ABAC_No_rooms_description')} /> |
||||
</Box> |
||||
) : ( |
||||
<> |
||||
<GenericTable> |
||||
<GenericTableHeader> |
||||
<GenericTableHeaderCell>{t('Room')}</GenericTableHeaderCell> |
||||
<GenericTableHeaderCell>{t('Members')}</GenericTableHeaderCell> |
||||
<GenericTableHeaderCell>{t('ABAC_Attributes')}</GenericTableHeaderCell> |
||||
<GenericTableHeaderCell>{t('ABAC_Attribute_Values')}</GenericTableHeaderCell> |
||||
<GenericTableHeaderCell key='spacer' w={40} /> |
||||
</GenericTableHeader> |
||||
<GenericTableBody> |
||||
{data?.rooms?.map((room) => ( |
||||
<GenericTableRow key={room._id}> |
||||
<GenericTableCell>{room.fname || room.name}</GenericTableCell> |
||||
<GenericTableCell>{room.usersCount}</GenericTableCell> |
||||
<GenericTableCell withTruncatedText> |
||||
{room.abacAttributes?.flatMap((attribute) => attribute.key ?? []).join(', ')} |
||||
</GenericTableCell> |
||||
<GenericTableCell withTruncatedText> |
||||
{room.abacAttributes?.flatMap((attribute) => attribute.values ?? []).join(', ')} |
||||
</GenericTableCell> |
||||
<GenericTableCell> |
||||
<RoomMenu room={{ rid: room._id, name: room.fname || room.name || room._id }} /> |
||||
</GenericTableCell> |
||||
</GenericTableRow> |
||||
))} |
||||
</GenericTableBody> |
||||
</GenericTable> |
||||
<Pagination |
||||
divider |
||||
current={current} |
||||
itemsPerPage={itemsPerPage} |
||||
count={data?.total || 0} |
||||
onSetItemsPerPage={setItemsPerPage} |
||||
onSetCurrent={setCurrent} |
||||
{...paginationProps} |
||||
/> |
||||
</> |
||||
)} |
||||
</> |
||||
); |
||||
}; |
||||
|
||||
export default RoomsPage; |
||||
@ -0,0 +1,95 @@ |
||||
import type { SettingValue } from '@rocket.chat/core-typings'; |
||||
import { useSetModal, useSettingsDispatch } from '@rocket.chat/ui-contexts'; |
||||
import { useCallback, useEffect, useState } from 'react'; |
||||
import { useTranslation } from 'react-i18next'; |
||||
|
||||
import WarningModal from './WarningModal'; |
||||
import type { EditableSetting } from '../../EditableSettingsContext'; |
||||
import { useEditableSetting } from '../../EditableSettingsContext'; |
||||
import MemoizedSetting from '../../settings/Setting/MemoizedSetting'; |
||||
import SettingSkeleton from '../../settings/Setting/SettingSkeleton'; |
||||
|
||||
type ABACEnabledToggleProps = { |
||||
hasABAC: 'loading' | boolean; |
||||
}; |
||||
|
||||
const ABACEnabledToggle = ({ hasABAC }: ABACEnabledToggleProps) => { |
||||
const setting = useEditableSetting('ABAC_Enabled'); |
||||
const setModal = useSetModal(); |
||||
const dispatch = useSettingsDispatch(); |
||||
const { t } = useTranslation(); |
||||
|
||||
const [value, setValue] = useState<boolean>(setting?.value === true); |
||||
|
||||
useEffect(() => { |
||||
setValue(setting?.value === true); |
||||
}, [setting]); |
||||
|
||||
const onChange = useCallback( |
||||
(value: boolean) => { |
||||
if (!setting) { |
||||
return; |
||||
} |
||||
|
||||
const handleChange = (value: boolean, setting: EditableSetting) => { |
||||
setValue(value); |
||||
dispatch([{ _id: setting._id, value }]); |
||||
}; |
||||
|
||||
if (value === false) { |
||||
return setModal( |
||||
<WarningModal |
||||
onConfirm={() => { |
||||
handleChange(value, setting); |
||||
setModal(); |
||||
}} |
||||
onCancel={() => setModal()} |
||||
/>, |
||||
); |
||||
} |
||||
handleChange(value, setting); |
||||
}, |
||||
[dispatch, setModal, setting], |
||||
); |
||||
|
||||
const onReset = useCallback(() => { |
||||
if (!setting) { |
||||
return; |
||||
} |
||||
const value = setting.packageValue as boolean; |
||||
setModal( |
||||
<WarningModal |
||||
onConfirm={() => { |
||||
setValue(value); |
||||
dispatch([{ _id: setting._id, value }]); |
||||
setModal(); |
||||
}} |
||||
onCancel={() => setModal()} |
||||
/>, |
||||
); |
||||
}, [dispatch, setModal, setting]); |
||||
|
||||
if (!setting) { |
||||
return null; |
||||
} |
||||
|
||||
if (hasABAC === 'loading') { |
||||
return <SettingSkeleton />; |
||||
} |
||||
|
||||
return ( |
||||
<MemoizedSetting |
||||
type='boolean' |
||||
_id={setting._id} |
||||
label={t(setting.i18nLabel)} |
||||
value={value} |
||||
packageValue={setting.packageValue === true} |
||||
hint={t(setting.i18nDescription || '')} |
||||
disabled={!hasABAC || setting.blocked} |
||||
hasResetButton={hasABAC && setting.packageValue !== setting.value} |
||||
onChangeValue={(value: SettingValue) => onChange(value === true)} |
||||
onResetButtonClick={() => onReset()} |
||||
/> |
||||
); |
||||
}; |
||||
export default ABACEnabledToggle; |
||||
@ -0,0 +1,58 @@ |
||||
import type { ISetting } from '@rocket.chat/core-typings'; |
||||
import { mockAppRoot } from '@rocket.chat/mock-providers'; |
||||
import { render, screen, waitFor } from '@testing-library/react'; |
||||
import userEvent from '@testing-library/user-event'; |
||||
|
||||
import SettingField from './SettingField'; |
||||
import EditableSettingsProvider from '../../settings/EditableSettingsProvider'; |
||||
|
||||
const settingStructure = { |
||||
packageValue: false, |
||||
blocked: false, |
||||
public: true, |
||||
type: 'boolean', |
||||
i18nLabel: 'Test_Setting', |
||||
i18nDescription: 'Test_Setting_Description', |
||||
enableQuery: undefined, |
||||
displayQuery: undefined, |
||||
} as Partial<ISetting>; |
||||
|
||||
const dispatchMock = jest.fn(); |
||||
|
||||
jest.mock('@rocket.chat/ui-contexts', () => ({ |
||||
...jest.requireActual('@rocket.chat/ui-contexts'), |
||||
useSettingsDispatch: () => dispatchMock, |
||||
})); |
||||
jest.mock('@rocket.chat/core-typings', () => ({ |
||||
...jest.requireActual('@rocket.chat/core-typings'), |
||||
isSetting: jest.fn().mockReturnValue(true), |
||||
})); |
||||
|
||||
describe('SettingField', () => { |
||||
beforeEach(() => { |
||||
jest.useFakeTimers(); |
||||
}); |
||||
|
||||
afterEach(() => { |
||||
jest.runOnlyPendingTimers(); |
||||
jest.useRealTimers(); |
||||
}); |
||||
|
||||
it('should call dispatch when setting value is changed', async () => { |
||||
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); |
||||
|
||||
render(<SettingField settingId='Test_Setting' />, { |
||||
wrapper: mockAppRoot() |
||||
.wrap((children) => <EditableSettingsProvider>{children}</EditableSettingsProvider>) |
||||
.withSetting('Test_Setting', false, settingStructure) |
||||
.build(), |
||||
}); |
||||
|
||||
const checkbox = screen.getByRole('checkbox'); |
||||
await user.click(checkbox); |
||||
|
||||
await waitFor(() => { |
||||
expect(dispatchMock).toHaveBeenCalled(); |
||||
}); |
||||
}); |
||||
}); |
||||
@ -0,0 +1,145 @@ |
||||
import type { ISettingColor, SettingEditor, SettingValue } from '@rocket.chat/core-typings'; |
||||
import { isSettingColor, isSetting } from '@rocket.chat/core-typings'; |
||||
import { useDebouncedCallback } from '@rocket.chat/fuselage-hooks'; |
||||
import { useSettingsDispatch, useSettingStructure } from '@rocket.chat/ui-contexts'; |
||||
import DOMPurify from 'dompurify'; |
||||
import type { ReactElement } from 'react'; |
||||
import { useEffect, useMemo, useState, useCallback } from 'react'; |
||||
import { useTranslation } from 'react-i18next'; |
||||
|
||||
import MarkdownText from '../../../../components/MarkdownText'; |
||||
import { useEditableSetting, useEditableSettingVisibilityQuery } from '../../EditableSettingsContext'; |
||||
import MemoizedSetting from '../../settings/Setting/MemoizedSetting'; |
||||
import { useHasSettingModule } from '../../settings/hooks/useHasSettingModule'; |
||||
|
||||
type SettingFieldProps = { |
||||
className?: string; |
||||
settingId: string; |
||||
sectionChanged?: boolean; |
||||
}; |
||||
|
||||
function SettingField({ className = undefined, settingId, sectionChanged }: SettingFieldProps): ReactElement { |
||||
const setting = useEditableSetting(settingId); |
||||
const persistedSetting = useSettingStructure(settingId); |
||||
const hasSettingModule = useHasSettingModule(setting); |
||||
|
||||
if (!setting || !persistedSetting) { |
||||
throw new Error(`Setting ${settingId} not found`); |
||||
} |
||||
|
||||
// Checks if setting has at least required fields before doing anything
|
||||
if (!isSetting(setting)) { |
||||
throw new Error(`Setting ${settingId} is not valid`); |
||||
} |
||||
|
||||
const dispatch = useSettingsDispatch(); |
||||
|
||||
const update = useDebouncedCallback( |
||||
({ value, editor }: { value?: SettingValue; editor?: SettingEditor }) => { |
||||
if (!persistedSetting) { |
||||
return; |
||||
} |
||||
|
||||
dispatch([ |
||||
{ |
||||
_id: persistedSetting._id, |
||||
...(value !== undefined && { value }), |
||||
...(editor !== undefined && { editor }), |
||||
}, |
||||
]); |
||||
}, |
||||
230, |
||||
[persistedSetting, dispatch], |
||||
); |
||||
|
||||
const { t, i18n } = useTranslation(); |
||||
|
||||
const [value, setValue] = useState(setting.value); |
||||
const [editor, setEditor] = useState(isSettingColor(setting) ? setting.editor : undefined); |
||||
|
||||
useEffect(() => { |
||||
setValue(setting.value); |
||||
}, [setting.value]); |
||||
|
||||
useEffect(() => { |
||||
setEditor(isSettingColor(setting) ? setting.editor : undefined); |
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [(setting as ISettingColor).editor]); |
||||
|
||||
const onChangeValue = useCallback( |
||||
(value: SettingValue) => { |
||||
setValue(value); |
||||
update({ value }); |
||||
}, |
||||
[update], |
||||
); |
||||
|
||||
const onChangeEditor = useCallback( |
||||
(editor: SettingEditor) => { |
||||
setEditor(editor); |
||||
update({ editor }); |
||||
}, |
||||
[update], |
||||
); |
||||
|
||||
const onResetButtonClick = useCallback(() => { |
||||
setValue(setting.value); |
||||
setEditor(isSettingColor(setting) ? setting.editor : undefined); |
||||
update({ |
||||
value: persistedSetting.packageValue, |
||||
...(isSettingColor(persistedSetting) && { editor: persistedSetting.packageEditor }), |
||||
}); |
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [setting.value, (setting as ISettingColor).editor, update, persistedSetting]); |
||||
|
||||
const { _id, readonly, type, packageValue, i18nLabel, i18nDescription, alert } = setting; |
||||
|
||||
const disabled = !useEditableSettingVisibilityQuery(persistedSetting.enableQuery); |
||||
const invisible = !useEditableSettingVisibilityQuery(persistedSetting.displayQuery); |
||||
|
||||
const labelText = (i18n.exists(i18nLabel) && t(i18nLabel)) || (i18n.exists(_id) && t(_id)) || i18nLabel || _id; |
||||
|
||||
const hint = useMemo( |
||||
() => (i18nDescription && i18n.exists(i18nDescription) ? <MarkdownText variant='inline' content={t(i18nDescription)} /> : undefined), |
||||
[i18n, i18nDescription, t], |
||||
); |
||||
|
||||
const callout = useMemo( |
||||
() => |
||||
alert && <span dangerouslySetInnerHTML={{ __html: i18n.exists(alert) ? DOMPurify.sanitize(t(alert)) : DOMPurify.sanitize(alert) }} />, |
||||
[alert, i18n, t], |
||||
); |
||||
|
||||
const shouldDisableEnterprise = setting.enterprise && !hasSettingModule; |
||||
|
||||
const hasResetButton = |
||||
!shouldDisableEnterprise && |
||||
!readonly && |
||||
type !== 'asset' && |
||||
((isSettingColor(setting) && JSON.stringify(setting.packageEditor) !== JSON.stringify(editor)) || |
||||
JSON.stringify(value) !== JSON.stringify(packageValue)) && |
||||
!disabled; |
||||
|
||||
// @todo: type check props based on setting type
|
||||
|
||||
return ( |
||||
<MemoizedSetting |
||||
className={className} |
||||
label={labelText} |
||||
hint={hint} |
||||
callout={callout} |
||||
sectionChanged={sectionChanged} |
||||
{...setting} |
||||
disabled={disabled || shouldDisableEnterprise} |
||||
value={value} |
||||
editor={editor} |
||||
hasResetButton={hasResetButton} |
||||
onChangeValue={onChangeValue} |
||||
onChangeEditor={onChangeEditor} |
||||
onResetButtonClick={onResetButtonClick} |
||||
invisible={invisible} |
||||
/> |
||||
); |
||||
} |
||||
|
||||
export default SettingField; |
||||
@ -0,0 +1,142 @@ |
||||
import type { ISetting } from '@rocket.chat/core-typings'; |
||||
import { mockAppRoot } from '@rocket.chat/mock-providers'; |
||||
import { render, screen, waitFor } from '@testing-library/react'; |
||||
import userEvent from '@testing-library/user-event'; |
||||
import { axe } from 'jest-axe'; |
||||
|
||||
import AbacEnabledToggle from './AbacEnabledToggle'; |
||||
import EditableSettingsProvider from '../../settings/EditableSettingsProvider'; |
||||
|
||||
const settingStructure = { |
||||
packageValue: false, |
||||
blocked: false, |
||||
public: true, |
||||
type: 'boolean', |
||||
i18nLabel: 'ABAC_Enabled', |
||||
i18nDescription: 'ABAC_Enabled_Description', |
||||
} as Partial<ISetting>; |
||||
|
||||
const baseAppRoot = mockAppRoot() |
||||
.wrap((children) => <EditableSettingsProvider>{children}</EditableSettingsProvider>) |
||||
.withTranslations('en', 'core', { |
||||
ABAC_Enabled: 'Enable ABAC', |
||||
ABAC_Enabled_Description: 'Enable Attribute-Based Access Control', |
||||
ABAC_Warning_Modal_Title: 'Disable ABAC', |
||||
ABAC_Warning_Modal_Confirm_Text: 'Disable', |
||||
Cancel: 'Cancel', |
||||
}); |
||||
|
||||
describe('AbacEnabledToggle', () => { |
||||
it('should render the setting toggle when setting exists', () => { |
||||
const { baseElement } = render(<AbacEnabledToggle hasABAC={true} />, { |
||||
wrapper: baseAppRoot.withSetting('ABAC_Enabled', true, settingStructure).build(), |
||||
}); |
||||
expect(baseElement).toMatchSnapshot(); |
||||
}); |
||||
|
||||
it('should show warning modal when disabling ABAC', async () => { |
||||
const user = userEvent.setup(); |
||||
render(<AbacEnabledToggle hasABAC={true} />, { |
||||
wrapper: baseAppRoot.withSetting('ABAC_Enabled', true, settingStructure).build(), |
||||
}); |
||||
|
||||
const toggle = screen.getByRole('checkbox'); |
||||
await waitFor(() => { |
||||
expect(toggle).not.toBeDisabled(); |
||||
}); |
||||
await user.click(toggle); |
||||
|
||||
await waitFor(() => { |
||||
expect(screen.getByText('Disable ABAC')).toBeInTheDocument(); |
||||
}); |
||||
|
||||
// TODO: discover how to automatically unmount all modals after each test
|
||||
const cancelButton = screen.getByRole('button', { name: /cancel/i }); |
||||
await user.click(cancelButton); |
||||
}); |
||||
|
||||
it('should not show warning modal when enabling ABAC', async () => { |
||||
const user = userEvent.setup(); |
||||
render(<AbacEnabledToggle hasABAC={true} />, { |
||||
wrapper: baseAppRoot.withSetting('ABAC_Enabled', false, settingStructure).build(), |
||||
}); |
||||
|
||||
const toggle = screen.getByRole('checkbox'); |
||||
await user.click(toggle); |
||||
|
||||
// The modal should not appear when enabling ABAC
|
||||
expect(screen.queryByText('Disable ABAC')).not.toBeInTheDocument(); |
||||
}); |
||||
|
||||
it('should show warning modal when resetting setting', async () => { |
||||
const user = userEvent.setup(); |
||||
render(<AbacEnabledToggle hasABAC={true} />, { |
||||
wrapper: baseAppRoot.withSetting('ABAC_Enabled', true, settingStructure).build(), |
||||
}); |
||||
|
||||
const resetButton = screen.getByRole('button', { name: /reset/i }); |
||||
await user.click(resetButton); |
||||
|
||||
await waitFor(() => { |
||||
expect(screen.getByText('Disable ABAC')).toBeInTheDocument(); |
||||
}); |
||||
|
||||
// TODO: discover how to automatically unmount all modals after each test
|
||||
const cancelButton = screen.getByRole('button', { name: /cancel/i }); |
||||
await user.click(cancelButton); |
||||
}); |
||||
|
||||
it('should have no accessibility violations', async () => { |
||||
const { container } = render(<AbacEnabledToggle hasABAC={true} />, { |
||||
wrapper: baseAppRoot.withSetting('ABAC_Enabled', true, settingStructure).build(), |
||||
}); |
||||
const results = await axe(container); |
||||
expect(results).toHaveNoViolations(); |
||||
}); |
||||
|
||||
it('should handle setting change correctly', async () => { |
||||
const user = userEvent.setup(); |
||||
render(<AbacEnabledToggle hasABAC={true} />, { |
||||
wrapper: baseAppRoot.withSetting('ABAC_Enabled', false, settingStructure).build(), |
||||
}); |
||||
|
||||
const toggle = await screen.findByRole('checkbox', { busy: false }); |
||||
expect(toggle).not.toBeChecked(); |
||||
|
||||
await user.click(toggle); |
||||
expect(toggle).toBeChecked(); |
||||
}); |
||||
|
||||
it('should be disabled when abac license is not installed', () => { |
||||
const { baseElement } = render(<AbacEnabledToggle hasABAC={false} />, { |
||||
wrapper: baseAppRoot.withSetting('ABAC_Enabled', true, settingStructure).build(), |
||||
}); |
||||
|
||||
const toggle = screen.getByRole('checkbox'); |
||||
expect(toggle).toBeDisabled(); |
||||
expect(baseElement).toMatchSnapshot(); |
||||
}); |
||||
|
||||
it('should show skeleton when loading', () => { |
||||
const { baseElement } = render(<AbacEnabledToggle hasABAC='loading' />, { |
||||
wrapper: baseAppRoot.withSetting('ABAC_Enabled', true, settingStructure).build(), |
||||
}); |
||||
expect(baseElement).toMatchSnapshot(); |
||||
}); |
||||
|
||||
it('should show reset button when value differs from package value', () => { |
||||
render(<AbacEnabledToggle hasABAC={true} />, { |
||||
wrapper: baseAppRoot.withSetting('ABAC_Enabled', true, settingStructure).build(), |
||||
}); |
||||
|
||||
expect(screen.getByRole('button', { name: /reset/i })).toBeInTheDocument(); |
||||
}); |
||||
|
||||
it('should not show reset button when value matches package value', () => { |
||||
render(<AbacEnabledToggle hasABAC={true} />, { |
||||
wrapper: baseAppRoot.withSetting('ABAC_Enabled', false, settingStructure).build(), |
||||
}); |
||||
|
||||
expect(screen.queryByRole('button', { name: /reset/i })).not.toBeInTheDocument(); |
||||
}); |
||||
}); |
||||
@ -0,0 +1,64 @@ |
||||
import { mockAppRoot } from '@rocket.chat/mock-providers'; |
||||
import type { Meta, StoryObj } from '@storybook/react'; |
||||
|
||||
import AbacEnabledToggle from './AbacEnabledToggle'; |
||||
import EditableSettingsProvider from '../../settings/EditableSettingsProvider'; |
||||
|
||||
const meta: Meta<typeof AbacEnabledToggle> = { |
||||
component: AbacEnabledToggle, |
||||
parameters: { |
||||
layout: 'padded', |
||||
}, |
||||
decorators: [ |
||||
(Story) => { |
||||
const AppRoot = mockAppRoot() |
||||
.wrap((children) => <EditableSettingsProvider>{children}</EditableSettingsProvider>) |
||||
.withTranslations('en', 'core', { |
||||
ABAC_Enabled: 'Enable ABAC', |
||||
ABAC_Enabled_Description: 'Enable Attribute-Based Access Control', |
||||
ABAC_Warning_Modal_Title: 'Disable ABAC', |
||||
ABAC_Warning_Modal_Confirm_Text: 'Disable', |
||||
Cancel: 'Cancel', |
||||
}) |
||||
.withSetting('ABAC_Enabled', true, { |
||||
packageValue: false, |
||||
blocked: false, |
||||
public: true, |
||||
type: 'boolean', |
||||
i18nLabel: 'ABAC_Enabled', |
||||
i18nDescription: 'ABAC_Enabled_Description', |
||||
}) |
||||
.build(); |
||||
|
||||
return ( |
||||
<AppRoot> |
||||
<Story /> |
||||
</AppRoot> |
||||
); |
||||
}, |
||||
], |
||||
args: { |
||||
hasABAC: true, |
||||
}, |
||||
}; |
||||
|
||||
export default meta; |
||||
type Story = StoryObj<typeof AbacEnabledToggle>; |
||||
|
||||
export const Default: Story = { |
||||
args: { |
||||
hasABAC: true, |
||||
}, |
||||
}; |
||||
|
||||
export const Loading: Story = { |
||||
args: { |
||||
hasABAC: 'loading', |
||||
}, |
||||
}; |
||||
|
||||
export const False: Story = { |
||||
args: { |
||||
hasABAC: false, |
||||
}, |
||||
}; |
||||
@ -0,0 +1,33 @@ |
||||
import { Box, Callout, Margins } from '@rocket.chat/fuselage'; |
||||
import { Trans } from 'react-i18next'; |
||||
|
||||
import AbacEnabledToggle from './AbacEnabledToggle'; |
||||
import SettingField from './SettingField'; |
||||
import { useHasLicenseModule } from '../../../../hooks/useHasLicenseModule'; |
||||
import { links } from '../../../../lib/links'; |
||||
|
||||
const SettingsPage = () => { |
||||
const { data: hasABAC = false } = useHasLicenseModule('abac'); |
||||
return ( |
||||
<Box maxWidth='x600' w='full' alignSelf='center'> |
||||
<Box> |
||||
<Margins block={24}> |
||||
<AbacEnabledToggle hasABAC={hasABAC} /> |
||||
<SettingField settingId='ABAC_ShowAttributesInRooms' /> |
||||
<SettingField settingId='Abac_Cache_Decision_Time_Seconds' /> |
||||
|
||||
<Callout> |
||||
<Trans i18nKey='ABAC_Enabled_callout'> |
||||
User attributes are synchronized via LDAP |
||||
<a href={links.go.abacLDAPDocs} rel='noopener noreferrer' target='_blank'> |
||||
Learn more |
||||
</a> |
||||
</Trans> |
||||
</Callout> |
||||
</Margins> |
||||
</Box> |
||||
</Box> |
||||
); |
||||
}; |
||||
|
||||
export default SettingsPage; |
||||
@ -0,0 +1,48 @@ |
||||
import { Box } from '@rocket.chat/fuselage'; |
||||
import { GenericModal } from '@rocket.chat/ui-client'; |
||||
import { useRouter } from '@rocket.chat/ui-contexts'; |
||||
import { Trans, useTranslation } from 'react-i18next'; |
||||
|
||||
type WarningModalProps = { |
||||
onConfirm: () => void; |
||||
onCancel: () => void; |
||||
}; |
||||
|
||||
const WarningModal = ({ onConfirm, onCancel }: WarningModalProps) => { |
||||
const { t } = useTranslation(); |
||||
const router = useRouter(); |
||||
const handleNavigate = () => { |
||||
onCancel(); |
||||
router.navigate({ |
||||
name: 'admin-ABAC', |
||||
params: { |
||||
tab: 'rooms', |
||||
}, |
||||
}); |
||||
}; |
||||
|
||||
return ( |
||||
<GenericModal |
||||
title={t('ABAC_Warning_Modal_Title')} |
||||
variant='secondary-danger' |
||||
confirmText={t('ABAC_Warning_Modal_Confirm_Text')} |
||||
cancelText={t('Cancel')} |
||||
onConfirm={onConfirm} |
||||
onCancel={onCancel} |
||||
onClose={onCancel} |
||||
onDismiss={onCancel} |
||||
> |
||||
<Trans i18nKey='ABAC_Warning_Modal_Content'> |
||||
You will not be able to automatically or manually manage users in existing ABAC-managed rooms. To restore a room's default access |
||||
control, it must be removed from ABAC management in |
||||
<Box is='a' onClick={handleNavigate}> |
||||
{' '} |
||||
ABAC {'>'} Rooms |
||||
</Box> |
||||
. |
||||
</Trans> |
||||
</GenericModal> |
||||
); |
||||
}; |
||||
|
||||
export default WarningModal; |
||||
@ -0,0 +1,152 @@ |
||||
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing |
||||
|
||||
exports[`AbacEnabledToggle should be disabled when abac license is not installed 1`] = ` |
||||
<body> |
||||
<div> |
||||
<div |
||||
class="rcx-box rcx-box--full rcx-field rcx-css-1gfu76s" |
||||
> |
||||
<div |
||||
class="rcx-box rcx-box--full rcx-css-1u2ihfm" |
||||
> |
||||
<div |
||||
class="rcx-box rcx-box--full rcx-field" |
||||
> |
||||
<span |
||||
class="rcx-box rcx-box--full rcx-field__row rcx-css-ctk2ij" |
||||
> |
||||
<label |
||||
class="rcx-box rcx-box--full rcx-field__label rcx-label" |
||||
for="ABAC_Enabled" |
||||
title="ABAC_Enabled" |
||||
> |
||||
Enable ABAC |
||||
</label> |
||||
<div |
||||
class="rcx-box rcx-box--full rcx-css-127j9mz" |
||||
> |
||||
<label |
||||
class="rcx-box rcx-box--full rcx-toggle-switch" |
||||
> |
||||
<input |
||||
checked="" |
||||
class="rcx-box rcx-box--full rcx-toggle-switch__input" |
||||
data-qa-setting-id="ABAC_Enabled" |
||||
disabled="" |
||||
id="ABAC_Enabled" |
||||
type="checkbox" |
||||
/> |
||||
<i |
||||
aria-hidden="true" |
||||
class="rcx-box rcx-box--full rcx-toggle-switch__fake" |
||||
/> |
||||
</label> |
||||
</div> |
||||
</span> |
||||
<span |
||||
class="rcx-box rcx-box--full rcx-field__hint" |
||||
> |
||||
Enable Attribute-Based Access Control |
||||
</span> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
</body> |
||||
`; |
||||
|
||||
exports[`AbacEnabledToggle should render the setting toggle when setting exists 1`] = ` |
||||
<body> |
||||
<div> |
||||
<div |
||||
class="rcx-box rcx-box--full rcx-field rcx-css-1gfu76s" |
||||
> |
||||
<div |
||||
class="rcx-box rcx-box--full rcx-css-1u2ihfm" |
||||
> |
||||
<div |
||||
class="rcx-box rcx-box--full rcx-field" |
||||
> |
||||
<span |
||||
class="rcx-box rcx-box--full rcx-field__row rcx-css-ctk2ij" |
||||
> |
||||
<label |
||||
class="rcx-box rcx-box--full rcx-field__label rcx-label" |
||||
for="ABAC_Enabled" |
||||
title="ABAC_Enabled" |
||||
> |
||||
Enable ABAC |
||||
</label> |
||||
<div |
||||
class="rcx-box rcx-box--full rcx-css-127j9mz" |
||||
> |
||||
<button |
||||
class="rcx-box rcx-box--full rcx-button--small-square rcx-button--icon-danger rcx-button--square rcx-button--icon rcx-button rcx-css-1rtu0k9" |
||||
data-qa-reset-setting-id="ABAC_Enabled" |
||||
title="Reset" |
||||
type="button" |
||||
> |
||||
<i |
||||
aria-hidden="true" |
||||
class="rcx-box rcx-box--full rcx-icon--name-undo rcx-icon rcx-css-4pvxx3" |
||||
> |
||||
❰ |
||||
</i> |
||||
</button> |
||||
<label |
||||
class="rcx-box rcx-box--full rcx-toggle-switch" |
||||
> |
||||
<input |
||||
checked="" |
||||
class="rcx-box rcx-box--full rcx-toggle-switch__input" |
||||
data-qa-setting-id="ABAC_Enabled" |
||||
id="ABAC_Enabled" |
||||
type="checkbox" |
||||
/> |
||||
<i |
||||
aria-hidden="true" |
||||
class="rcx-box rcx-box--full rcx-toggle-switch__fake" |
||||
/> |
||||
</label> |
||||
</div> |
||||
</span> |
||||
<span |
||||
class="rcx-box rcx-box--full rcx-field__hint" |
||||
> |
||||
Enable Attribute-Based Access Control |
||||
</span> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
</body> |
||||
`; |
||||
|
||||
exports[`AbacEnabledToggle should show skeleton when loading 1`] = ` |
||||
<body> |
||||
<div> |
||||
<div |
||||
class="rcx-box rcx-box--full rcx-field" |
||||
> |
||||
<label |
||||
class="rcx-box rcx-box--full rcx-field__label rcx-label rcx-css-1exa5vl" |
||||
> |
||||
<span |
||||
class="rcx-skeleton rcx-skeleton--text rcx-css-1v1eapn" |
||||
/> |
||||
</label> |
||||
<span |
||||
class="rcx-box rcx-box--full rcx-field__row" |
||||
> |
||||
<div |
||||
class="rcx-box rcx-box--full rcx-skeleton__input" |
||||
> |
||||
<span |
||||
class="rcx-skeleton rcx-skeleton--text rcx-css-1qcz93u" |
||||
/> |
||||
</div> |
||||
</span> |
||||
</div> |
||||
</div> |
||||
</body> |
||||
`; |
||||
@ -0,0 +1,103 @@ |
||||
import { Box, Button, Callout } from '@rocket.chat/fuselage'; |
||||
import { useEffectEvent } from '@rocket.chat/fuselage-hooks'; |
||||
import { ContextualbarDialog, Page, PageContent, PageHeader } from '@rocket.chat/ui-client'; |
||||
import { useRouteParameter, useRouter } from '@rocket.chat/ui-contexts'; |
||||
import { Trans, useTranslation } from 'react-i18next'; |
||||
|
||||
import AttributesContextualBar from './ABACAttributesTab/AttributesContextualBar'; |
||||
import AttributesContextualBarWithData from './ABACAttributesTab/AttributesContextualBarWithData'; |
||||
import AttributesPage from './ABACAttributesTab/AttributesPage'; |
||||
import LogsPage from './ABACLogsTab/LogsPage'; |
||||
import RoomsContextualBar from './ABACRoomsTab/RoomsContextualBar'; |
||||
import RoomsContextualBarWithData from './ABACRoomsTab/RoomsContextualBarWithData'; |
||||
import RoomsPage from './ABACRoomsTab/RoomsPage'; |
||||
import SettingsPage from './ABACSettingTab/SettingsPage'; |
||||
import AdminABACTabs from './AdminABACTabs'; |
||||
import { useIsABACAvailable } from './hooks/useIsABACAvailable'; |
||||
import { useExternalLink } from '../../../hooks/useExternalLink'; |
||||
import { links } from '../../../lib/links'; |
||||
|
||||
type AdminABACPageProps = { |
||||
shouldShowWarning: boolean; |
||||
}; |
||||
|
||||
const AdminABACPage = ({ shouldShowWarning }: AdminABACPageProps) => { |
||||
const { t } = useTranslation(); |
||||
const router = useRouter(); |
||||
const tab = useRouteParameter('tab'); |
||||
const _id = useRouteParameter('id'); |
||||
const context = useRouteParameter('context'); |
||||
const learnMore = useExternalLink(); |
||||
const isABACAvailable = useIsABACAvailable(); |
||||
|
||||
const handleCloseContextualbar = useEffectEvent((): void => { |
||||
if (!context) { |
||||
return; |
||||
} |
||||
|
||||
router.navigate( |
||||
{ |
||||
name: 'admin-ABAC', |
||||
params: { ...router.getRouteParameters(), context: '', id: '' }, |
||||
}, |
||||
{ replace: true }, |
||||
); |
||||
}); |
||||
|
||||
return ( |
||||
<Page flexDirection='row'> |
||||
<Page> |
||||
<PageHeader title={t('ABAC')}> |
||||
<Button icon='new-window' secondary onClick={() => learnMore(links.go.abacDocs)}> |
||||
{t('ABAC_Learn_More')} |
||||
</Button> |
||||
</PageHeader> |
||||
{shouldShowWarning && ( |
||||
<Box mi={24} mb={16}> |
||||
<Callout type='warning' title={t('ABAC_automatically_disabled_callout')}> |
||||
<Trans |
||||
i18nKey='ABAC_automatically_disabled_callout_description' |
||||
components={{ |
||||
1: ( |
||||
<a href={links.go.abacDocs} rel='noopener noreferrer' target='_blank'> |
||||
ABAC capabilities without restriction. |
||||
</a> |
||||
), |
||||
}} |
||||
/> |
||||
</Callout> |
||||
</Box> |
||||
)} |
||||
<AdminABACTabs /> |
||||
<PageContent> |
||||
{tab === 'settings' && <SettingsPage />} |
||||
{tab === 'room-attributes' && <AttributesPage />} |
||||
{tab === 'rooms' && <RoomsPage />} |
||||
{tab === 'logs' && <LogsPage />} |
||||
</PageContent> |
||||
</Page> |
||||
{tab !== undefined && context !== undefined && ( |
||||
<ContextualbarDialog onClose={() => handleCloseContextualbar()}> |
||||
{tab === 'room-attributes' && ( |
||||
<> |
||||
{context === 'new' && isABACAvailable === true && <AttributesContextualBar onClose={() => handleCloseContextualbar()} />} |
||||
{context === 'edit' && _id && isABACAvailable === true && ( |
||||
<AttributesContextualBarWithData id={_id} onClose={() => handleCloseContextualbar()} /> |
||||
)} |
||||
</> |
||||
)} |
||||
{tab === 'rooms' && ( |
||||
<> |
||||
{context === 'new' && isABACAvailable === true && <RoomsContextualBar onClose={() => handleCloseContextualbar()} />} |
||||
{context === 'edit' && _id && isABACAvailable === true && ( |
||||
<RoomsContextualBarWithData id={_id} onClose={() => handleCloseContextualbar()} /> |
||||
)} |
||||
</> |
||||
)} |
||||
</ContextualbarDialog> |
||||
)} |
||||
</Page> |
||||
); |
||||
}; |
||||
|
||||
export default AdminABACPage; |
||||
@ -1,135 +0,0 @@ |
||||
import { |
||||
Box, |
||||
Button, |
||||
ButtonGroup, |
||||
ContextualbarFooter, |
||||
Field, |
||||
FieldError, |
||||
FieldLabel, |
||||
FieldRow, |
||||
IconButton, |
||||
TextInput, |
||||
} from '@rocket.chat/fuselage'; |
||||
import { ContextualbarScrollableContent } from '@rocket.chat/ui-client'; |
||||
import { useCallback, useId, useMemo } from 'react'; |
||||
import { useFieldArray, useFormContext } from 'react-hook-form'; |
||||
import { useTranslation } from 'react-i18next'; |
||||
|
||||
export type AdminABACRoomAttributesFormFormData = { |
||||
name: string; |
||||
attributeValues: { value: string }[]; |
||||
lockedAttributes: { value: string }[]; |
||||
}; |
||||
|
||||
type AdminABACRoomAttributesFormProps = { |
||||
onSave: (data: unknown) => void; |
||||
onCancel: () => void; |
||||
description: string; |
||||
}; |
||||
|
||||
const AdminABACRoomAttributesForm = ({ onSave, onCancel, description }: AdminABACRoomAttributesFormProps) => { |
||||
const { |
||||
handleSubmit, |
||||
register, |
||||
formState: { errors }, |
||||
watch, |
||||
} = useFormContext<AdminABACRoomAttributesFormFormData>(); |
||||
|
||||
const { fields: lockedAttributesFields, remove: removeLockedAttribute } = useFieldArray({ |
||||
name: 'lockedAttributes', |
||||
}); |
||||
|
||||
const { fields, append, remove } = useFieldArray({ |
||||
name: 'attributeValues', |
||||
rules: { |
||||
minLength: 1, |
||||
}, |
||||
}); |
||||
const { t } = useTranslation(); |
||||
|
||||
const formId = useId(); |
||||
const nameField = useId(); |
||||
const valuesField = useId(); |
||||
const attributeValues = watch('attributeValues'); |
||||
|
||||
const getAttributeValuesError = useCallback(() => { |
||||
if (errors.attributeValues?.length && errors.attributeValues?.length > 0) { |
||||
return t('Required_field', { field: t('Values') }); |
||||
} |
||||
return ''; |
||||
}, [errors.attributeValues, t]); |
||||
|
||||
const hasValuesErrors = useMemo(() => { |
||||
const attributeValuesErrors = Array.isArray(errors?.attributeValues) && errors.attributeValues.some((error) => !!error?.value?.message); |
||||
const lockedAttributesErrors = |
||||
Array.isArray(errors?.lockedAttributes) && errors.lockedAttributes.some((error) => !!error?.value?.message); |
||||
return attributeValuesErrors || lockedAttributesErrors; |
||||
}, [errors.attributeValues, errors.lockedAttributes]); |
||||
|
||||
return ( |
||||
<Box is='form' onSubmit={handleSubmit(onSave)} id={formId}> |
||||
<ContextualbarScrollableContent> |
||||
<Box>{description}</Box> |
||||
<Field mb={16}> |
||||
<FieldLabel htmlFor={nameField} required> |
||||
{t('Name')} |
||||
</FieldLabel> |
||||
<FieldRow> |
||||
<TextInput |
||||
error={errors.name?.message} |
||||
id={nameField} |
||||
{...register('name', { required: t('Required_field', { field: t('Name') }) })} |
||||
/> |
||||
</FieldRow> |
||||
<FieldError>{errors.name?.message || ''}</FieldError> |
||||
</Field> |
||||
<Field mb={16}> |
||||
<FieldLabel required id={valuesField}> |
||||
{t('Values')} |
||||
</FieldLabel> |
||||
{lockedAttributesFields.map((field, index) => ( |
||||
<FieldRow key={field.id}> |
||||
<TextInput |
||||
disabled |
||||
aria-labelledby={valuesField} |
||||
error={errors.lockedAttributes?.[index]?.value?.message || ''} |
||||
{...register(`lockedAttributes.${index}.value`, { required: t('Required_field', { field: t('Values') }) })} |
||||
/> |
||||
{index !== 0 && <IconButton aria-label={t('Remove')} icon='trash' onClick={() => removeLockedAttribute(index)} />} |
||||
</FieldRow> |
||||
))} |
||||
{fields.map((field, index) => ( |
||||
<FieldRow key={field.id}> |
||||
<TextInput |
||||
aria-labelledby={valuesField} |
||||
error={errors.attributeValues?.[index]?.value?.message || ''} |
||||
{...register(`attributeValues.${index}.value`, { required: t('Required_field', { field: t('Values') }) })} |
||||
/> |
||||
{(index !== 0 || lockedAttributesFields.length > 0) && ( |
||||
<IconButton aria-label={t('Remove')} icon='trash' onClick={() => remove(index)} /> |
||||
)} |
||||
</FieldRow> |
||||
))} |
||||
<FieldError>{getAttributeValuesError()}</FieldError> |
||||
<Button |
||||
onClick={() => append({ value: '' })} |
||||
// Checking for values since rhf does consider the newly added field as dirty after an append() call
|
||||
disabled={!!getAttributeValuesError() || attributeValues?.some((value: { value: string }) => value?.value === '')} |
||||
> |
||||
{t('Add Value')} |
||||
</Button> |
||||
</Field> |
||||
</ContextualbarScrollableContent> |
||||
<ContextualbarFooter> |
||||
<ButtonGroup stretch> |
||||
<Button onClick={() => onCancel()}>{t('Cancel')}</Button> |
||||
<Button type='submit' disabled={hasValuesErrors || !!errors.name} primary> |
||||
{t('Save')} |
||||
</Button> |
||||
</ButtonGroup> |
||||
</ContextualbarFooter> |
||||
</Box> |
||||
); |
||||
}; |
||||
|
||||
export default AdminABACRoomAttributesForm; |
||||
@ -0,0 +1,64 @@ |
||||
import { usePermission, useSetModal, useCurrentModal, useRouter, useRouteParameter, useSettingStructure } from '@rocket.chat/ui-contexts'; |
||||
import type { ReactElement } from 'react'; |
||||
import { memo, useEffect, useLayoutEffect } from 'react'; |
||||
import { useTranslation } from 'react-i18next'; |
||||
|
||||
import AdminABACPage from './AdminABACPage'; |
||||
import ABACUpsellModal from '../../../components/ABAC/ABACUpsellModal/ABACUpsellModal'; |
||||
import { useUpsellActions } from '../../../components/GenericUpsellModal/hooks'; |
||||
import PageSkeleton from '../../../components/PageSkeleton'; |
||||
import { useHasLicenseModule } from '../../../hooks/useHasLicenseModule'; |
||||
import SettingsProvider from '../../../providers/SettingsProvider'; |
||||
import NotAuthorizedPage from '../../notAuthorized/NotAuthorizedPage'; |
||||
import EditableSettingsProvider from '../settings/EditableSettingsProvider'; |
||||
|
||||
const AdminABACRoute = (): ReactElement => { |
||||
const { t } = useTranslation(); |
||||
// TODO: Check what permission is needed to view the ABAC page
|
||||
const canViewABACPage = usePermission('abac-management'); |
||||
const { data: hasABAC = false } = useHasLicenseModule('abac'); |
||||
const isModalOpen = !!useCurrentModal(); |
||||
const tab = useRouteParameter('tab'); |
||||
const router = useRouter(); |
||||
|
||||
// Check if setting exists in the DB to decide if we show warning or upsell
|
||||
const ABACEnabledSetting = useSettingStructure('ABAC_Enabled'); |
||||
|
||||
useLayoutEffect(() => { |
||||
if (!tab) { |
||||
router.navigate({ |
||||
name: 'admin-ABAC', |
||||
params: { tab: 'settings' }, |
||||
}); |
||||
} |
||||
}, [tab, router]); |
||||
|
||||
const { shouldShowUpsell, handleManageSubscription } = useUpsellActions(hasABAC); |
||||
|
||||
const setModal = useSetModal(); |
||||
|
||||
useEffect(() => { |
||||
// WS has never activated ABAC
|
||||
if (shouldShowUpsell && ABACEnabledSetting === undefined) { |
||||
setModal(<ABACUpsellModal onClose={() => setModal(null)} onConfirm={handleManageSubscription} />); |
||||
} |
||||
}, [shouldShowUpsell, setModal, t, handleManageSubscription, ABACEnabledSetting]); |
||||
|
||||
if (isModalOpen) { |
||||
return <PageSkeleton />; |
||||
} |
||||
|
||||
if (!canViewABACPage || (ABACEnabledSetting === undefined && !hasABAC)) { |
||||
return <NotAuthorizedPage />; |
||||
} |
||||
|
||||
return ( |
||||
<SettingsProvider> |
||||
<EditableSettingsProvider> |
||||
<AdminABACPage shouldShowWarning={ABACEnabledSetting !== undefined && !hasABAC} /> |
||||
</EditableSettingsProvider> |
||||
</SettingsProvider> |
||||
); |
||||
}; |
||||
|
||||
export default memo(AdminABACRoute); |
||||
@ -0,0 +1,33 @@ |
||||
import { Tabs, TabsItem } from '@rocket.chat/fuselage'; |
||||
import { useRouteParameter, useRouter } from '@rocket.chat/ui-contexts'; |
||||
import { useTranslation } from 'react-i18next'; |
||||
|
||||
const AdminABACTabs = () => { |
||||
const { t } = useTranslation(); |
||||
const router = useRouter(); |
||||
const tab = useRouteParameter('tab'); |
||||
const handleTabClick = (tab: string) => { |
||||
router.navigate({ |
||||
name: 'admin-ABAC', |
||||
params: { tab }, |
||||
}); |
||||
}; |
||||
return ( |
||||
<Tabs> |
||||
<TabsItem selected={tab === 'settings'} onClick={() => handleTabClick('settings')}> |
||||
{t('Settings')} |
||||
</TabsItem> |
||||
<TabsItem selected={tab === 'room-attributes'} onClick={() => handleTabClick('room-attributes')}> |
||||
{t('ABAC_Room_Attributes')} |
||||
</TabsItem> |
||||
<TabsItem selected={tab === 'rooms'} onClick={() => handleTabClick('rooms')}> |
||||
{t('Rooms')} |
||||
</TabsItem> |
||||
<TabsItem selected={tab === 'logs'} onClick={() => handleTabClick('logs')}> |
||||
{t('ABAC_Logs')} |
||||
</TabsItem> |
||||
</Tabs> |
||||
); |
||||
}; |
||||
|
||||
export default AdminABACTabs; |
||||
@ -0,0 +1,39 @@ |
||||
import { useEndpoint } from '@rocket.chat/ui-contexts'; |
||||
import { useQuery } from '@tanstack/react-query'; |
||||
|
||||
import { useIsABACAvailable } from './useIsABACAvailable'; |
||||
import { ABACQueryKeys } from '../../../../lib/queryKeys'; |
||||
|
||||
const COUNT = 150; |
||||
|
||||
export const useAttributeList = () => { |
||||
const attributesAutoCompleteEndpoint = useEndpoint('GET', '/v1/abac/attributes'); |
||||
const isABACAvailable = useIsABACAvailable(); |
||||
|
||||
return useQuery({ |
||||
enabled: isABACAvailable, |
||||
queryKey: ABACQueryKeys.roomAttributes.list(), |
||||
queryFn: async () => { |
||||
const firstPage = await attributesAutoCompleteEndpoint({ offset: 0, count: COUNT }); |
||||
const { attributes: firstPageAttributes, total } = firstPage; |
||||
|
||||
let currentPage = COUNT; |
||||
const pages = []; |
||||
|
||||
while (currentPage < total) { |
||||
pages.push(attributesAutoCompleteEndpoint({ offset: currentPage, count: COUNT })); |
||||
currentPage += COUNT; |
||||
} |
||||
const remainingPages = await Promise.all(pages); |
||||
|
||||
return { |
||||
attributes: [...firstPageAttributes, ...remainingPages.flatMap((page) => page.attributes)].map((attribute) => ({ |
||||
_id: attribute._id, |
||||
label: attribute.key, |
||||
value: attribute.key, |
||||
attributeValues: attribute.values, |
||||
})), |
||||
}; |
||||
}, |
||||
}); |
||||
}; |
||||
@ -0,0 +1,267 @@ |
||||
import { mockAppRoot } from '@rocket.chat/mock-providers'; |
||||
import { renderHook, waitFor } from '@testing-library/react'; |
||||
|
||||
import { useAttributeOptions } from './useAttributeOptions'; |
||||
import { createFakeLicenseInfo } from '../../../../../tests/mocks/data'; |
||||
|
||||
const mockNavigate = jest.fn(); |
||||
const mockSetModal = jest.fn(); |
||||
const mockDispatchToastMessage = jest.fn(); |
||||
const useIsABACAvailableMock = jest.fn(() => true); |
||||
|
||||
jest.mock('./useIsABACAvailable', () => ({ |
||||
useIsABACAvailable: () => useIsABACAvailableMock(), |
||||
})); |
||||
|
||||
jest.mock('@rocket.chat/ui-contexts', () => ({ |
||||
...jest.requireActual('@rocket.chat/ui-contexts'), |
||||
useRouter: () => ({ |
||||
navigate: mockNavigate, |
||||
}), |
||||
useSetModal: () => mockSetModal, |
||||
useToastMessageDispatch: () => mockDispatchToastMessage, |
||||
})); |
||||
|
||||
const mockAttribute = { |
||||
_id: 'attribute-1', |
||||
key: 'Room Type', |
||||
}; |
||||
|
||||
const baseAppRoot = mockAppRoot() |
||||
.withTranslations('en', 'core', { |
||||
Edit: 'Edit', |
||||
Delete: 'Delete', |
||||
ABAC_Attribute_deleted: 'Attribute {{attributeName}} deleted', |
||||
ABAC_Cannot_delete_attribute: 'Cannot delete attribute', |
||||
ABAC_Cannot_delete_attribute_content: |
||||
'The attribute <bold>{{attributeName}}</bold> is currently in use and cannot be deleted. Please remove it from all rooms before deleting.', |
||||
ABAC_Delete_room_attribute: 'Delete room attribute', |
||||
ABAC_Delete_room_attribute_content: |
||||
'Are you sure you want to delete the attribute <bold>{{attributeName}}</bold>? This action cannot be undone.', |
||||
View_rooms: 'View rooms', |
||||
Cancel: 'Cancel', |
||||
}) |
||||
.withSetting('ABAC_Enabled', true, { |
||||
packageValue: false, |
||||
blocked: false, |
||||
public: true, |
||||
type: 'boolean', |
||||
i18nLabel: 'ABAC_Enabled', |
||||
i18nDescription: 'ABAC_Enabled_Description', |
||||
}) |
||||
.withEndpoint('GET', '/v1/licenses.info', async () => ({ |
||||
license: createFakeLicenseInfo({ activeModules: ['abac'] }), |
||||
})); |
||||
|
||||
describe('useAttributeOptions', () => { |
||||
beforeEach(() => { |
||||
jest.clearAllMocks(); |
||||
mockNavigate.mockClear(); |
||||
mockSetModal.mockClear(); |
||||
mockDispatchToastMessage.mockClear(); |
||||
}); |
||||
|
||||
it('should return menu items with correct structure', () => { |
||||
const { result } = renderHook(() => useAttributeOptions(mockAttribute), { |
||||
wrapper: baseAppRoot |
||||
.withEndpoint('DELETE', '/v1/abac/attributes/:_id', async () => null) |
||||
.withEndpoint('GET', '/v1/abac/attributes/:key/is-in-use', async () => ({ inUse: false })) |
||||
.build(), |
||||
}); |
||||
|
||||
expect(result.current).toHaveLength(2); |
||||
expect(result.current[0]).toMatchObject({ |
||||
id: 'edit', |
||||
icon: 'edit', |
||||
content: 'Edit', |
||||
}); |
||||
expect(result.current[1]).toMatchObject({ |
||||
id: 'delete', |
||||
icon: 'trash', |
||||
iconColor: 'danger', |
||||
}); |
||||
}); |
||||
|
||||
it('should enable edit when ABAC is available', async () => { |
||||
const { result } = renderHook(() => useAttributeOptions(mockAttribute), { |
||||
wrapper: baseAppRoot |
||||
.withEndpoint('DELETE', '/v1/abac/attributes/:_id', async () => null) |
||||
.withEndpoint('GET', '/v1/abac/attributes/:key/is-in-use', async () => ({ inUse: false })) |
||||
.build(), |
||||
}); |
||||
|
||||
await waitFor(() => { |
||||
expect(result.current[0].disabled).toBe(false); |
||||
}); |
||||
}); |
||||
|
||||
it('should navigate to edit page when edit action is clicked', async () => { |
||||
const { result } = renderHook(() => useAttributeOptions(mockAttribute), { |
||||
wrapper: baseAppRoot |
||||
.withEndpoint('DELETE', '/v1/abac/attributes/:_id', async () => null) |
||||
.withEndpoint('GET', '/v1/abac/attributes/:key/is-in-use', async () => ({ inUse: false })) |
||||
.build(), |
||||
}); |
||||
|
||||
const editAction = result.current[0].onClick; |
||||
if (editAction) { |
||||
editAction(); |
||||
} |
||||
|
||||
expect(mockNavigate).toHaveBeenCalledWith( |
||||
{ |
||||
name: 'admin-ABAC', |
||||
params: { |
||||
tab: 'room-attributes', |
||||
context: 'edit', |
||||
id: mockAttribute._id, |
||||
}, |
||||
}, |
||||
{ replace: true }, |
||||
); |
||||
}); |
||||
|
||||
it('should disable edit when ABAC is not available', () => { |
||||
useIsABACAvailableMock.mockReturnValue(false); |
||||
const { result } = renderHook(() => useAttributeOptions(mockAttribute), { |
||||
wrapper: baseAppRoot |
||||
.withSetting('ABAC_Enabled', false, { |
||||
packageValue: false, |
||||
blocked: false, |
||||
public: true, |
||||
type: 'boolean', |
||||
i18nLabel: 'ABAC_Enabled', |
||||
i18nDescription: 'ABAC_Enabled_Description', |
||||
}) |
||||
.withEndpoint('GET', '/v1/licenses.info', async () => ({ |
||||
license: createFakeLicenseInfo({ activeModules: [] }), |
||||
})) |
||||
.withEndpoint('DELETE', '/v1/abac/attributes/:_id', async () => null) |
||||
.withEndpoint('GET', '/v1/abac/attributes/:key/is-in-use', async () => ({ inUse: false })) |
||||
.build(), |
||||
}); |
||||
|
||||
expect(result.current[0].disabled).toBe(true); |
||||
}); |
||||
|
||||
it('should show warning modal when delete is clicked and attribute is in use', async () => { |
||||
const { result } = renderHook(() => useAttributeOptions(mockAttribute), { |
||||
wrapper: baseAppRoot |
||||
.withEndpoint('DELETE', '/v1/abac/attributes/:_id', async () => null) |
||||
.withEndpoint('GET', '/v1/abac/attributes/:key/is-in-use', async () => ({ inUse: true })) |
||||
.build(), |
||||
}); |
||||
|
||||
const deleteAction = result.current[1].onClick; |
||||
if (deleteAction) { |
||||
deleteAction(); |
||||
} |
||||
|
||||
await waitFor(() => { |
||||
expect(mockSetModal).toHaveBeenCalled(); |
||||
}); |
||||
|
||||
const modalCall = mockSetModal.mock.calls[0][0]; |
||||
expect(modalCall.props.variant).toBe('warning'); |
||||
expect(modalCall.props.title).toBe('Cannot delete attribute'); |
||||
}); |
||||
|
||||
it('should show delete confirmation modal when delete is clicked and attribute is not in use', async () => { |
||||
const deleteEndpointMock = jest.fn().mockResolvedValue(null); |
||||
const { result } = renderHook(() => useAttributeOptions(mockAttribute), { |
||||
wrapper: baseAppRoot |
||||
.withEndpoint('DELETE', '/v1/abac/attributes/:_id', deleteEndpointMock) |
||||
.withEndpoint('GET', '/v1/abac/attributes/:key/is-in-use', async () => ({ inUse: false })) |
||||
.build(), |
||||
}); |
||||
|
||||
const deleteAction = result.current[1].onClick; |
||||
if (deleteAction) { |
||||
deleteAction(); |
||||
} |
||||
|
||||
await waitFor(() => { |
||||
expect(mockSetModal).toHaveBeenCalled(); |
||||
}); |
||||
|
||||
const modalCall = mockSetModal.mock.calls[0][0]; |
||||
expect(modalCall.props.variant).toBe('danger'); |
||||
expect(modalCall.props.title).toBe('Delete room attribute'); |
||||
expect(modalCall.props.confirmText).toBe('Delete'); |
||||
}); |
||||
|
||||
it('should call delete endpoint when delete is confirmed', async () => { |
||||
const deleteEndpointMock = jest.fn().mockResolvedValue(null); |
||||
|
||||
let confirmHandler: (() => void) | undefined; |
||||
|
||||
mockSetModal.mockImplementation((modal) => { |
||||
if (modal?.props?.onConfirm) { |
||||
confirmHandler = modal.props.onConfirm; |
||||
} |
||||
}); |
||||
|
||||
const { result } = renderHook(() => useAttributeOptions(mockAttribute), { |
||||
wrapper: baseAppRoot |
||||
.withEndpoint('DELETE', '/v1/abac/attributes/:_id', deleteEndpointMock) |
||||
.withEndpoint('GET', '/v1/abac/attributes/:key/is-in-use', async () => ({ inUse: false })) |
||||
.build(), |
||||
}); |
||||
|
||||
const deleteAction = result.current[1].onClick; |
||||
if (deleteAction) { |
||||
deleteAction(); |
||||
} |
||||
|
||||
await waitFor(() => { |
||||
expect(mockSetModal).toHaveBeenCalled(); |
||||
}); |
||||
|
||||
if (confirmHandler) { |
||||
confirmHandler(); |
||||
} |
||||
|
||||
await waitFor(() => { |
||||
expect(deleteEndpointMock).toHaveBeenCalled(); |
||||
}); |
||||
}); |
||||
|
||||
it('should show success toast when delete succeeds', async () => { |
||||
const deleteEndpointMock = jest.fn().mockResolvedValue(null); |
||||
|
||||
let confirmHandler: (() => void) | undefined; |
||||
|
||||
mockSetModal.mockImplementation((modal) => { |
||||
if (modal?.props?.onConfirm) { |
||||
confirmHandler = modal.props.onConfirm; |
||||
} |
||||
}); |
||||
|
||||
const { result } = renderHook(() => useAttributeOptions(mockAttribute), { |
||||
wrapper: baseAppRoot |
||||
.withEndpoint('DELETE', '/v1/abac/attributes/:_id', deleteEndpointMock) |
||||
.withEndpoint('GET', '/v1/abac/attributes/:key/is-in-use', async () => ({ inUse: false })) |
||||
.build(), |
||||
}); |
||||
|
||||
const deleteAction = result.current[1].onClick; |
||||
if (deleteAction) { |
||||
deleteAction(); |
||||
} |
||||
|
||||
await waitFor(() => { |
||||
expect(mockSetModal).toHaveBeenCalled(); |
||||
}); |
||||
|
||||
if (confirmHandler) { |
||||
confirmHandler(); |
||||
} |
||||
|
||||
await waitFor(() => { |
||||
expect(mockDispatchToastMessage).toHaveBeenCalledWith({ |
||||
type: 'success', |
||||
message: 'Attribute Room Type deleted', |
||||
}); |
||||
}); |
||||
}); |
||||
}); |
||||
@ -0,0 +1,101 @@ |
||||
import { Box } from '@rocket.chat/fuselage'; |
||||
import { useEffectEvent } from '@rocket.chat/fuselage-hooks'; |
||||
import type { GenericMenuItemProps } from '@rocket.chat/ui-client'; |
||||
import { GenericModal } from '@rocket.chat/ui-client'; |
||||
import { useRouter, useSetModal, useEndpoint, useToastMessageDispatch } from '@rocket.chat/ui-contexts'; |
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'; |
||||
import { Trans, useTranslation } from 'react-i18next'; |
||||
|
||||
import { useIsABACAvailable } from './useIsABACAvailable'; |
||||
import { ABACQueryKeys } from '../../../../lib/queryKeys'; |
||||
|
||||
export const useAttributeOptions = (attribute: { _id: string; key: string }): GenericMenuItemProps[] => { |
||||
const { t } = useTranslation(); |
||||
const router = useRouter(); |
||||
const setModal = useSetModal(); |
||||
const queryClient = useQueryClient(); |
||||
const deleteAttribute = useEndpoint('DELETE', '/v1/abac/attributes/:_id', { _id: attribute._id }); |
||||
const isAttributeUsed = useEndpoint('GET', '/v1/abac/attributes/:key/is-in-use', { key: attribute.key }); |
||||
const dispatchToastMessage = useToastMessageDispatch(); |
||||
const isABACAvailable = useIsABACAvailable(); |
||||
|
||||
const editAction = useEffectEvent(() => { |
||||
return router.navigate( |
||||
{ |
||||
name: 'admin-ABAC', |
||||
params: { |
||||
tab: 'room-attributes', |
||||
context: 'edit', |
||||
id: attribute._id, |
||||
}, |
||||
}, |
||||
{ replace: true }, |
||||
); |
||||
}); |
||||
|
||||
const deleteMutation = useMutation({ |
||||
mutationFn: deleteAttribute, |
||||
onSuccess: () => { |
||||
dispatchToastMessage({ type: 'success', message: t('ABAC_Attribute_deleted', { attributeName: attribute.key }) }); |
||||
}, |
||||
onError: (error) => { |
||||
dispatchToastMessage({ type: 'error', message: error }); |
||||
}, |
||||
onSettled: () => { |
||||
queryClient.invalidateQueries({ queryKey: ABACQueryKeys.roomAttributes.all() }); |
||||
setModal(null); |
||||
}, |
||||
}); |
||||
|
||||
const deleteAction = useEffectEvent(async () => { |
||||
const isUsed = await isAttributeUsed(); |
||||
if (isUsed.inUse) { |
||||
return setModal( |
||||
<GenericModal |
||||
variant='warning' |
||||
icon={null} |
||||
title={t('ABAC_Cannot_delete_attribute')} |
||||
confirmText={t('View_rooms')} |
||||
// TODO Route to rooms tab once implemented
|
||||
onConfirm={() => setModal(null)} |
||||
onCancel={() => setModal(null)} |
||||
> |
||||
<Trans |
||||
i18nKey='ABAC_Cannot_delete_attribute_content' |
||||
values={{ attributeName: attribute.key }} |
||||
components={{ bold: <Box is='span' fontWeight='bold' /> }} |
||||
/> |
||||
</GenericModal>, |
||||
); |
||||
} |
||||
setModal( |
||||
<GenericModal |
||||
variant='danger' |
||||
icon={null} |
||||
title={t('ABAC_Delete_room_attribute')} |
||||
confirmText={t('Delete')} |
||||
onConfirm={() => { |
||||
deleteMutation.mutateAsync(undefined); |
||||
}} |
||||
onCancel={() => setModal(null)} |
||||
> |
||||
<Trans |
||||
i18nKey='ABAC_Delete_room_attribute_content' |
||||
values={{ attributeName: attribute.key }} |
||||
components={{ bold: <Box is='span' fontWeight='bold' /> }} |
||||
/> |
||||
</GenericModal>, |
||||
); |
||||
}); |
||||
|
||||
return [ |
||||
{ id: 'edit', icon: 'edit' as const, content: t('Edit'), onClick: () => editAction(), disabled: !isABACAvailable }, |
||||
{ |
||||
id: 'delete', |
||||
iconColor: 'danger', |
||||
icon: 'trash' as const, |
||||
content: <Box color='danger'>{t('Delete')}</Box>, |
||||
onClick: () => deleteAction(), |
||||
}, |
||||
]; |
||||
}; |
||||
@ -0,0 +1,52 @@ |
||||
import { faker } from '@faker-js/faker'; |
||||
import { mockAppRoot } from '@rocket.chat/mock-providers'; |
||||
import { renderHook, waitFor } from '@testing-library/react'; |
||||
|
||||
import { useDeleteRoomModal } from './useDeleteRoomModal'; |
||||
|
||||
const mockSetModal = jest.fn(); |
||||
|
||||
jest.mock('@rocket.chat/ui-contexts', () => { |
||||
const originalModule = jest.requireActual('@rocket.chat/ui-contexts'); |
||||
return { |
||||
...originalModule, |
||||
useSetModal: () => mockSetModal, |
||||
}; |
||||
}); |
||||
|
||||
describe('useDeleteRoomModal', () => { |
||||
beforeEach(() => { |
||||
jest.clearAllMocks(); |
||||
mockSetModal.mockClear(); |
||||
}); |
||||
|
||||
it('should show delete confirmation modal when hook is called', async () => { |
||||
const { result } = renderHook( |
||||
() => |
||||
useDeleteRoomModal({ |
||||
rid: faker.database.mongodbObjectId(), |
||||
name: faker.lorem.words(3), |
||||
}), |
||||
{ |
||||
wrapper: mockAppRoot() |
||||
.withTranslations('en', 'core', { |
||||
Edit: 'Edit', |
||||
Remove: 'Remove', |
||||
ABAC_Room_removed: 'Room {{roomName}} removed from ABAC management', |
||||
ABAC_Delete_room: 'Remove room from ABAC management', |
||||
ABAC_Delete_room_annotation: 'Proceed with caution', |
||||
ABAC_Delete_room_content: |
||||
'Removing <bold>{{roomName}}</bold> from ABAC management may result in unintended users gaining access.', |
||||
Cancel: 'Cancel', |
||||
}) |
||||
.build(), |
||||
}, |
||||
); |
||||
|
||||
result.current(); |
||||
|
||||
await waitFor(() => { |
||||
expect(mockSetModal).toHaveBeenCalled(); |
||||
}); |
||||
}); |
||||
}); |
||||
@ -0,0 +1,11 @@ |
||||
import { useSetModal } from '@rocket.chat/ui-contexts'; |
||||
|
||||
import DeleteRoomModal from '../ABACRoomsTab/DeleteRoomModal'; |
||||
|
||||
export const useDeleteRoomModal = (room: { rid: string; name: string }) => { |
||||
const setModal = useSetModal(); |
||||
|
||||
return () => { |
||||
setModal(<DeleteRoomModal rid={room.rid} roomName={room.name} onClose={() => setModal(null)} />); |
||||
}; |
||||
}; |
||||
@ -0,0 +1,10 @@ |
||||
import { useSetting } from '@rocket.chat/ui-contexts'; |
||||
|
||||
import { useHasLicenseModule } from '../../../../hooks/useHasLicenseModule'; |
||||
|
||||
export const useIsABACAvailable = () => { |
||||
const { data: hasABAC = false } = useHasLicenseModule('abac'); |
||||
const isABACSettingEnabled = useSetting('ABAC_Enabled', false); |
||||
|
||||
return hasABAC && isABACSettingEnabled; |
||||
}; |
||||
@ -0,0 +1,124 @@ |
||||
import { faker } from '@faker-js/faker'; |
||||
import { mockAppRoot } from '@rocket.chat/mock-providers'; |
||||
import { renderHook, waitFor } from '@testing-library/react'; |
||||
|
||||
import { useRoomItems } from './useRoomItems'; |
||||
|
||||
const navigateMock = jest.fn(); |
||||
const setDeleteRoomModalMock = jest.fn(); |
||||
const useIsABACAvailableMock = jest.fn(() => true); |
||||
|
||||
jest.mock('./useIsABACAvailable', () => ({ |
||||
useIsABACAvailable: () => useIsABACAvailableMock(), |
||||
})); |
||||
jest.mock('./useDeleteRoomModal', () => ({ |
||||
useDeleteRoomModal: () => setDeleteRoomModalMock, |
||||
})); |
||||
jest.mock('@rocket.chat/ui-contexts', () => ({ |
||||
...jest.requireActual('@rocket.chat/ui-contexts'), |
||||
useRouter: () => ({ |
||||
navigate: navigateMock, |
||||
}), |
||||
})); |
||||
|
||||
const mockRoom = { |
||||
rid: faker.database.mongodbObjectId(), |
||||
name: 'Test Room', |
||||
}; |
||||
|
||||
const createAppRoot = () => |
||||
mockAppRoot() |
||||
.withTranslations('en', 'core', { |
||||
Edit: 'Edit', |
||||
Remove: 'Remove', |
||||
ABAC_Room_removed: 'Room {{roomName}} removed from ABAC management', |
||||
ABAC_Delete_room: 'Remove room from ABAC management', |
||||
ABAC_Delete_room_annotation: 'Proceed with caution', |
||||
ABAC_Delete_room_content: 'Removing <bold>{{roomName}}</bold> from ABAC management may result in unintended users gaining access.', |
||||
Cancel: 'Cancel', |
||||
}) |
||||
.withEndpoint('DELETE', '/v1/abac/rooms/:rid/attributes', async () => null); |
||||
|
||||
describe('useRoomItems', () => { |
||||
beforeEach(() => { |
||||
jest.clearAllMocks(); |
||||
navigateMock.mockClear(); |
||||
useIsABACAvailableMock.mockReturnValue(true); |
||||
}); |
||||
|
||||
it('should return menu items with correct structure', () => { |
||||
const { result } = renderHook(() => useRoomItems(mockRoom), { |
||||
wrapper: createAppRoot().build(), |
||||
}); |
||||
|
||||
expect(result.current).toHaveLength(2); |
||||
expect(result.current[0]).toMatchObject({ |
||||
id: 'edit', |
||||
icon: 'edit', |
||||
content: 'Edit', |
||||
}); |
||||
expect(result.current[1]).toMatchObject({ |
||||
id: 'delete', |
||||
icon: 'cross', |
||||
iconColor: 'danger', |
||||
}); |
||||
}); |
||||
|
||||
it('should enable edit when ABAC is available', async () => { |
||||
const { result } = renderHook(() => useRoomItems(mockRoom), { |
||||
wrapper: createAppRoot().build(), |
||||
}); |
||||
|
||||
await waitFor(() => { |
||||
expect(result.current[0].disabled).toBe(false); |
||||
}); |
||||
}); |
||||
|
||||
it('should navigate to edit page when edit action is clicked', async () => { |
||||
const { result } = renderHook(() => useRoomItems(mockRoom), { |
||||
wrapper: createAppRoot().build(), |
||||
}); |
||||
|
||||
const editAction = result.current[0].onClick; |
||||
if (editAction) { |
||||
editAction(); |
||||
} |
||||
|
||||
expect(navigateMock).toHaveBeenCalledWith( |
||||
{ |
||||
name: 'admin-ABAC', |
||||
params: { |
||||
tab: 'rooms', |
||||
context: 'edit', |
||||
id: mockRoom.rid, |
||||
}, |
||||
}, |
||||
{ replace: true }, |
||||
); |
||||
}); |
||||
|
||||
it('should disable edit when ABAC is not available', () => { |
||||
useIsABACAvailableMock.mockReturnValue(false); |
||||
|
||||
const { result } = renderHook(() => useRoomItems(mockRoom), { |
||||
wrapper: createAppRoot().build(), |
||||
}); |
||||
|
||||
expect(result.current[0].disabled).toBe(true); |
||||
}); |
||||
|
||||
it('should show delete modal when delete is clicked', async () => { |
||||
const { result } = renderHook(() => useRoomItems(mockRoom), { |
||||
wrapper: createAppRoot().build(), |
||||
}); |
||||
|
||||
const deleteAction = result.current[1].onClick; |
||||
if (deleteAction) { |
||||
deleteAction(); |
||||
} |
||||
|
||||
await waitFor(() => { |
||||
expect(setDeleteRoomModalMock).toHaveBeenCalled(); |
||||
}); |
||||
}); |
||||
}); |
||||
@ -0,0 +1,40 @@ |
||||
import { Box } from '@rocket.chat/fuselage'; |
||||
import { useEffectEvent } from '@rocket.chat/fuselage-hooks'; |
||||
import type { GenericMenuItemProps } from '@rocket.chat/ui-client'; |
||||
import { useRouter } from '@rocket.chat/ui-contexts'; |
||||
import { useTranslation } from 'react-i18next'; |
||||
|
||||
import { useDeleteRoomModal } from './useDeleteRoomModal'; |
||||
import { useIsABACAvailable } from './useIsABACAvailable'; |
||||
|
||||
export const useRoomItems = (room: { rid: string; name: string }): GenericMenuItemProps[] => { |
||||
const { t } = useTranslation(); |
||||
const router = useRouter(); |
||||
const setDeleteRoomModal = useDeleteRoomModal(room); |
||||
const isABACAvailable = useIsABACAvailable(); |
||||
|
||||
const editAction = useEffectEvent(() => { |
||||
return router.navigate( |
||||
{ |
||||
name: 'admin-ABAC', |
||||
params: { |
||||
tab: 'rooms', |
||||
context: 'edit', |
||||
id: room.rid, |
||||
}, |
||||
}, |
||||
{ replace: true }, |
||||
); |
||||
}); |
||||
|
||||
return [ |
||||
{ id: 'edit', icon: 'edit' as const, content: t('Edit'), onClick: () => editAction(), disabled: isABACAvailable !== true }, |
||||
{ |
||||
id: 'delete', |
||||
iconColor: 'danger', |
||||
icon: 'cross' as const, |
||||
content: <Box color='danger'>{t('Remove')}</Box>, |
||||
onClick: setDeleteRoomModal, |
||||
}, |
||||
]; |
||||
}; |
||||
@ -0,0 +1,421 @@ |
||||
import { Abac } from '@rocket.chat/core-services'; |
||||
import type { AbacActor } from '@rocket.chat/core-services'; |
||||
import type { IServerEvents, IUser } from '@rocket.chat/core-typings'; |
||||
import { ServerEvents, Users } from '@rocket.chat/models'; |
||||
import { validateUnauthorizedErrorResponse } from '@rocket.chat/rest-typings/src/v1/Ajv'; |
||||
import { convertSubObjectsIntoPaths } from '@rocket.chat/tools'; |
||||
|
||||
import { |
||||
GenericSuccessSchema, |
||||
PUTAbacAttributeUpdateBodySchema, |
||||
GETAbacAttributesQuerySchema, |
||||
GETAbacAttributesResponseSchema, |
||||
GETAbacAttributeByIdResponseSchema, |
||||
POSTAbacAttributeDefinitionSchema, |
||||
GETAbacAttributeIsInUseResponseSchema, |
||||
POSTRoomAbacAttributesBodySchema, |
||||
POSTSingleRoomAbacAttributeBodySchema, |
||||
PUTRoomAbacAttributeValuesBodySchema, |
||||
POSTAbacUsersSyncBodySchema, |
||||
GenericErrorSchema, |
||||
GETAbacRoomsListQueryValidator, |
||||
GETAbacRoomsResponseValidator, |
||||
GETAbacAuditEventsQuerySchema, |
||||
GETAbacAuditEventsResponseSchema, |
||||
} from './schemas'; |
||||
import { API } from '../../../../app/api/server'; |
||||
import type { ExtractRoutesFromAPI } from '../../../../app/api/server/ApiClass'; |
||||
import { getPaginationItems } from '../../../../app/api/server/helpers/getPaginationItems'; |
||||
import { settings } from '../../../../app/settings/server'; |
||||
import { LDAPEE } from '../../sdk'; |
||||
|
||||
const getActorFromUser = (user?: IUser | null): AbacActor | undefined => |
||||
user?._id |
||||
? { |
||||
_id: user._id, |
||||
username: user.username, |
||||
name: user.name, |
||||
} |
||||
: undefined; |
||||
|
||||
const abacEndpoints = API.v1 |
||||
.post( |
||||
'abac/rooms/:rid/attributes', |
||||
{ |
||||
authRequired: true, |
||||
permissionsRequired: ['abac-management'], |
||||
body: POSTRoomAbacAttributesBodySchema, |
||||
response: { |
||||
200: GenericSuccessSchema, |
||||
401: validateUnauthorizedErrorResponse, |
||||
400: GenericErrorSchema, |
||||
403: validateUnauthorizedErrorResponse, |
||||
}, |
||||
license: ['abac'], |
||||
}, |
||||
async function action() { |
||||
const { rid } = this.urlParams; |
||||
const { attributes } = this.bodyParams; |
||||
|
||||
if (!settings.get('ABAC_Enabled')) { |
||||
throw new Error('error-abac-not-enabled'); |
||||
} |
||||
|
||||
// This is a replace-all operation
|
||||
// IF you need fine grained, use the other endpoints for removing, editing & adding single attributes
|
||||
await Abac.setRoomAbacAttributes(rid, attributes, getActorFromUser(this.user)); |
||||
return API.v1.success(); |
||||
}, |
||||
) |
||||
.delete( |
||||
'abac/rooms/:rid/attributes', |
||||
{ |
||||
authRequired: true, |
||||
permissionsRequired: ['abac-management'], |
||||
response: { |
||||
200: GenericSuccessSchema, |
||||
401: validateUnauthorizedErrorResponse, |
||||
400: GenericErrorSchema, |
||||
403: validateUnauthorizedErrorResponse, |
||||
}, |
||||
}, |
||||
async function action() { |
||||
const { rid } = this.urlParams; |
||||
|
||||
// We don't need to check if ABAC is enabled to clear attributes
|
||||
// Since we're always allowing this operation
|
||||
// license check is also not required
|
||||
await Abac.setRoomAbacAttributes(rid, {}, getActorFromUser(this.user)); |
||||
return API.v1.success(); |
||||
}, |
||||
) |
||||
// add an abac attribute by key
|
||||
.post( |
||||
'abac/rooms/:rid/attributes/:key', |
||||
{ |
||||
authRequired: true, |
||||
permissionsRequired: ['abac-management'], |
||||
license: ['abac'], |
||||
body: POSTSingleRoomAbacAttributeBodySchema, |
||||
response: { |
||||
200: GenericSuccessSchema, |
||||
401: validateUnauthorizedErrorResponse, |
||||
400: GenericErrorSchema, |
||||
403: validateUnauthorizedErrorResponse, |
||||
}, |
||||
}, |
||||
async function action() { |
||||
const { rid, key } = this.urlParams; |
||||
const { values } = this.bodyParams; |
||||
|
||||
if (!settings.get('ABAC_Enabled')) { |
||||
throw new Error('error-abac-not-enabled'); |
||||
} |
||||
|
||||
await Abac.addRoomAbacAttributeByKey(rid, key, values, getActorFromUser(this.user)); |
||||
return API.v1.success(); |
||||
}, |
||||
) |
||||
// edit a room attribute
|
||||
.put( |
||||
'abac/rooms/:rid/attributes/:key', |
||||
{ |
||||
authRequired: true, |
||||
permissionsRequired: ['abac-management'], |
||||
body: PUTRoomAbacAttributeValuesBodySchema, |
||||
response: { |
||||
200: GenericSuccessSchema, |
||||
401: validateUnauthorizedErrorResponse, |
||||
400: GenericErrorSchema, |
||||
403: validateUnauthorizedErrorResponse, |
||||
}, |
||||
license: ['abac'], |
||||
}, |
||||
async function action() { |
||||
const { rid, key } = this.urlParams; |
||||
const { values } = this.bodyParams; |
||||
|
||||
if (!settings.get('ABAC_Enabled')) { |
||||
throw new Error('error-abac-not-enabled'); |
||||
} |
||||
|
||||
await Abac.replaceRoomAbacAttributeByKey(rid, key, values, getActorFromUser(this.user)); |
||||
return API.v1.success(); |
||||
}, |
||||
) |
||||
// delete a room attribute
|
||||
.delete( |
||||
'abac/rooms/:rid/attributes/:key', |
||||
{ |
||||
authRequired: true, |
||||
permissionsRequired: ['abac-management'], |
||||
response: { |
||||
200: GenericSuccessSchema, |
||||
401: validateUnauthorizedErrorResponse, |
||||
400: GenericErrorSchema, |
||||
403: validateUnauthorizedErrorResponse, |
||||
}, |
||||
}, |
||||
async function action() { |
||||
const { rid, key } = this.urlParams; |
||||
|
||||
await Abac.removeRoomAbacAttribute(rid, key, getActorFromUser(this.user)); |
||||
return API.v1.success(); |
||||
}, |
||||
) |
||||
// attribute endpoints
|
||||
// list attributes
|
||||
.get( |
||||
'abac/attributes', |
||||
{ |
||||
authRequired: true, |
||||
permissionsRequired: ['abac-management'], |
||||
query: GETAbacAttributesQuerySchema, |
||||
response: { |
||||
200: GETAbacAttributesResponseSchema, |
||||
401: validateUnauthorizedErrorResponse, |
||||
400: GenericErrorSchema, |
||||
403: validateUnauthorizedErrorResponse, |
||||
}, |
||||
}, |
||||
async function action() { |
||||
const { offset, count } = await getPaginationItems(this.queryParams as Record<string, string | string[] | number | null | undefined>); |
||||
const { key, values } = this.queryParams; |
||||
|
||||
return API.v1.success( |
||||
await Abac.listAbacAttributes( |
||||
{ |
||||
key, |
||||
values, |
||||
offset, |
||||
count, |
||||
}, |
||||
getActorFromUser(this.user), |
||||
), |
||||
); |
||||
}, |
||||
) |
||||
|
||||
.post( |
||||
'abac/users/sync', |
||||
{ |
||||
authRequired: true, |
||||
permissionsRequired: ['abac-management'], |
||||
license: ['abac', 'ldap-enterprise'], |
||||
body: POSTAbacUsersSyncBodySchema, |
||||
response: { |
||||
200: GenericSuccessSchema, |
||||
401: validateUnauthorizedErrorResponse, |
||||
400: GenericErrorSchema, |
||||
403: validateUnauthorizedErrorResponse, |
||||
}, |
||||
}, |
||||
async function action() { |
||||
if (!settings.get('ABAC_Enabled')) { |
||||
throw new Error('error-abac-not-enabled'); |
||||
} |
||||
|
||||
const { usernames, ids, emails, ldapIds } = this.bodyParams; |
||||
|
||||
await LDAPEE.syncUsersAbacAttributes(Users.findUsersByIdentifiers({ usernames, ids, emails, ldapIds })); |
||||
|
||||
return API.v1.success(); |
||||
}, |
||||
) |
||||
.post( |
||||
'abac/attributes', |
||||
{ |
||||
authRequired: true, |
||||
permissionsRequired: ['abac-management'], |
||||
license: ['abac'], |
||||
body: POSTAbacAttributeDefinitionSchema, |
||||
response: { |
||||
200: GenericSuccessSchema, |
||||
401: validateUnauthorizedErrorResponse, |
||||
400: GenericErrorSchema, |
||||
403: validateUnauthorizedErrorResponse, |
||||
}, |
||||
}, |
||||
async function action() { |
||||
if (!settings.get('ABAC_Enabled')) { |
||||
throw new Error('error-abac-not-enabled'); |
||||
} |
||||
|
||||
await Abac.addAbacAttribute(this.bodyParams, getActorFromUser(this.user)); |
||||
return API.v1.success(); |
||||
}, |
||||
) |
||||
// update attribute definition (key and/or values)
|
||||
.put( |
||||
'abac/attributes/:_id', |
||||
{ |
||||
authRequired: true, |
||||
permissionsRequired: ['abac-management'], |
||||
license: ['abac'], |
||||
body: PUTAbacAttributeUpdateBodySchema, |
||||
response: { |
||||
200: GenericSuccessSchema, |
||||
401: validateUnauthorizedErrorResponse, |
||||
400: GenericErrorSchema, |
||||
403: validateUnauthorizedErrorResponse, |
||||
}, |
||||
}, |
||||
async function action() { |
||||
const { _id } = this.urlParams; |
||||
if (!settings.get('ABAC_Enabled')) { |
||||
throw new Error('error-abac-not-enabled'); |
||||
} |
||||
|
||||
await Abac.updateAbacAttributeById(_id, this.bodyParams, getActorFromUser(this.user)); |
||||
return API.v1.success(); |
||||
}, |
||||
) |
||||
// get single attribute with usage
|
||||
.get( |
||||
'abac/attributes/:_id', |
||||
{ |
||||
authRequired: true, |
||||
permissionsRequired: ['abac-management'], |
||||
response: { |
||||
200: GETAbacAttributeByIdResponseSchema, |
||||
401: validateUnauthorizedErrorResponse, |
||||
400: GenericErrorSchema, |
||||
403: validateUnauthorizedErrorResponse, |
||||
}, |
||||
}, |
||||
async function action() { |
||||
const { _id } = this.urlParams; |
||||
const result = await Abac.getAbacAttributeById(_id, getActorFromUser(this.user)); |
||||
return API.v1.success(result); |
||||
}, |
||||
) |
||||
// delete attribute (only if not in use)
|
||||
.delete( |
||||
'abac/attributes/:_id', |
||||
{ |
||||
authRequired: true, |
||||
permissionsRequired: ['abac-management'], |
||||
response: { |
||||
200: GenericSuccessSchema, |
||||
401: validateUnauthorizedErrorResponse, |
||||
400: GenericErrorSchema, |
||||
403: validateUnauthorizedErrorResponse, |
||||
}, |
||||
}, |
||||
async function action() { |
||||
const { _id } = this.urlParams; |
||||
await Abac.deleteAbacAttributeById(_id, getActorFromUser(this.user)); |
||||
return API.v1.success(); |
||||
}, |
||||
) |
||||
// check if attribute is in use
|
||||
.get( |
||||
'abac/attributes/:key/is-in-use', |
||||
{ |
||||
authRequired: true, |
||||
permissionsRequired: ['abac-management'], |
||||
response: { |
||||
200: GETAbacAttributeIsInUseResponseSchema, |
||||
401: validateUnauthorizedErrorResponse, |
||||
400: GenericErrorSchema, |
||||
403: validateUnauthorizedErrorResponse, |
||||
}, |
||||
}, |
||||
async function action() { |
||||
const { key } = this.urlParams; |
||||
const inUse = await Abac.isAbacAttributeInUseByKey(key); |
||||
return API.v1.success({ inUse }); |
||||
}, |
||||
) |
||||
.get( |
||||
'abac/rooms', |
||||
{ |
||||
authRequired: true, |
||||
permissionsRequired: ['abac-management'], |
||||
response: { |
||||
200: GETAbacRoomsResponseValidator, |
||||
401: validateUnauthorizedErrorResponse, |
||||
400: GenericErrorSchema, |
||||
403: validateUnauthorizedErrorResponse, |
||||
}, |
||||
query: GETAbacRoomsListQueryValidator, |
||||
}, |
||||
async function action() { |
||||
const { offset, count } = await getPaginationItems(this.queryParams as Record<string, string | string[] | number | null | undefined>); |
||||
const { filter, filterType } = this.queryParams; |
||||
|
||||
const result = await Abac.listAbacRooms( |
||||
{ |
||||
offset, |
||||
count, |
||||
filter, |
||||
filterType, |
||||
}, |
||||
getActorFromUser(this.user), |
||||
); |
||||
|
||||
return API.v1.success(result); |
||||
}, |
||||
) |
||||
.get( |
||||
'abac/audit', |
||||
{ |
||||
response: { |
||||
200: GETAbacAuditEventsResponseSchema, |
||||
400: GenericErrorSchema, |
||||
401: validateUnauthorizedErrorResponse, |
||||
403: validateUnauthorizedErrorResponse, |
||||
}, |
||||
query: GETAbacAuditEventsQuerySchema, |
||||
authRequired: true, |
||||
permissionsRequired: ['abac-management'], |
||||
license: ['abac', 'auditing'], |
||||
}, |
||||
async function action() { |
||||
const { start, end, actor } = this.queryParams; |
||||
|
||||
const { offset, count } = await getPaginationItems(this.queryParams as Record<string, string | number | null | undefined>); |
||||
const { sort } = await this.parseJsonQuery(); |
||||
const _sort = { ts: sort?.ts ? sort?.ts : -1 }; |
||||
|
||||
const { cursor, totalCount } = ServerEvents.findPaginated( |
||||
{ |
||||
...(actor && convertSubObjectsIntoPaths({ actor })), |
||||
ts: { |
||||
$gte: start ? new Date(start) : new Date(0), |
||||
$lte: end ? new Date(end) : new Date(), |
||||
}, |
||||
t: { |
||||
$in: ['abac.attribute.changed', 'abac.object.attribute.changed', 'abac.object.attributes.removed', 'abac.action.performed'], |
||||
}, |
||||
}, |
||||
{ |
||||
sort: _sort, |
||||
skip: offset, |
||||
limit: count, |
||||
allowDiskUse: true, |
||||
}, |
||||
); |
||||
|
||||
const [events, total] = await Promise.all([cursor.toArray(), totalCount]); |
||||
|
||||
return API.v1.success({ |
||||
events: events as ( |
||||
| IServerEvents['abac.action.performed'] |
||||
| IServerEvents['abac.attribute.changed'] |
||||
| IServerEvents['abac.object.attribute.changed'] |
||||
| IServerEvents['abac.object.attributes.removed'] |
||||
)[], |
||||
count: events.length, |
||||
offset, |
||||
total, |
||||
}); |
||||
}, |
||||
); |
||||
|
||||
export type AbacEndpoints = ExtractRoutesFromAPI<typeof abacEndpoints>; |
||||
|
||||
declare module '@rocket.chat/rest-typings' { |
||||
// eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface
|
||||
interface Endpoints extends AbacEndpoints {} |
||||
} |
||||
@ -0,0 +1,389 @@ |
||||
import type { IAbacAttribute, IAbacAttributeDefinition, IAuditServerActor, IRoom, IServerEvents } from '@rocket.chat/core-typings'; |
||||
import type { PaginatedResult, PaginatedRequest } from '@rocket.chat/rest-typings'; |
||||
import { ajv } from '@rocket.chat/rest-typings'; |
||||
|
||||
const ATTRIBUTE_KEY_PATTERN = '^[A-Za-z0-9_-]+$'; |
||||
const MAX_ROOM_ATTRIBUTE_VALUES = 10; |
||||
const MAX_USERS_SYNC_ITEMS = 100; |
||||
const MAX_ROOM_ATTRIBUTE_KEYS = 10; |
||||
|
||||
const GenericSuccess = { |
||||
type: 'object', |
||||
properties: { |
||||
success: { type: 'boolean', enum: [true] }, |
||||
}, |
||||
additionalProperties: false, |
||||
}; |
||||
|
||||
export const GenericSuccessSchema = ajv.compile<void>(GenericSuccess); |
||||
|
||||
// Update ABAC attribute (request body)
|
||||
const UpdateAbacAttributeBody = { |
||||
type: 'object', |
||||
properties: { |
||||
key: { type: 'string', minLength: 1, pattern: ATTRIBUTE_KEY_PATTERN }, |
||||
values: { |
||||
type: 'array', |
||||
items: { type: 'string', minLength: 1, pattern: ATTRIBUTE_KEY_PATTERN }, |
||||
minItems: 1, |
||||
uniqueItems: true, |
||||
}, |
||||
}, |
||||
additionalProperties: false, |
||||
anyOf: [{ required: ['key'] }, { required: ['values'] }], |
||||
}; |
||||
|
||||
export const PUTAbacAttributeUpdateBodySchema = ajv.compile<IAbacAttributeDefinition>(UpdateAbacAttributeBody); |
||||
|
||||
const AbacAttributeDefinition = { |
||||
type: 'object', |
||||
|
||||
properties: { |
||||
key: { type: 'string', minLength: 1, pattern: ATTRIBUTE_KEY_PATTERN }, |
||||
values: { |
||||
type: 'array', |
||||
items: { type: 'string', minLength: 1, pattern: ATTRIBUTE_KEY_PATTERN }, |
||||
minItems: 1, |
||||
uniqueItems: true, |
||||
}, |
||||
}, |
||||
required: ['key', 'values'], |
||||
additionalProperties: false, |
||||
}; |
||||
|
||||
export const POSTAbacAttributeDefinitionSchema = ajv.compile<IAbacAttributeDefinition>(AbacAttributeDefinition); |
||||
|
||||
const GetAbacAttributesQuery = { |
||||
type: 'object', |
||||
properties: { |
||||
key: { type: 'string', minLength: 1, pattern: ATTRIBUTE_KEY_PATTERN }, |
||||
values: { type: 'string', minLength: 1, pattern: ATTRIBUTE_KEY_PATTERN }, |
||||
offset: { type: 'number' }, |
||||
count: { type: 'number' }, |
||||
}, |
||||
additionalProperties: false, |
||||
}; |
||||
|
||||
export const GETAbacAttributesQuerySchema = ajv.compile<{ key?: string; values?: string; offset: number; count?: number }>( |
||||
GetAbacAttributesQuery, |
||||
); |
||||
|
||||
const AbacAttributeRecord = { |
||||
type: 'object', |
||||
properties: { |
||||
_id: { type: 'string', minLength: 1 }, |
||||
key: { type: 'string', minLength: 1, pattern: ATTRIBUTE_KEY_PATTERN }, |
||||
values: { |
||||
type: 'array', |
||||
items: { type: 'string', minLength: 1, pattern: ATTRIBUTE_KEY_PATTERN }, |
||||
minItems: 1, |
||||
uniqueItems: true, |
||||
}, |
||||
}, |
||||
required: ['_id', 'key', 'values'], |
||||
additionalProperties: false, |
||||
}; |
||||
|
||||
const GetAbacAttributesResponse = { |
||||
type: 'object', |
||||
properties: { |
||||
success: { type: 'boolean', enum: [true] }, |
||||
attributes: { |
||||
type: 'array', |
||||
items: AbacAttributeRecord, |
||||
}, |
||||
offset: { type: 'number' }, |
||||
count: { type: 'number' }, |
||||
total: { type: 'number' }, |
||||
}, |
||||
required: ['attributes', 'offset', 'count', 'total'], |
||||
additionalProperties: false, |
||||
}; |
||||
|
||||
export const GETAbacAttributesResponseSchema = ajv.compile<{ |
||||
attributes: IAbacAttribute[]; |
||||
offset: number; |
||||
count: number; |
||||
total: number; |
||||
}>(GetAbacAttributesResponse); |
||||
|
||||
const GetAbacAttributeByIdResponse = { |
||||
type: 'object', |
||||
properties: { |
||||
success: { type: 'boolean', enum: [true] }, |
||||
_id: { type: 'string', minLength: 1 }, |
||||
key: { type: 'string', minLength: 1, pattern: ATTRIBUTE_KEY_PATTERN }, |
||||
values: { |
||||
type: 'array', |
||||
items: { type: 'string', minLength: 1, pattern: ATTRIBUTE_KEY_PATTERN }, |
||||
minItems: 1, |
||||
uniqueItems: true, |
||||
}, |
||||
}, |
||||
required: ['key', 'values'], |
||||
additionalProperties: false, |
||||
}; |
||||
|
||||
export const GETAbacAttributeByIdResponseSchema = ajv.compile<{ |
||||
key: string; |
||||
values: string[]; |
||||
}>(GetAbacAttributeByIdResponse); |
||||
|
||||
const GetAbacAttributeIsInUseResponse = { |
||||
type: 'object', |
||||
properties: { |
||||
success: { type: 'boolean', enum: [true] }, |
||||
inUse: { type: 'boolean' }, |
||||
}, |
||||
required: ['inUse'], |
||||
additionalProperties: false, |
||||
}; |
||||
|
||||
export const GETAbacAttributeIsInUseResponseSchema = ajv.compile<{ inUse: boolean }>(GetAbacAttributeIsInUseResponse); |
||||
|
||||
const GetAbacAuditEventsQuerySchemaObject = { |
||||
type: 'object', |
||||
properties: { |
||||
start: { type: 'string', format: 'date-time', nullable: true }, |
||||
end: { type: 'string', format: 'date-time', nullable: true }, |
||||
offset: { type: 'number', nullable: true }, |
||||
count: { type: 'number', nullable: true }, |
||||
actor: { |
||||
type: 'object', |
||||
nullable: true, |
||||
properties: { |
||||
type: { |
||||
type: 'string', |
||||
nullable: true, |
||||
}, |
||||
_id: { |
||||
type: 'string', |
||||
nullable: true, |
||||
}, |
||||
username: { |
||||
type: 'string', |
||||
nullable: true, |
||||
}, |
||||
ip: { |
||||
type: 'string', |
||||
nullable: true, |
||||
}, |
||||
useragent: { |
||||
type: 'string', |
||||
nullable: true, |
||||
}, |
||||
reason: { |
||||
type: 'string', |
||||
nullable: true, |
||||
}, |
||||
}, |
||||
}, |
||||
}, |
||||
additionalProperties: false, |
||||
}; |
||||
|
||||
export const GETAbacAuditEventsQuerySchema = ajv.compile< |
||||
PaginatedRequest<{ |
||||
start?: string; |
||||
end?: string; |
||||
actor?: IAuditServerActor; |
||||
}> |
||||
>(GetAbacAuditEventsQuerySchemaObject); |
||||
|
||||
const GetAbacAuditEventsResponseSchemaObject = { |
||||
type: 'object', |
||||
properties: { |
||||
success: { type: 'boolean', enum: [true] }, |
||||
events: { |
||||
type: 'array', |
||||
items: { |
||||
type: 'object', |
||||
}, |
||||
}, |
||||
count: { |
||||
type: 'number', |
||||
description: 'The number of events returned in this response.', |
||||
}, |
||||
offset: { |
||||
type: 'number', |
||||
description: 'The number of events that were skipped in this response.', |
||||
}, |
||||
total: { |
||||
type: 'number', |
||||
description: 'The total number of events that match the query.', |
||||
}, |
||||
}, |
||||
required: ['events', 'count', 'offset', 'total'], |
||||
additionalProperties: false, |
||||
}; |
||||
|
||||
export const GETAbacAuditEventsResponseSchema = ajv.compile<{ |
||||
events: ( |
||||
| IServerEvents['abac.action.performed'] |
||||
| IServerEvents['abac.attribute.changed'] |
||||
| IServerEvents['abac.object.attribute.changed'] |
||||
| IServerEvents['abac.object.attributes.removed'] |
||||
)[]; |
||||
count: number; |
||||
offset: number; |
||||
total: number; |
||||
}>(GetAbacAuditEventsResponseSchemaObject); |
||||
|
||||
const PostRoomAbacAttributesBody = { |
||||
type: 'object', |
||||
properties: { |
||||
attributes: { |
||||
type: 'object', |
||||
propertyNames: { type: 'string', pattern: ATTRIBUTE_KEY_PATTERN }, |
||||
minProperties: 1, |
||||
maxProperties: MAX_ROOM_ATTRIBUTE_KEYS, |
||||
additionalProperties: { |
||||
type: 'array', |
||||
items: { type: 'string', minLength: 1, pattern: ATTRIBUTE_KEY_PATTERN }, |
||||
maxItems: MAX_ROOM_ATTRIBUTE_VALUES, |
||||
uniqueItems: true, |
||||
}, |
||||
}, |
||||
}, |
||||
required: ['attributes'], |
||||
additionalProperties: false, |
||||
}; |
||||
|
||||
export const POSTRoomAbacAttributesBodySchema = ajv.compile<{ attributes: Record<string, string[]> }>(PostRoomAbacAttributesBody); |
||||
|
||||
const PostSingleRoomAbacAttributeBody = { |
||||
type: 'object', |
||||
properties: { |
||||
values: { |
||||
type: 'array', |
||||
items: { type: 'string', minLength: 1, pattern: ATTRIBUTE_KEY_PATTERN }, |
||||
minItems: 1, |
||||
maxItems: MAX_ROOM_ATTRIBUTE_VALUES, |
||||
uniqueItems: true, |
||||
}, |
||||
}, |
||||
required: ['values'], |
||||
additionalProperties: false, |
||||
}; |
||||
|
||||
export const POSTSingleRoomAbacAttributeBodySchema = ajv.compile<{ values: string[] }>(PostSingleRoomAbacAttributeBody); |
||||
|
||||
const PutRoomAbacAttributeValuesBody = { |
||||
type: 'object', |
||||
properties: { |
||||
values: { |
||||
type: 'array', |
||||
items: { type: 'string', minLength: 1, pattern: ATTRIBUTE_KEY_PATTERN }, |
||||
minItems: 1, |
||||
maxItems: MAX_ROOM_ATTRIBUTE_VALUES, |
||||
uniqueItems: true, |
||||
}, |
||||
}, |
||||
required: ['values'], |
||||
additionalProperties: false, |
||||
}; |
||||
|
||||
export const PUTRoomAbacAttributeValuesBodySchema = ajv.compile<{ values: string[] }>(PutRoomAbacAttributeValuesBody); |
||||
|
||||
const GenericError = { |
||||
type: 'object', |
||||
properties: { |
||||
success: { |
||||
type: 'boolean', |
||||
}, |
||||
message: { |
||||
type: 'string', |
||||
}, |
||||
}, |
||||
}; |
||||
|
||||
const PostAbacUsersSyncBody = { |
||||
type: 'object', |
||||
properties: { |
||||
usernames: { |
||||
type: 'array', |
||||
items: { type: 'string', minLength: 1 }, |
||||
minItems: 1, |
||||
maxItems: MAX_USERS_SYNC_ITEMS, |
||||
uniqueItems: true, |
||||
}, |
||||
ids: { |
||||
type: 'array', |
||||
items: { type: 'string', minLength: 1 }, |
||||
minItems: 1, |
||||
maxItems: MAX_USERS_SYNC_ITEMS, |
||||
uniqueItems: true, |
||||
}, |
||||
emails: { |
||||
type: 'array', |
||||
items: { type: 'string', minLength: 1 }, |
||||
minItems: 1, |
||||
maxItems: MAX_USERS_SYNC_ITEMS, |
||||
uniqueItems: true, |
||||
}, |
||||
ldapIds: { |
||||
type: 'array', |
||||
items: { type: 'string', minLength: 1 }, |
||||
minItems: 1, |
||||
maxItems: MAX_USERS_SYNC_ITEMS, |
||||
uniqueItems: true, |
||||
}, |
||||
}, |
||||
additionalProperties: false, |
||||
anyOf: [{ required: ['usernames'] }, { required: ['ids'] }, { required: ['emails'] }, { required: ['ldapIds'] }], |
||||
}; |
||||
|
||||
export const POSTAbacUsersSyncBodySchema = ajv.compile<{ |
||||
usernames?: string[]; |
||||
ids?: string[]; |
||||
emails?: string[]; |
||||
ldapIds?: string[]; |
||||
}>(PostAbacUsersSyncBody); |
||||
|
||||
export const GenericErrorSchema = ajv.compile<{ success: boolean; message: string }>(GenericError); |
||||
|
||||
const GETAbacRoomsListQuerySchema = { |
||||
type: 'object', |
||||
properties: { |
||||
filter: { type: 'string', minLength: 1 }, |
||||
filterType: { type: 'string', enum: ['all', 'roomName', 'attribute', 'value'] }, |
||||
offset: { type: 'number' }, |
||||
count: { type: 'number' }, |
||||
}, |
||||
additionalProperties: false, |
||||
}; |
||||
|
||||
type GETAbacRoomsListQuery = PaginatedRequest<{ filter?: string; filterType?: 'all' | 'roomName' | 'attribute' | 'value' }>; |
||||
|
||||
export const GETAbacRoomsListQueryValidator = ajv.compile<GETAbacRoomsListQuery>(GETAbacRoomsListQuerySchema); |
||||
|
||||
export const GETAbacRoomsResponseSchema = { |
||||
type: 'object', |
||||
properties: { |
||||
success: { |
||||
type: 'boolean', |
||||
enum: [true], |
||||
}, |
||||
rooms: { |
||||
type: 'array', |
||||
items: { type: 'object' }, |
||||
}, |
||||
offset: { |
||||
type: 'number', |
||||
}, |
||||
count: { |
||||
type: 'number', |
||||
}, |
||||
total: { |
||||
type: 'number', |
||||
}, |
||||
}, |
||||
required: ['rooms', 'offset', 'count', 'total'], |
||||
additionalProperties: false, |
||||
}; |
||||
|
||||
type GETAbacRoomsResponse = PaginatedResult<{ |
||||
rooms: IRoom[]; |
||||
}>; |
||||
|
||||
export const GETAbacRoomsResponseValidator = ajv.compile<GETAbacRoomsResponse>(GETAbacRoomsResponseSchema); |
||||
@ -0,0 +1,29 @@ |
||||
import { License } from '@rocket.chat/license'; |
||||
import { Users } from '@rocket.chat/models'; |
||||
|
||||
import { settings } from '../../../app/settings/server'; |
||||
import { LDAPEE } from '../sdk'; |
||||
|
||||
Meteor.startup(async () => { |
||||
let stopWatcher: () => void; |
||||
License.onToggledFeature('abac', { |
||||
up: async () => { |
||||
const { addSettings } = await import('../settings/abac'); |
||||
const { createPermissions } = await import('../lib/abac'); |
||||
|
||||
await addSettings(); |
||||
await createPermissions(); |
||||
|
||||
await import('../hooks/abac'); |
||||
|
||||
stopWatcher = settings.watch('ABAC_Enabled', async (value) => { |
||||
if (value) { |
||||
await LDAPEE.syncUsersAbacAttributes(Users.findLDAPUsers()); |
||||
} |
||||
}); |
||||
}, |
||||
down: () => { |
||||
stopWatcher?.(); |
||||
}, |
||||
}); |
||||
}); |
||||
@ -0,0 +1,22 @@ |
||||
import { Abac } from '@rocket.chat/core-services'; |
||||
import { License } from '@rocket.chat/license'; |
||||
|
||||
import { beforeAddUserToRoom } from '../../../../app/lib/server/lib/beforeAddUserToRoom'; |
||||
import { settings } from '../../../../app/settings/server'; |
||||
|
||||
beforeAddUserToRoom.patch(async (prev, users, room, actor) => { |
||||
await prev(users, room, actor); |
||||
|
||||
const validUsers = users.filter(Boolean); |
||||
// No need to check ABAC when theres no users or when room is not private or when room is not ABAC managed
|
||||
if (!validUsers.length || room.t !== 'p' || !room?.abacAttributes?.length) { |
||||
return; |
||||
} |
||||
|
||||
// Throw error (prevent add) if ABAC is disabled (setting, license) but room is ABAC managed
|
||||
if (!settings.get('ABAC_Enabled') || !License.hasModule('abac')) { |
||||
throw new Error('error-room-is-abac-managed'); |
||||
} |
||||
|
||||
await Abac.checkUsernamesMatchAttributes(validUsers as string[], room.abacAttributes, room); |
||||
}); |
||||
@ -0,0 +1 @@ |
||||
import './beforeAddUserToRoom'; |
||||
@ -0,0 +1,9 @@ |
||||
import { Permissions } from '@rocket.chat/models'; |
||||
|
||||
export const createPermissions = async () => { |
||||
const permissions = [{ _id: 'abac-management', roles: ['admin'] }]; |
||||
|
||||
for (const permission of permissions) { |
||||
void Permissions.create(permission._id, permission.roles); |
||||
} |
||||
}; |
||||
@ -1,5 +1,10 @@ |
||||
import type { IUser } from '@rocket.chat/core-typings'; |
||||
import type { FindCursor } from 'mongodb'; |
||||
|
||||
export interface ILDAPEEService { |
||||
sync(): Promise<void>; |
||||
syncAvatars(): Promise<void>; |
||||
syncLogout(): Promise<void>; |
||||
syncAbacAttributes(): Promise<void>; |
||||
syncUsersAbacAttributes(users: FindCursor<IUser>): Promise<void>; |
||||
} |
||||
|
||||
@ -0,0 +1,35 @@ |
||||
import { settingsRegistry } from '../../../app/settings/server'; |
||||
|
||||
export function addSettings(): Promise<void> { |
||||
return settingsRegistry.addGroup('General', async function () { |
||||
await this.with( |
||||
{ |
||||
enterprise: true, |
||||
modules: ['abac'], |
||||
}, |
||||
async function () { |
||||
await this.add('ABAC_Enabled', false, { |
||||
type: 'boolean', |
||||
public: true, |
||||
invalidValue: false, |
||||
section: 'ABAC', |
||||
i18nDescription: 'ABAC_Enabled_Description', |
||||
}); |
||||
await this.add('ABAC_ShowAttributesInRooms', false, { |
||||
type: 'boolean', |
||||
public: true, |
||||
invalidValue: false, |
||||
section: 'ABAC', |
||||
enableQuery: { _id: 'ABAC_Enabled', value: true }, |
||||
}); |
||||
await this.add('Abac_Cache_Decision_Time_Seconds', 300, { |
||||
type: 'int', |
||||
public: true, |
||||
section: 'ABAC', |
||||
invalidValue: 0, |
||||
enableQuery: { _id: 'ABAC_Enabled', value: true }, |
||||
}); |
||||
}, |
||||
); |
||||
}); |
||||
} |
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in new issue