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,