Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions application/admin-client/cypress.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
wipeDB,
calculateHash,
readCommonFile,
seedAuditLogs,
} from 'common/testing/TestHelpers'
import { defineConfig } from 'cypress'

Expand Down Expand Up @@ -49,6 +50,10 @@ export default defineConfig({
readCommonFile(fileName: string) {
return readCommonFile(fileName)
},
async seedAuditLogs(count: number) {
await seedAuditLogs(count)
return null
},
})
},
},
Expand Down
45 changes: 45 additions & 0 deletions application/admin-client/cypress/e2e/auditLogs.cy.js
Original file line number Diff line number Diff line change
@@ -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')
})
})
13 changes: 13 additions & 0 deletions application/admin-client/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
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,
Expand All @@ -39,6 +40,7 @@
AdminPanelSettings,
RestoreFromTrash,
LibraryBooks,
History,
} from '@mui/icons-material'
import { ParticipantEdit } from './pages/participants/edit'
import { SetupPage } from './pages/setup'
Expand Down Expand Up @@ -158,6 +160,16 @@
list: '/studies',
meta: {
icon: <LibraryBooks />,
label: 'Manage Studies',
parent: 'admin',
},
},
{
name: 'audit-logs',
list: '/audit-logs',
meta: {
icon: <History />,
label: 'Audit Logs',
parent: 'admin',
},
},
Expand All @@ -180,7 +192,7 @@
clientConfig: {
defaultOptions: {
queries: {
retry: (failureCount: number, error: any) => {

Check warning on line 195 in application/admin-client/src/App.tsx

View workflow job for this annotation

GitHub Actions / build and check

Unexpected any. Specify a different type

Check warning on line 195 in application/admin-client/src/App.tsx

View workflow job for this annotation

GitHub Actions / build and check

Unexpected any. Specify a different type
if (error.status == 401) {
return false
}
Expand Down Expand Up @@ -245,6 +257,7 @@
<Route path="/settings" index element={<SettingsPage />} />
<Route path="/restore" index element={<RestorePage />} />
<Route path="/studies" index element={<StudiesPage />} />
<Route path="/audit-logs" index element={<AuditLogList />} />
<Route path="*" element={<ErrorComponent />} />
</Route>

Expand Down
15 changes: 7 additions & 8 deletions application/admin-client/src/components/RedcapLogo.tsx
Original file line number Diff line number Diff line change
@@ -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' ? (
<img src="/redcap-logo-dark.png" alt="REDCap Logo" style={{ height: '100px' }} />
) : (
<img src="/redcap-logo-light.png" alt="REDCap Logo" style={{ height: '100px' }} />
)
}
return mode === 'dark' ? (
<img src="/redcap-logo-dark.png" alt="REDCap Logo" style={{ height: '100px' }} />
) : (
<img src="/redcap-logo-light.png" alt="REDCap Logo" style={{ height: '100px' }} />
)
}
1 change: 1 addition & 0 deletions application/admin-client/src/pages/audit-logs/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './list'
173 changes: 173 additions & 0 deletions application/admin-client/src/pages/audit-logs/list.tsx
Original file line number Diff line number Diff line change
@@ -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 <Typography>-</Typography>
}

return (
<Box sx={{ width: '100%', py: 1 }}>
<Button
size="small"
variant="outlined"
color="inherit"
data-cy="toggle-payload-view"
onClick={() => setExpanded((prev) => !prev)}
sx={{ mb: expanded ? 1 : 0, textTransform: 'none' }}
>
{expanded ? 'Hide Payload' : 'View Payload'}
</Button>

<Collapse in={expanded} unmountOnExit data-cy="payload-viewer">
<Box
sx={{
maxHeight: 300,
overflow: 'auto',
p: 1,
bgcolor: 'action.hover',
borderRadius: 1,
fontFamily: 'monospace',
fontSize: '0.75rem',
whiteSpace: 'pre-wrap',
wordBreak: 'break-all',
}}
>
{JSON.stringify(value, null, 2)}
</Box>
</Collapse>
</Box>
)
}

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<GridColDef[]>(() => {
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 <DateField sx={{ p: 2 }} value={value} format="YYYY-MM-DD HH:mm:ss" />
},
},
{
field: 'userId',
headerName: 'userId',
width: 70,
},
{
field: 'meta',
headerName: 'Request Details',
flex: 1,
minWidth: 350,
renderCell: ({ value }) => {
if (!value || !value.method) return '-'

return (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Chip
label={value.method}
size="small"
color={
value.method === 'DELETE'
? 'error'
: value.method === 'POST'
? 'success'
: 'default'
}
sx={{ fontWeight: 'bold', fontSize: '0.7rem' }}
/>
<Typography variant="body2" sx={{ fontFamily: 'monospace' }}>
{value.url}
</Typography>
</Box>
)
},
},
{
field: 'requestBody',
headerName: 'RequestBody',
flex: 2,
minWidth: 300,
renderCell: ({ value }) => <ExpandableJsonCell value={value} />,
},
]
return baseColumns.map((col) => ({
...col,
sortable: col.sortable ?? AUDIT_LOG_SORTABLE_FIELDS.includes(col.field as any),
}))
}, [])
return (
<Box>
<List headerProps={{ title: 'Audit Logs' }}>
<DataGrid
{...dataGridProps}
columns={columns}
getRowHeight={() => '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',
},
}}
/>
</List>
</Box>
)
}
2 changes: 1 addition & 1 deletion application/admin-client/src/pages/restore/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -229,7 +229,7 @@ const RestorePage = () => {
</List>
</>
)}
</Box >
</Box>
)
}

Expand Down
1 change: 0 additions & 1 deletion application/admin-client/src/pages/settings/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,6 @@ const SettingsPage = () => {
</Button>
</Box>
</Box>

</Container>
)
}
Expand Down
11 changes: 10 additions & 1 deletion application/admin-client/src/pages/surveys/edit.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,16 @@ export const SurveyEditor = () => {
return isLoading ? null : (
<Box sx={{ display: 'flex', flexDirection: 'row' }}>
<Box sx={{ border: '1px solid lightgrey', height: '100vh', ml: -3, mt: -3 }}>
<Box sx={{ p: 2, display: 'flex', flexDirection: 'row', gap: 1, alignItems: 'center', justifyContent: 'center' }}>
<Box
sx={{
p: 2,
display: 'flex',
flexDirection: 'row',
gap: 1,
alignItems: 'center',
justifyContent: 'center',
}}
>
<Button
variant="outlined"
data-cy="publish-button"
Expand Down
13 changes: 12 additions & 1 deletion application/admin-client/src/providers/dataProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,10 +84,21 @@ export const dataProvider = (): DataProvider => {
}

if (sorters?.at(0)) {
params.append(`orderBy[${sorters[0].field}]`, sorters[0].order)
if (resource === 'audit-logs') {
params.append('sortBy', sorters[0].field)
params.append('sortDirection', sorters[0].order)
} else {
params.append(`orderBy[${sorters[0].field}]`, sorters[0].order)
}
}

if (studyResources.includes(resource)) url = `/studies/${studyId}/${url}?${params.toString()}`
else if (resource === 'audit-logs') {
const queryString = params.toString()
if (queryString) {
url = `${url}?${queryString}`
}
}
const response = await axiosInstance.get(url)
const data = response.data.data

Expand Down
Loading
Loading