Readur/frontend/src/components/FileIntegrityDisplay.tsx

504 lines
17 KiB
TypeScript

import React, { useState } from 'react';
import {
Box,
Typography,
Chip,
Paper,
IconButton,
Tooltip,
Stack,
CircularProgress,
Alert,
} from '@mui/material';
import {
Security as SecurityIcon,
Fingerprint as FingerprintIcon,
ContentCopy as CopyIcon,
CheckCircle as CheckIcon,
Warning as WarningIcon,
Error as ErrorIcon,
Info as InfoIcon,
} from '@mui/icons-material';
import { useTheme } from '../contexts/ThemeContext';
import { useTheme as useMuiTheme } from '@mui/material/styles';
interface FileIntegrityDisplayProps {
fileHash?: string;
fileName: string;
fileSize: number;
mimeType: string;
createdAt: string;
updatedAt: string;
userId?: string;
username?: string;
// Additional metadata fields
sourceType?: string;
sourcePath?: string;
filePermissions?: number;
fileOwner?: string;
fileGroup?: string;
originalCreatedAt?: string;
originalModifiedAt?: string;
sourceMetadata?: any;
compact?: boolean;
}
const FileIntegrityDisplay: React.FC<FileIntegrityDisplayProps> = ({
fileHash,
fileName,
fileSize,
mimeType,
createdAt,
updatedAt,
userId,
username,
sourceType,
sourcePath,
filePermissions,
fileOwner,
fileGroup,
originalCreatedAt,
originalModifiedAt,
sourceMetadata,
compact = false,
}) => {
const [copied, setCopied] = useState(false);
const { modernTokens } = useTheme();
const theme = useMuiTheme();
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
const formatHash = (hash: string) => {
if (!hash) return 'Not available';
return `${hash.substring(0, 8)}...${hash.substring(hash.length - 8)}`;
};
const getIntegrityStatus = () => {
if (!fileHash) {
return {
status: 'unknown',
icon: <InfoIcon />,
color: theme.palette.text.secondary,
message: 'Hash not available',
};
}
// Simple validation - in real implementation you'd verify against stored hash
if (fileHash.length === 64) { // SHA256 length
return {
status: 'verified',
icon: <CheckIcon />,
color: theme.palette.success.main,
message: 'File integrity verified',
};
}
return {
status: 'warning',
icon: <WarningIcon />,
color: theme.palette.warning.main,
message: 'Hash format unusual',
};
};
const integrityStatus = getIntegrityStatus();
const formatFileSize = (bytes: number): string => {
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
if (bytes === 0) return '0 Bytes';
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return Math.round(bytes / Math.pow(1024, i) * 100) / 100 + ' ' + sizes[i];
};
const formatDate = (dateString: string): string => {
return new Date(dateString).toLocaleString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
if (compact) {
return (
<Paper
sx={{
p: 2,
backgroundColor: theme.palette.background.paper,
border: `1px solid ${theme.palette.divider}`,
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<SecurityIcon
sx={{
fontSize: 18,
mr: 1,
color: integrityStatus.color
}}
/>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
File Integrity
</Typography>
</Box>
<Chip
size="small"
label={integrityStatus.status}
sx={{
backgroundColor: `${integrityStatus.color}20`,
color: integrityStatus.color,
border: `1px solid ${integrityStatus.color}40`,
textTransform: 'capitalize',
}}
/>
</Box>
<Stack spacing={1}>
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
<Typography variant="caption" color="text.secondary">
Hash (SHA256)
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<Typography
variant="caption"
sx={{
fontFamily: 'monospace',
fontWeight: 500,
mr: 0.5,
}}
>
{formatHash(fileHash || '')}
</Typography>
{fileHash && (
<Tooltip title={copied ? 'Copied!' : 'Copy full hash'}>
<IconButton
size="small"
onClick={() => copyToClipboard(fileHash)}
sx={{ p: 0.25 }}
>
<CopyIcon sx={{ fontSize: 12 }} />
</IconButton>
</Tooltip>
)}
</Box>
</Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
<Typography variant="caption" color="text.secondary">
Size
</Typography>
<Typography variant="caption" sx={{ fontWeight: 500 }}>
{formatFileSize(fileSize)}
</Typography>
</Box>
</Stack>
</Paper>
);
}
return (
<Paper
sx={{
p: 3,
backgroundColor: theme.palette.background.paper,
border: `1px solid ${theme.palette.divider}`,
}}
>
{/* Header */}
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 3 }}>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<SecurityIcon
sx={{
fontSize: 24,
mr: 1.5,
color: theme.palette.primary.main
}}
/>
<Typography variant="h6" sx={{ fontWeight: 600 }}>
Document Details
</Typography>
</Box>
<Chip
icon={React.cloneElement(integrityStatus.icon, { sx: { fontSize: 18 } })}
label={integrityStatus.message}
sx={{
backgroundColor: `${integrityStatus.color}20`,
color: integrityStatus.color,
border: `1px solid ${integrityStatus.color}40`,
fontWeight: 500,
}}
/>
</Box>
{/* Hash Information */}
<Box sx={{ mb: 3 }}>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 1 }}>
<FingerprintIcon
sx={{
fontSize: 18,
mr: 1,
color: theme.palette.text.secondary
}}
/>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
SHA256 Hash
</Typography>
</Box>
{fileHash ? (
<Box
sx={{
display: 'flex',
alignItems: 'center',
p: 2,
backgroundColor: theme.palette.action.hover,
borderRadius: 1,
border: `1px solid ${theme.palette.divider}`,
}}
>
<Typography
variant="body2"
sx={{
fontFamily: 'monospace',
flex: 1,
wordBreak: 'break-all',
fontSize: '0.8rem',
color: theme.palette.text.primary,
}}
>
{fileHash}
</Typography>
<Tooltip title={copied ? 'Copied!' : 'Copy hash'}>
<IconButton
size="small"
onClick={() => copyToClipboard(fileHash)}
sx={{ ml: 1 }}
>
<CopyIcon fontSize="small" />
</IconButton>
</Tooltip>
</Box>
) : (
<Alert severity="info" sx={{ mt: 1 }}>
File hash not available. Enable hash generation in upload settings.
</Alert>
)}
</Box>
{/* File Properties */}
<Box sx={{ mb: 2 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 2 }}>
File Properties
</Typography>
<Stack spacing={2}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="body2" color="text.secondary">
File Size
</Typography>
<Typography variant="body2" sx={{ fontWeight: 500 }}>
{formatFileSize(fileSize)}
</Typography>
</Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="body2" color="text.secondary">
MIME Type
</Typography>
<Chip
label={mimeType}
size="small"
sx={{
fontSize: '0.75rem',
backgroundColor: theme.palette.action.hover,
border: `1px solid ${theme.palette.divider}`,
}}
/>
</Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="body2" color="text.secondary">
Uploaded
</Typography>
<Typography variant="body2" sx={{ fontWeight: 500 }}>
{formatDate(createdAt)}
</Typography>
</Box>
{createdAt !== updatedAt && (
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="body2" color="text.secondary">
Last Modified
</Typography>
<Typography variant="body2" sx={{ fontWeight: 500 }}>
{formatDate(updatedAt)}
</Typography>
</Box>
)}
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="body2" color="text.secondary">
Uploaded By
</Typography>
<Chip
label={username || (userId ? `User: ${userId.substring(0, 8)}...` : 'Unknown')}
size="small"
sx={{
fontSize: '0.75rem',
backgroundColor: theme.palette.primary.light,
color: theme.palette.primary.dark,
border: `1px solid ${theme.palette.primary.main}`,
}}
/>
</Box>
{fileOwner && (
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="body2" color="text.secondary">
Owner
</Typography>
<Typography variant="body2" sx={{ fontFamily: 'monospace', fontSize: '0.8rem', fontWeight: 500 }}>
{fileOwner}
</Typography>
</Box>
)}
{sourcePath && (
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="body2" color="text.secondary">
Source Path
</Typography>
<Typography variant="body2" sx={{ fontFamily: 'monospace', fontSize: '0.8rem', maxWidth: '60%', overflow: 'hidden', textOverflow: 'ellipsis', fontWeight: 500 }}>
{sourcePath}
</Typography>
</Box>
)}
</Stack>
</Box>
{/* Additional Source Information */}
{(sourceType || fileGroup || filePermissions) && (
<Box sx={{ pt: 3, borderTop: `1px solid ${theme.palette.divider}` }}>
<Typography variant="subtitle1" sx={{ mb: 2, fontWeight: 600, display: 'flex', alignItems: 'center' }}>
<InfoIcon sx={{ mr: 1, fontSize: 18, color: theme.palette.info.main }} />
Additional Source Details
</Typography>
<Stack spacing={2}>
{sourceType && (
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="body2" color="text.secondary">Source Type:</Typography>
<Chip
label={sourceType}
size="small"
sx={{
fontSize: '0.75rem',
backgroundColor: theme.palette.info.light,
color: theme.palette.info.dark,
}}
/>
</Box>
)}
{fileGroup && (
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="body2" color="text.secondary">File Group:</Typography>
<Typography variant="body2" sx={{ fontFamily: 'monospace', fontSize: '0.8rem' }}>
{fileGroup}
</Typography>
</Box>
)}
{filePermissions && (
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="body2" color="text.secondary">Permissions:</Typography>
<Typography variant="body2" sx={{ fontFamily: 'monospace', fontSize: '0.8rem' }}>
{filePermissions.toString(8)} ({filePermissions})
</Typography>
</Box>
)}
</Stack>
</Box>
)}
{/* Timestamps */}
{(originalCreatedAt || originalModifiedAt) && (
<Box sx={{ pt: 3, borderTop: `1px solid ${theme.palette.divider}` }}>
<Typography variant="subtitle1" sx={{ mb: 2, fontWeight: 600, display: 'flex', alignItems: 'center' }}>
<InfoIcon sx={{ mr: 1, fontSize: 18, color: theme.palette.secondary.main }} />
Original Timestamps
</Typography>
<Stack spacing={2}>
{originalCreatedAt && (
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="body2" color="text.secondary">Original Created:</Typography>
<Typography variant="body2" sx={{ fontSize: '0.8rem' }}>
{new Date(originalCreatedAt).toLocaleString()}
</Typography>
</Box>
)}
{originalModifiedAt && (
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="body2" color="text.secondary">Original Modified:</Typography>
<Typography variant="body2" sx={{ fontSize: '0.8rem' }}>
{new Date(originalModifiedAt).toLocaleString()}
</Typography>
</Box>
)}
</Stack>
</Box>
)}
{/* Source Metadata - displayed as simple key-value pairs */}
{sourceMetadata && Object.keys(sourceMetadata).length > 0 && (
<Box sx={{ pt: 3, borderTop: `1px solid ${theme.palette.divider}` }}>
<Typography variant="subtitle1" sx={{ mb: 2, fontWeight: 600, display: 'flex', alignItems: 'center' }}>
<InfoIcon sx={{ mr: 1, fontSize: 18, color: theme.palette.secondary.main }} />
Source Metadata
</Typography>
<Stack spacing={2}>
{Object.entries(sourceMetadata).map(([key, value]) => {
// Skip null/undefined values and complex objects
if (value === null || value === undefined || typeof value === 'object') return null;
// Format the key to be more readable
const formattedKey = key
.replace(/_/g, ' ')
.replace(/([A-Z])/g, ' $1')
.replace(/^./, str => str.toUpperCase())
.trim();
// Format the value
const formattedValue = typeof value === 'boolean'
? (value ? 'Yes' : 'No')
: String(value);
return (
<Box key={key} sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="body2" color="text.secondary">
{formattedKey}:
</Typography>
<Typography variant="body2" sx={{ fontSize: '0.8rem', fontWeight: 500, maxWidth: '60%', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{formattedValue}
</Typography>
</Box>
);
}).filter(Boolean)}
</Stack>
</Box>
)}
</Paper>
);
};
export default FileIntegrityDisplay;