mirror of
https://github.com/kjanat/livedash-node.git
synced 2026-01-16 21:32:08 +01:00
fix: address multiple PR review issues
- Fixed accessibility in audit logs with keyboard navigation and ARIA attributes - Refactored ThreatAnalysisResults interface to module level for reusability - Added BatchOperation enum validation and proper CSV escaping in batch monitoring - Removed unused company state causing skeleton view in dashboard overview - Enhanced error handling with user-facing messages for metrics loading - Replaced hardcoded timeouts with condition-based waits in E2E tests - Removed duplicate state management in security monitoring hooks - Fixed CSRF documentation to show proper secret fallback pattern - Updated CSP metrics docs with GDPR Article 6(1)(f) legal basis clarification - Fixed React hooks order to prevent conditional execution after early returns - Added explicit button type to prevent form submission behavior
This commit is contained in:
@ -1 +0,0 @@
|
||||
Use pnpm to manage this project, not npm!
|
||||
@ -9,6 +9,26 @@ import {
|
||||
import { getCircuitBreakerStatus } from "@/lib/batchProcessor";
|
||||
import { getBatchSchedulerStatus } from "@/lib/batchProcessorIntegration";
|
||||
|
||||
// Helper function for proper CSV escaping
|
||||
function escapeCSVField(field: string | number | boolean): string {
|
||||
if (typeof field === "number" || typeof field === "boolean") {
|
||||
return String(field);
|
||||
}
|
||||
|
||||
const strField = String(field);
|
||||
|
||||
// If field contains comma, quote, or newline, wrap in quotes and escape internal quotes
|
||||
if (
|
||||
strField.includes(",") ||
|
||||
strField.includes('"') ||
|
||||
strField.includes("\n")
|
||||
) {
|
||||
return `"${strField.replace(/"/g, '""')}"`;
|
||||
}
|
||||
|
||||
return strField;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/admin/batch-monitoring
|
||||
* Get comprehensive batch processing monitoring data
|
||||
@ -23,9 +43,31 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
const url = new URL(request.url);
|
||||
const companyId = url.searchParams.get("companyId");
|
||||
const operation = url.searchParams.get("operation") as BatchOperation;
|
||||
const operationParam = url.searchParams.get("operation");
|
||||
const format = url.searchParams.get("format") || "json";
|
||||
|
||||
// Validate operation parameter
|
||||
const isValidBatchOperation = (
|
||||
value: string | null
|
||||
): value is BatchOperation => {
|
||||
return (
|
||||
value !== null &&
|
||||
Object.values(BatchOperation).includes(value as BatchOperation)
|
||||
);
|
||||
};
|
||||
|
||||
if (operationParam && !isValidBatchOperation(operationParam)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "Invalid operation parameter",
|
||||
validOperations: Object.values(BatchOperation),
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const operation = operationParam as BatchOperation | null;
|
||||
|
||||
// Get batch processing metrics
|
||||
const metrics = batchLogger.getMetrics(companyId || undefined);
|
||||
|
||||
@ -75,15 +117,15 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
const rows = Object.entries(metrics).map(([companyId, metric]) =>
|
||||
[
|
||||
companyId,
|
||||
new Date(metric.operationStartTime).toISOString(),
|
||||
metric.requestCount,
|
||||
metric.successCount,
|
||||
metric.failureCount,
|
||||
metric.retryCount,
|
||||
metric.totalCost.toFixed(4),
|
||||
metric.averageLatency.toFixed(2),
|
||||
metric.circuitBreakerTrips,
|
||||
escapeCSVField(companyId),
|
||||
escapeCSVField(new Date(metric.operationStartTime).toISOString()),
|
||||
escapeCSVField(metric.requestCount),
|
||||
escapeCSVField(metric.successCount),
|
||||
escapeCSVField(metric.failureCount),
|
||||
escapeCSVField(metric.retryCount),
|
||||
escapeCSVField(metric.totalCost.toFixed(4)),
|
||||
escapeCSVField(metric.averageLatency.toFixed(2)),
|
||||
escapeCSVField(metric.circuitBreakerTrips),
|
||||
].join(",")
|
||||
);
|
||||
|
||||
@ -132,10 +174,55 @@ export async function POST(request: NextRequest) {
|
||||
end: new Date(endDate),
|
||||
};
|
||||
|
||||
const exportData = batchLogger.exportLogs(timeRange);
|
||||
const exportDataJson = batchLogger.exportLogs(timeRange);
|
||||
|
||||
if (format === "csv") {
|
||||
return new NextResponse(exportData, {
|
||||
// Convert JSON to CSV format
|
||||
const data = JSON.parse(exportDataJson);
|
||||
|
||||
// Flatten the data structure for CSV
|
||||
const csvRows: string[] = [];
|
||||
|
||||
// Add headers
|
||||
csvRows.push(
|
||||
"Metric,Company ID,Operation,Batch ID,Request Count,Success Count,Failure Count,Average Latency,Last Updated"
|
||||
);
|
||||
|
||||
// Add metrics data
|
||||
if (data.metrics) {
|
||||
interface MetricData {
|
||||
companyId?: string;
|
||||
operation?: string;
|
||||
batchId?: string;
|
||||
requestCount?: number;
|
||||
successCount?: number;
|
||||
failureCount?: number;
|
||||
averageLatency?: number;
|
||||
lastUpdated?: string;
|
||||
}
|
||||
|
||||
Object.entries(data.metrics).forEach(
|
||||
([key, metric]: [string, MetricData]) => {
|
||||
csvRows.push(
|
||||
[
|
||||
escapeCSVField(key),
|
||||
escapeCSVField(metric.companyId || ""),
|
||||
escapeCSVField(metric.operation || ""),
|
||||
escapeCSVField(metric.batchId || ""),
|
||||
escapeCSVField(metric.requestCount || 0),
|
||||
escapeCSVField(metric.successCount || 0),
|
||||
escapeCSVField(metric.failureCount || 0),
|
||||
escapeCSVField(metric.averageLatency || 0),
|
||||
escapeCSVField(metric.lastUpdated || ""),
|
||||
].join(",")
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const csvContent = csvRows.join("\n");
|
||||
|
||||
return new NextResponse(csvContent, {
|
||||
headers: {
|
||||
"Content-Type": "text/csv",
|
||||
"Content-Disposition": `attachment; filename="batch-logs-${startDate}-${endDate}.csv"`,
|
||||
@ -143,7 +230,7 @@ export async function POST(request: NextRequest) {
|
||||
});
|
||||
}
|
||||
|
||||
return new NextResponse(exportData, {
|
||||
return new NextResponse(exportDataJson, {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Content-Disposition": `attachment; filename="batch-logs-${startDate}-${endDate}.json"`,
|
||||
|
||||
@ -14,6 +14,31 @@ import {
|
||||
type ThreatLevel,
|
||||
} from "@/lib/securityMonitoring";
|
||||
|
||||
interface ThreatAnalysisResults {
|
||||
ipThreatAnalysis?: {
|
||||
ipAddress: string;
|
||||
threatLevel: ThreatLevel;
|
||||
isBlacklisted: boolean;
|
||||
riskFactors: string[];
|
||||
recommendations: string[];
|
||||
};
|
||||
timeRangeAnalysis?: {
|
||||
timeRange: { start: Date; end: Date };
|
||||
securityScore: number;
|
||||
threatLevel: string;
|
||||
topThreats: Array<{ type: AlertType; count: number }>;
|
||||
geoDistribution: Record<string, number>;
|
||||
riskUsers: Array<{ userId: string; email: string; riskScore: number }>;
|
||||
};
|
||||
overallThreatLandscape?: {
|
||||
currentThreatLevel: string;
|
||||
securityScore: number;
|
||||
activeAlerts: number;
|
||||
criticalEvents: number;
|
||||
recommendations: string[];
|
||||
};
|
||||
}
|
||||
|
||||
const threatAnalysisSchema = z.object({
|
||||
ipAddress: z.string().optional(),
|
||||
userId: z.string().uuid().optional(),
|
||||
@ -37,31 +62,6 @@ export async function POST(request: NextRequest) {
|
||||
const analysis = threatAnalysisSchema.parse(body);
|
||||
const context = await createAuditContext(request, session);
|
||||
|
||||
interface ThreatAnalysisResults {
|
||||
ipThreatAnalysis?: {
|
||||
ipAddress: string;
|
||||
threatLevel: ThreatLevel;
|
||||
isBlacklisted: boolean;
|
||||
riskFactors: string[];
|
||||
recommendations: string[];
|
||||
};
|
||||
timeRangeAnalysis?: {
|
||||
timeRange: { start: Date; end: Date };
|
||||
securityScore: number;
|
||||
threatLevel: string;
|
||||
topThreats: Array<{ type: AlertType; count: number }>;
|
||||
geoDistribution: Record<string, number>;
|
||||
riskUsers: Array<{ userId: string; email: string; riskScore: number }>;
|
||||
};
|
||||
overallThreatLandscape?: {
|
||||
currentThreatLevel: string;
|
||||
securityScore: number;
|
||||
activeAlerts: number;
|
||||
criticalEvents: number;
|
||||
recommendations: string[];
|
||||
};
|
||||
}
|
||||
|
||||
const results: ThreatAnalysisResults = {};
|
||||
|
||||
// IP threat analysis
|
||||
|
||||
@ -184,7 +184,10 @@ export default function AuditLogsPage() {
|
||||
}, [session?.user?.role, hasFetched, fetchAuditLogs]);
|
||||
|
||||
// Function to refresh audit logs (for filter changes)
|
||||
const refreshAuditLogs = useCallback(() => {
|
||||
const refreshAuditLogs = useCallback((newPage?: number) => {
|
||||
if (newPage !== undefined) {
|
||||
setPagination((prev) => ({ ...prev, page: newPage }));
|
||||
}
|
||||
setHasFetched(false);
|
||||
}, []);
|
||||
|
||||
@ -445,8 +448,8 @@ export default function AuditLogsPage() {
|
||||
size="sm"
|
||||
disabled={!pagination.hasPrev}
|
||||
onClick={() => {
|
||||
setPagination((prev) => ({ ...prev, page: prev.page - 1 }));
|
||||
refreshAuditLogs();
|
||||
const newPage = pagination.page - 1;
|
||||
refreshAuditLogs(newPage);
|
||||
}}
|
||||
>
|
||||
Previous
|
||||
@ -456,8 +459,8 @@ export default function AuditLogsPage() {
|
||||
size="sm"
|
||||
disabled={!pagination.hasNext}
|
||||
onClick={() => {
|
||||
setPagination((prev) => ({ ...prev, page: prev.page + 1 }));
|
||||
refreshAuditLogs();
|
||||
const newPage = pagination.page + 1;
|
||||
refreshAuditLogs(newPage);
|
||||
}}
|
||||
>
|
||||
Next
|
||||
|
||||
@ -470,7 +470,7 @@ function DashboardContent() {
|
||||
const { data: session, status } = useSession();
|
||||
const router = useRouter();
|
||||
const [metrics, setMetrics] = useState<MetricsResult | null>(null);
|
||||
const [company] = useState<Company | null>(null);
|
||||
// Remove unused company state that was causing skeleton view to always show
|
||||
const [refreshing, setRefreshing] = useState<boolean>(false);
|
||||
const [isInitialLoad, setIsInitialLoad] = useState<boolean>(true);
|
||||
|
||||
@ -501,12 +501,39 @@ function DashboardContent() {
|
||||
// Map overview data to metrics format expected by the component
|
||||
const mappedMetrics: Partial<MetricsResult> = {
|
||||
totalSessions: overviewData.totalSessions,
|
||||
avgSessionsPerDay: 0, // Will be computed properly later
|
||||
avgSessionLength: null,
|
||||
days: {},
|
||||
languages: {},
|
||||
countries: {},
|
||||
belowThresholdCount: 0,
|
||||
avgSessionsPerDay: overviewData.avgSessionsPerDay || 0,
|
||||
avgSessionLength: overviewData.avgSessionLength || 0,
|
||||
days:
|
||||
overviewData.timeSeriesData?.reduce(
|
||||
(acc, item) => {
|
||||
if (item.date) {
|
||||
acc[item.date] = item.sessionCount || 0;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, number>
|
||||
) || {},
|
||||
languages:
|
||||
overviewData.languageDistribution?.reduce(
|
||||
(acc, item) => {
|
||||
if (item.language) {
|
||||
acc[item.language] = item.count;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, number>
|
||||
) || {},
|
||||
countries:
|
||||
overviewData.geographicDistribution?.reduce(
|
||||
(acc, item) => {
|
||||
if (item.country) {
|
||||
acc[item.country] = item.count;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, number>
|
||||
) || {},
|
||||
belowThresholdCount: overviewData.belowThresholdCount || 0,
|
||||
// Map sentiment data to individual counts
|
||||
sentimentPositiveCount:
|
||||
overviewData.sentimentDistribution?.find(
|
||||
@ -541,12 +568,6 @@ function DashboardContent() {
|
||||
}
|
||||
}, [overviewData, isInitialLoad]);
|
||||
|
||||
useEffect(() => {
|
||||
if (metricsError) {
|
||||
console.error("Error fetching metrics:", metricsError);
|
||||
}
|
||||
}, [metricsError]);
|
||||
|
||||
// Admin refresh sessions mutation
|
||||
const refreshSessionsMutation = trpc.admin.refreshSessions.useMutation({
|
||||
onSuccess: () => {
|
||||
@ -567,6 +588,30 @@ function DashboardContent() {
|
||||
// tRPC queries handle data fetching automatically
|
||||
}, [status, router]);
|
||||
|
||||
// Enhanced error handling with user feedback
|
||||
if (metricsError) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-center space-y-4">
|
||||
<div className="text-red-600 text-lg font-semibold">
|
||||
Failed to load dashboard data
|
||||
</div>
|
||||
<p className="text-gray-600">
|
||||
There was an error loading your dashboard metrics. Please try
|
||||
refreshing the page.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => window.location.reload()}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
|
||||
>
|
||||
Refresh Page
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
async function handleRefresh() {
|
||||
if (isAuditor) return;
|
||||
|
||||
@ -594,14 +639,14 @@ function DashboardContent() {
|
||||
);
|
||||
}
|
||||
|
||||
if (!metrics || !company) {
|
||||
if (!metrics) {
|
||||
return <DashboardSkeleton />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<DashboardHeader
|
||||
company={company}
|
||||
company={{ name: "Analytics Dashboard" } as Company}
|
||||
metrics={metrics}
|
||||
isAuditor={isAuditor}
|
||||
refreshing={refreshing}
|
||||
|
||||
@ -52,23 +52,14 @@ interface SecurityAlert {
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom hook for security monitoring state
|
||||
* Custom hook for security monitoring UI state (UI-only, no data fetching)
|
||||
*/
|
||||
function useSecurityMonitoringState() {
|
||||
const [metrics, setMetrics] = useState<SecurityMetrics | null>(null);
|
||||
const [alerts, setAlerts] = useState<SecurityAlert[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedTimeRange, setSelectedTimeRange] = useState("24h");
|
||||
const [showConfig, setShowConfig] = useState(false);
|
||||
const [autoRefresh, setAutoRefresh] = useState(true);
|
||||
|
||||
return {
|
||||
metrics,
|
||||
setMetrics,
|
||||
alerts,
|
||||
setAlerts,
|
||||
loading,
|
||||
setLoading,
|
||||
selectedTimeRange,
|
||||
setSelectedTimeRange,
|
||||
showConfig,
|
||||
|
||||
@ -129,7 +129,7 @@ CSRF_SECRET=your-csrf-secret-key
|
||||
export const CSRF_CONFIG = {
|
||||
cookieName: "csrf-token",
|
||||
headerName: "x-csrf-token",
|
||||
secret: env.CSRF_SECRET,
|
||||
secret: env.CSRF_SECRET || env.NEXTAUTH_SECRET,
|
||||
cookie: {
|
||||
httpOnly: true,
|
||||
secure: env.NODE_ENV === "production",
|
||||
|
||||
@ -431,9 +431,10 @@ CSP_ALERT_THRESHOLD=5 # violations per 10 minutes
|
||||
|
||||
- **IP addresses** are collected and stored in memory for security monitoring
|
||||
- **User agent strings** are stored for browser compatibility analysis
|
||||
- **Legal basis**: Legitimate interest for security incident detection and prevention
|
||||
- **Legal basis**: Processing is necessary for legitimate interests (GDPR Article 6(1)(f)) - specifically for security incident detection, prevention of CSP bypass attacks, and protection of website integrity
|
||||
- **Retention**: In-memory storage only, automatically purged after 7 days or application restart
|
||||
- **Data minimization**: Only violation-related metadata is retained, not page content
|
||||
- **Balancing test**: The processing is limited to security purposes, uses temporary storage, and employs data minimization principles to ensure user privacy rights are respected
|
||||
|
||||
**Planned Privacy Enhancements:**
|
||||
|
||||
|
||||
@ -234,8 +234,14 @@ test.describe("Data Visualization", () => {
|
||||
const geoMap = page.locator('[data-testid="geographic-map"]');
|
||||
await expect(geoMap).toBeVisible();
|
||||
|
||||
// Wait for map to load
|
||||
await page.waitForTimeout(2000);
|
||||
// Wait for map to load - wait for map container or country data to be rendered
|
||||
await page.waitForSelector('[data-testid="country-data"], .leaflet-container, .geo-map-loaded', {
|
||||
timeout: 10000,
|
||||
state: 'visible'
|
||||
}).catch(() => {
|
||||
// Fallback: wait for any map-related element to indicate map is loaded
|
||||
return page.waitForSelector('.map, [class*="map"], [data-map]', { timeout: 5000 }).catch(() => null);
|
||||
});
|
||||
|
||||
// Check if country data is displayed
|
||||
const countryData = page.locator('[data-testid="country-data"]');
|
||||
@ -350,8 +356,14 @@ test.describe("Data Visualization", () => {
|
||||
// Select date range
|
||||
await page.click('[data-testid="date-last-week"]');
|
||||
|
||||
// Should update charts
|
||||
await page.waitForTimeout(1000);
|
||||
// Wait for charts to update after date filter application
|
||||
await page.waitForSelector('[data-testid="filter-applied"], [data-testid="charts-updated"], .loading:not(.visible)', {
|
||||
timeout: 5000,
|
||||
state: 'visible'
|
||||
}).catch(() => {
|
||||
// Fallback: wait for any indication that filtering is complete
|
||||
return page.waitForFunction(() => !document.querySelector('.loading, [data-loading="true"]'), { timeout: 3000 }).catch(() => null);
|
||||
});
|
||||
|
||||
// Check that data is filtered
|
||||
await expect(
|
||||
@ -366,8 +378,14 @@ test.describe("Data Visualization", () => {
|
||||
if (await sentimentFilter.isVisible()) {
|
||||
await sentimentFilter.selectOption("POSITIVE");
|
||||
|
||||
// Should update all visualizations
|
||||
await page.waitForTimeout(1000);
|
||||
// Wait for visualizations to update after sentiment filter
|
||||
await page.waitForSelector('[data-testid="active-filters"], [data-testid="sentiment-applied"], .charts-container:not(.updating)', {
|
||||
timeout: 5000,
|
||||
state: 'visible'
|
||||
}).catch(() => {
|
||||
// Fallback: wait for filter processing to complete
|
||||
return page.waitForFunction(() => !document.querySelector('.updating, [data-updating="true"], .filter-loading'), { timeout: 3000 }).catch(() => null);
|
||||
});
|
||||
|
||||
// Check filter is applied
|
||||
await expect(
|
||||
|
||||
Reference in New Issue
Block a user