mirror of
https://github.com/kjanat/livedash-node.git
synced 2026-01-16 12:32:10 +01:00
refactor: fix biome linting issues and update project documentation
- Fix 36+ biome linting issues reducing errors/warnings from 227 to 191 - Replace explicit 'any' types with proper TypeScript interfaces - Fix React hooks dependencies and useCallback patterns - Resolve unused variables and parameter assignment issues - Improve accessibility with proper label associations - Add comprehensive API documentation for admin and security features - Update README.md with accurate PostgreSQL setup and current tech stack - Create complete documentation for audit logging, CSP monitoring, and batch processing - Fix outdated project information and missing developer workflows
This commit is contained in:
220
app/api/admin/audit-logs/retention/route.ts
Normal file
220
app/api/admin/audit-logs/retention/route.ts
Normal file
@ -0,0 +1,220 @@
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { getServerSession } from "next-auth/next";
|
||||
import {
|
||||
AuditLogRetentionManager,
|
||||
DEFAULT_RETENTION_POLICIES,
|
||||
executeScheduledRetention,
|
||||
} from "../../../../../lib/auditLogRetention";
|
||||
import { auditLogScheduler } from "../../../../../lib/auditLogScheduler";
|
||||
import { authOptions } from "../../../../../lib/auth";
|
||||
import { extractClientIP } from "../../../../../lib/rateLimiter";
|
||||
import {
|
||||
AuditOutcome,
|
||||
createAuditMetadata,
|
||||
securityAuditLogger,
|
||||
} from "../../../../../lib/securityAuditLogger";
|
||||
|
||||
// GET /api/admin/audit-logs/retention - Get retention statistics and policy status
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
const ip = extractClientIP(request);
|
||||
const userAgent = request.headers.get("user-agent") || undefined;
|
||||
|
||||
if (!session?.user) {
|
||||
await securityAuditLogger.logAuthorization(
|
||||
"audit_retention_unauthorized_access",
|
||||
AuditOutcome.BLOCKED,
|
||||
{
|
||||
ipAddress: ip,
|
||||
userAgent,
|
||||
metadata: createAuditMetadata({
|
||||
error: "no_session",
|
||||
}),
|
||||
},
|
||||
"Unauthorized attempt to access audit retention management"
|
||||
);
|
||||
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "Unauthorized" },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
// Only allow ADMIN users to manage audit log retention
|
||||
if (session.user.role !== "ADMIN") {
|
||||
await securityAuditLogger.logAuthorization(
|
||||
"audit_retention_insufficient_permissions",
|
||||
AuditOutcome.BLOCKED,
|
||||
{
|
||||
userId: session.user.id,
|
||||
companyId: session.user.companyId,
|
||||
ipAddress: ip,
|
||||
userAgent,
|
||||
metadata: createAuditMetadata({
|
||||
userRole: session.user.role,
|
||||
requiredRole: "ADMIN",
|
||||
}),
|
||||
},
|
||||
"Insufficient permissions to access audit retention management"
|
||||
);
|
||||
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "Insufficient permissions" },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
const manager = new AuditLogRetentionManager();
|
||||
|
||||
// Get retention statistics and policy information
|
||||
const [statistics, policyValidation, schedulerStatus] = await Promise.all([
|
||||
manager.getRetentionStatistics(),
|
||||
manager.validateRetentionPolicies(),
|
||||
Promise.resolve(auditLogScheduler.getStatus()),
|
||||
]);
|
||||
|
||||
// Log successful retention info access
|
||||
await securityAuditLogger.logDataPrivacy(
|
||||
"audit_retention_info_accessed",
|
||||
AuditOutcome.SUCCESS,
|
||||
{
|
||||
userId: session.user.id,
|
||||
companyId: session.user.companyId,
|
||||
ipAddress: ip,
|
||||
userAgent,
|
||||
metadata: createAuditMetadata({
|
||||
totalLogs: statistics.totalLogs,
|
||||
schedulerRunning: schedulerStatus.isRunning,
|
||||
}),
|
||||
},
|
||||
"Audit retention information accessed by admin"
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
statistics,
|
||||
policies: DEFAULT_RETENTION_POLICIES,
|
||||
policyValidation,
|
||||
scheduler: schedulerStatus,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error fetching audit retention info:", error);
|
||||
|
||||
await securityAuditLogger.logDataPrivacy(
|
||||
"audit_retention_info_error",
|
||||
AuditOutcome.FAILURE,
|
||||
{
|
||||
userId: session?.user?.id,
|
||||
companyId: session?.user?.companyId,
|
||||
ipAddress: extractClientIP(request),
|
||||
userAgent: request.headers.get("user-agent") || undefined,
|
||||
metadata: createAuditMetadata({
|
||||
error: "server_error",
|
||||
}),
|
||||
},
|
||||
`Server error while fetching audit retention info: ${error}`
|
||||
);
|
||||
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "Internal server error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/admin/audit-logs/retention - Execute retention policies manually
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
const ip = extractClientIP(request);
|
||||
const userAgent = request.headers.get("user-agent") || undefined;
|
||||
|
||||
if (!session?.user || session.user.role !== "ADMIN") {
|
||||
await securityAuditLogger.logAuthorization(
|
||||
"audit_retention_execute_unauthorized",
|
||||
AuditOutcome.BLOCKED,
|
||||
{
|
||||
userId: session?.user?.id,
|
||||
companyId: session?.user?.companyId,
|
||||
ipAddress: ip,
|
||||
userAgent,
|
||||
metadata: createAuditMetadata({
|
||||
error: "insufficient_permissions",
|
||||
}),
|
||||
},
|
||||
"Unauthorized attempt to execute audit retention"
|
||||
);
|
||||
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "Unauthorized" },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { action, isDryRun = true } = body;
|
||||
|
||||
if (action !== "execute") {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "Invalid action. Use 'execute'" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Log retention execution attempt
|
||||
await securityAuditLogger.logDataPrivacy(
|
||||
"audit_retention_manual_execution",
|
||||
AuditOutcome.SUCCESS,
|
||||
{
|
||||
userId: session.user.id,
|
||||
companyId: session.user.companyId,
|
||||
ipAddress: ip,
|
||||
userAgent,
|
||||
metadata: createAuditMetadata({
|
||||
isDryRun,
|
||||
triggerType: "manual_admin",
|
||||
}),
|
||||
},
|
||||
`Admin manually triggered audit retention (dry run: ${isDryRun})`
|
||||
);
|
||||
|
||||
// Execute retention policies
|
||||
const results = await executeScheduledRetention(isDryRun);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
message: isDryRun
|
||||
? "Dry run completed successfully"
|
||||
: "Retention policies executed successfully",
|
||||
isDryRun,
|
||||
results,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error executing audit retention:", error);
|
||||
|
||||
await securityAuditLogger.logDataPrivacy(
|
||||
"audit_retention_execution_error",
|
||||
AuditOutcome.FAILURE,
|
||||
{
|
||||
userId: session?.user?.id,
|
||||
companyId: session?.user?.companyId,
|
||||
ipAddress: extractClientIP(request),
|
||||
userAgent: request.headers.get("user-agent") || undefined,
|
||||
metadata: createAuditMetadata({
|
||||
error: "server_error",
|
||||
}),
|
||||
},
|
||||
`Server error while executing audit retention: ${error}`
|
||||
);
|
||||
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "Internal server error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
208
app/api/admin/audit-logs/route.ts
Normal file
208
app/api/admin/audit-logs/route.ts
Normal file
@ -0,0 +1,208 @@
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { getServerSession } from "next-auth/next";
|
||||
import { authOptions } from "../../../../lib/auth";
|
||||
import { prisma } from "../../../../lib/prisma";
|
||||
import { extractClientIP } from "../../../../lib/rateLimiter";
|
||||
import {
|
||||
AuditOutcome,
|
||||
createAuditMetadata,
|
||||
securityAuditLogger,
|
||||
} from "../../../../lib/securityAuditLogger";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
const ip = extractClientIP(request);
|
||||
const userAgent = request.headers.get("user-agent") || undefined;
|
||||
|
||||
if (!session?.user) {
|
||||
await securityAuditLogger.logAuthorization(
|
||||
"audit_logs_unauthorized_access",
|
||||
AuditOutcome.BLOCKED,
|
||||
{
|
||||
ipAddress: ip,
|
||||
userAgent,
|
||||
metadata: createAuditMetadata({
|
||||
error: "no_session",
|
||||
}),
|
||||
},
|
||||
"Unauthorized attempt to access audit logs"
|
||||
);
|
||||
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "Unauthorized" },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
// Only allow ADMIN users to view audit logs
|
||||
if (session.user.role !== "ADMIN") {
|
||||
await securityAuditLogger.logAuthorization(
|
||||
"audit_logs_insufficient_permissions",
|
||||
AuditOutcome.BLOCKED,
|
||||
{
|
||||
userId: session.user.id,
|
||||
companyId: session.user.companyId,
|
||||
ipAddress: ip,
|
||||
userAgent,
|
||||
metadata: createAuditMetadata({
|
||||
userRole: session.user.role,
|
||||
requiredRole: "ADMIN",
|
||||
}),
|
||||
},
|
||||
"Insufficient permissions to access audit logs"
|
||||
);
|
||||
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "Insufficient permissions" },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
const url = new URL(request.url);
|
||||
const page = Number.parseInt(url.searchParams.get("page") || "1");
|
||||
const limit = Math.min(
|
||||
Number.parseInt(url.searchParams.get("limit") || "50"),
|
||||
100
|
||||
);
|
||||
const eventType = url.searchParams.get("eventType");
|
||||
const outcome = url.searchParams.get("outcome");
|
||||
const severity = url.searchParams.get("severity");
|
||||
const userId = url.searchParams.get("userId");
|
||||
const startDate = url.searchParams.get("startDate");
|
||||
const endDate = url.searchParams.get("endDate");
|
||||
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
// Build filter conditions
|
||||
const where: {
|
||||
companyId: string;
|
||||
eventType?: string;
|
||||
outcome?: string;
|
||||
timestamp?: {
|
||||
gte?: Date;
|
||||
lte?: Date;
|
||||
};
|
||||
} = {
|
||||
companyId: session.user.companyId, // Only show logs for user's company
|
||||
};
|
||||
|
||||
if (eventType) {
|
||||
where.eventType = eventType;
|
||||
}
|
||||
|
||||
if (outcome) {
|
||||
where.outcome = outcome;
|
||||
}
|
||||
|
||||
if (severity) {
|
||||
where.severity = severity;
|
||||
}
|
||||
|
||||
if (userId) {
|
||||
where.userId = userId;
|
||||
}
|
||||
|
||||
if (startDate || endDate) {
|
||||
where.timestamp = {};
|
||||
if (startDate) {
|
||||
where.timestamp.gte = new Date(startDate);
|
||||
}
|
||||
if (endDate) {
|
||||
where.timestamp.lte = new Date(endDate);
|
||||
}
|
||||
}
|
||||
|
||||
// Get audit logs with pagination
|
||||
const [auditLogs, totalCount] = await Promise.all([
|
||||
prisma.securityAuditLog.findMany({
|
||||
where,
|
||||
skip,
|
||||
take: limit,
|
||||
orderBy: { timestamp: "desc" },
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
role: true,
|
||||
},
|
||||
},
|
||||
platformUser: {
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
role: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
prisma.securityAuditLog.count({ where }),
|
||||
]);
|
||||
|
||||
// Log successful audit log access
|
||||
await securityAuditLogger.logDataPrivacy(
|
||||
"audit_logs_accessed",
|
||||
AuditOutcome.SUCCESS,
|
||||
{
|
||||
userId: session.user.id,
|
||||
companyId: session.user.companyId,
|
||||
ipAddress: ip,
|
||||
userAgent,
|
||||
metadata: createAuditMetadata({
|
||||
page,
|
||||
limit,
|
||||
filters: {
|
||||
eventType,
|
||||
outcome,
|
||||
severity,
|
||||
userId,
|
||||
startDate,
|
||||
endDate,
|
||||
},
|
||||
recordsReturned: auditLogs.length,
|
||||
}),
|
||||
},
|
||||
"Audit logs accessed by admin user"
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
auditLogs,
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
totalCount,
|
||||
totalPages: Math.ceil(totalCount / limit),
|
||||
hasNext: skip + limit < totalCount,
|
||||
hasPrev: page > 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error fetching audit logs:", error);
|
||||
|
||||
await securityAuditLogger.logDataPrivacy(
|
||||
"audit_logs_server_error",
|
||||
AuditOutcome.FAILURE,
|
||||
{
|
||||
userId: session?.user?.id,
|
||||
companyId: session?.user?.companyId,
|
||||
ipAddress: extractClientIP(request),
|
||||
userAgent: request.headers.get("user-agent") || undefined,
|
||||
metadata: createAuditMetadata({
|
||||
error: "server_error",
|
||||
}),
|
||||
},
|
||||
`Server error while fetching audit logs: ${error}`
|
||||
);
|
||||
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "Internal server error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
159
app/api/admin/batch-monitoring/route.ts
Normal file
159
app/api/admin/batch-monitoring/route.ts
Normal file
@ -0,0 +1,159 @@
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
import {
|
||||
type BatchOperation,
|
||||
batchLogger,
|
||||
logBatchMetrics,
|
||||
} from "@/lib/batchLogger";
|
||||
import { getCircuitBreakerStatus } from "@/lib/batchProcessor";
|
||||
import { getBatchSchedulerStatus } from "@/lib/batchProcessorIntegration";
|
||||
|
||||
/**
|
||||
* GET /api/admin/batch-monitoring
|
||||
* Get comprehensive batch processing monitoring data
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!session?.user || session.user.role !== "ADMIN") {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const url = new URL(request.url);
|
||||
const companyId = url.searchParams.get("companyId");
|
||||
const operation = url.searchParams.get("operation") as BatchOperation;
|
||||
const format = url.searchParams.get("format") || "json";
|
||||
|
||||
// Get batch processing metrics
|
||||
const metrics = batchLogger.getMetrics(companyId || undefined);
|
||||
|
||||
// Get scheduler status
|
||||
const schedulerStatus = getBatchSchedulerStatus();
|
||||
|
||||
// Get circuit breaker status
|
||||
const circuitBreakerStatus = getCircuitBreakerStatus();
|
||||
|
||||
// Generate performance metrics for specific operation if requested
|
||||
if (operation) {
|
||||
await logBatchMetrics(operation);
|
||||
}
|
||||
|
||||
const monitoringData = {
|
||||
timestamp: new Date().toISOString(),
|
||||
metrics,
|
||||
schedulerStatus,
|
||||
circuitBreakerStatus,
|
||||
systemHealth: {
|
||||
schedulerRunning: schedulerStatus.isRunning,
|
||||
circuitBreakersOpen: Object.values(circuitBreakerStatus).some(
|
||||
(cb) => cb.isOpen
|
||||
),
|
||||
pausedDueToErrors: schedulerStatus.isPaused,
|
||||
consecutiveErrors: schedulerStatus.consecutiveErrors,
|
||||
},
|
||||
};
|
||||
|
||||
if (
|
||||
format === "csv" &&
|
||||
typeof metrics === "object" &&
|
||||
!Array.isArray(metrics)
|
||||
) {
|
||||
// Convert metrics to CSV format
|
||||
const headers = [
|
||||
"company_id",
|
||||
"operation_start_time",
|
||||
"request_count",
|
||||
"success_count",
|
||||
"failure_count",
|
||||
"retry_count",
|
||||
"total_cost",
|
||||
"average_latency",
|
||||
"circuit_breaker_trips",
|
||||
].join(",");
|
||||
|
||||
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,
|
||||
].join(",")
|
||||
);
|
||||
|
||||
return new NextResponse([headers, ...rows].join("\n"), {
|
||||
headers: {
|
||||
"Content-Type": "text/csv",
|
||||
"Content-Disposition": `attachment; filename="batch-monitoring-${Date.now()}.csv"`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json(monitoringData);
|
||||
} catch (error) {
|
||||
console.error("Batch monitoring API error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch batch monitoring data" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/admin/batch-monitoring/export
|
||||
* Export batch processing logs
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!session?.user || session.user.role !== "ADMIN") {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { startDate, endDate, format = "json" } = body;
|
||||
|
||||
if (!startDate || !endDate) {
|
||||
return NextResponse.json(
|
||||
{ error: "Start date and end date are required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const timeRange = {
|
||||
start: new Date(startDate),
|
||||
end: new Date(endDate),
|
||||
};
|
||||
|
||||
const exportData = batchLogger.exportLogs(timeRange);
|
||||
|
||||
if (format === "csv") {
|
||||
return new NextResponse(exportData, {
|
||||
headers: {
|
||||
"Content-Type": "text/csv",
|
||||
"Content-Disposition": `attachment; filename="batch-logs-${startDate}-${endDate}.csv"`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return new NextResponse(exportData, {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Content-Disposition": `attachment; filename="batch-logs-${startDate}-${endDate}.json"`,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Batch log export error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to export batch logs" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
142
app/api/admin/security-monitoring/alerts/route.ts
Normal file
142
app/api/admin/security-monitoring/alerts/route.ts
Normal file
@ -0,0 +1,142 @@
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { z } from "zod";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
import {
|
||||
AuditOutcome,
|
||||
createAuditContext,
|
||||
securityAuditLogger,
|
||||
} from "@/lib/securityAuditLogger";
|
||||
import {
|
||||
type AlertSeverity,
|
||||
securityMonitoring,
|
||||
} from "@/lib/securityMonitoring";
|
||||
|
||||
const alertQuerySchema = z.object({
|
||||
severity: z.enum(["LOW", "MEDIUM", "HIGH", "CRITICAL"]).optional(),
|
||||
acknowledged: z.enum(["true", "false"]).optional(),
|
||||
limit: z
|
||||
.string()
|
||||
.transform((val) => Number.parseInt(val, 10))
|
||||
.optional(),
|
||||
offset: z
|
||||
.string()
|
||||
.transform((val) => Number.parseInt(val, 10))
|
||||
.optional(),
|
||||
});
|
||||
|
||||
const acknowledgeAlertSchema = z.object({
|
||||
alertId: z.string().uuid(),
|
||||
action: z.literal("acknowledge"),
|
||||
});
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!session?.user || !session.user.isPlatformUser) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const url = new URL(request.url);
|
||||
const params = Object.fromEntries(url.searchParams.entries());
|
||||
const query = alertQuerySchema.parse(params);
|
||||
|
||||
const context = await createAuditContext(request, session);
|
||||
|
||||
// Get alerts based on filters
|
||||
const alerts = securityMonitoring.getActiveAlerts(
|
||||
query.severity as AlertSeverity
|
||||
);
|
||||
|
||||
// Apply pagination
|
||||
const limit = query.limit || 50;
|
||||
const offset = query.offset || 0;
|
||||
const paginatedAlerts = alerts.slice(offset, offset + limit);
|
||||
|
||||
// Log alert access
|
||||
await securityAuditLogger.logPlatformAdmin(
|
||||
"security_alerts_access",
|
||||
AuditOutcome.SUCCESS,
|
||||
context,
|
||||
undefined,
|
||||
{
|
||||
alertCount: alerts.length,
|
||||
filters: query,
|
||||
}
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
alerts: paginatedAlerts,
|
||||
total: alerts.length,
|
||||
limit,
|
||||
offset,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Security alerts API error:", error);
|
||||
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid query parameters", details: error.errors },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!session?.user || !session.user.isPlatformUser) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { alertId, action } = acknowledgeAlertSchema.parse(body);
|
||||
const context = await createAuditContext(request, session);
|
||||
|
||||
if (action === "acknowledge") {
|
||||
const success = await securityMonitoring.acknowledgeAlert(
|
||||
alertId,
|
||||
session.user.id
|
||||
);
|
||||
|
||||
if (!success) {
|
||||
return NextResponse.json({ error: "Alert not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
// Log alert acknowledgment
|
||||
await securityAuditLogger.logPlatformAdmin(
|
||||
"security_alert_acknowledged",
|
||||
AuditOutcome.SUCCESS,
|
||||
context,
|
||||
undefined,
|
||||
{ alertId }
|
||||
);
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "Invalid action" }, { status: 400 });
|
||||
} catch (error) {
|
||||
console.error("Security alert action error:", error);
|
||||
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid request", details: error.errors },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
90
app/api/admin/security-monitoring/export/route.ts
Normal file
90
app/api/admin/security-monitoring/export/route.ts
Normal file
@ -0,0 +1,90 @@
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { z } from "zod";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
import {
|
||||
AuditOutcome,
|
||||
createAuditContext,
|
||||
securityAuditLogger,
|
||||
} from "@/lib/securityAuditLogger";
|
||||
import { securityMonitoring } from "@/lib/securityMonitoring";
|
||||
|
||||
const exportQuerySchema = z.object({
|
||||
format: z.enum(["json", "csv"]).default("json"),
|
||||
startDate: z.string().datetime(),
|
||||
endDate: z.string().datetime(),
|
||||
type: z.enum(["alerts", "metrics"]).default("alerts"),
|
||||
});
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!session?.user || !session.user.isPlatformUser) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const url = new URL(request.url);
|
||||
const params = Object.fromEntries(url.searchParams.entries());
|
||||
const query = exportQuerySchema.parse(params);
|
||||
|
||||
const context = await createAuditContext(request, session);
|
||||
|
||||
const timeRange = {
|
||||
start: new Date(query.startDate),
|
||||
end: new Date(query.endDate),
|
||||
};
|
||||
|
||||
let data: string;
|
||||
let filename: string;
|
||||
let contentType: string;
|
||||
|
||||
if (query.type === "alerts") {
|
||||
data = securityMonitoring.exportSecurityData(query.format, timeRange);
|
||||
filename = `security-alerts-${query.startDate.split("T")[0]}-to-${query.endDate.split("T")[0]}.${query.format}`;
|
||||
contentType = query.format === "csv" ? "text/csv" : "application/json";
|
||||
} else {
|
||||
// Export metrics
|
||||
const metrics = await securityMonitoring.getSecurityMetrics(timeRange);
|
||||
data = JSON.stringify(metrics, null, 2);
|
||||
filename = `security-metrics-${query.startDate.split("T")[0]}-to-${query.endDate.split("T")[0]}.json`;
|
||||
contentType = "application/json";
|
||||
}
|
||||
|
||||
// Log data export
|
||||
await securityAuditLogger.logPlatformAdmin(
|
||||
"security_data_export",
|
||||
AuditOutcome.SUCCESS,
|
||||
context,
|
||||
undefined,
|
||||
{
|
||||
exportType: query.type,
|
||||
format: query.format,
|
||||
timeRange,
|
||||
dataSize: data.length,
|
||||
}
|
||||
);
|
||||
|
||||
const headers = new Headers({
|
||||
"Content-Type": contentType,
|
||||
"Content-Disposition": `attachment; filename="${filename}"`,
|
||||
"Content-Length": data.length.toString(),
|
||||
});
|
||||
|
||||
return new NextResponse(data, { headers });
|
||||
} catch (error) {
|
||||
console.error("Security data export error:", error);
|
||||
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid query parameters", details: error.errors },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
169
app/api/admin/security-monitoring/route.ts
Normal file
169
app/api/admin/security-monitoring/route.ts
Normal file
@ -0,0 +1,169 @@
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { z } from "zod";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
import {
|
||||
AuditOutcome,
|
||||
createAuditContext,
|
||||
securityAuditLogger,
|
||||
} from "@/lib/securityAuditLogger";
|
||||
import {
|
||||
type AlertSeverity,
|
||||
securityMonitoring,
|
||||
} from "@/lib/securityMonitoring";
|
||||
|
||||
const metricsQuerySchema = z.object({
|
||||
startDate: z.string().datetime().optional(),
|
||||
endDate: z.string().datetime().optional(),
|
||||
companyId: z.string().uuid().optional(),
|
||||
severity: z.enum(["LOW", "MEDIUM", "HIGH", "CRITICAL"]).optional(),
|
||||
});
|
||||
|
||||
const configUpdateSchema = z.object({
|
||||
thresholds: z
|
||||
.object({
|
||||
failedLoginsPerMinute: z.number().min(1).max(100).optional(),
|
||||
failedLoginsPerHour: z.number().min(1).max(1000).optional(),
|
||||
rateLimitViolationsPerMinute: z.number().min(1).max(100).optional(),
|
||||
cspViolationsPerMinute: z.number().min(1).max(100).optional(),
|
||||
adminActionsPerHour: z.number().min(1).max(100).optional(),
|
||||
massDataAccessThreshold: z.number().min(10).max(10000).optional(),
|
||||
suspiciousIPThreshold: z.number().min(1).max(100).optional(),
|
||||
})
|
||||
.optional(),
|
||||
alerting: z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
channels: z
|
||||
.array(z.enum(["EMAIL", "WEBHOOK", "SLACK", "DISCORD", "PAGERDUTY"]))
|
||||
.optional(),
|
||||
suppressDuplicateMinutes: z.number().min(1).max(1440).optional(),
|
||||
escalationTimeoutMinutes: z.number().min(5).max(1440).optional(),
|
||||
})
|
||||
.optional(),
|
||||
retention: z
|
||||
.object({
|
||||
alertRetentionDays: z.number().min(1).max(3650).optional(),
|
||||
metricsRetentionDays: z.number().min(1).max(3650).optional(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
// Only platform admins can access security monitoring
|
||||
if (!session.user.isPlatformUser) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const url = new URL(request.url);
|
||||
const params = Object.fromEntries(url.searchParams.entries());
|
||||
const query = metricsQuerySchema.parse(params);
|
||||
|
||||
const context = await createAuditContext(request, session);
|
||||
|
||||
const timeRange = {
|
||||
start: query.startDate
|
||||
? new Date(query.startDate)
|
||||
: new Date(Date.now() - 24 * 60 * 60 * 1000),
|
||||
end: query.endDate ? new Date(query.endDate) : new Date(),
|
||||
};
|
||||
|
||||
// Get security metrics
|
||||
const metrics = await securityMonitoring.getSecurityMetrics(
|
||||
timeRange,
|
||||
query.companyId
|
||||
);
|
||||
|
||||
// Get active alerts
|
||||
const alerts = securityMonitoring.getActiveAlerts(
|
||||
query.severity as AlertSeverity
|
||||
);
|
||||
|
||||
// Get monitoring configuration
|
||||
const config = securityMonitoring.getConfig();
|
||||
|
||||
// Log access to security monitoring
|
||||
await securityAuditLogger.logPlatformAdmin(
|
||||
"security_monitoring_access",
|
||||
AuditOutcome.SUCCESS,
|
||||
context
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
metrics,
|
||||
alerts,
|
||||
config,
|
||||
timeRange,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Security monitoring API error:", error);
|
||||
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid query parameters", details: error.errors },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
if (!session.user.isPlatformUser) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const config = configUpdateSchema.parse(body);
|
||||
const context = await createAuditContext(request, session);
|
||||
|
||||
// Update monitoring configuration
|
||||
securityMonitoring.updateConfig(config);
|
||||
|
||||
// Log configuration change
|
||||
await securityAuditLogger.logPlatformAdmin(
|
||||
"security_monitoring_config_update",
|
||||
AuditOutcome.SUCCESS,
|
||||
context,
|
||||
undefined,
|
||||
{ configChanges: config }
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
config: securityMonitoring.getConfig(),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Security monitoring config update error:", error);
|
||||
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid configuration", details: error.errors },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
191
app/api/admin/security-monitoring/threat-analysis/route.ts
Normal file
191
app/api/admin/security-monitoring/threat-analysis/route.ts
Normal file
@ -0,0 +1,191 @@
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { z } from "zod";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
import {
|
||||
AuditOutcome,
|
||||
createAuditContext,
|
||||
securityAuditLogger,
|
||||
} from "@/lib/securityAuditLogger";
|
||||
import { securityMonitoring, type SecurityMetrics, type AlertType } from "@/lib/securityMonitoring";
|
||||
|
||||
const threatAnalysisSchema = z.object({
|
||||
ipAddress: z.string().ip().optional(),
|
||||
userId: z.string().uuid().optional(),
|
||||
timeRange: z
|
||||
.object({
|
||||
start: z.string().datetime(),
|
||||
end: z.string().datetime(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!session?.user || !session.user.isPlatformUser) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const analysis = threatAnalysisSchema.parse(body);
|
||||
const context = await createAuditContext(request, session);
|
||||
|
||||
interface ThreatAnalysisResults {
|
||||
ipThreatAnalysis?: {
|
||||
ipAddress: string;
|
||||
threatLevel: number;
|
||||
isBlacklisted: boolean;
|
||||
riskFactors: 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
|
||||
if (analysis.ipAddress) {
|
||||
const ipThreat = await securityMonitoring.calculateIPThreatLevel(
|
||||
analysis.ipAddress
|
||||
);
|
||||
results.ipThreatAnalysis = {
|
||||
ipAddress: analysis.ipAddress,
|
||||
...ipThreat,
|
||||
};
|
||||
}
|
||||
|
||||
// Time-based analysis
|
||||
if (analysis.timeRange) {
|
||||
const timeRange = {
|
||||
start: new Date(analysis.timeRange.start),
|
||||
end: new Date(analysis.timeRange.end),
|
||||
};
|
||||
|
||||
const metrics = await securityMonitoring.getSecurityMetrics(timeRange);
|
||||
results.timeRangeAnalysis = {
|
||||
timeRange,
|
||||
securityScore: metrics.securityScore,
|
||||
threatLevel: metrics.threatLevel,
|
||||
topThreats: metrics.topThreats,
|
||||
geoDistribution: metrics.geoDistribution,
|
||||
riskUsers: metrics.userRiskScores.slice(0, 5),
|
||||
};
|
||||
}
|
||||
|
||||
// General threat landscape
|
||||
const defaultTimeRange = {
|
||||
start: new Date(Date.now() - 24 * 60 * 60 * 1000), // Last 24 hours
|
||||
end: new Date(),
|
||||
};
|
||||
|
||||
const overallMetrics =
|
||||
await securityMonitoring.getSecurityMetrics(defaultTimeRange);
|
||||
results.overallThreatLandscape = {
|
||||
currentThreatLevel: overallMetrics.threatLevel,
|
||||
securityScore: overallMetrics.securityScore,
|
||||
activeAlerts: overallMetrics.activeAlerts,
|
||||
criticalEvents: overallMetrics.criticalEvents,
|
||||
recommendations: generateThreatRecommendations(overallMetrics),
|
||||
};
|
||||
|
||||
// Log threat analysis request
|
||||
await securityAuditLogger.logPlatformAdmin(
|
||||
"threat_analysis_performed",
|
||||
AuditOutcome.SUCCESS,
|
||||
context,
|
||||
undefined,
|
||||
{
|
||||
analysisType: Object.keys(analysis),
|
||||
threatLevel: results.overallThreatLandscape?.currentThreatLevel,
|
||||
}
|
||||
);
|
||||
|
||||
return NextResponse.json(results);
|
||||
} catch (error) {
|
||||
console.error("Threat analysis error:", error);
|
||||
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid request", details: error.errors },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function generateThreatRecommendations(metrics: SecurityMetrics): string[] {
|
||||
const recommendations: string[] = [];
|
||||
|
||||
if (metrics.securityScore < 70) {
|
||||
recommendations.push(
|
||||
"Security score is below acceptable threshold - immediate action required"
|
||||
);
|
||||
}
|
||||
|
||||
if (metrics.activeAlerts > 5) {
|
||||
recommendations.push(
|
||||
"High number of active alerts - prioritize alert resolution"
|
||||
);
|
||||
}
|
||||
|
||||
if (metrics.criticalEvents > 0) {
|
||||
recommendations.push(
|
||||
"Critical security events detected - investigate immediately"
|
||||
);
|
||||
}
|
||||
|
||||
const highRiskUsers = metrics.userRiskScores.filter(
|
||||
(user) => user.riskScore > 50
|
||||
);
|
||||
if (highRiskUsers.length > 0) {
|
||||
recommendations.push(
|
||||
`${highRiskUsers.length} users have elevated risk scores - review accounts`
|
||||
);
|
||||
}
|
||||
|
||||
// Check for geographic anomalies
|
||||
const countries = Object.keys(metrics.geoDistribution);
|
||||
if (countries.length > 10) {
|
||||
recommendations.push(
|
||||
"High geographic diversity detected - review for suspicious activity"
|
||||
);
|
||||
}
|
||||
|
||||
// Check for common attack patterns
|
||||
const bruteForceAlerts = metrics.topThreats.filter(
|
||||
(threat) => threat.type === "BRUTE_FORCE_ATTACK"
|
||||
);
|
||||
if (bruteForceAlerts.length > 0) {
|
||||
recommendations.push(
|
||||
"Brute force attacks detected - strengthen authentication controls"
|
||||
);
|
||||
}
|
||||
|
||||
if (recommendations.length === 0) {
|
||||
recommendations.push(
|
||||
"Security posture appears stable - continue monitoring"
|
||||
);
|
||||
}
|
||||
|
||||
return recommendations;
|
||||
}
|
||||
@ -87,7 +87,6 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
// Start processing (this will run asynchronously)
|
||||
const _startTime = Date.now();
|
||||
|
||||
// Note: We're calling the function but not awaiting it to avoid timeout
|
||||
// The processing will continue in the background
|
||||
|
||||
110
app/api/csp-metrics/route.ts
Normal file
110
app/api/csp-metrics/route.ts
Normal file
@ -0,0 +1,110 @@
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { cspMonitoring } from "@/lib/csp-monitoring";
|
||||
import { rateLimiter } from "@/lib/rateLimiter";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
// Rate limiting for metrics endpoint
|
||||
const ip =
|
||||
request.ip || request.headers.get("x-forwarded-for") || "unknown";
|
||||
const rateLimitResult = await rateLimiter.check(
|
||||
`csp-metrics:${ip}`,
|
||||
30, // 30 requests
|
||||
60 * 1000 // per minute
|
||||
);
|
||||
|
||||
if (!rateLimitResult.success) {
|
||||
return NextResponse.json({ error: "Too many requests" }, { status: 429 });
|
||||
}
|
||||
|
||||
// Parse query parameters
|
||||
const url = new URL(request.url);
|
||||
const timeRange = url.searchParams.get("range") || "24h";
|
||||
const format = url.searchParams.get("format") || "json";
|
||||
|
||||
// Calculate time range
|
||||
const now = new Date();
|
||||
let start: Date;
|
||||
|
||||
switch (timeRange) {
|
||||
case "1h":
|
||||
start = new Date(now.getTime() - 60 * 60 * 1000);
|
||||
break;
|
||||
case "6h":
|
||||
start = new Date(now.getTime() - 6 * 60 * 60 * 1000);
|
||||
break;
|
||||
case "24h":
|
||||
start = new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
||||
break;
|
||||
case "7d":
|
||||
start = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||
break;
|
||||
case "30d":
|
||||
start = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
||||
break;
|
||||
default:
|
||||
start = new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
// Get metrics from monitoring service
|
||||
const metrics = cspMonitoring.getMetrics({ start, end: now });
|
||||
|
||||
// Get policy recommendations
|
||||
const recommendations = cspMonitoring.generatePolicyRecommendations({
|
||||
start,
|
||||
end: now,
|
||||
});
|
||||
|
||||
const response = {
|
||||
timeRange: {
|
||||
start: start.toISOString(),
|
||||
end: now.toISOString(),
|
||||
range: timeRange,
|
||||
},
|
||||
summary: {
|
||||
totalViolations: metrics.totalViolations,
|
||||
criticalViolations: metrics.criticalViolations,
|
||||
bypassAttempts: metrics.bypassAttempts,
|
||||
violationRate:
|
||||
metrics.totalViolations /
|
||||
((now.getTime() - start.getTime()) / (60 * 60 * 1000)), // per hour
|
||||
},
|
||||
topViolatedDirectives: metrics.topViolatedDirectives,
|
||||
topBlockedUris: metrics.topBlockedUris,
|
||||
violationTrends: metrics.violationTrends,
|
||||
recommendations: recommendations,
|
||||
lastUpdated: now.toISOString(),
|
||||
};
|
||||
|
||||
// Export format handling
|
||||
if (format === "csv") {
|
||||
const csv = cspMonitoring.exportViolations("csv");
|
||||
return new NextResponse(csv, {
|
||||
headers: {
|
||||
"Content-Type": "text/csv",
|
||||
"Content-Disposition": `attachment; filename="csp-violations-${timeRange}.csv"`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json(response);
|
||||
} catch (error) {
|
||||
console.error("Error fetching CSP metrics:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch metrics" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle preflight requests
|
||||
export async function OPTIONS() {
|
||||
return new NextResponse(null, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "GET, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "Content-Type",
|
||||
},
|
||||
});
|
||||
}
|
||||
130
app/api/csp-report/route.ts
Normal file
130
app/api/csp-report/route.ts
Normal file
@ -0,0 +1,130 @@
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
type CSPViolationReport,
|
||||
detectCSPBypass,
|
||||
parseCSPViolation,
|
||||
} from "@/lib/csp";
|
||||
import { cspMonitoring } from "@/lib/csp-monitoring";
|
||||
import { rateLimiter } from "@/lib/rateLimiter";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
// Rate limiting for CSP reports
|
||||
const ip =
|
||||
request.ip || request.headers.get("x-forwarded-for") || "unknown";
|
||||
const rateLimitResult = await rateLimiter.check(
|
||||
`csp-report:${ip}`,
|
||||
10, // 10 reports
|
||||
60 * 1000 // per minute
|
||||
);
|
||||
|
||||
if (!rateLimitResult.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "Too many CSP reports" },
|
||||
{ status: 429 }
|
||||
);
|
||||
}
|
||||
|
||||
const contentType = request.headers.get("content-type");
|
||||
if (
|
||||
!contentType?.includes("application/csp-report") &&
|
||||
!contentType?.includes("application/json")
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid content type" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const report: CSPViolationReport = await request.json();
|
||||
|
||||
if (!report["csp-report"]) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid CSP report format" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Process violation through monitoring service
|
||||
const monitoringResult = await cspMonitoring.processViolation(
|
||||
report,
|
||||
ip,
|
||||
request.headers.get("user-agent") || undefined
|
||||
);
|
||||
|
||||
// Enhanced logging based on monitoring analysis
|
||||
const logEntry = {
|
||||
timestamp: new Date().toISOString(),
|
||||
ip,
|
||||
userAgent: request.headers.get("user-agent"),
|
||||
violation: parseCSPViolation(report),
|
||||
bypassDetection: detectCSPBypass(
|
||||
report["csp-report"]["blocked-uri"] +
|
||||
" " +
|
||||
(report["csp-report"]["script-sample"] || "")
|
||||
),
|
||||
originalReport: report,
|
||||
alertLevel: monitoringResult.alertLevel,
|
||||
shouldAlert: monitoringResult.shouldAlert,
|
||||
recommendations: monitoringResult.recommendations,
|
||||
};
|
||||
|
||||
// In development, log to console with recommendations
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
console.warn("🚨 CSP Violation Detected:", {
|
||||
...logEntry,
|
||||
recommendations: monitoringResult.recommendations,
|
||||
});
|
||||
|
||||
if (monitoringResult.recommendations.length > 0) {
|
||||
console.info("💡 Recommendations:", monitoringResult.recommendations);
|
||||
}
|
||||
}
|
||||
|
||||
// Enhanced alerting based on monitoring service analysis
|
||||
if (monitoringResult.shouldAlert) {
|
||||
const alertEmoji = {
|
||||
low: "🟡",
|
||||
medium: "🟠",
|
||||
high: "🔴",
|
||||
critical: "🚨",
|
||||
}[monitoringResult.alertLevel];
|
||||
|
||||
console.error(
|
||||
`${alertEmoji} CSP ${monitoringResult.alertLevel.toUpperCase()} ALERT:`,
|
||||
{
|
||||
directive: logEntry.violation.directive,
|
||||
blockedUri: logEntry.violation.blockedUri,
|
||||
isBypassAttempt: logEntry.bypassDetection.isDetected,
|
||||
riskLevel: logEntry.bypassDetection.riskLevel,
|
||||
recommendations: monitoringResult.recommendations.slice(0, 3), // Limit to 3 recommendations
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Clean up old violations periodically (every 100 requests)
|
||||
if (Math.random() < 0.01) {
|
||||
cspMonitoring.cleanupOldViolations();
|
||||
}
|
||||
|
||||
return new NextResponse(null, { status: 204 });
|
||||
} catch (error) {
|
||||
console.error("Error processing CSP report:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to process report" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle preflight requests
|
||||
export async function OPTIONS() {
|
||||
return new NextResponse(null, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "POST, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "Content-Type",
|
||||
},
|
||||
});
|
||||
}
|
||||
@ -5,7 +5,7 @@
|
||||
* It generates a new token and sets it as an HTTP-only cookie.
|
||||
*/
|
||||
|
||||
import { NextRequest } from "next/server";
|
||||
import type { NextRequest } from "next/server";
|
||||
import { generateCSRFTokenResponse } from "../../../middleware/csrfProtection";
|
||||
|
||||
/**
|
||||
@ -14,6 +14,6 @@ import { generateCSRFTokenResponse } from "../../../middleware/csrfProtection";
|
||||
* Generates and returns a new CSRF token.
|
||||
* The token is also set as an HTTP-only cookie for automatic inclusion in requests.
|
||||
*/
|
||||
export function GET(request: NextRequest) {
|
||||
export function GET() {
|
||||
return generateCSRFTokenResponse();
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,7 +3,7 @@ import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "../../../../lib/auth";
|
||||
import { prisma } from "../../../../lib/prisma";
|
||||
|
||||
export async function GET(_request: NextRequest) {
|
||||
export async function GET() {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: "Not logged in" }, { status: 401 });
|
||||
|
||||
@ -3,7 +3,7 @@ import { getServerSession } from "next-auth/next";
|
||||
import { authOptions } from "../../../../lib/auth";
|
||||
import { prisma } from "../../../../lib/prisma";
|
||||
|
||||
export async function GET(_request: NextRequest) {
|
||||
export async function GET() {
|
||||
const authSession = await getServerSession(authOptions);
|
||||
|
||||
if (!authSession || !authSession.user?.companyId) {
|
||||
|
||||
@ -11,7 +11,7 @@ interface UserBasicInfo {
|
||||
role: string;
|
||||
}
|
||||
|
||||
export async function GET(_request: NextRequest) {
|
||||
export async function GET() {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user || session.user.role !== "ADMIN") {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
|
||||
@ -2,6 +2,11 @@ import crypto from "node:crypto";
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "../../../lib/prisma";
|
||||
import { extractClientIP, InMemoryRateLimiter } from "../../../lib/rateLimiter";
|
||||
import {
|
||||
AuditOutcome,
|
||||
createAuditMetadata,
|
||||
securityAuditLogger,
|
||||
} from "../../../lib/securityAuditLogger";
|
||||
import { sendEmail } from "../../../lib/sendEmail";
|
||||
import { forgotPasswordSchema, validateInput } from "../../../lib/validation";
|
||||
|
||||
@ -17,9 +22,25 @@ export async function POST(request: NextRequest) {
|
||||
try {
|
||||
// Rate limiting check using shared utility
|
||||
const ip = extractClientIP(request);
|
||||
const userAgent = request.headers.get("user-agent") || undefined;
|
||||
const rateLimitResult = passwordResetLimiter.checkRateLimit(ip);
|
||||
|
||||
if (!rateLimitResult.allowed) {
|
||||
await securityAuditLogger.logPasswordReset(
|
||||
"password_reset_rate_limited",
|
||||
AuditOutcome.RATE_LIMITED,
|
||||
{
|
||||
ipAddress: ip,
|
||||
userAgent,
|
||||
metadata: createAuditMetadata({
|
||||
resetTime: rateLimitResult.resetTime,
|
||||
maxAttempts: 5,
|
||||
windowMs: 15 * 60 * 1000,
|
||||
}),
|
||||
},
|
||||
"Password reset rate limit exceeded"
|
||||
);
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
@ -34,6 +55,19 @@ export async function POST(request: NextRequest) {
|
||||
// Validate input
|
||||
const validation = validateInput(forgotPasswordSchema, body);
|
||||
if (!validation.success) {
|
||||
await securityAuditLogger.logPasswordReset(
|
||||
"password_reset_invalid_input",
|
||||
AuditOutcome.FAILURE,
|
||||
{
|
||||
ipAddress: ip,
|
||||
userAgent,
|
||||
metadata: createAuditMetadata({
|
||||
error: "invalid_email_format",
|
||||
}),
|
||||
},
|
||||
"Invalid email format in password reset request"
|
||||
);
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
@ -65,11 +99,55 @@ export async function POST(request: NextRequest) {
|
||||
subject: "Password Reset",
|
||||
text: `Reset your password: ${resetUrl}`,
|
||||
});
|
||||
|
||||
await securityAuditLogger.logPasswordReset(
|
||||
"password_reset_email_sent",
|
||||
AuditOutcome.SUCCESS,
|
||||
{
|
||||
userId: user.id,
|
||||
companyId: user.companyId,
|
||||
ipAddress: ip,
|
||||
userAgent,
|
||||
metadata: createAuditMetadata({
|
||||
email: "[REDACTED]",
|
||||
tokenExpiry: expiry.toISOString(),
|
||||
}),
|
||||
},
|
||||
"Password reset email sent successfully"
|
||||
);
|
||||
} else {
|
||||
// Log attempt for non-existent user
|
||||
await securityAuditLogger.logPasswordReset(
|
||||
"password_reset_user_not_found",
|
||||
AuditOutcome.FAILURE,
|
||||
{
|
||||
ipAddress: ip,
|
||||
userAgent,
|
||||
metadata: createAuditMetadata({
|
||||
email: "[REDACTED]",
|
||||
}),
|
||||
},
|
||||
"Password reset attempt for non-existent user"
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true }, { status: 200 });
|
||||
} catch (error) {
|
||||
console.error("Forgot password error:", error);
|
||||
|
||||
await securityAuditLogger.logPasswordReset(
|
||||
"password_reset_server_error",
|
||||
AuditOutcome.FAILURE,
|
||||
{
|
||||
ipAddress: extractClientIP(request),
|
||||
userAgent: request.headers.get("user-agent") || undefined,
|
||||
metadata: createAuditMetadata({
|
||||
error: "server_error",
|
||||
}),
|
||||
},
|
||||
`Server error in password reset: ${error}`
|
||||
);
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
|
||||
@ -73,14 +73,13 @@ export async function POST(
|
||||
{ error: "User already exists in this company" },
|
||||
{ status: 400 }
|
||||
);
|
||||
} else {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `Email already in use by a user in company: ${existingUser.company.name}. Each email address can only be used once across all companies.`
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `Email already in use by a user in company: ${existingUser.company.name}. Each email address can only be used once across all companies.`,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Generate a temporary password (in a real app, you'd send an invitation email)
|
||||
|
||||
@ -3,13 +3,34 @@ import { type NextRequest, NextResponse } from "next/server";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { platformAuthOptions } from "../../../../lib/platform-auth";
|
||||
import { prisma } from "../../../../lib/prisma";
|
||||
import { extractClientIP } from "../../../../lib/rateLimiter";
|
||||
import {
|
||||
AuditOutcome,
|
||||
createAuditMetadata,
|
||||
securityAuditLogger,
|
||||
} from "../../../../lib/securityAuditLogger";
|
||||
|
||||
// GET /api/platform/companies - List all companies
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const session = await getServerSession(platformAuthOptions);
|
||||
const ip = extractClientIP(request);
|
||||
const userAgent = request.headers.get("user-agent") || undefined;
|
||||
|
||||
if (!session?.user?.isPlatformUser) {
|
||||
await securityAuditLogger.logPlatformAdmin(
|
||||
"platform_companies_unauthorized_access",
|
||||
AuditOutcome.BLOCKED,
|
||||
{
|
||||
ipAddress: ip,
|
||||
userAgent,
|
||||
metadata: createAuditMetadata({
|
||||
error: "no_platform_session",
|
||||
}),
|
||||
},
|
||||
"Unauthorized attempt to access platform companies list"
|
||||
);
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: "Platform access required" },
|
||||
{ status: 401 }
|
||||
@ -63,6 +84,24 @@ export async function GET(request: NextRequest) {
|
||||
prisma.company.count({ where }),
|
||||
]);
|
||||
|
||||
// Log successful platform companies access
|
||||
await securityAuditLogger.logPlatformAdmin(
|
||||
"platform_companies_list_accessed",
|
||||
AuditOutcome.SUCCESS,
|
||||
{
|
||||
platformUserId: session.user.id,
|
||||
ipAddress: ip,
|
||||
userAgent,
|
||||
metadata: createAuditMetadata({
|
||||
companiesReturned: companies.length,
|
||||
totalCompanies: total,
|
||||
filters: { status, search },
|
||||
pagination: { page, limit },
|
||||
}),
|
||||
},
|
||||
"Platform companies list accessed"
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
companies,
|
||||
pagination: {
|
||||
@ -74,6 +113,21 @@ export async function GET(request: NextRequest) {
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Platform companies list error:", error);
|
||||
|
||||
await securityAuditLogger.logPlatformAdmin(
|
||||
"platform_companies_list_error",
|
||||
AuditOutcome.FAILURE,
|
||||
{
|
||||
platformUserId: session?.user?.id,
|
||||
ipAddress: extractClientIP(request),
|
||||
userAgent: request.headers.get("user-agent") || undefined,
|
||||
metadata: createAuditMetadata({
|
||||
error: "server_error",
|
||||
}),
|
||||
},
|
||||
`Server error in platform companies list: ${error}`
|
||||
);
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 }
|
||||
@ -85,11 +139,29 @@ export async function GET(request: NextRequest) {
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const session = await getServerSession(platformAuthOptions);
|
||||
const ip = extractClientIP(request);
|
||||
const userAgent = request.headers.get("user-agent") || undefined;
|
||||
|
||||
if (
|
||||
!session?.user?.isPlatformUser ||
|
||||
session.user.platformRole === "SUPPORT"
|
||||
) {
|
||||
await securityAuditLogger.logPlatformAdmin(
|
||||
"platform_company_create_unauthorized",
|
||||
AuditOutcome.BLOCKED,
|
||||
{
|
||||
platformUserId: session?.user?.id,
|
||||
ipAddress: ip,
|
||||
userAgent,
|
||||
metadata: createAuditMetadata({
|
||||
error: "insufficient_permissions",
|
||||
requiredRole: "ADMIN",
|
||||
currentRole: session?.user?.platformRole,
|
||||
}),
|
||||
},
|
||||
"Unauthorized attempt to create platform company"
|
||||
);
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: "Admin access required" },
|
||||
{ status: 403 }
|
||||
@ -165,6 +237,27 @@ export async function POST(request: NextRequest) {
|
||||
};
|
||||
});
|
||||
|
||||
// Log successful company creation
|
||||
await securityAuditLogger.logCompanyManagement(
|
||||
"platform_company_created",
|
||||
AuditOutcome.SUCCESS,
|
||||
{
|
||||
platformUserId: session.user.id,
|
||||
companyId: result.company.id,
|
||||
ipAddress: ip,
|
||||
userAgent,
|
||||
metadata: createAuditMetadata({
|
||||
companyName: result.company.name,
|
||||
companyStatus: result.company.status,
|
||||
adminUserEmail: "[REDACTED]",
|
||||
adminUserName: result.adminUser.name,
|
||||
maxUsers: result.company.maxUsers,
|
||||
hasGeneratedPassword: !!result.generatedPassword,
|
||||
}),
|
||||
},
|
||||
"Platform company created successfully"
|
||||
);
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
company: result.company,
|
||||
@ -179,6 +272,21 @@ export async function POST(request: NextRequest) {
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Platform company creation error:", error);
|
||||
|
||||
await securityAuditLogger.logCompanyManagement(
|
||||
"platform_company_create_error",
|
||||
AuditOutcome.FAILURE,
|
||||
{
|
||||
platformUserId: session?.user?.id,
|
||||
ipAddress: extractClientIP(request),
|
||||
userAgent: request.headers.get("user-agent") || undefined,
|
||||
metadata: createAuditMetadata({
|
||||
error: "server_error",
|
||||
}),
|
||||
},
|
||||
`Server error in platform company creation: ${error}`
|
||||
);
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 }
|
||||
|
||||
@ -2,15 +2,37 @@ import crypto from "node:crypto";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "../../../lib/prisma";
|
||||
import { extractClientIP } from "../../../lib/rateLimiter";
|
||||
import {
|
||||
AuditOutcome,
|
||||
createAuditMetadata,
|
||||
securityAuditLogger,
|
||||
} from "../../../lib/securityAuditLogger";
|
||||
import { resetPasswordSchema, validateInput } from "../../../lib/validation";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const ip = extractClientIP(request);
|
||||
const userAgent = request.headers.get("user-agent") || undefined;
|
||||
const body = await request.json();
|
||||
|
||||
// Validate input with strong password requirements
|
||||
const validation = validateInput(resetPasswordSchema, body);
|
||||
if (!validation.success) {
|
||||
await securityAuditLogger.logPasswordReset(
|
||||
"password_reset_validation_failed",
|
||||
AuditOutcome.FAILURE,
|
||||
{
|
||||
ipAddress: ip,
|
||||
userAgent,
|
||||
metadata: createAuditMetadata({
|
||||
error: "validation_failed",
|
||||
validationErrors: validation.errors,
|
||||
}),
|
||||
},
|
||||
"Password reset validation failed"
|
||||
);
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
@ -34,6 +56,19 @@ export async function POST(request: NextRequest) {
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
await securityAuditLogger.logPasswordReset(
|
||||
"password_reset_invalid_token",
|
||||
AuditOutcome.FAILURE,
|
||||
{
|
||||
ipAddress: ip,
|
||||
userAgent,
|
||||
metadata: createAuditMetadata({
|
||||
error: "invalid_or_expired_token",
|
||||
}),
|
||||
},
|
||||
"Password reset attempt with invalid or expired token"
|
||||
);
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
@ -56,6 +91,22 @@ export async function POST(request: NextRequest) {
|
||||
},
|
||||
});
|
||||
|
||||
await securityAuditLogger.logPasswordReset(
|
||||
"password_reset_completed",
|
||||
AuditOutcome.SUCCESS,
|
||||
{
|
||||
userId: user.id,
|
||||
companyId: user.companyId,
|
||||
ipAddress: ip,
|
||||
userAgent,
|
||||
metadata: createAuditMetadata({
|
||||
email: "[REDACTED]",
|
||||
passwordChanged: true,
|
||||
}),
|
||||
},
|
||||
"Password reset completed successfully"
|
||||
);
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: true,
|
||||
@ -65,6 +116,20 @@ export async function POST(request: NextRequest) {
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Reset password error:", error);
|
||||
|
||||
await securityAuditLogger.logPasswordReset(
|
||||
"password_reset_server_error",
|
||||
AuditOutcome.FAILURE,
|
||||
{
|
||||
ipAddress: extractClientIP(request),
|
||||
userAgent: request.headers.get("user-agent") || undefined,
|
||||
metadata: createAuditMetadata({
|
||||
error: "server_error",
|
||||
}),
|
||||
},
|
||||
`Server error in password reset completion: ${error}`
|
||||
);
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
|
||||
556
app/dashboard/audit-logs/page.tsx
Normal file
556
app/dashboard/audit-logs/page.tsx
Normal file
@ -0,0 +1,556 @@
|
||||
"use client";
|
||||
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { Alert, AlertDescription } from "../../../components/ui/alert";
|
||||
import { Badge } from "../../../components/ui/badge";
|
||||
import { Button } from "../../../components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "../../../components/ui/card";
|
||||
import { Input } from "../../../components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "../../../components/ui/select";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "../../../components/ui/table";
|
||||
|
||||
interface AuditLog {
|
||||
id: string;
|
||||
eventType: string;
|
||||
action: string;
|
||||
outcome: string;
|
||||
severity: string;
|
||||
userId?: string;
|
||||
platformUserId?: string;
|
||||
ipAddress?: string;
|
||||
userAgent?: string;
|
||||
country?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
errorMessage?: string;
|
||||
sessionId?: string;
|
||||
requestId?: string;
|
||||
timestamp: string;
|
||||
user?: {
|
||||
id: string;
|
||||
email: string;
|
||||
name?: string;
|
||||
role: string;
|
||||
};
|
||||
platformUser?: {
|
||||
id: string;
|
||||
email: string;
|
||||
name?: string;
|
||||
role: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface AuditLogsResponse {
|
||||
success: boolean;
|
||||
data?: {
|
||||
auditLogs: AuditLog[];
|
||||
pagination: {
|
||||
page: number;
|
||||
limit: number;
|
||||
totalCount: number;
|
||||
totalPages: number;
|
||||
hasNext: boolean;
|
||||
hasPrev: boolean;
|
||||
};
|
||||
};
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const eventTypeLabels: Record<string, string> = {
|
||||
AUTHENTICATION: "Authentication",
|
||||
AUTHORIZATION: "Authorization",
|
||||
USER_MANAGEMENT: "User Management",
|
||||
COMPANY_MANAGEMENT: "Company Management",
|
||||
RATE_LIMITING: "Rate Limiting",
|
||||
CSRF_PROTECTION: "CSRF Protection",
|
||||
SECURITY_HEADERS: "Security Headers",
|
||||
PASSWORD_RESET: "Password Reset",
|
||||
PLATFORM_ADMIN: "Platform Admin",
|
||||
DATA_PRIVACY: "Data Privacy",
|
||||
SYSTEM_CONFIG: "System Config",
|
||||
API_SECURITY: "API Security",
|
||||
};
|
||||
|
||||
const outcomeColors: Record<string, string> = {
|
||||
SUCCESS: "bg-green-100 text-green-800",
|
||||
FAILURE: "bg-red-100 text-red-800",
|
||||
BLOCKED: "bg-orange-100 text-orange-800",
|
||||
RATE_LIMITED: "bg-yellow-100 text-yellow-800",
|
||||
SUSPICIOUS: "bg-purple-100 text-purple-800",
|
||||
};
|
||||
|
||||
const severityColors: Record<string, string> = {
|
||||
INFO: "bg-blue-100 text-blue-800",
|
||||
LOW: "bg-gray-100 text-gray-800",
|
||||
MEDIUM: "bg-yellow-100 text-yellow-800",
|
||||
HIGH: "bg-orange-100 text-orange-800",
|
||||
CRITICAL: "bg-red-100 text-red-800",
|
||||
};
|
||||
|
||||
export default function AuditLogsPage() {
|
||||
const { data: session } = useSession();
|
||||
const [auditLogs, setAuditLogs] = useState<AuditLog[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [pagination, setPagination] = useState({
|
||||
page: 1,
|
||||
limit: 50,
|
||||
totalCount: 0,
|
||||
totalPages: 0,
|
||||
hasNext: false,
|
||||
hasPrev: false,
|
||||
});
|
||||
|
||||
// Filter states
|
||||
const [filters, setFilters] = useState({
|
||||
eventType: "",
|
||||
outcome: "",
|
||||
severity: "",
|
||||
userId: "",
|
||||
startDate: "",
|
||||
endDate: "",
|
||||
});
|
||||
|
||||
const [selectedLog, setSelectedLog] = useState<AuditLog | null>(null);
|
||||
|
||||
const fetchAuditLogs = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const params = new URLSearchParams({
|
||||
page: pagination.page.toString(),
|
||||
limit: pagination.limit.toString(),
|
||||
...filters,
|
||||
});
|
||||
|
||||
Object.keys(filters).forEach((key) => {
|
||||
if (!filters[key as keyof typeof filters]) {
|
||||
params.delete(key);
|
||||
}
|
||||
});
|
||||
|
||||
const response = await fetch(
|
||||
`/api/admin/audit-logs?${params.toString()}`
|
||||
);
|
||||
const data: AuditLogsResponse = await response.json();
|
||||
|
||||
if (data.success && data.data) {
|
||||
setAuditLogs(data.data.auditLogs);
|
||||
setPagination(data.data.pagination);
|
||||
setError(null);
|
||||
} else {
|
||||
setError(data.error || "Failed to fetch audit logs");
|
||||
}
|
||||
} catch (err) {
|
||||
setError("An error occurred while fetching audit logs");
|
||||
console.error("Audit logs fetch error:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [pagination.page, pagination.limit, filters]);
|
||||
|
||||
useEffect(() => {
|
||||
if (session?.user?.role === "ADMIN") {
|
||||
fetchAuditLogs();
|
||||
}
|
||||
}, [session, fetchAuditLogs]);
|
||||
|
||||
const handleFilterChange = (key: keyof typeof filters, value: string) => {
|
||||
setFilters((prev) => ({ ...prev, [key]: value }));
|
||||
setPagination((prev) => ({ ...prev, page: 1 })); // Reset to first page
|
||||
};
|
||||
|
||||
const clearFilters = () => {
|
||||
setFilters({
|
||||
eventType: "",
|
||||
outcome: "",
|
||||
severity: "",
|
||||
userId: "",
|
||||
startDate: "",
|
||||
endDate: "",
|
||||
});
|
||||
};
|
||||
|
||||
if (session?.user?.role !== "ADMIN") {
|
||||
return (
|
||||
<div className="container mx-auto py-8">
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
You don't have permission to view audit logs. Only administrators
|
||||
can access this page.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-8 space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<h1 className="text-3xl font-bold">Security Audit Logs</h1>
|
||||
<Button onClick={fetchAuditLogs} disabled={loading}>
|
||||
{loading ? "Loading..." : "Refresh"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Filters</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium">Event Type</label>
|
||||
<Select
|
||||
value={filters.eventType}
|
||||
onValueChange={(value) =>
|
||||
handleFilterChange("eventType", value)
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="All event types" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">All event types</SelectItem>
|
||||
{Object.entries(eventTypeLabels).map(([value, label]) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium">Outcome</label>
|
||||
<Select
|
||||
value={filters.outcome}
|
||||
onValueChange={(value) => handleFilterChange("outcome", value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="All outcomes" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">All outcomes</SelectItem>
|
||||
<SelectItem value="SUCCESS">Success</SelectItem>
|
||||
<SelectItem value="FAILURE">Failure</SelectItem>
|
||||
<SelectItem value="BLOCKED">Blocked</SelectItem>
|
||||
<SelectItem value="RATE_LIMITED">Rate Limited</SelectItem>
|
||||
<SelectItem value="SUSPICIOUS">Suspicious</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium">Severity</label>
|
||||
<Select
|
||||
value={filters.severity}
|
||||
onValueChange={(value) => handleFilterChange("severity", value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="All severities" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">All severities</SelectItem>
|
||||
<SelectItem value="INFO">Info</SelectItem>
|
||||
<SelectItem value="LOW">Low</SelectItem>
|
||||
<SelectItem value="MEDIUM">Medium</SelectItem>
|
||||
<SelectItem value="HIGH">High</SelectItem>
|
||||
<SelectItem value="CRITICAL">Critical</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium">Start Date</label>
|
||||
<Input
|
||||
type="datetime-local"
|
||||
value={filters.startDate}
|
||||
onChange={(e) =>
|
||||
handleFilterChange("startDate", e.target.value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium">End Date</label>
|
||||
<Input
|
||||
type="datetime-local"
|
||||
value={filters.endDate}
|
||||
onChange={(e) => handleFilterChange("endDate", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-end">
|
||||
<Button variant="outline" onClick={clearFilters}>
|
||||
Clear Filters
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Audit Logs Table */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Audit Logs ({pagination.totalCount} total)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Timestamp</TableHead>
|
||||
<TableHead>Event Type</TableHead>
|
||||
<TableHead>Action</TableHead>
|
||||
<TableHead>Outcome</TableHead>
|
||||
<TableHead>Severity</TableHead>
|
||||
<TableHead>User</TableHead>
|
||||
<TableHead>IP Address</TableHead>
|
||||
<TableHead>Details</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{auditLogs.map((log) => (
|
||||
<TableRow
|
||||
key={log.id}
|
||||
className="cursor-pointer hover:bg-gray-50"
|
||||
onClick={() => setSelectedLog(log)}
|
||||
>
|
||||
<TableCell className="font-mono text-sm">
|
||||
{formatDistanceToNow(new Date(log.timestamp), {
|
||||
addSuffix: true,
|
||||
})}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline">
|
||||
{eventTypeLabels[log.eventType] || log.eventType}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="max-w-48 truncate">
|
||||
{log.action}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
className={
|
||||
outcomeColors[log.outcome] ||
|
||||
"bg-gray-100 text-gray-800"
|
||||
}
|
||||
>
|
||||
{log.outcome}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
className={
|
||||
severityColors[log.severity] ||
|
||||
"bg-gray-100 text-gray-800"
|
||||
}
|
||||
>
|
||||
{log.severity}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{log.user?.email || log.platformUser?.email || "System"}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-sm">
|
||||
{log.ipAddress || "N/A"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button variant="ghost" size="sm">
|
||||
View
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
<div className="flex justify-between items-center mt-4">
|
||||
<div className="text-sm text-gray-600">
|
||||
Showing {(pagination.page - 1) * pagination.limit + 1} to{" "}
|
||||
{Math.min(
|
||||
pagination.page * pagination.limit,
|
||||
pagination.totalCount
|
||||
)}{" "}
|
||||
of {pagination.totalCount} results
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!pagination.hasPrev}
|
||||
onClick={() =>
|
||||
setPagination((prev) => ({ ...prev, page: prev.page - 1 }))
|
||||
}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!pagination.hasNext}
|
||||
onClick={() =>
|
||||
setPagination((prev) => ({ ...prev, page: prev.page + 1 }))
|
||||
}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Log Detail Modal */}
|
||||
{selectedLog && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
|
||||
<div className="bg-white rounded-lg max-w-4xl w-full max-h-[90vh] overflow-auto">
|
||||
<div className="p-6">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-xl font-bold">Audit Log Details</h2>
|
||||
<Button variant="ghost" onClick={() => setSelectedLog(null)}>
|
||||
×
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="font-medium">Timestamp:</label>
|
||||
<p className="font-mono text-sm">
|
||||
{new Date(selectedLog.timestamp).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="font-medium">Event Type:</label>
|
||||
<p>
|
||||
{eventTypeLabels[selectedLog.eventType] ||
|
||||
selectedLog.eventType}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="font-medium">Action:</label>
|
||||
<p>{selectedLog.action}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="font-medium">Outcome:</label>
|
||||
<Badge className={outcomeColors[selectedLog.outcome]}>
|
||||
{selectedLog.outcome}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="font-medium">Severity:</label>
|
||||
<Badge className={severityColors[selectedLog.severity]}>
|
||||
{selectedLog.severity}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="font-medium">IP Address:</label>
|
||||
<p className="font-mono text-sm">
|
||||
{selectedLog.ipAddress || "N/A"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{selectedLog.user && (
|
||||
<div>
|
||||
<label className="font-medium">User:</label>
|
||||
<p>
|
||||
{selectedLog.user.email} ({selectedLog.user.role})
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedLog.platformUser && (
|
||||
<div>
|
||||
<label className="font-medium">Platform User:</label>
|
||||
<p>
|
||||
{selectedLog.platformUser.email} (
|
||||
{selectedLog.platformUser.role})
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedLog.country && (
|
||||
<div>
|
||||
<label className="font-medium">Country:</label>
|
||||
<p>{selectedLog.country}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedLog.sessionId && (
|
||||
<div>
|
||||
<label className="font-medium">Session ID:</label>
|
||||
<p className="font-mono text-sm">{selectedLog.sessionId}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedLog.requestId && (
|
||||
<div>
|
||||
<label className="font-medium">Request ID:</label>
|
||||
<p className="font-mono text-sm">{selectedLog.requestId}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedLog.errorMessage && (
|
||||
<div className="mt-4">
|
||||
<label className="font-medium">Error Message:</label>
|
||||
<p className="text-red-600 bg-red-50 p-2 rounded text-sm">
|
||||
{selectedLog.errorMessage}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedLog.userAgent && (
|
||||
<div className="mt-4">
|
||||
<label className="font-medium">User Agent:</label>
|
||||
<p className="text-sm break-all">{selectedLog.userAgent}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedLog.metadata && (
|
||||
<div className="mt-4">
|
||||
<label className="font-medium">Metadata:</label>
|
||||
<pre className="bg-gray-100 p-2 rounded text-xs overflow-auto max-h-40">
|
||||
{JSON.stringify(selectedLog.metadata, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -470,7 +470,7 @@ function DashboardContent() {
|
||||
const { data: session, status } = useSession();
|
||||
const router = useRouter();
|
||||
const [metrics, setMetrics] = useState<MetricsResult | null>(null);
|
||||
const [company, setCompany] = useState<Company | null>(null);
|
||||
const [company, _setCompany] = useState<Company | null>(null);
|
||||
const [refreshing, setRefreshing] = useState<boolean>(false);
|
||||
const [isInitialLoad, setIsInitialLoad] = useState<boolean>(true);
|
||||
|
||||
@ -499,13 +499,20 @@ function DashboardContent() {
|
||||
useEffect(() => {
|
||||
if (overviewData) {
|
||||
// Map overview data to metrics format expected by the component
|
||||
const mappedMetrics = {
|
||||
const mappedMetrics: Partial<MetricsResult> = {
|
||||
totalSessions: overviewData.totalSessions,
|
||||
avgMessagesSent: overviewData.avgMessagesSent,
|
||||
avgSessionsPerDay: 0, // Will be computed properly later
|
||||
avgSessionLength: null,
|
||||
days: { data: [], labels: [] },
|
||||
languages: { data: [], labels: [] },
|
||||
categories: { data: [], labels: [] },
|
||||
countries: { data: [], labels: [] },
|
||||
belowThresholdCount: 0,
|
||||
// Map the available data
|
||||
sentimentDistribution: overviewData.sentimentDistribution,
|
||||
categoryDistribution: overviewData.categoryDistribution,
|
||||
};
|
||||
setMetrics(mappedMetrics as any); // Type assertion for compatibility
|
||||
setMetrics(mappedMetrics as MetricsResult);
|
||||
|
||||
if (isInitialLoad) {
|
||||
setIsInitialLoad(false);
|
||||
|
||||
@ -21,6 +21,8 @@ import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { formatCategory } from "@/lib/format-enums";
|
||||
import { trpc } from "@/lib/trpc-client";
|
||||
import { sessionFilterSchema } from "@/lib/validation";
|
||||
import type { z } from "zod";
|
||||
import type { ChatSession } from "../../../lib/types";
|
||||
|
||||
interface FilterOptions {
|
||||
@ -30,21 +32,21 @@ interface FilterOptions {
|
||||
|
||||
interface FilterSectionProps {
|
||||
filtersExpanded: boolean;
|
||||
setFiltersExpanded: (expanded: boolean) => void;
|
||||
setFiltersExpanded: (_expanded: boolean) => void;
|
||||
searchTerm: string;
|
||||
setSearchTerm: (term: string) => void;
|
||||
setSearchTerm: (_term: string) => void;
|
||||
selectedCategory: string;
|
||||
setSelectedCategory: (category: string) => void;
|
||||
setSelectedCategory: (_category: string) => void;
|
||||
selectedLanguage: string;
|
||||
setSelectedLanguage: (language: string) => void;
|
||||
setSelectedLanguage: (_language: string) => void;
|
||||
startDate: string;
|
||||
setStartDate: (date: string) => void;
|
||||
setStartDate: (_date: string) => void;
|
||||
endDate: string;
|
||||
setEndDate: (date: string) => void;
|
||||
setEndDate: (_date: string) => void;
|
||||
sortKey: string;
|
||||
setSortKey: (key: string) => void;
|
||||
setSortKey: (_key: string) => void;
|
||||
sortOrder: string;
|
||||
setSortOrder: (order: string) => void;
|
||||
setSortOrder: (_order: string) => void;
|
||||
filterOptions: FilterOptions;
|
||||
searchHeadingId: string;
|
||||
filtersHeadingId: string;
|
||||
@ -305,7 +307,7 @@ function SessionList({
|
||||
)}
|
||||
|
||||
{!loading && !error && sessions.length > 0 && (
|
||||
<ul className="space-y-4" role="list">
|
||||
<ul className="space-y-4">
|
||||
{sessions.map((session) => (
|
||||
<li key={session.id}>
|
||||
<Card>
|
||||
@ -316,7 +318,7 @@ function SessionList({
|
||||
<h3 className="font-medium text-base mb-1">
|
||||
Session{" "}
|
||||
{session.sessionId ||
|
||||
session.id.substring(0, 8) + "..."}
|
||||
`${session.id.substring(0, 8)}...`}
|
||||
</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
@ -382,7 +384,7 @@ function SessionList({
|
||||
interface PaginationProps {
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
setCurrentPage: (page: number | ((prev: number) => number)) => void;
|
||||
setCurrentPage: (_page: number | ((_prev: number) => number)) => void;
|
||||
}
|
||||
|
||||
function Pagination({
|
||||
@ -487,7 +489,7 @@ export default function SessionsPage() {
|
||||
} = trpc.dashboard.getSessions.useQuery(
|
||||
{
|
||||
search: debouncedSearchTerm || undefined,
|
||||
category: (selectedCategory as any) || undefined,
|
||||
category: selectedCategory ? selectedCategory as z.infer<typeof sessionFilterSchema>["category"] : undefined,
|
||||
// language: selectedLanguage || undefined, // Not supported in schema yet
|
||||
startDate: startDate || undefined,
|
||||
endDate: endDate || undefined,
|
||||
@ -505,7 +507,7 @@ export default function SessionsPage() {
|
||||
// Update state when data changes
|
||||
useEffect(() => {
|
||||
if (sessionsData) {
|
||||
setSessions((sessionsData.sessions as any) || []);
|
||||
setSessions(sessionsData.sessions || []);
|
||||
setTotalPages(sessionsData.pagination.totalPages);
|
||||
setError(null);
|
||||
}
|
||||
|
||||
@ -2,6 +2,8 @@
|
||||
import "./globals.css";
|
||||
import type { ReactNode } from "react";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { NonceProvider } from "@/lib/nonce-context";
|
||||
import { getNonce } from "@/lib/nonce-utils";
|
||||
import { Providers } from "./providers";
|
||||
|
||||
export const metadata = {
|
||||
@ -88,7 +90,13 @@ export const metadata = {
|
||||
},
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: ReactNode }) {
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const nonce = await getNonce();
|
||||
|
||||
const jsonLd = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "SoftwareApplication",
|
||||
@ -126,7 +134,8 @@ export default function RootLayout({ children }: { children: ReactNode }) {
|
||||
<head>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
// biome-ignore lint/security/noDangerouslySetInnerHtml: Safe use for JSON-LD structured data
|
||||
nonce={nonce}
|
||||
// biome-ignore lint/security/noDangerouslySetInnerHtml: Safe use for JSON-LD structured data with CSP nonce
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||
/>
|
||||
</head>
|
||||
@ -138,7 +147,9 @@ export default function RootLayout({ children }: { children: ReactNode }) {
|
||||
>
|
||||
Skip to main content
|
||||
</a>
|
||||
<Providers>{children}</Providers>
|
||||
<NonceProvider nonce={nonce}>
|
||||
<Providers>{children}</Providers>
|
||||
</NonceProvider>
|
||||
<Toaster />
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -12,6 +12,7 @@ import {
|
||||
Plus,
|
||||
Search,
|
||||
Settings,
|
||||
Shield,
|
||||
User,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
@ -234,7 +235,7 @@ export default function PlatformDashboard() {
|
||||
description: (
|
||||
<div className="space-y-3">
|
||||
<p className="font-medium">
|
||||
Company "{companyName}" has been created.
|
||||
Company "{companyName}" has been created.
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between bg-muted p-2 rounded">
|
||||
@ -366,6 +367,15 @@ export default function PlatformDashboard() {
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-4 items-center">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => router.push("/platform/security")}
|
||||
>
|
||||
<Shield className="w-4 h-4 mr-2" />
|
||||
Security Monitoring
|
||||
</Button>
|
||||
|
||||
<ThemeToggle />
|
||||
|
||||
{/* Search Filter */}
|
||||
@ -491,7 +501,7 @@ export default function PlatformDashboard() {
|
||||
<div className="flex items-center gap-2">
|
||||
{searchTerm && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
Search: "{searchTerm}"
|
||||
Search: "{searchTerm}"
|
||||
</Badge>
|
||||
)}
|
||||
<Dialog open={showAddCompany} onOpenChange={setShowAddCompany}>
|
||||
@ -693,7 +703,7 @@ export default function PlatformDashboard() {
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
{searchTerm ? (
|
||||
<div className="space-y-2">
|
||||
<p>No companies match "{searchTerm}".</p>
|
||||
<p>No companies match "{searchTerm}".</p>
|
||||
<Button
|
||||
variant="link"
|
||||
onClick={() => setSearchTerm("")}
|
||||
|
||||
528
app/platform/security/page.tsx
Normal file
528
app/platform/security/page.tsx
Normal file
@ -0,0 +1,528 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Activity,
|
||||
AlertTriangle,
|
||||
Bell,
|
||||
BellOff,
|
||||
CheckCircle,
|
||||
Download,
|
||||
Settings,
|
||||
Shield,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { SecurityConfigModal } from "@/components/security/SecurityConfigModal";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
|
||||
interface SecurityMetrics {
|
||||
totalEvents: number;
|
||||
criticalEvents: number;
|
||||
activeAlerts: number;
|
||||
resolvedAlerts: number;
|
||||
securityScore: number;
|
||||
threatLevel: string;
|
||||
eventsByType: Record<string, number>;
|
||||
alertsByType: Record<string, number>;
|
||||
topThreats: Array<{ type: string; count: number }>;
|
||||
geoDistribution: Record<string, number>;
|
||||
timeDistribution: Array<{ hour: number; count: number }>;
|
||||
userRiskScores: Array<{ userId: string; email: string; riskScore: number }>;
|
||||
}
|
||||
|
||||
interface SecurityAlert {
|
||||
id: string;
|
||||
timestamp: string;
|
||||
severity: string;
|
||||
type: string;
|
||||
title: string;
|
||||
description: string;
|
||||
eventType: string;
|
||||
context: Record<string, unknown>;
|
||||
metadata: Record<string, unknown>;
|
||||
acknowledged: boolean;
|
||||
}
|
||||
|
||||
export default function SecurityMonitoringPage() {
|
||||
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);
|
||||
|
||||
useEffect(() => {
|
||||
loadSecurityData();
|
||||
|
||||
if (autoRefresh) {
|
||||
const interval = setInterval(loadSecurityData, 30000); // Refresh every 30 seconds
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
}, [autoRefresh, loadSecurityData]);
|
||||
|
||||
const loadSecurityData = useCallback(async () => {
|
||||
try {
|
||||
const startDate = getStartDateForRange(selectedTimeRange);
|
||||
const endDate = new Date().toISOString();
|
||||
|
||||
const response = await fetch(
|
||||
`/api/admin/security-monitoring?startDate=${startDate}&endDate=${endDate}`
|
||||
);
|
||||
|
||||
if (!response.ok) throw new Error("Failed to load security data");
|
||||
|
||||
const data = await response.json();
|
||||
setMetrics(data.metrics);
|
||||
setAlerts(data.alerts);
|
||||
} catch (error) {
|
||||
console.error("Error loading security data:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [selectedTimeRange]);
|
||||
|
||||
const acknowledgeAlert = async (alertId: string) => {
|
||||
try {
|
||||
const response = await fetch("/api/admin/security-monitoring/alerts", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ alertId, action: "acknowledge" }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
setAlerts(
|
||||
alerts.map((alert) =>
|
||||
alert.id === alertId ? { ...alert, acknowledged: true } : alert
|
||||
)
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error acknowledging alert:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const exportData = async (
|
||||
format: "json" | "csv",
|
||||
type: "alerts" | "metrics"
|
||||
) => {
|
||||
try {
|
||||
const startDate = getStartDateForRange(selectedTimeRange);
|
||||
const endDate = new Date().toISOString();
|
||||
|
||||
const response = await fetch(
|
||||
`/api/admin/security-monitoring/export?format=${format}&type=${type}&startDate=${startDate}&endDate=${endDate}`
|
||||
);
|
||||
|
||||
if (!response.ok) throw new Error("Export failed");
|
||||
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `security-${type}-${new Date().toISOString().split("T")[0]}.${format}`;
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
} catch (error) {
|
||||
console.error("Error exporting data:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const getStartDateForRange = (range: string): string => {
|
||||
const now = new Date();
|
||||
switch (range) {
|
||||
case "1h":
|
||||
return new Date(now.getTime() - 60 * 60 * 1000).toISOString();
|
||||
case "24h":
|
||||
return new Date(now.getTime() - 24 * 60 * 60 * 1000).toISOString();
|
||||
case "7d":
|
||||
return new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000).toISOString();
|
||||
case "30d":
|
||||
return new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000).toISOString();
|
||||
default:
|
||||
return new Date(now.getTime() - 24 * 60 * 60 * 1000).toISOString();
|
||||
}
|
||||
};
|
||||
|
||||
const getThreatLevelColor = (level: string) => {
|
||||
switch (level?.toLowerCase()) {
|
||||
case "critical":
|
||||
return "bg-red-500";
|
||||
case "high":
|
||||
return "bg-orange-500";
|
||||
case "moderate":
|
||||
return "bg-yellow-500";
|
||||
case "low":
|
||||
return "bg-green-500";
|
||||
default:
|
||||
return "bg-gray-500";
|
||||
}
|
||||
};
|
||||
|
||||
const getSeverityColor = (severity: string) => {
|
||||
switch (severity?.toLowerCase()) {
|
||||
case "critical":
|
||||
return "destructive";
|
||||
case "high":
|
||||
return "destructive";
|
||||
case "medium":
|
||||
return "secondary";
|
||||
case "low":
|
||||
return "outline";
|
||||
default:
|
||||
return "outline";
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="animate-spin rounded-full h-32 w-32 border-b-2 border-gray-900" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-6 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">
|
||||
Security Monitoring
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Real-time security monitoring and threat detection
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setAutoRefresh(!autoRefresh)}
|
||||
>
|
||||
{autoRefresh ? (
|
||||
<Bell className="h-4 w-4" />
|
||||
) : (
|
||||
<BellOff className="h-4 w-4" />
|
||||
)}
|
||||
Auto Refresh
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowConfig(true)}
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
Configure
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => exportData("json", "alerts")}
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
Export
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Time Range Selector */}
|
||||
<div className="flex gap-2">
|
||||
{["1h", "24h", "7d", "30d"].map((range) => (
|
||||
<Button
|
||||
key={range}
|
||||
variant={selectedTimeRange === range ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setSelectedTimeRange(range)}
|
||||
>
|
||||
{range}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Overview Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
Security Score
|
||||
</CardTitle>
|
||||
<Shield className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{metrics?.securityScore || 0}/100
|
||||
</div>
|
||||
<div
|
||||
className={`inline-flex items-center px-2 py-1 rounded text-xs font-medium ${getThreatLevelColor(metrics?.threatLevel || "")}`}
|
||||
>
|
||||
{metrics?.threatLevel || "Unknown"} Threat Level
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Active Alerts</CardTitle>
|
||||
<AlertTriangle className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{metrics?.activeAlerts || 0}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{metrics?.resolvedAlerts || 0} resolved
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
Security Events
|
||||
</CardTitle>
|
||||
<Activity className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{metrics?.totalEvents || 0}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{metrics?.criticalEvents || 0} critical
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Top Threat</CardTitle>
|
||||
<AlertTriangle className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-sm font-bold">
|
||||
{metrics?.topThreats?.[0]?.type?.replace(/_/g, " ") || "None"}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{metrics?.topThreats?.[0]?.count || 0} instances
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="alerts" className="space-y-4">
|
||||
<TabsList>
|
||||
<TabsTrigger value="alerts">Active Alerts</TabsTrigger>
|
||||
<TabsTrigger value="metrics">Security Metrics</TabsTrigger>
|
||||
<TabsTrigger value="threats">Threat Analysis</TabsTrigger>
|
||||
<TabsTrigger value="geography">Geographic View</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="alerts" className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Active Security Alerts</CardTitle>
|
||||
<CardDescription>
|
||||
Real-time security alerts requiring attention
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{alerts.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<CheckCircle className="h-12 w-12 mx-auto mb-4" />
|
||||
<p>No active alerts - system is secure</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{alerts.map((alert) => (
|
||||
<div
|
||||
key={alert.id}
|
||||
className="flex items-center justify-between p-4 border rounded-lg"
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant={getSeverityColor(alert.severity)}>
|
||||
{alert.severity}
|
||||
</Badge>
|
||||
<span className="font-medium">{alert.title}</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{alert.description}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{new Date(alert.timestamp).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{!alert.acknowledged && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => acknowledgeAlert(alert.id)}
|
||||
>
|
||||
Acknowledge
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="metrics" className="space-y-4">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Event Distribution</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{metrics?.eventsByType && (
|
||||
<div className="space-y-2">
|
||||
{Object.entries(metrics.eventsByType).map(
|
||||
([type, count]) => (
|
||||
<div key={type} className="flex justify-between">
|
||||
<span className="text-sm">
|
||||
{type.replace(/_/g, " ")}
|
||||
</span>
|
||||
<span className="font-medium">{count}</span>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>High-Risk Users</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{metrics?.userRiskScores?.length ? (
|
||||
<div className="space-y-2">
|
||||
{metrics.userRiskScores.slice(0, 5).map((user) => (
|
||||
<div key={user.userId} className="flex justify-between">
|
||||
<span className="text-sm truncate">{user.email}</span>
|
||||
<Badge
|
||||
variant={
|
||||
user.riskScore > 70
|
||||
? "destructive"
|
||||
: user.riskScore > 40
|
||||
? "secondary"
|
||||
: "outline"
|
||||
}
|
||||
>
|
||||
{user.riskScore}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No high-risk users detected
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="threats" className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Threat Analysis</CardTitle>
|
||||
<CardDescription>
|
||||
Analysis of current security threats and recommendations
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{metrics?.topThreats?.length ? (
|
||||
<div className="space-y-4">
|
||||
{metrics.topThreats.map((threat, index) => (
|
||||
<div
|
||||
key={threat.type}
|
||||
className="flex items-center justify-between p-3 border rounded"
|
||||
>
|
||||
<div>
|
||||
<span className="font-medium">
|
||||
{threat.type.replace(/_/g, " ")}
|
||||
</span>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{threat.count} occurrences
|
||||
</p>
|
||||
</div>
|
||||
<Badge
|
||||
variant={index === 0 ? "destructive" : "secondary"}
|
||||
>
|
||||
{index === 0 ? "Highest Priority" : "Monitor"}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-center py-8 text-muted-foreground">
|
||||
No significant threats detected
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="geography" className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Geographic Distribution</CardTitle>
|
||||
<CardDescription>
|
||||
Security events by geographic location
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{metrics?.geoDistribution &&
|
||||
Object.keys(metrics.geoDistribution).length > 0 ? (
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
{Object.entries(metrics.geoDistribution)
|
||||
.sort(([, a], [, b]) => b - a)
|
||||
.slice(0, 12)
|
||||
.map(([country, count]) => (
|
||||
<div
|
||||
key={country}
|
||||
className="text-center p-3 border rounded"
|
||||
>
|
||||
<div className="text-2xl font-bold">{count}</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{country}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-center py-8 text-muted-foreground">
|
||||
No geographic data available
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{showConfig && (
|
||||
<SecurityConfigModal
|
||||
onClose={() => setShowConfig(false)}
|
||||
onSave={() => {
|
||||
setShowConfig(false);
|
||||
loadSecurityData();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -18,7 +18,15 @@ import { useToast } from "@/hooks/use-toast";
|
||||
|
||||
// Platform session hook - same as in dashboard
|
||||
function usePlatformSession() {
|
||||
const [session, setSession] = useState<any>(null);
|
||||
const [session, setSession] = useState<{
|
||||
user: {
|
||||
id: string;
|
||||
email: string;
|
||||
name?: string;
|
||||
role: string;
|
||||
companyId?: string;
|
||||
};
|
||||
} | null>(null);
|
||||
const [status, setStatus] = useState<
|
||||
"loading" | "authenticated" | "unauthenticated"
|
||||
>("loading");
|
||||
@ -89,7 +97,7 @@ export default function PlatformSettings() {
|
||||
title: "Profile Updated",
|
||||
description: "Your profile has been updated successfully.",
|
||||
});
|
||||
} catch (error) {
|
||||
} catch (_error) {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Failed to update profile. Please try again.",
|
||||
@ -134,7 +142,7 @@ export default function PlatformSettings() {
|
||||
newPassword: "",
|
||||
confirmPassword: "",
|
||||
});
|
||||
} catch (error) {
|
||||
} catch (_error) {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Failed to change password. Please try again.",
|
||||
|
||||
@ -2,9 +2,9 @@
|
||||
|
||||
import { SessionProvider } from "next-auth/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { CSRFProvider } from "@/components/providers/CSRFProvider";
|
||||
import { TRPCProvider } from "@/components/providers/TRPCProvider";
|
||||
import { ThemeProvider } from "@/components/theme-provider";
|
||||
import { CSRFProvider } from "@/components/providers/CSRFProvider";
|
||||
|
||||
export function Providers({ children }: { children: ReactNode }) {
|
||||
// Including error handling and refetch interval for better user experience
|
||||
|
||||
Reference in New Issue
Block a user