diff --git a/client/modules/_hooks/src/audit/index.ts b/client/modules/_hooks/src/audit/index.ts new file mode 100644 index 000000000..e555c196d --- /dev/null +++ b/client/modules/_hooks/src/audit/index.ts @@ -0,0 +1,6 @@ +export * from './useGetRecentSession'; +export { useGetTapisFileHistory } from './useGetTapisFileHistory'; +export { useGetPortalFileHistory } from './useGetPortalFileHistory'; +export { useGetUsernames } from './useGetUsernames'; +export type { TapisFileAuditEntry } from './useGetTapisFileHistory'; +export type { PortalFileAuditEntry } from './useGetPortalFileHistory'; diff --git a/client/modules/_hooks/src/audit/useGetPortalFileHistory.ts b/client/modules/_hooks/src/audit/useGetPortalFileHistory.ts new file mode 100644 index 000000000..8198d1209 --- /dev/null +++ b/client/modules/_hooks/src/audit/useGetPortalFileHistory.ts @@ -0,0 +1,31 @@ +import { useQuery } from '@tanstack/react-query'; + +interface PortalFileAuditResponse { + data: PortalFileAuditEntry[]; +} + +export interface PortalFileAuditEntry { + timestamp: string; + portal: string; + username: string; + action: string; + tracking_id: string; + data: object; +} + +async function fetchPortalFileHistory( + filename: string +): Promise { + const encoded = encodeURIComponent(filename); + const response = await fetch(`/audit/api/file/${encoded}/portal/combined/`); + if (!response.ok) throw new Error(`API Error: ${response.status}`); + return response.json(); +} + +export function useGetPortalFileHistory(filename: string, enabled: boolean) { + return useQuery({ + queryKey: ['portalFileHistory', filename], + queryFn: () => fetchPortalFileHistory(filename), + enabled, + }); +} diff --git a/client/modules/_hooks/src/audit/useGetRecentSession.ts b/client/modules/_hooks/src/audit/useGetRecentSession.ts new file mode 100644 index 000000000..0cab87ada --- /dev/null +++ b/client/modules/_hooks/src/audit/useGetRecentSession.ts @@ -0,0 +1,35 @@ +import { useQuery } from '@tanstack/react-query'; + +interface PortalAuditResponse { + data: PortalAuditEntry[]; +} + +export interface PortalAuditEntry { + timestamp: string; + portal: string; + username: string; + action: string; + tracking_id: string; + data: object; +} + +async function fetchPortalAudit( + username: string +): Promise { + const encoded = encodeURIComponent(username); + const response = await fetch(`/audit/api/user/${encoded}/portal/`); + if (!response.ok) { + throw new Error( + `API request failed: ${response.status} ${response.statusText}` + ); + } + return response.json(); +} + +export function useGetRecentSession(username: string, enabled: boolean) { + return useQuery({ + queryKey: ['audit', username], + queryFn: () => fetchPortalAudit(username), + enabled, + }); +} diff --git a/client/modules/_hooks/src/audit/useGetTapisFileHistory.ts b/client/modules/_hooks/src/audit/useGetTapisFileHistory.ts new file mode 100644 index 000000000..6d8113d50 --- /dev/null +++ b/client/modules/_hooks/src/audit/useGetTapisFileHistory.ts @@ -0,0 +1,31 @@ +import { useQuery } from '@tanstack/react-query'; + +interface TapisFileAuditResponse { + data: TapisFileAuditEntry[]; +} + +export interface TapisFileAuditEntry { + writer_logtime: string; //date&time + obo_user: string; //user + action: string; //action + target_path: string; //path flow - maybe location + source_path: string; //path flow - maybe location + data: object; //details +} + +async function fetchTapisFileHistory( + filename: string +): Promise { + const encoded = encodeURIComponent(filename); + const response = await fetch(`/audit/api/file/${encoded}/tapis/`); + if (!response.ok) throw new Error(`API Error: ${response.status}`); + return response.json(); +} + +export function useGetTapisFileHistory(filename: string, enabled: boolean) { + return useQuery({ + queryKey: ['fileHistory', filename], + queryFn: () => fetchTapisFileHistory(filename), + enabled, + }); +} diff --git a/client/modules/_hooks/src/audit/useGetUsernames.ts b/client/modules/_hooks/src/audit/useGetUsernames.ts new file mode 100644 index 000000000..4a72dd3b3 --- /dev/null +++ b/client/modules/_hooks/src/audit/useGetUsernames.ts @@ -0,0 +1,15 @@ +import { useQuery } from '@tanstack/react-query'; + +export async function fetchPortalUsernames(): Promise { + const response = await fetch('/audit/api/usernames/portal'); + if (!response.ok) throw new Error('Failed to fetch usernames'); + const data = await response.json(); + return data.usernames || []; +} + +export function useGetUsernames() { + return useQuery({ + queryKey: ['portalUsernames'], + queryFn: fetchPortalUsernames, + }); +} diff --git a/client/modules/_hooks/src/index.ts b/client/modules/_hooks/src/index.ts index 701dc2d83..9a1bb215e 100644 --- a/client/modules/_hooks/src/index.ts +++ b/client/modules/_hooks/src/index.ts @@ -8,3 +8,4 @@ export * from './datafiles'; export * from './systems'; export * from './notifications'; export * from './onboarding'; +export * from './audit'; diff --git a/client/src/audit/AuditTrail.tsx b/client/src/audit/AuditTrail.tsx new file mode 100644 index 000000000..dd9fd3de0 --- /dev/null +++ b/client/src/audit/AuditTrail.tsx @@ -0,0 +1,121 @@ +import React, { useState } from 'react'; +import { AutoComplete } from 'antd'; +import { + useGetRecentSession, + useGetPortalFileHistory, + useGetUsernames, +} from '@client/hooks'; +import AuditTrailSessionTable from './AuditTrailSessionTable'; +import AuditTrailFileTable from './AuditTrailFileTable'; + +const AuditTrail: React.FC = () => { + type Mode = 'user-session' | 'portal-file'; + const [query, setQuery] = useState(''); + const [mode, setMode] = useState('user-session'); + + const { data: allUsernames } = useGetUsernames(); + const { + data: portalData, + error: portalError, + isLoading: portalLoading, + refetch: refetchPortal, + } = useGetRecentSession(query, false); + + const { + data: fileData, + error: fileError, + isLoading: fileLoading, + refetch: refetchFile, + } = useGetPortalFileHistory(query, false); + + const filteredUsernames = + query.length > 0 && allUsernames + ? allUsernames + .filter((name) => name.toLowerCase().includes(query.toLowerCase())) + .slice(0, 20) + : []; + + const auditData = mode === 'user-session' ? portalData : fileData; + const auditError = mode === 'user-session' ? portalError : fileError; + const auditLoading = mode === 'user-session' ? portalLoading : fileLoading; + const auditRefetch = mode === 'user-session' ? refetchPortal : refetchFile; + + const onSearch = (e: React.FormEvent) => { + e.preventDefault(); + const trimmed = query.trim(); + if (!trimmed) return; + if (trimmed !== query) setQuery(trimmed); + auditRefetch(); + }; + + return ( +
+
+
+ + {mode === 'user-session' ? ( + ({ + value: name, + label: name, + }))} + onSelect={(value) => setQuery(value)} + onSearch={(searchText) => setQuery(searchText)} + placeholder="Username" + /> + ) : ( + setQuery(e.target.value)} + placeholder="Filename" + style={{ width: '200px' }} + maxLength={512} + /> + )} + +
+
+ + {mode === 'user-session' ? ( + + ) : ( + + )} +
+ ); +}; + +export default AuditTrail; diff --git a/client/src/audit/AuditTrailFileTable.tsx b/client/src/audit/AuditTrailFileTable.tsx new file mode 100644 index 000000000..13ff84562 --- /dev/null +++ b/client/src/audit/AuditTrailFileTable.tsx @@ -0,0 +1,252 @@ +import React, { useState } from 'react'; +import { + DownOutlined, + UpOutlined, + InfoCircleOutlined, +} from '@ant-design/icons'; +import { Tooltip } from 'antd'; +import AuditTrailFileTimeline from './AuditTrailFileTimeline'; +import { PortalFileAuditEntry } from '@client/hooks'; +import { Spinner } from '@client/common-components'; +import styles from './AuditTrails.module.css'; + +interface AuditTrailFileTableProps { + auditData: { data: PortalFileAuditEntry[] } | undefined; + auditError: Error | null; + auditLoading: boolean; + searchTerm?: string; +} + +type DataObj = { + path?: string; + body?: { file_name?: string; new_name?: string }; +}; + +const AuditTrailFileTable: React.FC = ({ + auditData, + auditError, + auditLoading, + searchTerm, +}) => { + const [expandedItems, setExpandedItems] = useState>(new Set()); + + const toggleExpanded = (index: number) => { + const newExpanded = new Set(expandedItems); + if (newExpanded.has(index)) { + newExpanded.delete(index); + } else { + newExpanded.add(index); + } + setExpandedItems(newExpanded); + }; + + //changing format to readable one + const formatTimestamp = (timestamp: string) => { + const date = new Date(timestamp); + return date.toLocaleString('en-US', { + hour: 'numeric', + minute: '2-digit', + hour12: true, + month: 'numeric', + day: 'numeric', + year: '2-digit', + }); + }; + + //getting filename to display on each dropdown menu, is "rename" row, uses body.new_name, if "upload" row, uses body.file_name + // if else, try to get basename from data.path, and if all fails use tracking_id as filename + const getFilename = (entry: PortalFileAuditEntry) => { + if (!entry) return 'Unknown file'; + const action = (entry.action || '').toLowerCase(); + const dataObj: DataObj | undefined = + typeof entry.data === 'string' ? undefined : (entry.data as DataObj); + const body = dataObj?.body; + if (action === 'rename' && body && body.new_name) { + return body.new_name; + } + if (body && body.file_name) { + return body.file_name; + } + const path = dataObj?.path; + if (typeof path === 'string' && path) { + const base = path.replace(/\/$/, '').split('/').pop(); + if (base) return base; + } + //Fallback to tracking_id if nothing else + return entry.tracking_id || 'Unknown file'; + }; + + const getFirstAppearance = (entry: PortalFileAuditEntry) => { + const dataObj: DataObj | undefined = + typeof entry.data === 'string' ? undefined : (entry.data as DataObj); + const rawPath = dataObj?.path; + if (!rawPath || typeof rawPath !== 'string') { + return 'Upload at (path unavailable)'; + } + + const segments = rawPath.split('/'); + const lastSegment = segments[segments.length - 1] || ''; + const directoryPath = lastSegment.includes('.') + ? segments.slice(0, -1).join('/') || '/' + : rawPath; + + return `Upload at ${directoryPath}`; + }; + + const getUser = (entry: PortalFileAuditEntry) => { + return (entry && entry.username) || 'Unknown user'; + }; + + //picking summary entry, if row is upload and search term matches the filename(data.body.file_name) in that row, then we use that one + //is no upload row is found, look for rename row and search terms that matches filename (data.body.new_name) in that row, then we use that one instead + const pickSummaryEntry = (entries: PortalFileAuditEntry[], term?: string) => { + if (!entries || entries.length === 0) return undefined; + const lowered = (term || '').toLowerCase(); + if (lowered) { + //upload search + const uploadHit = entries.find((e) => { + if ((e.action || '').toLowerCase() !== 'upload') return false; + const d: DataObj | undefined = + typeof e.data === 'string' ? undefined : (e.data as DataObj); + return (d?.body?.file_name || '').toLowerCase() === lowered; + }); + if (uploadHit) return uploadHit; + + //rename search + const renameHit = entries.find((e) => { + if ((e.action || '').toLowerCase() !== 'rename') return false; + const d: DataObj | undefined = + typeof e.data === 'string' ? undefined : (e.data as DataObj); + return (d?.body?.new_name || '').toLowerCase() === lowered; + }); + if (renameHit) return renameHit; + } + return entries[0]; + }; + + if (auditLoading) { + return ; + } + + if (auditError) { + return
Error loading file table: {auditError.message}
; + } + + const rawData = auditData && auditData.data ? auditData.data : []; + + const displayData = Array.isArray(rawData[0]) ? rawData : []; + + return ( +
+

File History

+ +
+ + + Timeline guide + +
+
    +
  • Click a row to open its timeline.
  • +
  • Each circle shows an action (upload, rename, move, trash).
  • +
  • Red outline highlights the file name you searched for.
  • +
  • + Hover over filename/first appearance text for full value; use + “View logs” for raw data. +
  • +
+
+
+ + {auditData && + displayData.length === 0 && + !auditLoading && + !auditError &&
No file history found.
} + +
+ {displayData.map((fileEntries, fileIndex) => { + const entriesArr = Array.isArray(fileEntries) + ? fileEntries + : [fileEntries]; + const firstEntry = + pickSummaryEntry( + entriesArr as PortalFileAuditEntry[], + searchTerm + ) || (entriesArr[0] as PortalFileAuditEntry); + const isExpanded = expandedItems.has(fileIndex); + const filename = getFilename(firstEntry); + const firstAppearance = getFirstAppearance(firstEntry); + const user = getUser(firstEntry); + const timestamp = formatTimestamp(firstEntry.timestamp); + + return ( +
+ {/* summary box clickable */} +
toggleExpanded(fileIndex)} + > + {/* filename on the left for each summary box */} +
+ + {filename} + +
+ + {/* middle details column (First Appearance, User, Timestamp) */} +
+
+ First Appearance: + + {firstAppearance} + +
+
+ User: + {user} +
+
+ Timestamp: + {timestamp} +
+
+ + {/* up/down outline icon*/} +
+ {isExpanded ? : } +
+
+ + {isExpanded && ( +
+ +
+ )} +
+ ); + })} +
+
+ ); +}; + +export default AuditTrailFileTable; diff --git a/client/src/audit/AuditTrailFileTimeline.tsx b/client/src/audit/AuditTrailFileTimeline.tsx new file mode 100644 index 000000000..246447de4 --- /dev/null +++ b/client/src/audit/AuditTrailFileTimeline.tsx @@ -0,0 +1,265 @@ +import React, { useState } from 'react'; +import { Modal } from 'antd'; +import { + UploadOutlined, + DownloadOutlined, + EditOutlined, + SwapOutlined, + ArrowRightOutlined, + DeleteOutlined, +} from '@ant-design/icons'; +//not sure what would be the best thing to use here for the icons +import styles from './AuditTrails.module.css'; +import { PortalFileAuditEntry } from '@client/hooks'; + +type DataObj = { + path?: string; + body?: { + file_name?: string; + new_name?: string; + dest_path?: string; + trash_path?: string; + }; +}; + +interface TimelineProps { + operations: PortalFileAuditEntry[]; + filename: string; + searchTerm?: string; +} + +const AuditTrailFileTimeline: React.FC = ({ + operations, + filename, + searchTerm, +}) => { + const [modalOpen, setModalOpen] = useState(false); + const [modalContent, setModalContent] = useState(''); + + //using in filetable as well, maybe move to another file? + const formatTimestamp = (timestamp: string) => { + const date = new Date(timestamp); + return date.toLocaleString('en-US', { + hour: 'numeric', + minute: '2-digit', + hour12: true, + month: 'numeric', + day: 'numeric', + year: '2-digit', + }); + }; + + const getActionIcon = (action: string) => { + switch (action.toLowerCase()) { + case 'upload': + return ; + case 'download': + return ; + case 'rename': + return ; + case 'move': + return ; + case 'delete': + case 'trash': + return ; + default: //don't think I need because all actions possible in query are already accounted for + return ; + } + }; + + const getActionDetails = (operation: PortalFileAuditEntry) => { + const { action } = operation; + const dataObj: DataObj | undefined = + typeof operation.data === 'string' + ? undefined + : (operation.data as DataObj); + + switch (action.toLowerCase()) { + case 'upload': + return { + source: 'N/A', + destination: dataObj?.path || 'N/A', + }; + case 'rename': + return { + source: dataObj?.path || 'N/A', + destination: dataObj?.body?.new_name || 'N/A', + }; + case 'move': + return { + source: dataObj?.path || 'N/A', + destination: dataObj?.body?.dest_path || 'N/A', + }; + case 'trash': + return { + source: dataObj?.path || 'N/A', + destination: dataObj?.body?.trash_path || 'N/A', + }; + default: + return { + source: 'Unknown', + destination: 'Unknown', + }; + } + }; + + const handleViewLogs = (operation: PortalFileAuditEntry) => { + setModalContent(JSON.stringify(operation, null, 2)); + setModalOpen(true); + }; + + const handleModalClose = () => { + setModalOpen(false); + setModalContent(''); + }; + + const isHighlightOperation = (operation: PortalFileAuditEntry): boolean => { + const term = (searchTerm || '').toLowerCase(); + if (!term) return false; + const action = (operation?.action || '').toLowerCase(); + const body = + typeof operation.data === 'string' + ? {} + : (operation.data as DataObj).body || {}; + if (action === 'upload') { + const fileName = (body?.file_name || '').toLowerCase(); + return fileName === term; + } + if (action === 'rename') { + const newName = (body?.new_name || '').toLowerCase(); + return newName === term; + } + return false; + }; + + return ( + <> + +
{modalContent}
+
+ +
+

