mirror of
https://github.com/strapi/strapi.git
synced 2025-11-18 02:58:05 +00:00
Merge pull request #19020 from strapi/chore/convert-audit-logs-ee-pages
chore(admin): convert auditLogs page to TS
This commit is contained in:
commit
4e6961c7b8
@ -5,10 +5,9 @@ export const ROUTES_EE: Route[] = [
|
|||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
async Component() {
|
async Component() {
|
||||||
// @ts-expect-error – No types, yet.
|
const { ProtectedAuditLogsListPage } = await import('./pages/AuditLogs/ListPage');
|
||||||
const component = await import('./pages/AuditLogs/ProtectedListPage');
|
|
||||||
|
|
||||||
return component;
|
return ProtectedAuditLogsListPage;
|
||||||
},
|
},
|
||||||
to: '/settings/audit-logs',
|
to: '/settings/audit-logs',
|
||||||
exact: true,
|
exact: true,
|
||||||
|
|||||||
@ -0,0 +1,148 @@
|
|||||||
|
import {
|
||||||
|
ActionLayout,
|
||||||
|
Box,
|
||||||
|
ContentLayout,
|
||||||
|
HeaderLayout,
|
||||||
|
Layout,
|
||||||
|
Main,
|
||||||
|
} from '@strapi/design-system';
|
||||||
|
import {
|
||||||
|
AnErrorOccurred,
|
||||||
|
DynamicTable,
|
||||||
|
SettingsPageTitle,
|
||||||
|
useFocusWhenNavigate,
|
||||||
|
useQueryParams,
|
||||||
|
useRBAC,
|
||||||
|
CheckPagePermissions,
|
||||||
|
} from '@strapi/helper-plugin';
|
||||||
|
import { useIntl } from 'react-intl';
|
||||||
|
import { useSelector } from 'react-redux';
|
||||||
|
|
||||||
|
import { Filters } from '../../../../../../../admin/src/pages/Settings/components/Filters';
|
||||||
|
import { selectAdminPermissions } from '../../../../../../../admin/src/selectors';
|
||||||
|
import { SanitizedAdminUserForAuditLogs } from '../../../../../../../shared/contracts/audit-logs';
|
||||||
|
|
||||||
|
import { Modal } from './components/Modal';
|
||||||
|
import { PaginationFooter } from './components/PaginationFooter';
|
||||||
|
import { TableHeader, TableRows } from './components/TableRows';
|
||||||
|
import { useAuditLogsData } from './hooks/useAuditLogsData';
|
||||||
|
import { getDisplayedFilters } from './utils/getDisplayedFilters';
|
||||||
|
|
||||||
|
export const ListView = () => {
|
||||||
|
const { formatMessage } = useIntl();
|
||||||
|
const permissions = useSelector(selectAdminPermissions);
|
||||||
|
|
||||||
|
const {
|
||||||
|
allowedActions: { canRead: canReadAuditLogs, canReadUsers },
|
||||||
|
} = useRBAC({
|
||||||
|
...permissions.settings?.auditLogs,
|
||||||
|
readUsers: permissions.settings?.users.read || [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const [{ query }, setQuery] = useQueryParams<{ id?: string | null }>();
|
||||||
|
const { auditLogs, users, isLoading, hasError } = useAuditLogsData({
|
||||||
|
canReadAuditLogs,
|
||||||
|
canReadUsers,
|
||||||
|
});
|
||||||
|
|
||||||
|
useFocusWhenNavigate();
|
||||||
|
|
||||||
|
const displayedFilters = getDisplayedFilters({ formatMessage, users, canReadUsers });
|
||||||
|
|
||||||
|
const title = formatMessage({
|
||||||
|
id: 'global.auditLogs',
|
||||||
|
defaultMessage: 'Audit Logs',
|
||||||
|
});
|
||||||
|
|
||||||
|
const headers = [
|
||||||
|
{
|
||||||
|
name: 'action',
|
||||||
|
key: 'action',
|
||||||
|
metadatas: {
|
||||||
|
label: formatMessage({
|
||||||
|
id: 'Settings.permissions.auditLogs.action',
|
||||||
|
defaultMessage: 'Action',
|
||||||
|
}),
|
||||||
|
sortable: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'date',
|
||||||
|
key: 'date',
|
||||||
|
metadatas: {
|
||||||
|
label: formatMessage({
|
||||||
|
id: 'Settings.permissions.auditLogs.date',
|
||||||
|
defaultMessage: 'Date',
|
||||||
|
}),
|
||||||
|
sortable: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'user',
|
||||||
|
name: 'user',
|
||||||
|
metadatas: {
|
||||||
|
label: formatMessage({
|
||||||
|
id: 'Settings.permissions.auditLogs.user',
|
||||||
|
defaultMessage: 'User',
|
||||||
|
}),
|
||||||
|
sortable: false,
|
||||||
|
},
|
||||||
|
// In this case, the passed parameter cannot and shouldn't be something else than User
|
||||||
|
cellFormatter: (user) => (user ? (user as SanitizedAdminUserForAuditLogs).displayName : ''),
|
||||||
|
},
|
||||||
|
] satisfies TableHeader[];
|
||||||
|
|
||||||
|
if (hasError) {
|
||||||
|
return (
|
||||||
|
<Layout>
|
||||||
|
<ContentLayout>
|
||||||
|
<Box paddingTop={8}>
|
||||||
|
<AnErrorOccurred />
|
||||||
|
</Box>
|
||||||
|
</ContentLayout>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Main aria-busy={isLoading}>
|
||||||
|
<SettingsPageTitle name={title} />
|
||||||
|
<HeaderLayout
|
||||||
|
title={title}
|
||||||
|
subtitle={formatMessage({
|
||||||
|
id: 'Settings.permissions.auditLogs.listview.header.subtitle',
|
||||||
|
defaultMessage: 'Logs of all the activities that happened in your environment',
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
{/* @ts-expect-error – TODO: fix the way filters work and are passed around, this will be a headache. */}
|
||||||
|
<ActionLayout startActions={<Filters displayedFilters={displayedFilters} />} />
|
||||||
|
<ContentLayout>
|
||||||
|
<DynamicTable
|
||||||
|
contentType="Audit logs"
|
||||||
|
headers={headers}
|
||||||
|
rows={auditLogs?.results || []}
|
||||||
|
withBulkActions
|
||||||
|
isLoading={isLoading}
|
||||||
|
>
|
||||||
|
<TableRows
|
||||||
|
headers={headers}
|
||||||
|
rows={auditLogs?.results || []}
|
||||||
|
onOpenModal={(id) => setQuery({ id: `${id}` })}
|
||||||
|
/>
|
||||||
|
</DynamicTable>
|
||||||
|
{auditLogs?.pagination && <PaginationFooter pagination={auditLogs.pagination} />}
|
||||||
|
</ContentLayout>
|
||||||
|
{query?.id && <Modal handleClose={() => setQuery({ id: null }, 'remove')} logId={query.id} />}
|
||||||
|
</Main>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ProtectedAuditLogsListPage = () => {
|
||||||
|
const permissions = useSelector(selectAdminPermissions);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CheckPagePermissions permissions={permissions.settings?.auditLogs?.main}>
|
||||||
|
<ListView />
|
||||||
|
</CheckPagePermissions>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -1,42 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
|
|
||||||
import { Combobox, ComboboxOption } from '@strapi/design-system';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import { useIntl } from 'react-intl';
|
|
||||||
|
|
||||||
const ComboboxFilter = ({ value, options, onChange }) => {
|
|
||||||
const { formatMessage } = useIntl();
|
|
||||||
const ariaLabel = formatMessage({
|
|
||||||
id: 'Settings.permissions.auditLogs.filter.aria-label',
|
|
||||||
defaultMessage: 'Search and select an option to filter',
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Combobox aria-label={ariaLabel} value={value} onChange={onChange}>
|
|
||||||
{options.map(({ label, customValue }) => {
|
|
||||||
return (
|
|
||||||
<ComboboxOption key={customValue} value={customValue}>
|
|
||||||
{label}
|
|
||||||
</ComboboxOption>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Combobox>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
ComboboxFilter.defaultProps = {
|
|
||||||
value: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
ComboboxFilter.propTypes = {
|
|
||||||
value: PropTypes.string,
|
|
||||||
options: PropTypes.arrayOf(
|
|
||||||
PropTypes.shape({
|
|
||||||
label: PropTypes.string.isRequired,
|
|
||||||
customValue: PropTypes.string.isRequired,
|
|
||||||
}).isRequired
|
|
||||||
).isRequired,
|
|
||||||
onChange: PropTypes.func.isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ComboboxFilter;
|
|
||||||
@ -1,107 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
|
|
||||||
import { Box, Flex, Grid, JSONInput, Loader, Typography } from '@strapi/design-system';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import { useIntl } from 'react-intl';
|
|
||||||
|
|
||||||
import { getDefaultMessage } from '../utils/getActionTypesDefaultMessages';
|
|
||||||
|
|
||||||
import ActionItem from './ActionItem';
|
|
||||||
|
|
||||||
const ActionBody = ({ status, data, formattedDate }) => {
|
|
||||||
const { formatMessage } = useIntl();
|
|
||||||
|
|
||||||
if (status === 'loading') {
|
|
||||||
return (
|
|
||||||
<Flex padding={7} justifyContent="center" alignItems="center">
|
|
||||||
<Loader>Loading content...</Loader>
|
|
||||||
</Flex>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { action, user, payload } = data;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Box marginBottom={3}>
|
|
||||||
<Typography variant="delta" id="title">
|
|
||||||
{formatMessage({
|
|
||||||
id: 'Settings.permissions.auditLogs.details',
|
|
||||||
defaultMessage: 'Log Details',
|
|
||||||
})}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
<Grid
|
|
||||||
gap={4}
|
|
||||||
gridCols={2}
|
|
||||||
paddingTop={4}
|
|
||||||
paddingBottom={4}
|
|
||||||
paddingLeft={6}
|
|
||||||
paddingRight={6}
|
|
||||||
marginBottom={4}
|
|
||||||
background="neutral100"
|
|
||||||
hasRadius
|
|
||||||
>
|
|
||||||
<ActionItem
|
|
||||||
actionLabel={formatMessage({
|
|
||||||
id: 'Settings.permissions.auditLogs.action',
|
|
||||||
defaultMessage: 'Action',
|
|
||||||
})}
|
|
||||||
actionName={formatMessage(
|
|
||||||
{
|
|
||||||
id: `Settings.permissions.auditLogs.${action}`,
|
|
||||||
defaultMessage: getDefaultMessage(action),
|
|
||||||
},
|
|
||||||
{ model: payload?.model }
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<ActionItem
|
|
||||||
actionLabel={formatMessage({
|
|
||||||
id: 'Settings.permissions.auditLogs.date',
|
|
||||||
defaultMessage: 'Date',
|
|
||||||
})}
|
|
||||||
actionName={formattedDate}
|
|
||||||
/>
|
|
||||||
<ActionItem
|
|
||||||
actionLabel={formatMessage({
|
|
||||||
id: 'Settings.permissions.auditLogs.user',
|
|
||||||
defaultMessage: 'User',
|
|
||||||
})}
|
|
||||||
actionName={user?.displayName || '-'}
|
|
||||||
/>
|
|
||||||
<ActionItem
|
|
||||||
actionLabel={formatMessage({
|
|
||||||
id: 'Settings.permissions.auditLogs.userId',
|
|
||||||
defaultMessage: 'User ID',
|
|
||||||
})}
|
|
||||||
actionName={user?.id.toString() || '-'}
|
|
||||||
/>
|
|
||||||
</Grid>
|
|
||||||
<JSONInput
|
|
||||||
value={JSON.stringify(payload, null, 2)}
|
|
||||||
disabled
|
|
||||||
label={formatMessage({
|
|
||||||
id: 'Settings.permissions.auditLogs.payload',
|
|
||||||
defaultMessage: 'Payload',
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
ActionBody.defaultProps = {
|
|
||||||
data: {},
|
|
||||||
};
|
|
||||||
|
|
||||||
ActionBody.propTypes = {
|
|
||||||
status: PropTypes.oneOf(['idle', 'loading', 'error', 'success']).isRequired,
|
|
||||||
data: PropTypes.shape({
|
|
||||||
action: PropTypes.string,
|
|
||||||
date: PropTypes.string,
|
|
||||||
payload: PropTypes.object,
|
|
||||||
user: PropTypes.object,
|
|
||||||
}),
|
|
||||||
formattedDate: PropTypes.string.isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ActionBody;
|
|
||||||
@ -1,22 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
|
|
||||||
import { Flex, Typography } from '@strapi/design-system';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
|
|
||||||
const ActionItem = ({ actionLabel, actionName }) => {
|
|
||||||
return (
|
|
||||||
<Flex direction="column" alignItems="baseline" gap={1}>
|
|
||||||
<Typography textColor="neutral600" variant="sigma">
|
|
||||||
{actionLabel}
|
|
||||||
</Typography>
|
|
||||||
<Typography textColor="neutral600">{actionName}</Typography>
|
|
||||||
</Flex>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
ActionItem.propTypes = {
|
|
||||||
actionLabel: PropTypes.string.isRequired,
|
|
||||||
actionName: PropTypes.string.isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ActionItem;
|
|
||||||
@ -1,62 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
|
|
||||||
import { ModalBody, ModalHeader, ModalLayout } from '@strapi/design-system';
|
|
||||||
import { Breadcrumbs, Crumb } from '@strapi/design-system/v2';
|
|
||||||
import { useFetchClient, useNotification } from '@strapi/helper-plugin';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import { useQuery } from 'react-query';
|
|
||||||
|
|
||||||
import useFormatTimeStamp from '../hooks/useFormatTimeStamp';
|
|
||||||
|
|
||||||
import ActionBody from './ActionBody';
|
|
||||||
|
|
||||||
const Modal = ({ handleClose, logId }) => {
|
|
||||||
const { get } = useFetchClient();
|
|
||||||
const toggleNotification = useNotification();
|
|
||||||
|
|
||||||
const fetchAuditLog = async (id) => {
|
|
||||||
const { data } = await get(`/admin/audit-logs/${id}`);
|
|
||||||
|
|
||||||
if (!data) {
|
|
||||||
throw new Error('Audit log not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
return data;
|
|
||||||
};
|
|
||||||
|
|
||||||
const { data, status } = useQuery(['audit-log', logId], () => fetchAuditLog(logId), {
|
|
||||||
onError() {
|
|
||||||
toggleNotification({
|
|
||||||
type: 'warning',
|
|
||||||
message: { id: 'notification.error', defaultMessage: 'An error occured' },
|
|
||||||
});
|
|
||||||
handleClose();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const formatTimeStamp = useFormatTimeStamp();
|
|
||||||
const formattedDate = data ? formatTimeStamp(data.date) : '';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ModalLayout onClose={handleClose} labelledBy="title">
|
|
||||||
<ModalHeader>
|
|
||||||
{/**
|
|
||||||
* TODO: this is not semantically correct and should be amended.
|
|
||||||
*/}
|
|
||||||
<Breadcrumbs label={formattedDate} id="title">
|
|
||||||
<Crumb isCurrent>{formattedDate}</Crumb>
|
|
||||||
</Breadcrumbs>
|
|
||||||
</ModalHeader>
|
|
||||||
<ModalBody>
|
|
||||||
<ActionBody status={status} data={data} formattedDate={formattedDate} />
|
|
||||||
</ModalBody>
|
|
||||||
</ModalLayout>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
Modal.propTypes = {
|
|
||||||
handleClose: PropTypes.func.isRequired,
|
|
||||||
logId: PropTypes.string.isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Modal;
|
|
||||||
@ -1,35 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
|
|
||||||
import { Box, Flex } from '@strapi/design-system';
|
|
||||||
import { PageSizeURLQuery, PaginationURLQuery } from '@strapi/helper-plugin';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
|
|
||||||
const PaginationFooter = ({ pagination }) => {
|
|
||||||
return (
|
|
||||||
<Box paddingTop={4}>
|
|
||||||
<Flex alignItems="flex-end" justifyContent="space-between">
|
|
||||||
<PageSizeURLQuery />
|
|
||||||
<PaginationURLQuery pagination={pagination} />
|
|
||||||
</Flex>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
PaginationFooter.defaultProps = {
|
|
||||||
pagination: {
|
|
||||||
pageCount: 0,
|
|
||||||
pageSize: 50,
|
|
||||||
total: 0,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
PaginationFooter.propTypes = {
|
|
||||||
pagination: PropTypes.shape({
|
|
||||||
page: PropTypes.number,
|
|
||||||
pageCount: PropTypes.number,
|
|
||||||
pageSize: PropTypes.number,
|
|
||||||
total: PropTypes.number,
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
export default PaginationFooter;
|
|
||||||
@ -1,110 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
|
|
||||||
import {
|
|
||||||
ActionLayout,
|
|
||||||
Box,
|
|
||||||
ContentLayout,
|
|
||||||
HeaderLayout,
|
|
||||||
Layout,
|
|
||||||
Main,
|
|
||||||
} from '@strapi/design-system';
|
|
||||||
import {
|
|
||||||
AnErrorOccurred,
|
|
||||||
DynamicTable,
|
|
||||||
SettingsPageTitle,
|
|
||||||
useFocusWhenNavigate,
|
|
||||||
useQueryParams,
|
|
||||||
useRBAC,
|
|
||||||
} from '@strapi/helper-plugin';
|
|
||||||
import { useIntl } from 'react-intl';
|
|
||||||
import { useSelector } from 'react-redux';
|
|
||||||
|
|
||||||
import { Filters } from '../../../../../../../../admin/src/pages/Settings/components/Filters';
|
|
||||||
import { selectAdminPermissions } from '../../../../../../../../admin/src/selectors';
|
|
||||||
|
|
||||||
import useAuditLogsData from './hooks/useAuditLogsData';
|
|
||||||
import Modal from './Modal';
|
|
||||||
import PaginationFooter from './PaginationFooter';
|
|
||||||
import TableRows from './TableRows';
|
|
||||||
import getDisplayedFilters from './utils/getDisplayedFilters';
|
|
||||||
import tableHeaders from './utils/tableHeaders';
|
|
||||||
|
|
||||||
const ListView = () => {
|
|
||||||
const { formatMessage } = useIntl();
|
|
||||||
const permissions = useSelector(selectAdminPermissions);
|
|
||||||
|
|
||||||
const {
|
|
||||||
allowedActions: { canRead: canReadAuditLogs, canReadUsers },
|
|
||||||
} = useRBAC({
|
|
||||||
...permissions.settings.auditLogs,
|
|
||||||
readUsers: permissions.settings.users.read,
|
|
||||||
});
|
|
||||||
|
|
||||||
const [{ query }, setQuery] = useQueryParams();
|
|
||||||
const { auditLogs, users, isLoading, hasError } = useAuditLogsData({
|
|
||||||
canReadAuditLogs,
|
|
||||||
canReadUsers,
|
|
||||||
});
|
|
||||||
|
|
||||||
useFocusWhenNavigate();
|
|
||||||
|
|
||||||
const displayedFilters = getDisplayedFilters({ formatMessage, users, canReadUsers });
|
|
||||||
|
|
||||||
const title = formatMessage({
|
|
||||||
id: 'global.auditLogs',
|
|
||||||
defaultMessage: 'Audit Logs',
|
|
||||||
});
|
|
||||||
|
|
||||||
const headers = tableHeaders.map((header) => ({
|
|
||||||
...header,
|
|
||||||
metadatas: {
|
|
||||||
...header.metadatas,
|
|
||||||
label: formatMessage(header.metadatas.label),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
if (hasError) {
|
|
||||||
return (
|
|
||||||
<Layout>
|
|
||||||
<ContentLayout>
|
|
||||||
<Box paddingTop={8}>
|
|
||||||
<AnErrorOccurred />
|
|
||||||
</Box>
|
|
||||||
</ContentLayout>
|
|
||||||
</Layout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Main aria-busy={isLoading}>
|
|
||||||
<SettingsPageTitle name={title} />
|
|
||||||
<HeaderLayout
|
|
||||||
title={title}
|
|
||||||
subtitle={formatMessage({
|
|
||||||
id: 'Settings.permissions.auditLogs.listview.header.subtitle',
|
|
||||||
defaultMessage: 'Logs of all the activities that happened in your environment',
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
<ActionLayout startActions={<Filters displayedFilters={displayedFilters} />} />
|
|
||||||
<ContentLayout canRead={canReadAuditLogs}>
|
|
||||||
<DynamicTable
|
|
||||||
contentType="Audit logs"
|
|
||||||
headers={headers}
|
|
||||||
rows={auditLogs?.results || []}
|
|
||||||
withBulkActions
|
|
||||||
isLoading={isLoading}
|
|
||||||
>
|
|
||||||
<TableRows
|
|
||||||
headers={headers}
|
|
||||||
rows={auditLogs?.results || []}
|
|
||||||
onOpenModal={(id) => setQuery({ id })}
|
|
||||||
/>
|
|
||||||
</DynamicTable>
|
|
||||||
<PaginationFooter pagination={auditLogs?.pagination} />
|
|
||||||
</ContentLayout>
|
|
||||||
{query?.id && <Modal handleClose={() => setQuery({ id: null }, 'remove')} logId={query.id} />}
|
|
||||||
</Main>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ListView;
|
|
||||||
@ -1,19 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
|
|
||||||
import { CheckPagePermissions } from '@strapi/helper-plugin';
|
|
||||||
import { useSelector } from 'react-redux';
|
|
||||||
|
|
||||||
import { selectAdminPermissions } from '../../../../../../../../admin/src/selectors';
|
|
||||||
import ListView from '../ListView';
|
|
||||||
|
|
||||||
const ProtectedListPage = () => {
|
|
||||||
const permissions = useSelector(selectAdminPermissions);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<CheckPagePermissions permissions={permissions.settings.auditLogs.main}>
|
|
||||||
<ListView />
|
|
||||||
</CheckPagePermissions>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ProtectedListPage;
|
|
||||||
@ -0,0 +1,32 @@
|
|||||||
|
import { Combobox, ComboboxOption, ComboboxProps } from '@strapi/design-system';
|
||||||
|
import { useIntl } from 'react-intl';
|
||||||
|
|
||||||
|
type ComboboxFilterProps = {
|
||||||
|
value?: string;
|
||||||
|
options?: { label: string; customValue: string }[];
|
||||||
|
onChange?: ComboboxProps['onChange'];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ComboboxFilter = (
|
||||||
|
{ value, options, onChange }: ComboboxFilterProps = {
|
||||||
|
value: undefined,
|
||||||
|
}
|
||||||
|
) => {
|
||||||
|
const { formatMessage } = useIntl();
|
||||||
|
const ariaLabel = formatMessage({
|
||||||
|
id: 'Settings.permissions.auditLogs.filter.aria-label',
|
||||||
|
defaultMessage: 'Search and select an option to filter',
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Combobox aria-label={ariaLabel} value={value} onChange={onChange}>
|
||||||
|
{options?.map(({ label, customValue }) => {
|
||||||
|
return (
|
||||||
|
<ComboboxOption key={customValue} value={customValue}>
|
||||||
|
{label}
|
||||||
|
</ComboboxOption>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Combobox>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,175 @@
|
|||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Flex,
|
||||||
|
Grid,
|
||||||
|
JSONInput,
|
||||||
|
Loader,
|
||||||
|
ModalBody,
|
||||||
|
ModalHeader,
|
||||||
|
ModalLayout,
|
||||||
|
Typography,
|
||||||
|
} from '@strapi/design-system';
|
||||||
|
import { Breadcrumbs, Crumb } from '@strapi/design-system/v2';
|
||||||
|
import { useFetchClient, useNotification } from '@strapi/helper-plugin';
|
||||||
|
import { useIntl } from 'react-intl';
|
||||||
|
import { QueryStatus, useQuery } from 'react-query';
|
||||||
|
|
||||||
|
import { AuditLog, Get } from '../../../../../../../../shared/contracts/audit-logs';
|
||||||
|
import { useFormatTimeStamp } from '../hooks/useFormatTimeStamp';
|
||||||
|
import { actionTypes, getDefaultMessage } from '../utils/getActionTypesDefaultMessages';
|
||||||
|
|
||||||
|
type ActionBodyProps = {
|
||||||
|
status: QueryStatus;
|
||||||
|
data: AuditLog;
|
||||||
|
formattedDate: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ModalProps = {
|
||||||
|
handleClose: () => void;
|
||||||
|
logId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ActionItemProps = {
|
||||||
|
actionLabel: string;
|
||||||
|
actionName: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Modal = ({ handleClose, logId }: ModalProps) => {
|
||||||
|
const { get } = useFetchClient();
|
||||||
|
const toggleNotification = useNotification();
|
||||||
|
|
||||||
|
const fetchAuditLog = async (id: string) => {
|
||||||
|
const { data } = await get<Get.Response>(`/admin/audit-logs/${id}`);
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
throw new Error('Audit log not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
const { data, status } = useQuery(['audit-log', logId], () => fetchAuditLog(logId), {
|
||||||
|
onError() {
|
||||||
|
toggleNotification({
|
||||||
|
type: 'warning',
|
||||||
|
message: { id: 'notification.error', defaultMessage: 'An error occured' },
|
||||||
|
});
|
||||||
|
handleClose();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const formatTimeStamp = useFormatTimeStamp();
|
||||||
|
const formattedDate = data && 'date' in data ? formatTimeStamp(data.date) : '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ModalLayout onClose={handleClose} labelledBy="title">
|
||||||
|
<ModalHeader>
|
||||||
|
{/**
|
||||||
|
* TODO: this is not semantically correct and should be amended.
|
||||||
|
*/}
|
||||||
|
<Breadcrumbs label={formattedDate} id="title">
|
||||||
|
<Crumb isCurrent>{formattedDate}</Crumb>
|
||||||
|
</Breadcrumbs>
|
||||||
|
</ModalHeader>
|
||||||
|
<ModalBody>
|
||||||
|
<ActionBody status={status} data={data as AuditLog} formattedDate={formattedDate} />
|
||||||
|
</ModalBody>
|
||||||
|
</ModalLayout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ActionBody = ({ status, data, formattedDate }: ActionBodyProps) => {
|
||||||
|
const { formatMessage } = useIntl();
|
||||||
|
|
||||||
|
if (status === 'loading') {
|
||||||
|
return (
|
||||||
|
<Flex padding={7} justifyContent="center" alignItems="center">
|
||||||
|
{/**
|
||||||
|
* TODO: this will need to be translated.
|
||||||
|
*/}
|
||||||
|
<Loader>Loading content...</Loader>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { action, user, payload } = data;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Box marginBottom={3}>
|
||||||
|
<Typography variant="delta" id="title">
|
||||||
|
{formatMessage({
|
||||||
|
id: 'Settings.permissions.auditLogs.details',
|
||||||
|
defaultMessage: 'Log Details',
|
||||||
|
})}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Grid
|
||||||
|
gap={4}
|
||||||
|
gridCols={2}
|
||||||
|
paddingTop={4}
|
||||||
|
paddingBottom={4}
|
||||||
|
paddingLeft={6}
|
||||||
|
paddingRight={6}
|
||||||
|
marginBottom={4}
|
||||||
|
background="neutral100"
|
||||||
|
hasRadius
|
||||||
|
>
|
||||||
|
<ActionItem
|
||||||
|
actionLabel={formatMessage({
|
||||||
|
id: 'Settings.permissions.auditLogs.action',
|
||||||
|
defaultMessage: 'Action',
|
||||||
|
})}
|
||||||
|
actionName={formatMessage(
|
||||||
|
{
|
||||||
|
id: `Settings.permissions.auditLogs.${action}`,
|
||||||
|
defaultMessage: getDefaultMessage(action as keyof typeof actionTypes),
|
||||||
|
},
|
||||||
|
// @ts-expect-error - any
|
||||||
|
{ model: payload?.model }
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<ActionItem
|
||||||
|
actionLabel={formatMessage({
|
||||||
|
id: 'Settings.permissions.auditLogs.date',
|
||||||
|
defaultMessage: 'Date',
|
||||||
|
})}
|
||||||
|
actionName={formattedDate}
|
||||||
|
/>
|
||||||
|
<ActionItem
|
||||||
|
actionLabel={formatMessage({
|
||||||
|
id: 'Settings.permissions.auditLogs.user',
|
||||||
|
defaultMessage: 'User',
|
||||||
|
})}
|
||||||
|
actionName={user?.displayName || '-'}
|
||||||
|
/>
|
||||||
|
<ActionItem
|
||||||
|
actionLabel={formatMessage({
|
||||||
|
id: 'Settings.permissions.auditLogs.userId',
|
||||||
|
defaultMessage: 'User ID',
|
||||||
|
})}
|
||||||
|
actionName={user?.id.toString() || '-'}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<JSONInput
|
||||||
|
value={JSON.stringify(payload, null, 2)}
|
||||||
|
disabled
|
||||||
|
label={formatMessage({
|
||||||
|
id: 'Settings.permissions.auditLogs.payload',
|
||||||
|
defaultMessage: 'Payload',
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ActionItem = ({ actionLabel, actionName }: ActionItemProps) => {
|
||||||
|
return (
|
||||||
|
<Flex direction="column" alignItems="baseline" gap={1}>
|
||||||
|
<Typography textColor="neutral600" variant="sigma">
|
||||||
|
{actionLabel}
|
||||||
|
</Typography>
|
||||||
|
<Typography textColor="neutral600">{actionName}</Typography>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,30 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import { Box, Flex } from '@strapi/design-system';
|
||||||
|
import { PageSizeURLQuery, PaginationURLQuery } from '@strapi/helper-plugin';
|
||||||
|
|
||||||
|
import { Pagination } from '../../../../../../../../shared/contracts/shared';
|
||||||
|
|
||||||
|
type PaginationFooterProps = {
|
||||||
|
pagination: Pagination;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PaginationFooter = (
|
||||||
|
{ pagination }: PaginationFooterProps = {
|
||||||
|
pagination: {
|
||||||
|
page: 1,
|
||||||
|
pageCount: 0,
|
||||||
|
pageSize: 50,
|
||||||
|
total: 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
) => {
|
||||||
|
return (
|
||||||
|
<Box paddingTop={4}>
|
||||||
|
<Flex alignItems="flex-end" justifyContent="space-between">
|
||||||
|
<PageSizeURLQuery />
|
||||||
|
<PaginationURLQuery pagination={pagination} />
|
||||||
|
</Flex>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -3,17 +3,37 @@ import React from 'react';
|
|||||||
import { Flex, IconButton, Tbody, Td, Tr, Typography } from '@strapi/design-system';
|
import { Flex, IconButton, Tbody, Td, Tr, Typography } from '@strapi/design-system';
|
||||||
import { onRowClick, stopPropagation } from '@strapi/helper-plugin';
|
import { onRowClick, stopPropagation } from '@strapi/helper-plugin';
|
||||||
import { Eye } from '@strapi/icons';
|
import { Eye } from '@strapi/icons';
|
||||||
|
import { Attribute, Entity } from '@strapi/types';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import { useIntl } from 'react-intl';
|
import { useIntl } from 'react-intl';
|
||||||
|
|
||||||
import useFormatTimeStamp from '../hooks/useFormatTimeStamp';
|
import { ListLayoutRow } from '../../../../../../../../admin/src/content-manager/utils/layouts';
|
||||||
|
import { AuditLog } from '../../../../../../../../shared/contracts/audit-logs';
|
||||||
|
import { useFormatTimeStamp } from '../hooks/useFormatTimeStamp';
|
||||||
import { getDefaultMessage } from '../utils/getActionTypesDefaultMessages';
|
import { getDefaultMessage } from '../utils/getActionTypesDefaultMessages';
|
||||||
|
|
||||||
const TableRows = ({ headers, rows, onOpenModal }) => {
|
export interface TableHeader extends Omit<ListLayoutRow, 'metadatas' | 'fieldSchema' | 'name'> {
|
||||||
|
metadatas: Omit<ListLayoutRow['metadatas'], 'label'> & {
|
||||||
|
label: string;
|
||||||
|
};
|
||||||
|
name: keyof AuditLog;
|
||||||
|
fieldSchema?: Attribute.Any | { type: 'custom' };
|
||||||
|
cellFormatter?: (data?: AuditLog[keyof AuditLog]) => React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
type TableRowsProps = {
|
||||||
|
headers: TableHeader[];
|
||||||
|
rows: AuditLog[];
|
||||||
|
onOpenModal: (id: Entity.ID) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TableRows = ({ headers, rows, onOpenModal }: TableRowsProps) => {
|
||||||
const { formatMessage } = useIntl();
|
const { formatMessage } = useIntl();
|
||||||
const formatTimeStamp = useFormatTimeStamp();
|
const formatTimeStamp = useFormatTimeStamp();
|
||||||
|
|
||||||
const getCellValue = ({ type, value, model }) => {
|
// Not sure that 'value' can be typed properly
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const getCellValue = ({ type, value, model }: { type: string; value: any; model: unknown }) => {
|
||||||
if (type === 'date') {
|
if (type === 'date') {
|
||||||
return formatTimeStamp(value);
|
return formatTimeStamp(value);
|
||||||
}
|
}
|
||||||
@ -24,6 +44,7 @@ const TableRows = ({ headers, rows, onOpenModal }) => {
|
|||||||
id: `Settings.permissions.auditLogs.${value}`,
|
id: `Settings.permissions.auditLogs.${value}`,
|
||||||
defaultMessage: getDefaultMessage(value),
|
defaultMessage: getDefaultMessage(value),
|
||||||
},
|
},
|
||||||
|
// @ts-expect-error - Model
|
||||||
{ model }
|
{ model }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -41,13 +62,15 @@ const TableRows = ({ headers, rows, onOpenModal }) => {
|
|||||||
fn: () => onOpenModal(data.id),
|
fn: () => onOpenModal(data.id),
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
{headers.map(({ key, name, cellFormatter }) => {
|
{headers?.map(({ key, name, cellFormatter }) => {
|
||||||
|
const rowValue = data[name];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Td key={key}>
|
<Td key={key}>
|
||||||
<Typography textColor="neutral800">
|
<Typography textColor="neutral800">
|
||||||
{getCellValue({
|
{getCellValue({
|
||||||
type: key,
|
type: key,
|
||||||
value: cellFormatter ? cellFormatter(data[name]) : data[name],
|
value: cellFormatter ? cellFormatter(rowValue) : rowValue,
|
||||||
model: data.payload?.model,
|
model: data.payload?.model,
|
||||||
})}
|
})}
|
||||||
</Typography>
|
</Typography>
|
||||||
@ -83,5 +106,3 @@ TableRows.propTypes = {
|
|||||||
rows: PropTypes.array,
|
rows: PropTypes.array,
|
||||||
onOpenModal: PropTypes.func.isRequired,
|
onOpenModal: PropTypes.func.isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default TableRows;
|
|
||||||
@ -1,16 +1,14 @@
|
|||||||
import React from 'react';
|
|
||||||
|
|
||||||
import { lightTheme, ThemeProvider } from '@strapi/design-system';
|
import { lightTheme, ThemeProvider } from '@strapi/design-system';
|
||||||
import { fireEvent, render, screen } from '@testing-library/react';
|
import { fireEvent, render, screen } from '@testing-library/react';
|
||||||
import { createMemoryHistory } from 'history';
|
import { createMemoryHistory } from 'history';
|
||||||
import { IntlProvider } from 'react-intl';
|
import { IntlProvider } from 'react-intl';
|
||||||
import { Router } from 'react-router-dom';
|
import { Router } from 'react-router-dom';
|
||||||
|
|
||||||
import TableRows from '..';
|
import { TableHeader, TableRows } from '../TableRows';
|
||||||
|
|
||||||
const history = createMemoryHistory();
|
const history = createMemoryHistory();
|
||||||
|
|
||||||
const headers = [
|
const headers: TableHeader[] = [
|
||||||
{
|
{
|
||||||
name: 'action',
|
name: 'action',
|
||||||
key: 'action',
|
key: 'action',
|
||||||
@ -25,7 +23,8 @@ const headers = [
|
|||||||
key: 'user',
|
key: 'user',
|
||||||
name: 'user',
|
name: 'user',
|
||||||
metadatas: { label: 'User', sortable: false },
|
metadatas: { label: 'User', sortable: false },
|
||||||
cellFormatter: (value) => (value ? value.fullname : null),
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
cellFormatter: (value: any) => (value ? value.fullname : null),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@ -34,37 +33,57 @@ const rows = [
|
|||||||
id: 1,
|
id: 1,
|
||||||
action: 'role.update',
|
action: 'role.update',
|
||||||
date: '2022-11-14T23:04:00.000Z',
|
date: '2022-11-14T23:04:00.000Z',
|
||||||
|
payload: {},
|
||||||
user: {
|
user: {
|
||||||
id: 1,
|
id: 1,
|
||||||
fullname: 'John Doe',
|
fullname: 'John Doe',
|
||||||
email: 'test@email.com',
|
email: 'test@email.com',
|
||||||
|
displayName: 'John Doe',
|
||||||
|
isActive: true,
|
||||||
|
blocked: false,
|
||||||
|
createdAt: '',
|
||||||
|
updatedAt: '',
|
||||||
|
roles: [],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 2,
|
id: 2,
|
||||||
action: 'permission.create',
|
action: 'permission.create',
|
||||||
date: '2022-11-04T18:24:00.000Z',
|
date: '2022-11-04T18:24:00.000Z',
|
||||||
|
payload: {},
|
||||||
user: {
|
user: {
|
||||||
id: 2,
|
id: 2,
|
||||||
fullname: 'Kai Doe',
|
fullname: 'Kai Doe',
|
||||||
email: 'test2@email.com',
|
email: 'test2@email.com',
|
||||||
|
displayName: 'Kai Doe',
|
||||||
|
isActive: true,
|
||||||
|
blocked: false,
|
||||||
|
createdAt: '',
|
||||||
|
updatedAt: '',
|
||||||
|
roles: [],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 3,
|
id: 3,
|
||||||
action: 'custom.action',
|
action: 'custom.action',
|
||||||
date: '2022-11-04T18:23:00.000Z',
|
date: '2022-11-04T18:23:00.000Z',
|
||||||
|
payload: {},
|
||||||
user: {
|
user: {
|
||||||
id: 2,
|
id: 2,
|
||||||
fullname: 'Kai Doe',
|
fullname: 'Kai Doe',
|
||||||
email: 'test2@email.com',
|
email: 'test2@email.com',
|
||||||
|
displayName: 'Kai Doe',
|
||||||
|
isActive: true,
|
||||||
|
blocked: false,
|
||||||
|
createdAt: '',
|
||||||
|
updatedAt: '',
|
||||||
|
roles: [],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const onModalOpen = jest.fn();
|
const onModalOpen = jest.fn();
|
||||||
|
|
||||||
// eslint-disable-next-line react/prop-types
|
|
||||||
const App = (
|
const App = (
|
||||||
<ThemeProvider theme={lightTheme}>
|
<ThemeProvider theme={lightTheme}>
|
||||||
<IntlProvider locale="en" messages={{}} defaultLocale="en" textComponent="span">
|
<IntlProvider locale="en" messages={{}} defaultLocale="en" textComponent="span">
|
||||||
@ -96,14 +115,15 @@ describe('ADMIN | Pages | AUDIT LOGS | ListView | Dynamic Table | Table Rows', (
|
|||||||
it('should open a modal when clicked on a view details icon button', () => {
|
it('should open a modal when clicked on a view details icon button', () => {
|
||||||
render(App);
|
render(App);
|
||||||
const label = screen.getByText(/update action details/i);
|
const label = screen.getByText(/update action details/i);
|
||||||
|
// eslint-disable-next-line testing-library/no-node-access
|
||||||
const viewDetailsButton = label.closest('button');
|
const viewDetailsButton = label.closest('button');
|
||||||
fireEvent.click(viewDetailsButton);
|
if (viewDetailsButton) fireEvent.click(viewDetailsButton);
|
||||||
expect(onModalOpen).toHaveBeenCalled();
|
expect(onModalOpen).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should open a modal when clicked on a row', () => {
|
it('should open a modal when clicked on a row', async () => {
|
||||||
render(App);
|
render(App);
|
||||||
const rows = document.querySelectorAll('tr');
|
const rows = await screen.findAllByRole('row');
|
||||||
fireEvent.click(rows[0]);
|
fireEvent.click(rows[0]);
|
||||||
expect(onModalOpen).toHaveBeenCalled();
|
expect(onModalOpen).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@ -2,9 +2,16 @@ import { useFetchClient, useNotification, useQueryParams } from '@strapi/helper-
|
|||||||
import { useQuery } from 'react-query';
|
import { useQuery } from 'react-query';
|
||||||
import { useLocation } from 'react-router-dom';
|
import { useLocation } from 'react-router-dom';
|
||||||
|
|
||||||
import { useAdminUsers } from '../../../../../../../../../admin/src/hooks/useAdminUsers';
|
import { useAdminUsers } from '../../../../../../../../admin/src/hooks/useAdminUsers';
|
||||||
|
import { GetAll } from '../../../../../../../../shared/contracts/audit-logs';
|
||||||
|
|
||||||
const useAuditLogsData = ({ canReadAuditLogs, canReadUsers }) => {
|
export const useAuditLogsData = ({
|
||||||
|
canReadAuditLogs,
|
||||||
|
canReadUsers,
|
||||||
|
}: {
|
||||||
|
canReadAuditLogs: boolean;
|
||||||
|
canReadUsers: boolean;
|
||||||
|
}) => {
|
||||||
const { get } = useFetchClient();
|
const { get } = useFetchClient();
|
||||||
const { search } = useLocation();
|
const { search } = useLocation();
|
||||||
const toggleNotification = useNotification();
|
const toggleNotification = useNotification();
|
||||||
@ -14,7 +21,7 @@ const useAuditLogsData = ({ canReadAuditLogs, canReadUsers }) => {
|
|||||||
keepPreviousData: true,
|
keepPreviousData: true,
|
||||||
retry: false,
|
retry: false,
|
||||||
staleTime: 1000 * 20, // 20 seconds
|
staleTime: 1000 * 20, // 20 seconds
|
||||||
onError: (error) => toggleNotification({ type: 'warning', message: error.message }),
|
onError: (error: Error) => toggleNotification({ type: 'warning', message: error.message }),
|
||||||
};
|
};
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@ -37,7 +44,7 @@ const useAuditLogsData = ({ canReadAuditLogs, canReadUsers }) => {
|
|||||||
} = useQuery(
|
} = useQuery(
|
||||||
['auditLogs', search],
|
['auditLogs', search],
|
||||||
async () => {
|
async () => {
|
||||||
const { data } = await get(`/admin/audit-logs`, {
|
const { data } = await get<GetAll.Response['data']>(`/admin/audit-logs`, {
|
||||||
params: query,
|
params: query,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -56,5 +63,3 @@ const useAuditLogsData = ({ canReadAuditLogs, canReadUsers }) => {
|
|||||||
hasError: isAuditLogsError || isUsersError,
|
hasError: isAuditLogsError || isUsersError,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export default useAuditLogsData;
|
|
||||||
@ -1,10 +1,10 @@
|
|||||||
import parseISO from 'date-fns/parseISO';
|
import parseISO from 'date-fns/parseISO';
|
||||||
import { useIntl } from 'react-intl';
|
import { useIntl } from 'react-intl';
|
||||||
|
|
||||||
const useFormatTimeStamp = () => {
|
export const useFormatTimeStamp = () => {
|
||||||
const { formatDate } = useIntl();
|
const { formatDate } = useIntl();
|
||||||
|
|
||||||
const formatTimeStamp = (value) => {
|
const formatTimeStamp = (value: string) => {
|
||||||
const date = parseISO(value);
|
const date = parseISO(value);
|
||||||
|
|
||||||
const formattedDate = formatDate(date, {
|
const formattedDate = formatDate(date, {
|
||||||
@ -20,5 +20,3 @@ const useFormatTimeStamp = () => {
|
|||||||
|
|
||||||
return formatTimeStamp;
|
return formatTimeStamp;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default useFormatTimeStamp;
|
|
||||||
@ -1,5 +1,3 @@
|
|||||||
import React from 'react';
|
|
||||||
|
|
||||||
import { configureStore } from '@reduxjs/toolkit';
|
import { configureStore } from '@reduxjs/toolkit';
|
||||||
import { fixtures } from '@strapi/admin-test-utils';
|
import { fixtures } from '@strapi/admin-test-utils';
|
||||||
import { lightTheme, ThemeProvider } from '@strapi/design-system';
|
import { lightTheme, ThemeProvider } from '@strapi/design-system';
|
||||||
@ -11,12 +9,14 @@ import { QueryClient, QueryClientProvider } from 'react-query';
|
|||||||
import { Provider } from 'react-redux';
|
import { Provider } from 'react-redux';
|
||||||
import { Router } from 'react-router-dom';
|
import { Router } from 'react-router-dom';
|
||||||
|
|
||||||
import useAuditLogsData from '../hooks/useAuditLogsData';
|
import { useAuditLogsData } from '../hooks/useAuditLogsData';
|
||||||
import ListView from '../index';
|
import { ListView } from '../ListPage';
|
||||||
|
|
||||||
import { getBigTestPageData, TEST_PAGE_DATA, TEST_SINGLE_DATA } from './utils/data';
|
import { getBigTestPageData, TEST_PAGE_DATA, TEST_SINGLE_DATA } from './utils/data';
|
||||||
|
|
||||||
jest.mock('../hooks/useAuditLogsData', () => jest.fn());
|
jest.mock('../hooks/useAuditLogsData', () => ({
|
||||||
|
useAuditLogsData: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
const mockUseQuery = jest.fn();
|
const mockUseQuery = jest.fn();
|
||||||
|
|
||||||
@ -41,8 +41,8 @@ jest.mock('@strapi/helper-plugin', () => ({
|
|||||||
|
|
||||||
const history = createMemoryHistory();
|
const history = createMemoryHistory();
|
||||||
|
|
||||||
const setup = (props) => ({
|
const setup = () => ({
|
||||||
...render(<ListView {...props} />, {
|
...render(<ListView />, {
|
||||||
wrapper({ children }) {
|
wrapper({ children }) {
|
||||||
const client = new QueryClient({
|
const client = new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
@ -105,6 +105,7 @@ describe('ADMIN | Pages | AUDIT LOGS | ListView', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should render page with right header details', () => {
|
it('should render page with right header details', () => {
|
||||||
|
// @ts-expect-error - mock
|
||||||
useAuditLogsData.mockReturnValue({
|
useAuditLogsData.mockReturnValue({
|
||||||
auditLogs: {
|
auditLogs: {
|
||||||
results: [],
|
results: [],
|
||||||
@ -121,6 +122,7 @@ describe('ADMIN | Pages | AUDIT LOGS | ListView', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should show a list of audit logs with all actions', async () => {
|
it('should show a list of audit logs with all actions', async () => {
|
||||||
|
// @ts-expect-error - mock
|
||||||
useAuditLogsData.mockReturnValue({
|
useAuditLogsData.mockReturnValue({
|
||||||
auditLogs: {
|
auditLogs: {
|
||||||
results: TEST_PAGE_DATA,
|
results: TEST_PAGE_DATA,
|
||||||
@ -128,12 +130,19 @@ describe('ADMIN | Pages | AUDIT LOGS | ListView', () => {
|
|||||||
isLoading: false,
|
isLoading: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { getByText } = setup();
|
setup();
|
||||||
|
|
||||||
await waitFor(() => expect(getByText('Create role')).toBeInTheDocument());
|
const createRoleEl = await screen.findByText('Create role');
|
||||||
await waitFor(() => expect(getByText('Delete role')).toBeInTheDocument());
|
expect(createRoleEl).toBeInTheDocument();
|
||||||
await waitFor(() => expect(getByText('Create entry (article)')).toBeInTheDocument());
|
|
||||||
await waitFor(() => expect(getByText('Admin logout')).toBeInTheDocument());
|
const deleteRoleEl = await screen.findByText('Delete role');
|
||||||
|
expect(deleteRoleEl).toBeInTheDocument();
|
||||||
|
|
||||||
|
const createEntryEl = await screen.findByText('Create entry (article)');
|
||||||
|
expect(createEntryEl).toBeInTheDocument();
|
||||||
|
|
||||||
|
const adminLogoutEl = await screen.findByText('Admin logout');
|
||||||
|
expect(adminLogoutEl).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should open a modal when clicked on a table row and close modal when clicked', async () => {
|
it('should open a modal when clicked on a table row and close modal when clicked', async () => {
|
||||||
@ -152,8 +161,10 @@ describe('ADMIN | Pages | AUDIT LOGS | ListView', () => {
|
|||||||
status: 'success',
|
status: 'success',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// eslint-disable-next-line testing-library/no-node-access
|
||||||
const auditLogRow = getByText('Create role').closest('tr');
|
const auditLogRow = getByText('Create role').closest('tr');
|
||||||
await user.click(auditLogRow);
|
|
||||||
|
if (auditLogRow) await user.click(auditLogRow);
|
||||||
|
|
||||||
const modal = screen.getByRole('dialog');
|
const modal = screen.getByRole('dialog');
|
||||||
expect(modal).toBeInTheDocument();
|
expect(modal).toBeInTheDocument();
|
||||||
@ -163,12 +174,16 @@ describe('ADMIN | Pages | AUDIT LOGS | ListView', () => {
|
|||||||
expect(modalContainer.getByText('test user')).toBeInTheDocument();
|
expect(modalContainer.getByText('test user')).toBeInTheDocument();
|
||||||
expect(modalContainer.getAllByText('December 22, 2022, 16:11:03')).toHaveLength(2);
|
expect(modalContainer.getAllByText('December 22, 2022, 16:11:03')).toHaveLength(2);
|
||||||
|
|
||||||
|
// eslint-disable-next-line testing-library/no-node-access
|
||||||
const closeButton = modalContainer.getByText(/close the modal/i).closest('button');
|
const closeButton = modalContainer.getByText(/close the modal/i).closest('button');
|
||||||
await user.click(closeButton);
|
|
||||||
|
if (closeButton) await user.click(closeButton);
|
||||||
|
|
||||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should show pagination and be on page 1 on first render', async () => {
|
it('should show pagination and be on page 1 on first render', async () => {
|
||||||
|
// @ts-expect-error - mock
|
||||||
useAuditLogsData.mockReturnValue({
|
useAuditLogsData.mockReturnValue({
|
||||||
auditLogs: {
|
auditLogs: {
|
||||||
results: getBigTestPageData(15),
|
results: getBigTestPageData(15),
|
||||||
@ -182,12 +197,17 @@ describe('ADMIN | Pages | AUDIT LOGS | ListView', () => {
|
|||||||
isLoading: false,
|
isLoading: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { getByText } = setup();
|
setup();
|
||||||
|
|
||||||
await waitFor(() => expect(getByText(/go to page 1/i).closest('a')).toHaveClass('active'));
|
const goToPageEl = await screen.findByText(/go to page 1/i);
|
||||||
|
// eslint-disable-next-line testing-library/no-node-access
|
||||||
|
const goToPageLink = goToPageEl.closest('a');
|
||||||
|
|
||||||
|
expect(goToPageLink).toHaveClass('active');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should paginate the results', async () => {
|
it('should paginate the results', async () => {
|
||||||
|
// @ts-expect-error - mock
|
||||||
useAuditLogsData.mockReturnValue({
|
useAuditLogsData.mockReturnValue({
|
||||||
auditLogs: {
|
auditLogs: {
|
||||||
results: getBigTestPageData(35),
|
results: getBigTestPageData(35),
|
||||||
@ -207,28 +227,37 @@ describe('ADMIN | Pages | AUDIT LOGS | ListView', () => {
|
|||||||
// Should have pagination section with 4 pages
|
// Should have pagination section with 4 pages
|
||||||
const pagination = getByLabelText(/pagination/i);
|
const pagination = getByLabelText(/pagination/i);
|
||||||
expect(pagination).toBeVisible();
|
expect(pagination).toBeVisible();
|
||||||
|
// eslint-disable-next-line testing-library/no-node-access
|
||||||
const pageButtons = getAllByText(/go to page \d+/i).map((el) => el.closest('a'));
|
const pageButtons = getAllByText(/go to page \d+/i).map((el) => el.closest('a'));
|
||||||
expect(pageButtons.length).toBe(4);
|
expect(pageButtons.length).toBe(4);
|
||||||
|
|
||||||
// Can't go to previous page since there isn't one
|
// Can't go to previous page since there isn't one
|
||||||
|
// eslint-disable-next-line testing-library/no-node-access
|
||||||
expect(getByText(/go to previous page/i).closest('a')).toHaveAttribute('aria-disabled', 'true');
|
expect(getByText(/go to previous page/i).closest('a')).toHaveAttribute('aria-disabled', 'true');
|
||||||
|
|
||||||
// Can go to next page
|
// Can go to next page
|
||||||
await user.click(getByText(/go to next page/i).closest('a'));
|
// eslint-disable-next-line testing-library/no-node-access
|
||||||
|
const nextPageLinkEl = getByText(/go to next page/i).closest('a');
|
||||||
|
if (nextPageLinkEl) await user.click(nextPageLinkEl);
|
||||||
expect(history.location.search).toBe('?page=2');
|
expect(history.location.search).toBe('?page=2');
|
||||||
|
|
||||||
// Can go to previous page
|
// Can go to previous page
|
||||||
await user.click(getByText(/go to previous page/i).closest('a'));
|
// eslint-disable-next-line testing-library/no-node-access
|
||||||
|
const previousPageLinkEl = getByText(/go to previous page/i).closest('a');
|
||||||
|
if (previousPageLinkEl) await user.click(previousPageLinkEl);
|
||||||
expect(history.location.search).toBe('?page=1');
|
expect(history.location.search).toBe('?page=1');
|
||||||
|
|
||||||
// Can go to specific page
|
// Can go to specific page
|
||||||
await user.click(getByText(/go to page 3/i).closest('a'));
|
// eslint-disable-next-line testing-library/no-node-access
|
||||||
|
const thirdPageLinkEl = getByText(/go to page 3/i).closest('a');
|
||||||
|
if (thirdPageLinkEl) await user.click(thirdPageLinkEl);
|
||||||
expect(history.location.search).toBe('?page=3');
|
expect(history.location.search).toBe('?page=3');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should show 20 elements if pageSize is 20', async () => {
|
it('should show 20 elements if pageSize is 20', async () => {
|
||||||
history.location.search = '?pageSize=20';
|
history.location.search = '?pageSize=20';
|
||||||
|
|
||||||
|
// @ts-expect-error - mock
|
||||||
useAuditLogsData.mockReturnValue({
|
useAuditLogsData.mockReturnValue({
|
||||||
auditLogs: {
|
auditLogs: {
|
||||||
results: getBigTestPageData(20),
|
results: getBigTestPageData(20),
|
||||||
@ -244,11 +273,17 @@ describe('ADMIN | Pages | AUDIT LOGS | ListView', () => {
|
|||||||
|
|
||||||
const { container } = setup();
|
const { container } = setup();
|
||||||
|
|
||||||
const rows = await waitFor(() => container.querySelector('tbody').querySelectorAll('tr'));
|
// eslint-disable-next-line testing-library/no-node-access
|
||||||
|
const tableRows = container.querySelector('tbody')?.querySelectorAll('tr');
|
||||||
|
|
||||||
|
if (tableRows) {
|
||||||
|
const rows = await waitFor(() => tableRows);
|
||||||
expect(rows.length).toEqual(20);
|
expect(rows.length).toEqual(20);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should show the correct inputs for filtering', async () => {
|
it('should show the correct inputs for filtering', async () => {
|
||||||
|
// @ts-expect-error - mock
|
||||||
useAuditLogsData.mockReturnValue({
|
useAuditLogsData.mockReturnValue({
|
||||||
auditLogs: {
|
auditLogs: {
|
||||||
results: TEST_PAGE_DATA,
|
results: TEST_PAGE_DATA,
|
||||||
@ -260,8 +295,8 @@ describe('ADMIN | Pages | AUDIT LOGS | ListView', () => {
|
|||||||
const filtersButton = getByRole('button', { name: /filters/i });
|
const filtersButton = getByRole('button', { name: /filters/i });
|
||||||
await user.click(filtersButton);
|
await user.click(filtersButton);
|
||||||
|
|
||||||
const filterButton = getByLabelText(/select field/i, { name: 'action' });
|
const filterButton = getByLabelText(/select field/i);
|
||||||
const operatorButton = getByLabelText(/select filter/i, { name: 'is' });
|
const operatorButton = getByLabelText(/select filter/i);
|
||||||
const comboBoxInput = getByPlaceholderText(/select or enter a value/i);
|
const comboBoxInput = getByPlaceholderText(/select or enter a value/i);
|
||||||
const addFilterButton = getByRole('button', { name: /add filter/i });
|
const addFilterButton = getByRole('button', { name: /add filter/i });
|
||||||
|
|
||||||
@ -272,6 +307,7 @@ describe('ADMIN | Pages | AUDIT LOGS | ListView', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should add filters to the query params', async () => {
|
it('should add filters to the query params', async () => {
|
||||||
|
// @ts-expect-error - mock
|
||||||
useAuditLogsData.mockReturnValue({
|
useAuditLogsData.mockReturnValue({
|
||||||
auditLogs: {
|
auditLogs: {
|
||||||
results: TEST_PAGE_DATA,
|
results: TEST_PAGE_DATA,
|
||||||
@ -57,7 +57,7 @@ const TEST_SINGLE_DATA = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const getBigTestPageData = (quantity) => {
|
const getBigTestPageData = (quantity: number) => {
|
||||||
const data = [];
|
const data = [];
|
||||||
|
|
||||||
for (let i = 0; i < quantity; i++) {
|
for (let i = 0; i < quantity; i++) {
|
||||||
@ -29,6 +29,6 @@ export const actionTypes = {
|
|||||||
'permission.delete': 'Delete permission',
|
'permission.delete': 'Delete permission',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getDefaultMessage = (value) => {
|
export const getDefaultMessage = (value: keyof typeof actionTypes) => {
|
||||||
return actionTypes[value] || value;
|
return actionTypes[value] || value;
|
||||||
};
|
};
|
||||||
@ -1,4 +1,7 @@
|
|||||||
import ComboboxFilter from '../ComboboxFilter';
|
import { IntlShape } from 'react-intl';
|
||||||
|
|
||||||
|
import { SanitizedAdminUser } from '../../../../../../../../shared/contracts/shared';
|
||||||
|
import { ComboboxFilter } from '../components/ComboboxFilter';
|
||||||
|
|
||||||
import { actionTypes, getDefaultMessage } from './getActionTypesDefaultMessages';
|
import { actionTypes, getDefaultMessage } from './getActionTypesDefaultMessages';
|
||||||
|
|
||||||
@ -13,8 +16,17 @@ const customOperators = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const getDisplayedFilters = ({ formatMessage, users, canReadUsers }) => {
|
export const getDisplayedFilters = ({
|
||||||
const actionOptions = Object.keys(actionTypes).map((action) => {
|
formatMessage,
|
||||||
|
users,
|
||||||
|
canReadUsers,
|
||||||
|
}: {
|
||||||
|
formatMessage: IntlShape['formatMessage'];
|
||||||
|
users: SanitizedAdminUser[];
|
||||||
|
canReadUsers: boolean;
|
||||||
|
}) => {
|
||||||
|
// Default return of Object.keys function is string
|
||||||
|
const actionOptions = (Object.keys(actionTypes) as (keyof typeof actionTypes)[]).map((action) => {
|
||||||
return {
|
return {
|
||||||
label: formatMessage(
|
label: formatMessage(
|
||||||
{
|
{
|
||||||
@ -54,7 +66,7 @@ const getDisplayedFilters = ({ formatMessage, users, canReadUsers }) => {
|
|||||||
];
|
];
|
||||||
|
|
||||||
if (canReadUsers && users) {
|
if (canReadUsers && users) {
|
||||||
const getDisplayNameFromUser = (user) => {
|
const getDisplayNameFromUser = (user: SanitizedAdminUser) => {
|
||||||
if (user.username) {
|
if (user.username) {
|
||||||
return user.username;
|
return user.username;
|
||||||
}
|
}
|
||||||
@ -103,5 +115,3 @@ const getDisplayedFilters = ({ formatMessage, users, canReadUsers }) => {
|
|||||||
|
|
||||||
return filters;
|
return filters;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default getDisplayedFilters;
|
|
||||||
@ -1,4 +1,7 @@
|
|||||||
const tableHeaders = [
|
import { SanitizedAdminUserForAuditLogs } from '../../../../../../../../shared/contracts/audit-logs';
|
||||||
|
import { TableHeader } from '../components/TableRows';
|
||||||
|
|
||||||
|
export const tableHeaders = [
|
||||||
{
|
{
|
||||||
name: 'action',
|
name: 'action',
|
||||||
key: 'action',
|
key: 'action',
|
||||||
@ -31,8 +34,7 @@ const tableHeaders = [
|
|||||||
},
|
},
|
||||||
sortable: false,
|
sortable: false,
|
||||||
},
|
},
|
||||||
cellFormatter: (user) => (user ? user.displayName : ''),
|
cellFormatter: (user: any) =>
|
||||||
|
user ? (user as SanitizedAdminUserForAuditLogs).displayName : '',
|
||||||
},
|
},
|
||||||
];
|
] as const;
|
||||||
|
|
||||||
export default tableHeaders;
|
|
||||||
@ -1,19 +1,27 @@
|
|||||||
import getDisplayedFilters from '../utils/getDisplayedFilters';
|
import { getDisplayedFilters } from '../getDisplayedFilters';
|
||||||
|
|
||||||
const mockUsers = [
|
const mockUsers = [
|
||||||
{
|
{
|
||||||
id: 1,
|
id: 1,
|
||||||
firstname: 'test',
|
firstname: 'test',
|
||||||
lastname: 'tester',
|
lastname: 'tester',
|
||||||
username: null,
|
|
||||||
email: 'test@test.com',
|
email: 'test@test.com',
|
||||||
|
isActive: true,
|
||||||
|
blocked: false,
|
||||||
|
createdAt: '',
|
||||||
|
updatedAt: '',
|
||||||
|
roles: [],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 2,
|
id: 2,
|
||||||
firstname: 'test2',
|
firstname: 'test2',
|
||||||
lastname: 'tester2',
|
lastname: 'tester2',
|
||||||
username: null,
|
|
||||||
email: 'test2@test.com',
|
email: 'test2@test.com',
|
||||||
|
isActive: true,
|
||||||
|
blocked: false,
|
||||||
|
createdAt: '',
|
||||||
|
updatedAt: '',
|
||||||
|
roles: [],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@ -21,6 +29,7 @@ describe('Audit Logs getDisplayedFilters', () => {
|
|||||||
it('should return all filters when canReadUsers is true', () => {
|
it('should return all filters when canReadUsers is true', () => {
|
||||||
const filters = getDisplayedFilters({
|
const filters = getDisplayedFilters({
|
||||||
users: mockUsers,
|
users: mockUsers,
|
||||||
|
// @ts-expect-error - mock
|
||||||
formatMessage: jest.fn(({ defaultMessage }) => defaultMessage),
|
formatMessage: jest.fn(({ defaultMessage }) => defaultMessage),
|
||||||
canReadUsers: true,
|
canReadUsers: true,
|
||||||
});
|
});
|
||||||
@ -31,6 +40,7 @@ describe('Audit Logs getDisplayedFilters', () => {
|
|||||||
it('should not return user filter when canReadUsers is false', () => {
|
it('should not return user filter when canReadUsers is false', () => {
|
||||||
const filters = getDisplayedFilters({
|
const filters = getDisplayedFilters({
|
||||||
users: mockUsers,
|
users: mockUsers,
|
||||||
|
// @ts-expect-error - mock
|
||||||
formatMessage: jest.fn(({ defaultMessage }) => defaultMessage),
|
formatMessage: jest.fn(({ defaultMessage }) => defaultMessage),
|
||||||
canReadUsers: false,
|
canReadUsers: false,
|
||||||
});
|
});
|
||||||
@ -1,6 +1,11 @@
|
|||||||
import { errors } from '@strapi/utils';
|
import { errors } from '@strapi/utils';
|
||||||
import { Entity, Pagination, SanitizedAdminUser } from './shared';
|
import { Entity, Pagination, SanitizedAdminUser } from './shared';
|
||||||
|
|
||||||
|
// displayName seems to be used only for audit logs
|
||||||
|
export interface SanitizedAdminUserForAuditLogs extends SanitizedAdminUser {
|
||||||
|
displayName: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface AuditLog extends Pick<Entity, 'id'> {
|
interface AuditLog extends Pick<Entity, 'id'> {
|
||||||
date: string;
|
date: string;
|
||||||
action: string;
|
action: string;
|
||||||
@ -9,7 +14,7 @@ interface AuditLog extends Pick<Entity, 'id'> {
|
|||||||
* However, we know it's JSON.
|
* However, we know it's JSON.
|
||||||
*/
|
*/
|
||||||
payload: Record<string, unknown>;
|
payload: Record<string, unknown>;
|
||||||
user?: SanitizedAdminUser;
|
user?: SanitizedAdminUserForAuditLogs;
|
||||||
}
|
}
|
||||||
|
|
||||||
namespace GetAll {
|
namespace GetAll {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user