diff --git a/packages/core/admin/admin/src/content-manager/components/AttributeFilter/AdminUsersFilter.js b/packages/core/admin/admin/src/content-manager/components/AttributeFilter/AdminUsersFilter.js new file mode 100644 index 0000000000..d2fefb42e5 --- /dev/null +++ b/packages/core/admin/admin/src/content-manager/components/AttributeFilter/AdminUsersFilter.js @@ -0,0 +1,44 @@ +import React from 'react'; + +import { Combobox, ComboboxOption } from '@strapi/design-system'; +import PropTypes from 'prop-types'; +import { useIntl } from 'react-intl'; + +import { useAdminUsers } from '../../../hooks/useAdminUsers'; +import { getDisplayName } from '../../utils'; + +const AdminUsersFilter = ({ value, onChange }) => { + const { formatMessage } = useIntl(); + const { users, isLoading } = useAdminUsers({}); + + return ( + + {users.map((user) => { + return ( + + {getDisplayName(user, formatMessage)} + + ); + })} + + ); +}; + +AdminUsersFilter.propTypes = { + onChange: PropTypes.func.isRequired, + value: PropTypes.string, +}; + +AdminUsersFilter.defaultProps = { + value: '', +}; + +export { AdminUsersFilter }; diff --git a/packages/core/admin/admin/src/content-manager/components/AttributeFilter/hooks/useAllowedAttributes.js b/packages/core/admin/admin/src/content-manager/components/AttributeFilter/hooks/useAllowedAttributes.js index 05068e0460..bdd3d5e499 100644 --- a/packages/core/admin/admin/src/content-manager/components/AttributeFilter/hooks/useAllowedAttributes.js +++ b/packages/core/admin/admin/src/content-manager/components/AttributeFilter/hooks/useAllowedAttributes.js @@ -1,11 +1,17 @@ -import { findMatchingPermissions, useRBACProvider } from '@strapi/helper-plugin'; -import get from 'lodash/get'; +import { findMatchingPermissions, useRBACProvider, useCollator } from '@strapi/helper-plugin'; +import { useIntl } from 'react-intl'; const NOT_ALLOWED_FILTERS = ['json', 'component', 'media', 'richtext', 'dynamiczone', 'password']; const TIMESTAMPS = ['createdAt', 'updatedAt']; +const CREATOR_ATTRIBUTES = ['createdBy', 'updatedBy']; const useAllowedAttributes = (contentType, slug) => { const { allPermissions } = useRBACProvider(); + const { locale } = useIntl(); + + const formatter = useCollator(locale, { + sensitivity: 'base', + }); const readPermissionsForSlug = findMatchingPermissions(allPermissions, [ { @@ -14,29 +20,37 @@ const useAllowedAttributes = (contentType, slug) => { }, ]); - const readPermissionForAttr = get(readPermissionsForSlug, ['0', 'properties', 'fields'], []); - const attributesArray = Object.keys(get(contentType, ['attributes']), {}); - const allowedAttributes = attributesArray - .filter((attr) => { - const current = get(contentType, ['attributes', attr], {}); + const canReadAdminUsers = + findMatchingPermissions(allPermissions, [ + { + action: 'admin::users.read', + subject: null, + }, + ]).length > 0; - if (!current.type) { - return false; - } + const attributesWithReadPermissions = readPermissionsForSlug?.[0]?.properties?.fields ?? []; - if (NOT_ALLOWED_FILTERS.includes(current.type)) { - return false; - } + const allowedAttributes = attributesWithReadPermissions.filter((attr) => { + const current = contentType?.attributes?.[attr] ?? {}; - if (!readPermissionForAttr.includes(attr) && attr !== 'id' && !TIMESTAMPS.includes(attr)) { - return false; - } + if (!current.type) { + return false; + } - return true; - }) - .sort(); + if (NOT_ALLOWED_FILTERS.includes(current.type)) { + return false; + } - return allowedAttributes; + return true; + }); + const allowedAndDefaultAttributes = [ + 'id', + ...allowedAttributes, + ...TIMESTAMPS, + ...(canReadAdminUsers ? CREATOR_ATTRIBUTES : []), + ]; + + return allowedAndDefaultAttributes.sort((a, b) => formatter.compare(a, b)); }; export default useAllowedAttributes; diff --git a/packages/core/admin/admin/src/content-manager/components/AttributeFilter/index.js b/packages/core/admin/admin/src/content-manager/components/AttributeFilter/index.js index b34e5a1d33..54bd961ebe 100644 --- a/packages/core/admin/admin/src/content-manager/components/AttributeFilter/index.js +++ b/packages/core/admin/admin/src/content-manager/components/AttributeFilter/index.js @@ -1,13 +1,41 @@ import React from 'react'; +import { useQueryParams } from '@strapi/helper-plugin'; import PropTypes from 'prop-types'; import { useIntl } from 'react-intl'; +import { useAdminUsers } from '../../../hooks/useAdminUsers'; +import { getDisplayName } from '../../utils'; + +import { AdminUsersFilter } from './AdminUsersFilter'; import Filters from './Filters'; import useAllowedAttributes from './hooks/useAllowedAttributes'; +const CREATOR_ATTRIBUTES = ['createdBy', 'updatedBy']; + const AttributeFilter = ({ contentType, slug, metadatas }) => { const { formatMessage } = useIntl(); + + const [{ query }] = useQueryParams(); + // We get the users selected' ids + const selectedUsers = + query?.filters?.$and?.reduce((acc, filter) => { + const [key, value] = Object.entries(filter)[0]; + const id = value.id?.$eq || value.id?.$ne; + + if (CREATOR_ATTRIBUTES.includes(key) && !acc.includes(id)) { + acc.push(id); + } + + return acc; + }, []) ?? []; + const { users, isLoading } = useAdminUsers( + { filter: { id: { in: selectedUsers } } }, + { + enabled: selectedUsers.length > 0, + } + ); + const allowedAttributes = useAllowedAttributes(contentType, slug); const displayedFilters = allowedAttributes.map((name) => { const attribute = contentType.attributes[name]; @@ -20,14 +48,47 @@ const AttributeFilter = ({ contentType, slug, metadatas }) => { const { mainField, label } = metadatas[name].list; - return { + const filter = { name, metadatas: { label: formatMessage({ id: label, defaultMessage: label }) }, fieldSchema: { type, options, mainField }, trackedEvent, }; + + if (attribute.type === 'relation' && attribute.target === 'admin::user') { + filter.metadatas = { + ...filter.metadatas, + customOperators: [ + { + intlLabel: { id: 'components.FilterOptions.FILTER_TYPES.$eq', defaultMessage: 'is' }, + value: '$eq', + }, + { + intlLabel: { + id: 'components.FilterOptions.FILTER_TYPES.$ne', + defaultMessage: 'is not', + }, + value: '$ne', + }, + ], + customInput: AdminUsersFilter, + options: users.map((user) => ({ + label: getDisplayName(user, formatMessage), + customValue: user.id.toString(), + })), + }; + filter.fieldSchema.mainField = { + name: 'id', + }; + } + + return filter; }); + if (isLoading) { + return null; + } + return ; }; diff --git a/packages/core/admin/admin/src/content-manager/components/AttributeFilter/tests/AdminUsersFilter.test.js b/packages/core/admin/admin/src/content-manager/components/AttributeFilter/tests/AdminUsersFilter.test.js new file mode 100644 index 0000000000..968b1ea414 --- /dev/null +++ b/packages/core/admin/admin/src/content-manager/components/AttributeFilter/tests/AdminUsersFilter.test.js @@ -0,0 +1,84 @@ +import React from 'react'; + +import { ThemeProvider, lightTheme } from '@strapi/design-system'; +import { render, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { rest } from 'msw'; +import { setupServer } from 'msw/node'; +import { IntlProvider } from 'react-intl'; +import { QueryClientProvider, QueryClient } from 'react-query'; + +import { AdminUsersFilter } from '../AdminUsersFilter'; + +const server = setupServer( + rest.get('*/admin/users', (req, res, ctx) => { + const mockUsers = [ + { id: 1, firstname: 'John', lastname: 'Doe' }, + { id: 2, firstname: 'Kai', lastname: 'Doe' }, + ]; + + return res( + ctx.json({ + data: { + results: mockUsers, + }, + }) + ); + }) +); + +const queryClient = new QueryClient(); + +const setup = (props) => { + return { + ...render(, { + wrapper: ({ children }) => ( + + + + {children} + + + + ), + }), + user: userEvent.setup(), + }; +}; + +describe('AdminUsersFilter', () => { + beforeAll(() => { + server.listen(); + }); + + afterAll(() => { + server.close(); + }); + + it('should render all the options fetched from the API', async () => { + const mockOnChange = jest.fn(); + const { getByText, user, getByRole } = setup({ onChange: mockOnChange }); + + await user.click(getByRole('combobox')); + + await waitFor(() => { + expect(getByText('John Doe')).toBeInTheDocument(); + expect(getByText('Kai Doe')).toBeInTheDocument(); + }); + }); + + it('should call the onChange function with the selected value', async () => { + const mockOnChange = jest.fn(); + const { getByText, user, getByRole } = setup({ onChange: mockOnChange }); + + await user.click(getByRole('combobox')); + + await waitFor(() => expect(getByText('John Doe')).toBeInTheDocument()); + + const option = getByText('John Doe'); + + await user.click(option); + + expect(mockOnChange).toHaveBeenCalledWith('1'); + }); +}); diff --git a/packages/core/admin/admin/src/content-manager/components/EditViewDataManagerProvider/reducer.js b/packages/core/admin/admin/src/content-manager/components/EditViewDataManagerProvider/reducer.js index a5dcf136d7..342d2f352e 100644 --- a/packages/core/admin/admin/src/content-manager/components/EditViewDataManagerProvider/reducer.js +++ b/packages/core/admin/admin/src/content-manager/components/EditViewDataManagerProvider/reducer.js @@ -239,7 +239,14 @@ const reducer = (state, action) => (value) => { return value.type === 'relation'; }, - (_, { path }) => { + (value, { path }) => { + const relationFieldName = path[path.length - 1]; + + // When editing, we don't want to fetch the relations with creator fields because we already have it + if (value && (relationFieldName === 'createdBy' || relationFieldName === 'updatedBy')) { + return value; + } + if (state.modifiedData?.id === data.id && get(state.modifiedData, path)) { return get(state.modifiedData, path); } diff --git a/packages/core/admin/admin/src/content-manager/pages/EditView/Information/index.js b/packages/core/admin/admin/src/content-manager/pages/EditView/Information/index.js index 91bb36ffbd..cf99c8bf8e 100644 --- a/packages/core/admin/admin/src/content-manager/pages/EditView/Information/index.js +++ b/packages/core/admin/admin/src/content-manager/pages/EditView/Information/index.js @@ -5,8 +5,7 @@ import { useCMEditViewDataManager } from '@strapi/helper-plugin'; import PropTypes from 'prop-types'; import { useIntl } from 'react-intl'; -import { getFullName } from '../../../../utils'; -import { getTrad } from '../../../utils'; +import { getTrad, getDisplayName } from '../../../utils'; import getUnits from './utils/getUnits'; @@ -42,9 +41,13 @@ const KeyValuePair = ({ label, value }) => { ); }; +KeyValuePair.defaultProps = { + value: '-', +}; + KeyValuePair.propTypes = { label: PropTypes.string.isRequired, - value: PropTypes.string.isRequired, + value: PropTypes.string, }; const Body = () => { @@ -53,18 +56,16 @@ const Body = () => { const currentTime = useRef(Date.now()); const getFieldInfo = (atField, byField) => { - const { firstname, lastname, username } = initialData[byField] ?? {}; + const user = initialData[byField] ?? {}; - const userFirstname = firstname ?? ''; - const userLastname = lastname ?? ''; - const user = username ?? getFullName(userFirstname, userLastname); + const displayName = getDisplayName(user, formatMessage); const timestamp = initialData[atField] ? new Date(initialData[atField]).getTime() : Date.now(); const elapsed = timestamp - currentTime.current; const { unit, value } = getUnits(-elapsed); return { at: formatRelativeTime(value, unit, { numeric: 'auto' }), - by: isCreatingEntry ? '-' : user, + by: isCreatingEntry ? '-' : displayName, }; }; diff --git a/packages/core/admin/admin/src/content-manager/pages/ListView/index.js b/packages/core/admin/admin/src/content-manager/pages/ListView/index.js index 64de5b6c1a..a3b3a3b738 100644 --- a/packages/core/admin/admin/src/content-manager/pages/ListView/index.js +++ b/packages/core/admin/admin/src/content-manager/pages/ListView/index.js @@ -50,7 +50,7 @@ import { useEnterprise } from '../../../hooks/useEnterprise'; import { selectAdminPermissions } from '../../../pages/App/selectors'; import { InjectionZone } from '../../../shared/components'; import AttributeFilter from '../../components/AttributeFilter'; -import { getTrad } from '../../utils'; +import { getTrad, getDisplayName } from '../../utils'; import { getData, getDataSucceeded, onChangeListHeaders, onResetListHeaders } from './actions'; import { Body } from './components/Body'; @@ -648,6 +648,17 @@ function ListView({ ); } + if (['createdBy', 'updatedBy'].includes(name.split('.')[0])) { + // Display the users full name + return ( + + + {getDisplayName(rowData[name.split('.')[0]], formatMessage)} + + + ); + } + if (typeof cellFormatter === 'function') { return ( {cellFormatter(rowData, { key, name, ...rest })} diff --git a/packages/core/admin/admin/src/content-manager/utils/getDisplayName.js b/packages/core/admin/admin/src/content-manager/utils/getDisplayName.js new file mode 100644 index 0000000000..b3dcd1e480 --- /dev/null +++ b/packages/core/admin/admin/src/content-manager/utils/getDisplayName.js @@ -0,0 +1,33 @@ +/** + * Retrieves the display name of an admin panel user + * @typedef AdminUserNamesAttributes + * @property {string} firstname + * @property {string} lastname + * @property {string} username + * @property {string} email + * + * @type {(user: AdminUserNamesAttributes, formatMessage: import('react-intl').formatMessage) => string} + */ +const getDisplayName = ({ firstname, lastname, username, email }, formatMessage) => { + if (username) { + return username; + } + + // firstname is not required if the user is created with a username + if (firstname) { + return formatMessage( + { + id: 'global.fullname', + defaultMessage: '{firstname} {lastname}', + }, + { + firstname, + lastname, + } + ).trim(); + } + + return email; +}; + +export { getDisplayName }; diff --git a/packages/core/admin/admin/src/content-manager/utils/index.js b/packages/core/admin/admin/src/content-manager/utils/index.js index a30e3a6fb9..49f4d04b01 100644 --- a/packages/core/admin/admin/src/content-manager/utils/index.js +++ b/packages/core/admin/admin/src/content-manager/utils/index.js @@ -12,3 +12,4 @@ export { default as mergeMetasWithSchema } from './mergeMetasWithSchema'; export { default as removeKeyInObject } from './removeKeyInObject'; export { default as removePasswordFieldsFromData } from './removePasswordFieldsFromData'; export { default as createYupSchema } from './schema'; +export { getDisplayName } from './getDisplayName'; diff --git a/packages/core/admin/admin/src/translations/en.json b/packages/core/admin/admin/src/translations/en.json index 3edb2e26db..0cb21b8d4c 100644 --- a/packages/core/admin/admin/src/translations/en.json +++ b/packages/core/admin/admin/src/translations/en.json @@ -671,6 +671,7 @@ "content-manager.components.FiltersPickWrapper.PluginHeader.description": "Set the conditions to apply to filter the entries", "content-manager.components.FiltersPickWrapper.PluginHeader.title.filter": "Filters", "content-manager.components.FiltersPickWrapper.hide": "Hide", + "content-manager.components.Filters.usersSelect.label": "Search and select a user to filter by", "content-manager.components.LeftMenu.Search.label": "Search for a content type", "content-manager.components.LeftMenu.collection-types": "Collection Types", "content-manager.components.LeftMenu.single-types": "Single Types", @@ -914,6 +915,7 @@ "global.settings": "Settings", "global.type": "Type", "global.users": "Users", + "global.fullname": "{firstname} {lastname}", "light": "Light", "notification.contentType.relations.conflict": "Content type has conflicting relations", "notification.default.title": "Information:", diff --git a/packages/core/admin/server/services/__tests__/permissions-manager-sanitize.test.js b/packages/core/admin/server/services/__tests__/permissions-manager-sanitize.test.js index 9c6c70822d..12d138fb82 100644 --- a/packages/core/admin/server/services/__tests__/permissions-manager-sanitize.test.js +++ b/packages/core/admin/server/services/__tests__/permissions-manager-sanitize.test.js @@ -97,4 +97,21 @@ describe('Permissions Manager - Sanitize', () => { expect(result).toEqual({ c: 'Bar' }); }); }); + + describe('Sanitize Query', () => { + it('Removes hidden fields on filters, sort, populate and fields', async () => { + const data = { + filters: { a: 'Foo', c: 'Bar' }, + sort: { a: 'asc', c: 'desc' }, + populate: { a: true, c: true }, + fields: ['a', 'c'], + }; + const result = await sanitizeHelpers.sanitizeQuery(data, { subject: fooModel.uid }); + + expect(result.filters).toEqual({ c: 'Bar' }); + expect(result.sort).toEqual({ c: 'desc' }); + expect(result.populate).toEqual({ c: true }); + expect(result.fields).toEqual([undefined, 'c']); + }); + }); }); diff --git a/packages/core/admin/server/services/permission/permissions-manager/sanitize.js b/packages/core/admin/server/services/permission/permissions-manager/sanitize.js index 3e42d9dfcf..60017b518c 100644 --- a/packages/core/admin/server/services/permission/permissions-manager/sanitize.js +++ b/packages/core/admin/server/services/permission/permissions-manager/sanitize.js @@ -56,6 +56,7 @@ module.exports = ({ action, ability, model }) => { const sanitizeFilters = pipeAsync( traverse.traverseQueryFilters(allowedFields(permittedFields), { schema }), traverse.traverseQueryFilters(omitDisallowedAdminUserFields, { schema }), + traverse.traverseQueryFilters(omitHiddenFields, { schema }), traverse.traverseQueryFilters(removePassword, { schema }), traverse.traverseQueryFilters( ({ key, value }, { remove }) => { @@ -70,6 +71,7 @@ module.exports = ({ action, ability, model }) => { const sanitizeSort = pipeAsync( traverse.traverseQuerySort(allowedFields(permittedFields), { schema }), traverse.traverseQuerySort(omitDisallowedAdminUserFields, { schema }), + traverse.traverseQuerySort(omitHiddenFields, { schema }), traverse.traverseQuerySort(removePassword, { schema }), traverse.traverseQuerySort( ({ key, attribute, value }, { remove }) => { @@ -84,11 +86,13 @@ module.exports = ({ action, ability, model }) => { const sanitizePopulate = pipeAsync( traverse.traverseQueryPopulate(allowedFields(permittedFields), { schema }), traverse.traverseQueryPopulate(omitDisallowedAdminUserFields, { schema }), + traverse.traverseQueryPopulate(omitHiddenFields, { schema }), traverse.traverseQueryPopulate(removePassword, { schema }) ); const sanitizeFields = pipeAsync( traverse.traverseQueryFields(allowedFields(permittedFields), { schema }), + traverse.traverseQueryFields(omitHiddenFields, { schema }), traverse.traverseQueryFields(removePassword, { schema }) ); @@ -256,13 +260,21 @@ module.exports = ({ action, ability, model }) => { }; const getQueryFields = (fields = []) => { + const nonVisibleAttributes = getNonVisibleAttributes(schema); + const writableAttributes = getWritableAttributes(schema); + + const nonVisibleWritableAttributes = intersection(nonVisibleAttributes, writableAttributes); + return uniq([ ...fields, ...STATIC_FIELDS, ...COMPONENT_FIELDS, + ...nonVisibleWritableAttributes, CREATED_AT_ATTRIBUTE, UPDATED_AT_ATTRIBUTE, PUBLISHED_AT_ATTRIBUTE, + CREATED_BY_ATTRIBUTE, + UPDATED_BY_ATTRIBUTE, ]); }; diff --git a/packages/core/content-manager/server/services/data-mapper.js b/packages/core/content-manager/server/services/data-mapper.js index d3650b2f52..3402c50cc0 100644 --- a/packages/core/content-manager/server/services/data-mapper.js +++ b/packages/core/content-manager/server/services/data-mapper.js @@ -35,11 +35,12 @@ module.exports = () => ({ }); const formatAttributes = (contentType) => { - const { getVisibleAttributes, getTimestamps } = contentTypesUtils; + const { getVisibleAttributes, getTimestamps, getCreatorFields } = contentTypesUtils; // only get attributes that can be seen in the auto generated Edit view or List view return getVisibleAttributes(contentType) .concat(getTimestamps(contentType)) + .concat(getCreatorFields(contentType)) .reduce((acc, key) => { const attribute = contentType.attributes[key]; diff --git a/packages/core/content-manager/server/services/utils/configuration/attributes.js b/packages/core/content-manager/server/services/utils/configuration/attributes.js index eaae0c47c7..cbedde0783 100644 --- a/packages/core/content-manager/server/services/utils/configuration/attributes.js +++ b/packages/core/content-manager/server/services/utils/configuration/attributes.js @@ -86,6 +86,10 @@ const isVisible = (schema, name) => { return false; } + if (isCreatorField(schema, name)) { + return false; + } + return true; }; @@ -108,6 +112,21 @@ const isTimestamp = (schema, name) => { } }; +const isCreatorField = (schema, name) => { + if (!_.has(schema.attributes, name)) { + return false; + } + + const creatorFields = contentTypesUtils.getCreatorFields(schema); + if (!creatorFields || !Array.isArray(creatorFields)) { + return false; + } + + if (creatorFields.includes(name)) { + return true; + } +}; + const isRelation = (attribute) => attribute.type === 'relation'; const hasRelationAttribute = (schema, name) => { diff --git a/packages/core/utils/src/content-types.ts b/packages/core/utils/src/content-types.ts index 7118c78d6c..0c4f3efb71 100644 --- a/packages/core/utils/src/content-types.ts +++ b/packages/core/utils/src/content-types.ts @@ -52,6 +52,20 @@ const getTimestamps = (model: Model) => { return attributes; }; +const getCreatorFields = (model: Model) => { + const attributes = []; + + if (has(CREATED_BY_ATTRIBUTE, model.attributes)) { + attributes.push(CREATED_BY_ATTRIBUTE); + } + + if (has(UPDATED_BY_ATTRIBUTE, model.attributes)) { + attributes.push(UPDATED_BY_ATTRIBUTE); + } + + return attributes; +}; + const getNonWritableAttributes = (model: Model) => { if (!model) return []; @@ -202,6 +216,7 @@ export { getNonVisibleAttributes, getVisibleAttributes, getTimestamps, + getCreatorFields, isVisibleAttribute, hasDraftAndPublish, getOptions,