diff --git a/application/admin-client/cypress.config.ts b/application/admin-client/cypress.config.ts index 9394a07f..a2e7d54b 100644 --- a/application/admin-client/cypress.config.ts +++ b/application/admin-client/cypress.config.ts @@ -8,6 +8,7 @@ import { wipeDB, calculateHash, readCommonFile, + seedAuditLogs, } from 'common/testing/TestHelpers' import { defineConfig } from 'cypress' @@ -49,6 +50,10 @@ export default defineConfig({ readCommonFile(fileName: string) { return readCommonFile(fileName) }, + async seedAuditLogs(count: number) { + await seedAuditLogs(count) + return null + }, }) }, }, diff --git a/application/admin-client/cypress/e2e/auditLogs.cy.js b/application/admin-client/cypress/e2e/auditLogs.cy.js new file mode 100644 index 00000000..e580dcba --- /dev/null +++ b/application/admin-client/cypress/e2e/auditLogs.cy.js @@ -0,0 +1,45 @@ +const { UserType } = require('../../../common/cypress/support/commands') + +before(() => { + cy.task('reset') + cy.task('seedAuditLogs', 55) +}) + +// Note: I've decided against testing the sorting and pagination as this comes from the library + +describe('Audit Logs', () => { + it('Organisation Admin can view Audit Log', () => { + cy.login(UserType.ORG_ADMIN) + cy.visit('/audit-logs') + cy.contains('Audit Logs').should('exist') + }) + + it('Study Amin can view Audit Log', () => { + cy.login(UserType.STUDY_ADMIN) + cy.visit('/audit-logs') + cy.contains('Audit Logs').should('exist') + }) + + it('should toggle the View Payload cell and display JSON', () => { + cy.login(UserType.STUDY_ADMIN) + cy.visit('/audit-logs') + + // Toggle button to view payload + cy.get('[data-cy="toggle-payload-view"]').first().should('contain.text', 'View Payload').click() + + // Assert text changes + cy.get('[data-cy="toggle-payload-view"]').first().should('contain.text', 'Hide Payload') + + // Assert JSON appears + // Note: this works because the most recent action is the Study Admin + // logging in at the start of the test :) + cy.get('.MuiCollapse-root').should('be.visible').and('contain.text', UserType.STUDY_ADMIN) + + // Toggle button to hide payload + cy.get('[data-cy="toggle-payload-view"]').first().should('contain.text', 'Hide Payload').click() + + cy.get('[data-cy="toggle-payload-view"]').first().should('contain.text', 'View Payload') + + cy.get('[data-cy="payload-viewer"]').should('not.exist') + }) +}) diff --git a/application/admin-client/src/App.tsx b/application/admin-client/src/App.tsx index 4e1fd7d1..bc5bb89e 100644 --- a/application/admin-client/src/App.tsx +++ b/application/admin-client/src/App.tsx @@ -30,6 +30,7 @@ import { authProvider } from './providers/authProvider' import { ParticipantList, ParticipantShow } from './pages/participants' import { SurveyImport, IntegrationsHome, ParticipantImport } from './pages/integrations' import { ResponsesView } from './pages/responses' +import { AuditLogList } from './pages/audit-logs' import { ListAlt, Person, @@ -39,6 +40,7 @@ import { AdminPanelSettings, RestoreFromTrash, LibraryBooks, + History, } from '@mui/icons-material' import { ParticipantEdit } from './pages/participants/edit' import { SetupPage } from './pages/setup' @@ -158,6 +160,16 @@ function App() { list: '/studies', meta: { icon: , + label: 'Manage Studies', + parent: 'admin', + }, + }, + { + name: 'audit-logs', + list: '/audit-logs', + meta: { + icon: , + label: 'Audit Logs', parent: 'admin', }, }, @@ -245,6 +257,7 @@ function App() { } /> } /> } /> + } /> } /> diff --git a/application/admin-client/src/components/RedcapLogo.tsx b/application/admin-client/src/components/RedcapLogo.tsx index f479eddf..913e4f9b 100644 --- a/application/admin-client/src/components/RedcapLogo.tsx +++ b/application/admin-client/src/components/RedcapLogo.tsx @@ -1,13 +1,12 @@ import { ColorModeContext } from '../contexts/color-mode' import { useContext } from 'react' - export const RedcapLogo = () => { - const { mode } = useContext(ColorModeContext) + const { mode } = useContext(ColorModeContext) - return mode === 'dark' ? ( - REDCap Logo - ) : ( - REDCap Logo - ) -} \ No newline at end of file + return mode === 'dark' ? ( + REDCap Logo + ) : ( + REDCap Logo + ) +} diff --git a/application/admin-client/src/pages/audit-logs/index.ts b/application/admin-client/src/pages/audit-logs/index.ts new file mode 100644 index 00000000..c1edf1e2 --- /dev/null +++ b/application/admin-client/src/pages/audit-logs/index.ts @@ -0,0 +1 @@ +export * from './list' diff --git a/application/admin-client/src/pages/audit-logs/list.tsx b/application/admin-client/src/pages/audit-logs/list.tsx new file mode 100644 index 00000000..c3b8bc2f --- /dev/null +++ b/application/admin-client/src/pages/audit-logs/list.tsx @@ -0,0 +1,173 @@ +import React, { useState } from 'react' +import { defaultAuditLogsPageSize } from '@common/src/config' +import { Box, Button, Chip, Collapse, Typography } from '@mui/material' +import { DataGrid, type GridColDef } from '@mui/x-data-grid' +import { DateField, List, useDataGrid } from '@refinedev/mui' +import { AUDIT_LOG_SORTABLE_FIELDS } from '@common/types/api/audit-logs/getAuditLogs' + +const ExpandableJsonCell = ({ value }: { value: any }) => { + const [expanded, setExpanded] = useState(false) + + if (!value || Object.keys(value).length === 0) { + return - + } + + return ( + + + + + + {JSON.stringify(value, null, 2)} + + + + ) +} + +export const AuditLogList = () => { + const { dataGridProps } = useDataGrid({ + syncWithLocation: false, + // Note: By default Refined uses 'server' mode for pagination, sorting and filtering + // pagination: { mode: 'off' }, + // sorters: { mode: 'off' }, + filters: { mode: 'off' }, + resource: 'audit-logs', + sorters: { + initial: [{ field: 'timestamp', order: 'desc' }], + }, + }) + + const columns = React.useMemo(() => { + const baseColumns: GridColDef[] = [ + { + field: 'id', + headerName: 'ID', + width: 10, + }, + { + field: 'resource', + flex: 1, + headerName: 'Resource', + minWidth: 150, + }, + { + field: 'operation', + headerName: 'Operation', + width: 90, + }, + { + field: 'success', + headerName: 'Success', + width: 80, + }, + { + field: 'timestamp', + headerName: 'Timestamp', + width: 200, + type: 'date', + valueGetter: (value) => { + if (!value) return null + return new Date(value) + }, + renderCell: function render({ value }) { + // ISO format for good sorting properties :) + return + }, + }, + { + field: 'userId', + headerName: 'userId', + width: 70, + }, + { + field: 'meta', + headerName: 'Request Details', + flex: 1, + minWidth: 350, + renderCell: ({ value }) => { + if (!value || !value.method) return '-' + + return ( + + + + {value.url} + + + ) + }, + }, + { + field: 'requestBody', + headerName: 'RequestBody', + flex: 2, + minWidth: 300, + renderCell: ({ value }) => , + }, + ] + return baseColumns.map((col) => ({ + ...col, + sortable: col.sortable ?? AUDIT_LOG_SORTABLE_FIELDS.includes(col.field as any), + })) + }, []) + return ( + + + 'auto'} + getEstimatedRowHeight={() => 52} + pageSizeOptions={[10, defaultAuditLogsPageSize, 50]} + initialState={{ + pagination: { paginationModel: { pageSize: defaultAuditLogsPageSize } }, + }} + slotProps={{ root: { 'data-cy': 'audit-logs-list' } }} + disableColumnMenu + sx={{ + '& .MuiDataGrid-columnHeaderTitleContainer': { + justifyContent: 'center', + }, + '& .MuiDataGrid-cell': { + display: 'flex', + alignItems: 'center', + }, + }} + /> + + + ) +} diff --git a/application/admin-client/src/pages/restore/index.tsx b/application/admin-client/src/pages/restore/index.tsx index 4e4884c6..38f19613 100644 --- a/application/admin-client/src/pages/restore/index.tsx +++ b/application/admin-client/src/pages/restore/index.tsx @@ -229,7 +229,7 @@ const RestorePage = () => { )} - + ) } diff --git a/application/admin-client/src/pages/settings/index.tsx b/application/admin-client/src/pages/settings/index.tsx index 5be76b58..5b34b259 100644 --- a/application/admin-client/src/pages/settings/index.tsx +++ b/application/admin-client/src/pages/settings/index.tsx @@ -198,7 +198,6 @@ const SettingsPage = () => { - ) } diff --git a/application/admin-client/src/pages/surveys/edit.tsx b/application/admin-client/src/pages/surveys/edit.tsx index 0d43e8c5..15b9c665 100644 --- a/application/admin-client/src/pages/surveys/edit.tsx +++ b/application/admin-client/src/pages/surveys/edit.tsx @@ -139,7 +139,16 @@ export const SurveyEditor = () => { return isLoading ? null : ( - + - {Object.keys(errors) && { }} + {Object.keys(errors) && {}} {Object.values(ContactMethod).map((val, idx) => ( - + {val[0] + val.slice(1).toLowerCase()} ))}