+ File Timeline: {filename} +

+ +
+
+ {operations.map((operation, index) => { + const actionDetails = getActionDetails(operation); + + return ( + // timeline excluding info box +
+ {/* Timestamp details */} +
+
+ {formatTimestamp(operation.timestamp)} +
+
+
+ {/* Operation Node */} + {(() => { + const highlight = isHighlightOperation(operation); + return ( +
+
{getActionIcon(operation.action)}
+
+ {operation.action.toUpperCase()} +
+
+ ); + })()} + +
+ + {/* Right Details(Info) Box */} +
+ {/* Action Section */} +
+
Action
+
+ {operation.action.charAt(0).toUpperCase() + + operation.action.slice(1)} +
+
+ + {/* File Details Section */} +
+
File Details
+
+ Source: + + {actionDetails.source} + +
+
+ Destination: + + {actionDetails.destination} + +
+
+ + {/* User Info Section */} +
+
User Information
+
+ User: + + {operation.username} + +
+
+ + {/* View Logs Link */} +
+ handleViewLogs(operation)} + > + View logs + +
+
+
+ ); + })} +
+
+ + ); +}; + +export default AuditTrailFileTimeline; diff --git a/client/src/audit/AuditTrailSessionTable.tsx b/client/src/audit/AuditTrailSessionTable.tsx new file mode 100644 index 000000000..5b57189b5 --- /dev/null +++ b/client/src/audit/AuditTrailSessionTable.tsx @@ -0,0 +1,216 @@ +import React, { useState } from 'react'; +import { Modal } from 'antd'; +import styles from './AuditTrails.module.css'; +import { PortalAuditEntry } from '@client/hooks'; +import { Spinner } from '@client/common-components'; + +interface AuditTrailSessionTableProps { + auditData: + | { + data: PortalAuditEntry[]; + } + | undefined; + auditError: Error | null; + auditLoading: boolean; +} + +const AuditTrailSessionTable: React.FC = ({ + auditData, + auditError, + auditLoading, +}) => { + const [modalOpen, setModalOpen] = useState(false); + const [modalContent, setModalContent] = useState(''); + const [footerEntry, setFooterEntry] = useState(null); + + const safePretty = (value: unknown) => { + try { + const parsed = typeof value === 'string' ? JSON.parse(value) : value; + return JSON.stringify(parsed, null, 2); + } catch { + return ''; + } + }; + + const handleViewLogs = (entry: PortalAuditEntry) => { + const content = safePretty(entry.data as unknown); + setModalContent(content); + setFooterEntry(entry); + setModalOpen(true); + }; + + const handleModalClose = () => { + setModalOpen(false); + setModalContent(''); + setFooterEntry(null); + }; + + function truncate(str: string, n: number) { + return str.length > n ? str.slice(0, n) + '…' : str; + } + + const extractActionData = (entry: PortalAuditEntry): string => { + if (!entry.data) return '-'; + + try { + const action = entry.action?.toLowerCase(); + const parsedData = + typeof entry.data == 'string' ? JSON.parse(entry.data) : entry.data; + switch (action) { + case 'submitjob': + return extractDataField(parsedData, 'body.job.name') || '-'; + + case 'getapp': + return extractDataField(parsedData, 'query.appId') || '-'; + + case 'trash': + return extractDataField(parsedData, 'path') || '-'; + + case 'upload': + return extractDataField(parsedData, 'body.file_name') || '-'; + + case 'download': + return extractDataField(parsedData, 'filePath') || '-'; + } + } catch { + return '-'; + } + return '-'; + }; + + const extractDataField = (data: unknown, path: string): string => { + if (!data) return '-'; + const fields = path.split('.'); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let value: any = data as any; + for (let i = 0; i < fields.length; i++) { + if (value && typeof value === 'object' && fields[i] in value) { + value = value[fields[i]]; + } else { + return '-'; + } + } + if (value === undefined || value == null || value === '') { + return '-'; + } + return String(value); + }; + + return ( + <> +

Latest Session History

+ + {footerEntry.username} | {footerEntry.timestamp} |{' '} + {footerEntry.portal} | {footerEntry.action} + + ) + } + centered + width={550} + > +
{modalContent}
+
+ + {auditError && ( +
Error: {auditError.message}
+ )} + + {auditLoading && } + + {auditData?.data && auditData.data.length === 0 && ( +
No records found.
+ )} + + {auditData?.data && auditData.data.length > 0 && ( +
+ + + + {[ + { label: 'User', width: '50px' }, + { label: 'Date', width: '50px' }, + { label: 'Time', width: '50px' }, + { label: 'Portal', width: '100px' }, + { label: 'Action', width: '200px' }, + { label: 'Tracking ID', width: '200px' }, + { label: 'Details', width: '100px' }, + ].map((col) => ( + + ))} + + + + + {(auditData.data as PortalAuditEntry[]).map( + (portalEntry, idx) => { + let dateStr = '-'; + let timeStr = '-'; + if (portalEntry.timestamp) { + const date = new Date(portalEntry.timestamp); + dateStr = date.toLocaleDateString(); + timeStr = date.toLocaleTimeString(); + } + const actionDetails = extractActionData(portalEntry); + + return ( + + + + + + + + + + ); + } + )} + +
+ {col.label} +
+ {portalEntry.username || '-'} + {dateStr}{timeStr} + {portalEntry.portal || '-'} + + {portalEntry.action || '-'} + {actionDetails !== '-' && + `: ${truncate(actionDetails, 50)}`} + + {portalEntry.tracking_id || '-'} + handleViewLogs(portalEntry)} + > + View Logs +
+
+ )} + + ); +}; + +export default AuditTrailSessionTable; diff --git a/client/src/audit/AuditTrails.module.css b/client/src/audit/AuditTrails.module.css new file mode 100644 index 000000000..2fdc4e739 --- /dev/null +++ b/client/src/audit/AuditTrails.module.css @@ -0,0 +1,188 @@ +.headerCell { + border: 1px solid var(--global-color-primary--dark); + padding: 10px; + text-align: center; + background: var(--global-color-primary--normal); +} + +.cell { + border: 1px solid #ccc; + padding: 8px; + overflow: hidden; + word-break: break-word; + overflow-wrap: anywhere; +} + +.tableWrapper { + width: 100%; + overflow-x: auto; +} + +/* FILE TABLE CSS BELOW ================== */ +.clip { + overflow: hidden; + white-space: nowrap; + display: block; + width: 100%; + max-width: 100%; + text-align: left; +} + +.summaryBox { + display: flex; + align-items: center; + padding: 15px; + border: 2px solid; + border-radius: 10px; + background-color: var(--global-color-primary--x-light); + cursor: pointer; + height: 100px; +} + +.filenameSummaryBox { + flex: 0 200px; + font-weight: bold; + font-size: 17px; + display: flex; + align-items: center; + min-width: 0; + overflow: hidden; +} + +/* middle details column (First Appearance, User, Timestamp) */ +.details { + flex: 1; + min-width: 0px; + padding-left: 40px; + display: flex; + flex-direction: column; + gap: 4px; + font-size: 14px; +} + +.row { + display: flex; +} + +.label { + font-weight: bold; + margin-right: 5px; + min-width: 0; + flex-shrink: 0; +} + +.value { + display: block; + white-space: nowrap; + overflow: hidden; + text-overflow: clip; + min-width: 0; +} +/* middle row columns end*/ + +.modalContent { + white-space: pre-wrap; + word-break: break-all; +} + +/* TIMELINE CSS BELOW ======================= */ + +.timelineDropDown { + border: 1px solid var(--global-color-primary--normal); + border-radius: 10px; + font-size: 14px; + padding: 25px; + background-color: #fafafa; + overflow: hidden; +} + +.timelineContainer { + display: flex; + flex-direction: column; + align-items: center; + gap: 30px; + position: relative; + overflow: hidden; +} + +.timeline { + display: flex; + align-items: center; + max-width: 1000px; + min-width: 1000px; + position: relative; + overflow: hidden; +} + +.nodeLine { + position: absolute; + left: calc(50% - 100px); + top: 0; + bottom: 0; + width: 2px; + background-color: var(--global-color-primary--light); +} + +.lineToNode { + width: 60px; + height: 2px; + background-color: var(--global-color-primary--light); +} + +.icon { + font-size: 18px; + color: black; + width: 40px; + height: 40px; + border-radius: 50%; + background-color: var(--global-color-primary--x-light); + display: flex; + align-items: center; + justify-content: center; +} + +.infoBox { + flex: 1; + margin-left: 24px; + min-width: 400px; + padding: 15px; + background-color: #ffffff; + border: 1px solid; + border-radius: 10px; +} + +.headerInfoBox { + font-weight: bold; + font-size: 14px; + margin-bottom: 8px; +} + +.textInfoBox { + margin-left: 6px; + font-size: 13px; +} + +.viewLogsLink { + cursor: pointer; + text-decoration: underline; + font-size: 13px; +} + +.node { + width: 200px; + height: 200px; + border-radius: 50%; + background-color: #ffffff; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; +} + +.timestamp { + font-size: 13px; + margin-right: 25px; + display: flex; + justify-content: flex-end; + min-width: 200px; +} diff --git a/client/src/audit/auditRouter.tsx b/client/src/audit/auditRouter.tsx new file mode 100644 index 000000000..c39052089 --- /dev/null +++ b/client/src/audit/auditRouter.tsx @@ -0,0 +1,13 @@ +import React from 'react'; +import AuditTrail from './AuditTrail'; + +import { createBrowserRouter } from 'react-router-dom'; + +const auditRouter = createBrowserRouter([ + { + path: '/audit', + element: , + }, +]); + +export default auditRouter; diff --git a/client/src/main.tsx b/client/src/main.tsx index 3aa5fc4f8..e142b5520 100644 --- a/client/src/main.tsx +++ b/client/src/main.tsx @@ -6,6 +6,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import workspaceRouter from './workspace/workspaceRouter'; import datafilesRouter from './datafiles/datafilesRouter'; import onboardingRouter from './onboarding/onboardingRouter'; +import auditRouter from './audit/auditRouter'; import { ConfigProvider, ThemeConfig } from 'antd'; const queryClient = new QueryClient(); @@ -88,3 +89,18 @@ if (onboardingElement) { ); } + +//audit trial component +const auditTrailElement = document.getElementById('audit-trail-root'); +if (auditTrailElement) { + const auditTrailRoot = ReactDOM.createRoot(auditTrailElement as HTMLElement); + auditTrailRoot.render( + + + + + + + + ); +} diff --git a/conf/env_files/designsafe.sample.env b/conf/env_files/designsafe.sample.env index c47165d15..7f69954b5 100644 --- a/conf/env_files/designsafe.sample.env +++ b/conf/env_files/designsafe.sample.env @@ -100,6 +100,13 @@ AGAVE_JWT_SERVICE_ACCOUNT= # Admin account PORTAL_ADMIN_USERNAME= +# AUDIT DATABASE +AUDIT_DB_NAME= +AUDIT_DB_HOST= +AUDIT_DB_PORT= +AUDIT_DB_USER= +AUDIT_DB_PASSWORD= + # Tapis Tenant. TAPIS_TENANT_BASEURL= diff --git a/designsafe/apps/audit/__init__.py b/designsafe/apps/audit/__init__.py new file mode 100644 index 000000000..38bae9a86 --- /dev/null +++ b/designsafe/apps/audit/__init__.py @@ -0,0 +1 @@ +# Audit Trail Django App diff --git a/designsafe/apps/audit/apps.py b/designsafe/apps/audit/apps.py new file mode 100644 index 000000000..436652d53 --- /dev/null +++ b/designsafe/apps/audit/apps.py @@ -0,0 +1,12 @@ +"""AppConfig for the audit app.""" + +from django.apps import AppConfig + + +class AuditConfig(AppConfig): + """Configuration for the DesignSafe Audit Trail app.""" + + # default_auto_field = 'django.db.models.BigAutoField' + name = "designsafe.apps.audit" + label = "designsafe_audit" + verbose_name = "DesignSafe Audit Trail" diff --git a/designsafe/apps/audit/templates/designsafe/apps/audit/audit_trail.html b/designsafe/apps/audit/templates/designsafe/apps/audit/audit_trail.html new file mode 100644 index 000000000..d6e11b00c --- /dev/null +++ b/designsafe/apps/audit/templates/designsafe/apps/audit/audit_trail.html @@ -0,0 +1,14 @@ +{% extends "base.html" %} {% load sekizai_tags %} {% block title %}Audit Trail{% endblock %} {% block content %} +
+

Audit Trail

+
+
+ +{% addtoblock "react_assets" %} {% if debug %} + + + +{% else %} {% include "react-assets.html" %} {% endif %} {% endaddtoblock %} {% endblock %} diff --git a/designsafe/apps/audit/urls.py b/designsafe/apps/audit/urls.py new file mode 100644 index 000000000..d1f92e3ac --- /dev/null +++ b/designsafe/apps/audit/urls.py @@ -0,0 +1,31 @@ +"""URL configuration for the audit app.""" + +from django.urls import path +from designsafe.apps.audit import views + +urlpatterns = [ + path("", views.audit_trail, name="audit_trail"), + path( + "api/user//portal/", + views.get_portal_session_audit_search, + name="get_portal_session_audit_search", + ), + path( + "api/file//upload/portal/", + views.get_upload_portal_search, + name="get_upload_portal_search", + ), + path( + "api/file//rename/portal/", + views.get_rename_portal_search, + name="get_rename_portal_search", + ), + path( + "api/file//portal/combined/", + views.get_portal_file_combined_search, + name="get_portal_file_combined_search", + ), + path( + "api/usernames/portal/", views.get_usernames_portal, name="get_usernames_portal" + ), +] diff --git a/designsafe/apps/audit/views.py b/designsafe/apps/audit/views.py new file mode 100644 index 000000000..22b2b5fc7 --- /dev/null +++ b/designsafe/apps/audit/views.py @@ -0,0 +1,491 @@ +"""Views for the audit app.""" + +import json +import logging +from django.shortcuts import render +from django.http import JsonResponse +from django.db import connections, Error as DatabaseError +from django.contrib.auth.decorators import login_required + +logger = logging.getLogger(__name__) + +_MAX_INPUT_LEN = 512 + + +def _validate_len_or_400(value, field_name): + """Return 400 JsonResponse if value is not a reasonable length; else None.""" + if not isinstance(value, str): + return JsonResponse({"error": f"Invalid {field_name}"}, status=400) + trimmed = value.strip() + if not trimmed or len(trimmed) > _MAX_INPUT_LEN: + return JsonResponse({"error": f"Invalid {field_name}"}, status=400) + return None + + +@login_required +def audit_trail(request): + """ + Audit trail view - renders React component for audit functionality + """ + context = {"title": "Audit Trail"} + return render(request, "designsafe/apps/audit/audit_trail.html", context) + + +@login_required +def get_portal_session_audit_search(request, username): + """ + Fetches audit records for given username from portal audit database + """ + try: + bad = _validate_len_or_400(username, "username") + if bad: + return bad + audit_db = connections["audit"] + cursor = audit_db.cursor() + + query = """ + SELECT timestamp, portal, username, action, tracking_id, data + FROM public.portal_audit + WHERE session_id = ( + SELECT session_id + FROM public.portal_audit + WHERE username = %s + ORDER BY timestamp DESC + LIMIT 1 + ) + ORDER BY timestamp ASC; + """ + + cursor.execute(query, [username]) + columns = [col[0] for col in cursor.description] + results = [dict(zip(columns, row)) for row in cursor.fetchall()] + cursor.close() + return JsonResponse({"data": results}) + except DatabaseError as exc: + logger.exception("Error in get_portal_audit_search") + return JsonResponse({"error": str(exc)}, status=500) + + +@login_required +def get_upload_portal_search(request, filename): + """ + Fetches audit records given filename under "upload" action from portal audit database + """ + try: + bad = _validate_len_or_400(filename, "filename") + if bad: + return bad + audit_db = connections["audit"] + cursor = audit_db.cursor() + + query = """ + WITH upload_ids AS ( + SELECT DISTINCT tracking_id + FROM public.portal_audit + WHERE lower(action) = 'upload' + AND lower(data->'body'->>'file_name') = lower(%s) + ) + SELECT timestamp, portal, username, action, tracking_id, data + FROM public.portal_audit + WHERE tracking_id IN (SELECT tracking_id FROM upload_ids) + AND lower(action) IN ('upload','rename','move', 'trash') + ORDER BY timestamp ASC, tracking_id ASC; + """ + + cursor.execute(query, [filename]) + columns = [col[0] for col in cursor.description] + results = [dict(zip(columns, row)) for row in cursor.fetchall()] + cursor.close() + + for row in results: + data_field = row.get("data") + if isinstance(data_field, str): + try: + row["data"] = json.loads(data_field) + except (json.JSONDecodeError, TypeError): + pass + + filtered_results = portal_upload_file_trace(results, filename) + return JsonResponse( + {"data": filtered_results}, + json_dumps_params={"indent": 2, "ensure_ascii": False}, + ) + except DatabaseError as exc: + logger.exception("Error in get_upload_portal_search") + return JsonResponse({"error": str(exc)}, status=500) + + +@login_required +def get_rename_portal_search(request, filename): + """ + Fetches audit records given filename under "rename" action from portal audit database + """ + try: + bad = _validate_len_or_400(filename, "filename") + if bad: + return bad + audit_db = connections["audit"] + cursor = audit_db.cursor() + + query = """ + WITH rename_ids AS ( + SELECT DISTINCT tracking_id + FROM public.portal_audit + WHERE lower(action) = 'rename' + AND lower(data->'body'->>'new_name') = lower(%s) + ) + SELECT timestamp, portal, username, action, tracking_id, data + FROM public.portal_audit + WHERE tracking_id IN (SELECT tracking_id FROM rename_ids) + AND lower(action) IN ('upload','rename','move', 'trash') + ORDER BY timestamp ASC, tracking_id ASC; + """ + + cursor.execute(query, [filename]) + columns = [col[0] for col in cursor.description] + results = [dict(zip(columns, row)) for row in cursor.fetchall()] + cursor.close() + + for row in results: + data_field = row.get("data") + if isinstance(data_field, str): + try: + row["data"] = json.loads(data_field) + except (json.JSONDecodeError, TypeError): + pass + + filtered_results = portal_rename_file_trace(results, filename) + return JsonResponse( + {"data": filtered_results}, + json_dumps_params={"indent": 2, "ensure_ascii": False}, + ) + except DatabaseError as exc: + logger.exception("Error in get_rename_portal_search") + return JsonResponse({"error": str(exc)}, status=500) + + +@login_required +def get_portal_file_combined_search(request, filename: str): + """ + Combined search returns merged results of upload trace and rename trace, + Response format: { "data": [ ...groups from upload..., ...groups from rename... ] } + """ + bad = _validate_len_or_400(filename, "filename") + if bad: + return bad + combined = [] + + for resp in ( + get_upload_portal_search(request, filename), + get_rename_portal_search(request, filename), + ): + try: + if getattr(resp, "status_code", 200) == 200: + payload = json.loads(resp.content or b"{}") + combined.extend(payload.get("data", [])) + except ValueError: + continue + + return JsonResponse( + {"data": combined}, json_dumps_params={"indent": 2, "ensure_ascii": False} + ) + + +# pylint: disable=too-many-locals,too-many-branches,too-many-statements,too-many-nested-blocks +def portal_upload_file_trace(payload: json, filename: str): + """ + Build upload-anchored timelines for a file. + - Start a new group per unique directory containing a matching upload + - Track aliases: directory (aliases_path) and filenames (aliases_filenames) + - Add subsequent rename/move/trash when they match current directory and filename + """ + if isinstance(payload, dict) and "data" in payload: + data = payload.get("data") or [] + elif isinstance(payload, list): + data = payload + else: + data = [] + + flat_data = [] + if isinstance(data, list): + for item in data: + if isinstance(item, list): + flat_data.extend(item) + elif isinstance(item, dict): + flat_data.append(item) + + filename = filename.lower() + kept_rows = ( + [] + ) # list of dicts contataining what will be returned to the frontend, this is going to be like a list of dicts, like list1 containitng 5 dict entries, list2 containning 4 dict entries, and so on until no more data to look at, and all those lists put under keptRows list + aliases_path = ( + [] + ) # list correlating with index on keptRows, ex if keptRows[0] has path "erikriv16/scratch/working", then aliasesPath will have the same as well, good way to keep track + aliases_filenames = ( + [] + ) # list of lists correlating with index on keptRows, ex if keptRows[0] (first dict entry) has a rename row that has new name as fileRename.txt, that file name will be stored in aliasesFilename[0] as a list ['fileRename.txt', .....] - adn keep adding from there if more renames come along + index = 0 + + def get_path_without_filename(full_path): + """Extract path without filename""" + if not full_path or "." not in full_path.split("/")[-1]: + return full_path + return "/".join(full_path.rstrip("/").split("/")[:-1]) + + def get_filename_from_path(full_path): + return full_path.rstrip("/").split("/")[-1].lower() + + def normalize_dir_path(p): + return (p or "").strip().strip("/").lower() + + for entry in flat_data: + action = entry.get("action", "").lower() + entry_data = entry.get("data", {}) + body = entry_data.get("body", {}) + path = entry_data.get("path", "") + file_name = body.get("file_name", "").lower() + + if action == "upload" and file_name == filename: + path_without_filename = normalize_dir_path(get_path_without_filename(path)) + if path_without_filename not in aliases_path: + kept_rows.append([entry]) + aliases_path.append(path_without_filename) + aliases_filenames.append([file_name]) + index = len(kept_rows) - 1 + else: + index = aliases_path.index(path_without_filename) + kept_rows[index].append(entry) + + elif action == "rename": + path_without_filename = normalize_dir_path(get_path_without_filename(path)) + filename_from_path = get_filename_from_path(path) + new_name = body.get("new_name", "").lower() + + # Check each alias group to see if this rename belongs to it + for i, alias_dir in enumerate(aliases_path): + if path_without_filename == alias_dir: + if filename_from_path in aliases_filenames[i]: + kept_rows[i].append(entry) + if new_name: + aliases_filenames[i].append(new_name) + index = i + break + + # if action is move, need to check if path without filename at end in corresponding aliasesPath, adn if filename at end of path in aliasesFilename, if it is, we add this row to keptRows to its correspoding list, and update aliasesPath to this path excluding the filename + elif action == "move": + path_without_filename = normalize_dir_path(get_path_without_filename(path)) + filename_from_path = get_filename_from_path(path) + dest_path = body.get("dest_path", "") + + for i, alias_dir in enumerate(aliases_path): + if ( + path_without_filename == alias_dir + and filename_from_path in aliases_filenames[i] + ): + kept_rows[i].append(entry) + aliases_path[i] = normalize_dir_path(dest_path) + index = i + break + + elif action == "trash": + path_without_filename = normalize_dir_path(get_path_without_filename(path)) + filename_from_path = get_filename_from_path(path) + trash_path = body.get("trash_path", "") + + for i, alias_dir in enumerate(aliases_path): + if ( + path_without_filename == alias_dir + and filename_from_path in aliases_filenames[i] + ): + kept_rows[i].append(entry) + if trash_path: + aliases_path[i] = normalize_dir_path(trash_path) + index = i + break + + return kept_rows + + +# pylint: disable=too-many-locals,too-many-branches,too-many-statements,too-many-nested-blocks +def portal_rename_file_trace(payload: json, filename: str): + """ + Build rename-anchored timelines for a file. + - Find rename rows whose body.new_name matches the searched filename + - Walk backward through renames to resolve the original name + - Prefer upload-style grouping over all rows for that origin + - Fallback: build a chain using filename aliases across rename/move/trash + """ + + if isinstance(payload, dict) and "data" in payload: + data = payload.get("data") or [] + elif isinstance(payload, list): + data = payload + else: + data = [] + + flat_data = [] + if isinstance(data, list): + for item in data: + if isinstance(item, list): + flat_data.extend(item) + elif isinstance(item, dict): + flat_data.append(item) + + # target filename we are tracing + target = (filename or "").lower() + + def get_filename_from_path(full_path): + return (full_path or "").rstrip("/").split("/")[-1].lower() + + def is_rename_target(row, name): + if (row.get("action") or "").lower() != "rename": + return False + data = row.get("data") or {} + body = data.get("body", {}) or {} + return (body.get("new_name") or "").lower() == name + + def resolve_origin_from_index(hit_index): + current = target + for j in range(hit_index, -1, -1): + row_j = flat_data[j] + if (row_j.get("action") or "").lower() != "rename": + continue + data_j = row_j.get("data") or {} + body_j = data_j.get("body", {}) or {} + if (body_j.get("new_name") or "").lower() == current: + previous = get_filename_from_path(data_j.get("path", "")) + if previous: + current = previous + return current + + def extract_origin_from_group(group): + if not group: + return "" + first = group[0] + data0 = first.get("data") or {} + if not isinstance(data0, dict): + try: + data0 = json.loads(data0) + except ValueError: + data0 = {} + body0 = data0.get("body", {}) or {} + file_from_body = (body0.get("file_name") or "").lower() + if file_from_body: + return file_from_body + return get_filename_from_path(data0.get("path", "")) + + def build_alias_chain(origin): + aliases_filename = {origin} + chain = [] + for row in flat_data: + action = (row.get("action") or "").lower() + data_obj = row.get("data") or {} + if not isinstance(data_obj, dict): + try: + data_obj = json.loads(data_obj) + except ValueError: + data_obj = {} + body_obj = data_obj.get("body", {}) or {} + if action == "rename": + old = get_filename_from_path(data_obj.get("path", "")) + if old in aliases_filename: + chain.append(row) + new_name = (body_obj.get("new_name") or "").lower() + if new_name: + aliases_filename.add(new_name) + elif action in ("move", "trash"): + base_name = get_filename_from_path( + data_obj.get("path") or body_obj.get("path") or "" + ) + if base_name in aliases_filename: + chain.append(row) + return chain + + # find rename targets and resolve origins + rename_hit_indexes = [] + for i, row in enumerate(flat_data): + if is_rename_target(row, target): + rename_hit_indexes.append(i) + origins = set() + for i in rename_hit_indexes: + origin = resolve_origin_from_index(i) + if origin: + origins.add(origin) + + # Prefer upload-style grouping for each origin + result_groups = [] + seen_keys = set() + for origin in origins: + groups = portal_upload_file_trace(flat_data, origin) or [] + for g in groups: + if not g: + continue + first = g[0] + first_data = first.get("data") or {} + if not isinstance(first_data, dict): + try: + first_data = json.loads(first_data) + except ValueError: + first_data = {} + first_path = ( + first_data.get("path") + or (first_data.get("body", {}) or {}).get("path") + or "" + ) + key = (first.get("tracking_id"), first.get("timestamp"), first_path) + if key in seen_keys: + continue + seen_keys.add(key) + result_groups.append(g) + + # fallback to alias chain if no upload-based group covered this origin + covered_origins = set() + for g in result_groups: + covered_origins.add(extract_origin_from_group(g)) + for origin in origins: + if origin in covered_origins: + continue + chain = build_alias_chain(origin) + if chain: + first = chain[0] + first_data = first.get("data") or {} + if not isinstance(first_data, dict): + try: + first_data = json.loads(first_data) + except ValueError: + first_data = {} + first_path = ( + first_data.get("path") + or (first_data.get("body", {}) or {}).get("path") + or "" + ) + key = (first.get("tracking_id"), first.get("timestamp"), first_path) + if key not in seen_keys: + seen_keys.add(key) + result_groups.append(chain) + + return result_groups + + +@login_required +def get_usernames_portal(request): + """ + Updating array with usernames for search + """ + try: + audit_db = connections["audit"] + cursor = audit_db.cursor() + + query = """ + SELECT DISTINCT username + FROM public.portal_audit + ORDER BY username; + """ + + cursor.execute(query) + usernames = [row[0] for row in cursor.fetchall()] + + return JsonResponse({"usernames": usernames}) + except DatabaseError as exc: + logger.exception("Error in get_usernames_portal") + return JsonResponse({"error": str(exc)}, status=500) diff --git a/designsafe/settings/common_settings.py b/designsafe/settings/common_settings.py index 1f41db5dd..c436e3666 100644 --- a/designsafe/settings/common_settings.py +++ b/designsafe/settings/common_settings.py @@ -92,6 +92,7 @@ 'designsafe.apps.api.publications_v2', 'designsafe.apps.api.filemeta', 'designsafe.apps.accounts', + 'designsafe.apps.audit', 'designsafe.apps.cms_plugins', 'designsafe.apps.box_integration', 'designsafe.apps.dropbox_integration', @@ -217,6 +218,14 @@ 'USER': os.environ.get('DATABASE_USER'), 'PASSWORD': os.environ.get('DATABASE_PASSWORD'), }, + 'audit': { + 'ENGINE': 'django.db.backends.postgresql', + 'NAME': os.environ.get('AUDIT_DB_NAME'), + 'HOST': os.environ.get('AUDIT_DB_HOST'), + 'PORT': os.environ.get('AUDIT_DB_PORT'), + 'USER': os.environ.get('AUDIT_DB_USER'), + 'PASSWORD': os.environ.get('AUDIT_DB_PASSWORD'), + } } else: DATABASES = { diff --git a/designsafe/settings/test_settings.py b/designsafe/settings/test_settings.py index 79156b581..a165db06e 100644 --- a/designsafe/settings/test_settings.py +++ b/designsafe/settings/test_settings.py @@ -88,6 +88,7 @@ 'designsafe.apps.api.publications_v2', 'designsafe.apps.api.filemeta', 'designsafe.apps.accounts', + 'designsafe.apps.audit', 'designsafe.apps.cms_plugins', 'designsafe.apps.box_integration', 'designsafe.apps.dropbox_integration', diff --git a/designsafe/templates/includes/header.html b/designsafe/templates/includes/header.html index 7d0b18422..ec2941ed1 100644 --- a/designsafe/templates/includes/header.html +++ b/designsafe/templates/includes/header.html @@ -1,72 +1,69 @@ -{% load static %} -{% load static %} -{% load auth_extras %} +{% load static %} {% load static %} {% load auth_extras %} diff --git a/designsafe/urls.py b/designsafe/urls.py index 7004aac4c..90badf06c 100644 --- a/designsafe/urls.py +++ b/designsafe/urls.py @@ -127,6 +127,10 @@ url(r'^register/$', RedirectView.as_view( pattern_name='designsafe_accounts:register', permanent=True), name='register'), + # audit-trail + url(r'^audit/', include(('designsafe.apps.audit.urls', 'designsafe.apps.audit'), + namespace='designsafe_audit')), + # onboarding url(r'^onboarding/', include(('designsafe.apps.onboarding.urls', 'designsafe.apps.onboarding'), namespace='designsafe_onboarding')),