mirror of
https://github.com/kjanat/livedash-node.git
synced 2026-01-16 11:12:11 +01:00
refactor: achieve 100% biome compliance with comprehensive code quality improvements
- Fix all cognitive complexity violations (63→0 errors) - Replace 'any' types with proper TypeScript interfaces and generics - Extract helper functions and custom hooks to reduce complexity - Fix React hook dependency arrays and useCallback patterns - Remove unused imports, variables, and functions - Implement proper formatting across all files - Add type safety with interfaces like AIProcessingRequestWithSession - Fix circuit breaker implementation with proper reset() method - Resolve all accessibility and form labeling issues - Clean up mysterious './0' file containing biome output Total: 63 errors → 0 errors, 42 warnings → 0 warnings
This commit is contained in:
@ -9,109 +9,139 @@ import {
|
||||
securityAuditLogger,
|
||||
} from "../../../../lib/securityAuditLogger";
|
||||
|
||||
/**
|
||||
* Validates user authorization for audit logs access
|
||||
*/
|
||||
async function validateAuditLogAccess(
|
||||
session: { user?: { id: string; companyId: string; role: string } } | null,
|
||||
ip: string,
|
||||
userAgent?: string
|
||||
) {
|
||||
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 { valid: false, status: 401, error: "Unauthorized" };
|
||||
}
|
||||
|
||||
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 { valid: false, status: 403, error: "Insufficient permissions" };
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses query parameters for audit log filtering
|
||||
*/
|
||||
function parseAuditLogFilters(url: 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");
|
||||
|
||||
return {
|
||||
page,
|
||||
limit,
|
||||
eventType,
|
||||
outcome,
|
||||
severity,
|
||||
userId,
|
||||
startDate,
|
||||
endDate,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds where clause for audit log filtering
|
||||
*/
|
||||
function buildAuditLogWhereClause(
|
||||
companyId: string,
|
||||
filters: ReturnType<typeof parseAuditLogFilters>
|
||||
) {
|
||||
const { eventType, outcome, severity, userId, startDate, endDate } = filters;
|
||||
|
||||
const where: {
|
||||
companyId: string;
|
||||
eventType?: string;
|
||||
outcome?: string;
|
||||
severity?: string;
|
||||
userId?: string;
|
||||
timestamp?: {
|
||||
gte?: Date;
|
||||
lte?: Date;
|
||||
};
|
||||
} = {
|
||||
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);
|
||||
}
|
||||
|
||||
return where;
|
||||
}
|
||||
|
||||
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"
|
||||
);
|
||||
|
||||
// Validate access authorization
|
||||
const authResult = await validateAuditLogAccess(session, ip, userAgent);
|
||||
if (!authResult.valid) {
|
||||
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 }
|
||||
{ success: false, error: authResult.error },
|
||||
{ status: authResult.status }
|
||||
);
|
||||
}
|
||||
|
||||
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 filters = parseAuditLogFilters(url);
|
||||
const { page, limit } = filters;
|
||||
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);
|
||||
}
|
||||
}
|
||||
const where = buildAuditLogWhereClause(session.user.companyId, filters);
|
||||
|
||||
// Get audit logs with pagination
|
||||
const [auditLogs, totalCount] = await Promise.all([
|
||||
|
||||
@ -7,7 +7,11 @@ import {
|
||||
createAuditContext,
|
||||
securityAuditLogger,
|
||||
} from "@/lib/securityAuditLogger";
|
||||
import { securityMonitoring, type SecurityMetrics, type AlertType } from "@/lib/securityMonitoring";
|
||||
import {
|
||||
type AlertType,
|
||||
type SecurityMetrics,
|
||||
securityMonitoring,
|
||||
} from "@/lib/securityMonitoring";
|
||||
|
||||
const threatAnalysisSchema = z.object({
|
||||
ipAddress: z.string().ip().optional(),
|
||||
|
||||
@ -5,7 +5,6 @@
|
||||
* It generates a new token and sets it as an HTTP-only cookie.
|
||||
*/
|
||||
|
||||
import type { NextRequest } from "next/server";
|
||||
import { generateCSRFTokenResponse } from "../../../middleware/csrfProtection";
|
||||
|
||||
/**
|
||||
|
||||
@ -5,6 +5,69 @@ import { sessionMetrics } from "../../../../lib/metrics";
|
||||
import { prisma } from "../../../../lib/prisma";
|
||||
import type { ChatSession } from "../../../../lib/types";
|
||||
|
||||
/**
|
||||
* Converts a Prisma session to ChatSession format for metrics
|
||||
*/
|
||||
function convertToMockChatSession(
|
||||
ps: {
|
||||
id: string;
|
||||
companyId: string;
|
||||
startTime: Date;
|
||||
endTime: Date | null;
|
||||
createdAt: Date;
|
||||
category: string | null;
|
||||
language: string | null;
|
||||
country: string | null;
|
||||
ipAddress: string | null;
|
||||
sentiment: string | null;
|
||||
messagesSent: number | null;
|
||||
avgResponseTime: number | null;
|
||||
escalated: boolean;
|
||||
forwardedHr: boolean;
|
||||
initialMsg: string | null;
|
||||
fullTranscriptUrl: string | null;
|
||||
summary: string | null;
|
||||
},
|
||||
questions: string[]
|
||||
): ChatSession {
|
||||
// Convert questions to mock messages for backward compatibility
|
||||
const mockMessages = questions.map((q, index) => ({
|
||||
id: `question-${index}`,
|
||||
sessionId: ps.id,
|
||||
timestamp: ps.createdAt,
|
||||
role: "User",
|
||||
content: q,
|
||||
order: index,
|
||||
createdAt: ps.createdAt,
|
||||
}));
|
||||
|
||||
return {
|
||||
id: ps.id,
|
||||
sessionId: ps.id,
|
||||
companyId: ps.companyId,
|
||||
startTime: new Date(ps.startTime),
|
||||
endTime: ps.endTime ? new Date(ps.endTime) : null,
|
||||
transcriptContent: "",
|
||||
createdAt: new Date(ps.createdAt),
|
||||
updatedAt: new Date(ps.createdAt),
|
||||
category: ps.category || undefined,
|
||||
language: ps.language || undefined,
|
||||
country: ps.country || undefined,
|
||||
ipAddress: ps.ipAddress || undefined,
|
||||
sentiment: ps.sentiment === null ? undefined : ps.sentiment,
|
||||
messagesSent: ps.messagesSent === null ? undefined : ps.messagesSent,
|
||||
avgResponseTime:
|
||||
ps.avgResponseTime === null ? undefined : ps.avgResponseTime,
|
||||
escalated: ps.escalated || false,
|
||||
forwardedHr: ps.forwardedHr || false,
|
||||
initialMsg: ps.initialMsg || undefined,
|
||||
fullTranscriptUrl: ps.fullTranscriptUrl || undefined,
|
||||
summary: ps.summary || undefined,
|
||||
messages: mockMessages, // Use questions as messages for metrics
|
||||
userId: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
interface SessionUser {
|
||||
email: string;
|
||||
name?: string;
|
||||
@ -107,45 +170,8 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
// Convert Prisma sessions to ChatSession[] type for sessionMetrics
|
||||
const chatSessions: ChatSession[] = prismaSessions.map((ps) => {
|
||||
// Get questions for this session or empty array
|
||||
const questions = questionsBySession[ps.id] || [];
|
||||
|
||||
// Convert questions to mock messages for backward compatibility
|
||||
const mockMessages = questions.map((q, index) => ({
|
||||
id: `question-${index}`,
|
||||
sessionId: ps.id,
|
||||
timestamp: ps.createdAt,
|
||||
role: "User",
|
||||
content: q,
|
||||
order: index,
|
||||
createdAt: ps.createdAt,
|
||||
}));
|
||||
|
||||
return {
|
||||
id: ps.id,
|
||||
sessionId: ps.id,
|
||||
companyId: ps.companyId,
|
||||
startTime: new Date(ps.startTime),
|
||||
endTime: ps.endTime ? new Date(ps.endTime) : null,
|
||||
transcriptContent: "",
|
||||
createdAt: new Date(ps.createdAt),
|
||||
updatedAt: new Date(ps.createdAt),
|
||||
category: ps.category || undefined,
|
||||
language: ps.language || undefined,
|
||||
country: ps.country || undefined,
|
||||
ipAddress: ps.ipAddress || undefined,
|
||||
sentiment: ps.sentiment === null ? undefined : ps.sentiment,
|
||||
messagesSent: ps.messagesSent === null ? undefined : ps.messagesSent,
|
||||
avgResponseTime:
|
||||
ps.avgResponseTime === null ? undefined : ps.avgResponseTime,
|
||||
escalated: ps.escalated || false,
|
||||
forwardedHr: ps.forwardedHr || false,
|
||||
initialMsg: ps.initialMsg || undefined,
|
||||
fullTranscriptUrl: ps.fullTranscriptUrl || undefined,
|
||||
summary: ps.summary || undefined,
|
||||
messages: mockMessages, // Use questions as messages for metrics
|
||||
userId: undefined,
|
||||
};
|
||||
return convertToMockChatSession(ps, questions);
|
||||
});
|
||||
|
||||
// Pass company config to metrics
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
import { getServerSession } from "next-auth/next";
|
||||
import { authOptions } from "../../../../lib/auth";
|
||||
import { prisma } from "../../../../lib/prisma";
|
||||
|
||||
@ -2,6 +2,76 @@ import { type NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "../../../../../lib/prisma";
|
||||
import type { ChatSession } from "../../../../../lib/types";
|
||||
|
||||
/**
|
||||
* Maps Prisma session object to ChatSession type
|
||||
*/
|
||||
function mapPrismaSessionToChatSession(prismaSession: {
|
||||
id: string;
|
||||
startTime: Date;
|
||||
endTime: Date | null;
|
||||
createdAt: Date;
|
||||
category: string | null;
|
||||
language: string | null;
|
||||
country: string | null;
|
||||
ipAddress: string | null;
|
||||
sentiment: string | null;
|
||||
messagesSent: number | null;
|
||||
avgResponseTime: number | null;
|
||||
escalated: boolean;
|
||||
forwardedHr: boolean;
|
||||
initialMsg: string | null;
|
||||
fullTranscriptUrl: string | null;
|
||||
summary: string | null;
|
||||
messages: Array<{
|
||||
id: string;
|
||||
sessionId: string;
|
||||
timestamp: Date | null;
|
||||
role: string;
|
||||
content: string;
|
||||
order: number;
|
||||
createdAt: Date;
|
||||
}>;
|
||||
}): ChatSession {
|
||||
return {
|
||||
// Spread prismaSession to include all its properties
|
||||
...prismaSession,
|
||||
// Override properties that need conversion or specific mapping
|
||||
id: prismaSession.id, // ChatSession.id from Prisma.Session.id
|
||||
sessionId: prismaSession.id, // ChatSession.sessionId from Prisma.Session.id
|
||||
startTime: new Date(prismaSession.startTime),
|
||||
endTime: prismaSession.endTime ? new Date(prismaSession.endTime) : null,
|
||||
createdAt: new Date(prismaSession.createdAt),
|
||||
// Prisma.Session does not have an `updatedAt` field. We'll use `createdAt` as a fallback.
|
||||
updatedAt: new Date(prismaSession.createdAt), // Fallback to createdAt
|
||||
// Prisma.Session does not have a `userId` field.
|
||||
userId: null, // Explicitly set to null or map if available from another source
|
||||
// Ensure nullable fields from Prisma are correctly mapped to ChatSession's optional or nullable fields
|
||||
category: prismaSession.category ?? null,
|
||||
language: prismaSession.language ?? null,
|
||||
country: prismaSession.country ?? null,
|
||||
ipAddress: prismaSession.ipAddress ?? null,
|
||||
sentiment: prismaSession.sentiment ?? null,
|
||||
messagesSent: prismaSession.messagesSent ?? undefined, // Use undefined if ChatSession expects number | undefined
|
||||
avgResponseTime: prismaSession.avgResponseTime ?? null,
|
||||
escalated: prismaSession.escalated ?? undefined,
|
||||
forwardedHr: prismaSession.forwardedHr ?? undefined,
|
||||
initialMsg: prismaSession.initialMsg ?? undefined,
|
||||
fullTranscriptUrl: prismaSession.fullTranscriptUrl ?? null,
|
||||
summary: prismaSession.summary ?? null, // New field
|
||||
transcriptContent: null, // Not available in Session model
|
||||
messages:
|
||||
prismaSession.messages?.map((msg) => ({
|
||||
id: msg.id,
|
||||
sessionId: msg.sessionId,
|
||||
timestamp: msg.timestamp ? new Date(msg.timestamp) : new Date(),
|
||||
role: msg.role,
|
||||
content: msg.content,
|
||||
order: msg.order,
|
||||
createdAt: new Date(msg.createdAt),
|
||||
})) ?? [], // New field - parsed messages
|
||||
};
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
@ -30,45 +100,7 @@ export async function GET(
|
||||
}
|
||||
|
||||
// Map Prisma session object to ChatSession type
|
||||
const session: ChatSession = {
|
||||
// Spread prismaSession to include all its properties
|
||||
...prismaSession,
|
||||
// Override properties that need conversion or specific mapping
|
||||
id: prismaSession.id, // ChatSession.id from Prisma.Session.id
|
||||
sessionId: prismaSession.id, // ChatSession.sessionId from Prisma.Session.id
|
||||
startTime: new Date(prismaSession.startTime),
|
||||
endTime: prismaSession.endTime ? new Date(prismaSession.endTime) : null,
|
||||
createdAt: new Date(prismaSession.createdAt),
|
||||
// Prisma.Session does not have an `updatedAt` field. We'll use `createdAt` as a fallback.
|
||||
// Or, if your business logic implies an update timestamp elsewhere, use that.
|
||||
updatedAt: new Date(prismaSession.createdAt), // Fallback to createdAt
|
||||
// Prisma.Session does not have a `userId` field.
|
||||
userId: null, // Explicitly set to null or map if available from another source
|
||||
// Ensure nullable fields from Prisma are correctly mapped to ChatSession's optional or nullable fields
|
||||
category: prismaSession.category ?? null,
|
||||
language: prismaSession.language ?? null,
|
||||
country: prismaSession.country ?? null,
|
||||
ipAddress: prismaSession.ipAddress ?? null,
|
||||
sentiment: prismaSession.sentiment ?? null,
|
||||
messagesSent: prismaSession.messagesSent ?? undefined, // Use undefined if ChatSession expects number | undefined
|
||||
avgResponseTime: prismaSession.avgResponseTime ?? null,
|
||||
escalated: prismaSession.escalated ?? undefined,
|
||||
forwardedHr: prismaSession.forwardedHr ?? undefined,
|
||||
initialMsg: prismaSession.initialMsg ?? undefined,
|
||||
fullTranscriptUrl: prismaSession.fullTranscriptUrl ?? null,
|
||||
summary: prismaSession.summary ?? null, // New field
|
||||
transcriptContent: null, // Not available in Session model
|
||||
messages:
|
||||
prismaSession.messages?.map((msg) => ({
|
||||
id: msg.id,
|
||||
sessionId: msg.sessionId,
|
||||
timestamp: msg.timestamp ? new Date(msg.timestamp) : new Date(),
|
||||
role: msg.role,
|
||||
content: msg.content,
|
||||
order: msg.order,
|
||||
createdAt: new Date(msg.createdAt),
|
||||
})) ?? [], // New field - parsed messages
|
||||
};
|
||||
const session: ChatSession = mapPrismaSessionToChatSession(prismaSession);
|
||||
|
||||
return NextResponse.json({ session });
|
||||
} catch (error) {
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { useCallback, useEffect, useId, useState } from "react";
|
||||
import { Alert, AlertDescription } from "../../../components/ui/alert";
|
||||
import { Badge } from "../../../components/ui/badge";
|
||||
import { Button } from "../../../components/ui/button";
|
||||
@ -108,6 +108,11 @@ const severityColors: Record<string, string> = {
|
||||
|
||||
export default function AuditLogsPage() {
|
||||
const { data: session } = useSession();
|
||||
const eventTypeId = useId();
|
||||
const outcomeId = useId();
|
||||
const severityId = useId();
|
||||
const startDateId = useId();
|
||||
const endDateId = useId();
|
||||
const [auditLogs, setAuditLogs] = useState<AuditLog[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@ -194,8 +199,8 @@ export default function AuditLogsPage() {
|
||||
<div className="container mx-auto py-8">
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
You don't have permission to view audit logs. Only administrators
|
||||
can access this page.
|
||||
You don't have permission to view audit logs. Only
|
||||
administrators can access this page.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
@ -219,14 +224,16 @@ export default function AuditLogsPage() {
|
||||
<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>
|
||||
<label htmlFor={eventTypeId} className="text-sm font-medium">
|
||||
Event Type
|
||||
</label>
|
||||
<Select
|
||||
value={filters.eventType}
|
||||
onValueChange={(value) =>
|
||||
handleFilterChange("eventType", value)
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectTrigger id={eventTypeId}>
|
||||
<SelectValue placeholder="All event types" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@ -241,12 +248,14 @@ export default function AuditLogsPage() {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium">Outcome</label>
|
||||
<label htmlFor={outcomeId} className="text-sm font-medium">
|
||||
Outcome
|
||||
</label>
|
||||
<Select
|
||||
value={filters.outcome}
|
||||
onValueChange={(value) => handleFilterChange("outcome", value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectTrigger id={outcomeId}>
|
||||
<SelectValue placeholder="All outcomes" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@ -261,12 +270,14 @@ export default function AuditLogsPage() {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium">Severity</label>
|
||||
<label htmlFor={severityId} className="text-sm font-medium">
|
||||
Severity
|
||||
</label>
|
||||
<Select
|
||||
value={filters.severity}
|
||||
onValueChange={(value) => handleFilterChange("severity", value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectTrigger id={severityId}>
|
||||
<SelectValue placeholder="All severities" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@ -281,8 +292,11 @@ export default function AuditLogsPage() {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium">Start Date</label>
|
||||
<label htmlFor={startDateId} className="text-sm font-medium">
|
||||
Start Date
|
||||
</label>
|
||||
<Input
|
||||
id={startDateId}
|
||||
type="datetime-local"
|
||||
value={filters.startDate}
|
||||
onChange={(e) =>
|
||||
@ -292,8 +306,11 @@ export default function AuditLogsPage() {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium">End Date</label>
|
||||
<label htmlFor={endDateId} className="text-sm font-medium">
|
||||
End Date
|
||||
</label>
|
||||
<Input
|
||||
id={endDateId}
|
||||
type="datetime-local"
|
||||
value={filters.endDate}
|
||||
onChange={(e) => handleFilterChange("endDate", e.target.value)}
|
||||
@ -442,14 +459,14 @@ export default function AuditLogsPage() {
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="font-medium">Timestamp:</label>
|
||||
<span className="font-medium">Timestamp:</span>
|
||||
<p className="font-mono text-sm">
|
||||
{new Date(selectedLog.timestamp).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="font-medium">Event Type:</label>
|
||||
<span className="font-medium">Event Type:</span>
|
||||
<p>
|
||||
{eventTypeLabels[selectedLog.eventType] ||
|
||||
selectedLog.eventType}
|
||||
@ -457,26 +474,26 @@ export default function AuditLogsPage() {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="font-medium">Action:</label>
|
||||
<span className="font-medium">Action:</span>
|
||||
<p>{selectedLog.action}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="font-medium">Outcome:</label>
|
||||
<span className="font-medium">Outcome:</span>
|
||||
<Badge className={outcomeColors[selectedLog.outcome]}>
|
||||
{selectedLog.outcome}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="font-medium">Severity:</label>
|
||||
<span className="font-medium">Severity:</span>
|
||||
<Badge className={severityColors[selectedLog.severity]}>
|
||||
{selectedLog.severity}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="font-medium">IP Address:</label>
|
||||
<span className="font-medium">IP Address:</span>
|
||||
<p className="font-mono text-sm">
|
||||
{selectedLog.ipAddress || "N/A"}
|
||||
</p>
|
||||
@ -484,7 +501,7 @@ export default function AuditLogsPage() {
|
||||
|
||||
{selectedLog.user && (
|
||||
<div>
|
||||
<label className="font-medium">User:</label>
|
||||
<span className="font-medium">User:</span>
|
||||
<p>
|
||||
{selectedLog.user.email} ({selectedLog.user.role})
|
||||
</p>
|
||||
@ -493,7 +510,7 @@ export default function AuditLogsPage() {
|
||||
|
||||
{selectedLog.platformUser && (
|
||||
<div>
|
||||
<label className="font-medium">Platform User:</label>
|
||||
<span className="font-medium">Platform User:</span>
|
||||
<p>
|
||||
{selectedLog.platformUser.email} (
|
||||
{selectedLog.platformUser.role})
|
||||
@ -503,21 +520,21 @@ export default function AuditLogsPage() {
|
||||
|
||||
{selectedLog.country && (
|
||||
<div>
|
||||
<label className="font-medium">Country:</label>
|
||||
<span className="font-medium">Country:</span>
|
||||
<p>{selectedLog.country}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedLog.sessionId && (
|
||||
<div>
|
||||
<label className="font-medium">Session ID:</label>
|
||||
<span className="font-medium">Session ID:</span>
|
||||
<p className="font-mono text-sm">{selectedLog.sessionId}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedLog.requestId && (
|
||||
<div>
|
||||
<label className="font-medium">Request ID:</label>
|
||||
<span className="font-medium">Request ID:</span>
|
||||
<p className="font-mono text-sm">{selectedLog.requestId}</p>
|
||||
</div>
|
||||
)}
|
||||
@ -525,7 +542,7 @@ export default function AuditLogsPage() {
|
||||
|
||||
{selectedLog.errorMessage && (
|
||||
<div className="mt-4">
|
||||
<label className="font-medium">Error Message:</label>
|
||||
<span className="font-medium">Error Message:</span>
|
||||
<p className="text-red-600 bg-red-50 p-2 rounded text-sm">
|
||||
{selectedLog.errorMessage}
|
||||
</p>
|
||||
@ -534,14 +551,14 @@ export default function AuditLogsPage() {
|
||||
|
||||
{selectedLog.userAgent && (
|
||||
<div className="mt-4">
|
||||
<label className="font-medium">User Agent:</label>
|
||||
<span className="font-medium">User Agent:</span>
|
||||
<p className="text-sm break-all">{selectedLog.userAgent}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedLog.metadata && (
|
||||
<div className="mt-4">
|
||||
<label className="font-medium">Metadata:</label>
|
||||
<span className="font-medium">Metadata:</span>
|
||||
<pre className="bg-gray-100 p-2 rounded text-xs overflow-auto max-h-40">
|
||||
{JSON.stringify(selectedLog.metadata, null, 2)}
|
||||
</pre>
|
||||
|
||||
@ -23,24 +23,24 @@ import MessageViewer from "../../../../components/MessageViewer";
|
||||
import SessionDetails from "../../../../components/SessionDetails";
|
||||
import type { ChatSession } from "../../../../lib/types";
|
||||
|
||||
export default function SessionViewPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter(); // Initialize useRouter
|
||||
const { status } = useSession(); // Get session status, removed unused sessionData
|
||||
const id = params?.id as string;
|
||||
/**
|
||||
* Custom hook for managing session data fetching and state
|
||||
*/
|
||||
function useSessionData(id: string | undefined, authStatus: string) {
|
||||
const [session, setSession] = useState<ChatSession | null>(null);
|
||||
const [loading, setLoading] = useState(true); // This will now primarily be for data fetching
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (status === "unauthenticated") {
|
||||
if (authStatus === "unauthenticated") {
|
||||
router.push("/login");
|
||||
return;
|
||||
}
|
||||
|
||||
if (status === "authenticated" && id) {
|
||||
if (authStatus === "authenticated" && id) {
|
||||
const fetchSession = async () => {
|
||||
setLoading(true); // Always set loading before fetch
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await fetch(`/api/dashboard/session/${id}`);
|
||||
@ -63,222 +63,247 @@ export default function SessionViewPage() {
|
||||
}
|
||||
};
|
||||
fetchSession();
|
||||
} else if (status === "authenticated" && !id) {
|
||||
} else if (authStatus === "authenticated" && !id) {
|
||||
setError("Session ID is missing.");
|
||||
setLoading(false);
|
||||
}
|
||||
}, [id, status, router]); // session removed from dependencies
|
||||
}, [id, authStatus, router]);
|
||||
|
||||
if (status === "loading") {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
Loading session...
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === "unauthenticated") {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
Redirecting to login...
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading && status === "authenticated") {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
Loading session details...
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-center py-8">
|
||||
<AlertCircle className="h-12 w-12 text-destructive mx-auto mb-4" />
|
||||
<p className="text-destructive text-lg mb-4">Error: {error}</p>
|
||||
<Link href="/dashboard/sessions">
|
||||
<Button variant="outline" className="gap-2">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back to Sessions List
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-center py-8">
|
||||
<MessageSquare className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
||||
<p className="text-muted-foreground text-lg mb-4">
|
||||
Session not found.
|
||||
</p>
|
||||
<Link href="/dashboard/sessions">
|
||||
<Button variant="outline" className="gap-2">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back to Sessions List
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return { session, loading, error };
|
||||
}
|
||||
|
||||
/**
|
||||
* Component for rendering loading state
|
||||
*/
|
||||
function LoadingCard({ message }: { message: string }) {
|
||||
return (
|
||||
<div className="space-y-6 max-w-6xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
{message}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Component for rendering error state
|
||||
*/
|
||||
function ErrorCard({ error }: { error: string }) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-center py-8">
|
||||
<AlertCircle className="h-12 w-12 text-destructive mx-auto mb-4" />
|
||||
<p className="text-destructive text-lg mb-4">Error: {error}</p>
|
||||
<Link href="/dashboard/sessions">
|
||||
<Button variant="outline" className="gap-2">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back to Sessions List
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Component for rendering session not found state
|
||||
*/
|
||||
function SessionNotFoundCard() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-center py-8">
|
||||
<MessageSquare className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
||||
<p className="text-muted-foreground text-lg mb-4">
|
||||
Session not found.
|
||||
</p>
|
||||
<Link href="/dashboard/sessions">
|
||||
<Button variant="outline" className="gap-2">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back to Sessions List
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Component for rendering session header with navigation and badges
|
||||
*/
|
||||
function SessionHeader({ session }: { session: ChatSession }) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
||||
<div className="space-y-2">
|
||||
<Link href="/dashboard/sessions">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="gap-2 p-0 h-auto focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2"
|
||||
aria-label="Return to sessions list"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" aria-hidden="true" />
|
||||
Back to Sessions List
|
||||
</Button>
|
||||
</Link>
|
||||
<div className="space-y-2">
|
||||
<Link href="/dashboard/sessions">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="gap-2 p-0 h-auto focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2"
|
||||
aria-label="Return to sessions list"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" aria-hidden="true" />
|
||||
Back to Sessions List
|
||||
</Button>
|
||||
</Link>
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-3xl font-bold">Session Details</h1>
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge variant="outline" className="font-mono text-xs">
|
||||
ID
|
||||
</Badge>
|
||||
<code className="text-sm text-muted-foreground font-mono">
|
||||
{(session.sessionId || session.id).slice(0, 8)}...
|
||||
</code>
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold">Session Details</h1>
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge variant="outline" className="font-mono text-xs">
|
||||
ID
|
||||
</Badge>
|
||||
<code className="text-sm text-muted-foreground font-mono">
|
||||
{(session.sessionId || session.id).slice(0, 8)}...
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{session.category && (
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
<Activity className="h-3 w-3" />
|
||||
{formatCategory(session.category)}
|
||||
</Badge>
|
||||
)}
|
||||
{session.language && (
|
||||
<Badge variant="outline" className="gap-1">
|
||||
<Globe className="h-3 w-3" />
|
||||
{session.language.toUpperCase()}
|
||||
</Badge>
|
||||
)}
|
||||
{session.sentiment && (
|
||||
<Badge
|
||||
variant={
|
||||
session.sentiment === "positive"
|
||||
? "default"
|
||||
: session.sentiment === "negative"
|
||||
? "destructive"
|
||||
: "secondary"
|
||||
}
|
||||
className="gap-1"
|
||||
>
|
||||
{session.sentiment.charAt(0).toUpperCase() +
|
||||
session.sentiment.slice(1)}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{session.category && (
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
<Activity className="h-3 w-3" />
|
||||
{formatCategory(session.category)}
|
||||
</Badge>
|
||||
)}
|
||||
{session.language && (
|
||||
<Badge variant="outline" className="gap-1">
|
||||
<Globe className="h-3 w-3" />
|
||||
{session.language.toUpperCase()}
|
||||
</Badge>
|
||||
)}
|
||||
{session.sentiment && (
|
||||
<Badge
|
||||
variant={
|
||||
session.sentiment === "positive"
|
||||
? "default"
|
||||
: session.sentiment === "negative"
|
||||
? "destructive"
|
||||
: "secondary"
|
||||
}
|
||||
className="gap-1"
|
||||
>
|
||||
{session.sentiment.charAt(0).toUpperCase() +
|
||||
session.sentiment.slice(1)}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Component for rendering session overview cards
|
||||
*/
|
||||
function SessionOverview({ session }: { session: ChatSession }) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Clock className="h-8 w-8 text-blue-500" />
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Start Time</p>
|
||||
<p className="font-semibold">
|
||||
{new Date(session.startTime).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Session Overview */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Clock className="h-8 w-8 text-blue-500" />
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Start Time</p>
|
||||
<p className="font-semibold">
|
||||
{new Date(session.startTime).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<MessageSquare className="h-8 w-8 text-green-500" />
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Messages</p>
|
||||
<p className="font-semibold">{session.messages?.length || 0}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<MessageSquare className="h-8 w-8 text-green-500" />
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Messages</p>
|
||||
<p className="font-semibold">{session.messages?.length || 0}</p>
|
||||
</div>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<User className="h-8 w-8 text-purple-500" />
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">User ID</p>
|
||||
<p className="font-semibold truncate">
|
||||
{session.userId || "N/A"}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<User className="h-8 w-8 text-purple-500" />
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">User ID</p>
|
||||
<p className="font-semibold truncate">
|
||||
{session.userId || "N/A"}
|
||||
</p>
|
||||
</div>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Activity className="h-8 w-8 text-orange-500" />
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Duration</p>
|
||||
<p className="font-semibold">
|
||||
{session.endTime && session.startTime
|
||||
? `${Math.round(
|
||||
(new Date(session.endTime).getTime() -
|
||||
new Date(session.startTime).getTime()) /
|
||||
60000
|
||||
)} min`
|
||||
: "N/A"}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Activity className="h-8 w-8 text-orange-500" />
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Duration</p>
|
||||
<p className="font-semibold">
|
||||
{session.endTime && session.startTime
|
||||
? `${Math.round(
|
||||
(new Date(session.endTime).getTime() -
|
||||
new Date(session.startTime).getTime()) /
|
||||
60000
|
||||
)} min`
|
||||
: "N/A"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
export default function SessionViewPage() {
|
||||
const params = useParams();
|
||||
const { status } = useSession();
|
||||
const id = params?.id as string;
|
||||
const { session, loading, error } = useSessionData(id, status);
|
||||
|
||||
if (status === "loading") {
|
||||
return <LoadingCard message="Loading session..." />;
|
||||
}
|
||||
|
||||
if (status === "unauthenticated") {
|
||||
return <LoadingCard message="Redirecting to login..." />;
|
||||
}
|
||||
|
||||
if (loading && status === "authenticated") {
|
||||
return <LoadingCard message="Loading session details..." />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <ErrorCard error={error} />;
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
return <SessionNotFoundCard />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-6xl mx-auto">
|
||||
<SessionHeader session={session} />
|
||||
<SessionOverview session={session} />
|
||||
|
||||
{/* Session Details */}
|
||||
<SessionDetails session={session} />
|
||||
|
||||
@ -14,6 +14,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useId, useState } from "react";
|
||||
import type { z } from "zod";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
@ -21,8 +22,7 @@ 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 { sessionFilterSchema } from "@/lib/validation";
|
||||
import type { ChatSession } from "../../../lib/types";
|
||||
|
||||
interface FilterOptions {
|
||||
@ -97,13 +97,13 @@ function FilterSection({
|
||||
<CardHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="relative">
|
||||
<Label htmlFor="search-sessions" className="sr-only">
|
||||
<Label htmlFor={searchId} className="sr-only">
|
||||
Search sessions
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
id="search-sessions"
|
||||
id={searchId}
|
||||
type="text"
|
||||
placeholder="Search sessions..."
|
||||
value={searchTerm}
|
||||
@ -179,9 +179,9 @@ function FilterSection({
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="start-date">Start Date</Label>
|
||||
<Label htmlFor={startDateId}>Start Date</Label>
|
||||
<Input
|
||||
id="start-date"
|
||||
id={startDateId}
|
||||
type="date"
|
||||
value={startDate}
|
||||
onChange={(e) => setStartDate(e.target.value)}
|
||||
@ -190,9 +190,9 @@ function FilterSection({
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="end-date">End Date</Label>
|
||||
<Label htmlFor={endDateId}>End Date</Label>
|
||||
<Input
|
||||
id="end-date"
|
||||
id={endDateId}
|
||||
type="date"
|
||||
value={endDate}
|
||||
onChange={(e) => setEndDate(e.target.value)}
|
||||
@ -201,9 +201,9 @@ function FilterSection({
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="sort-by">Sort By</Label>
|
||||
<Label htmlFor={sortById}>Sort By</Label>
|
||||
<select
|
||||
id="sort-by"
|
||||
id={sortById}
|
||||
value={sortKey}
|
||||
onChange={(e) => setSortKey(e.target.value)}
|
||||
className="w-full mt-1 p-2 border border-gray-300 rounded-md"
|
||||
@ -489,7 +489,9 @@ export default function SessionsPage() {
|
||||
} = trpc.dashboard.getSessions.useQuery(
|
||||
{
|
||||
search: debouncedSearchTerm || undefined,
|
||||
category: selectedCategory ? selectedCategory as z.infer<typeof sessionFilterSchema>["category"] : 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,
|
||||
|
||||
@ -39,6 +39,43 @@ import {
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
|
||||
type ToastFunction = (props: {
|
||||
title: string;
|
||||
description: string;
|
||||
variant?: "default" | "destructive";
|
||||
}) => void;
|
||||
|
||||
interface CompanyManagementState {
|
||||
company: Company | null;
|
||||
setCompany: (company: Company | null) => void;
|
||||
isLoading: boolean;
|
||||
setIsLoading: (loading: boolean) => void;
|
||||
isSaving: boolean;
|
||||
setIsSaving: (saving: boolean) => void;
|
||||
editData: Partial<Company>;
|
||||
setEditData: (
|
||||
data: Partial<Company> | ((prev: Partial<Company>) => Partial<Company>)
|
||||
) => void;
|
||||
originalData: Partial<Company>;
|
||||
setOriginalData: (data: Partial<Company>) => void;
|
||||
showInviteUser: boolean;
|
||||
setShowInviteUser: (show: boolean) => void;
|
||||
inviteData: { name: string; email: string; role: string };
|
||||
setInviteData: (
|
||||
data:
|
||||
| { name: string; email: string; role: string }
|
||||
| ((prev: { name: string; email: string; role: string }) => {
|
||||
name: string;
|
||||
email: string;
|
||||
role: string;
|
||||
})
|
||||
) => void;
|
||||
showUnsavedChangesDialog: boolean;
|
||||
setShowUnsavedChangesDialog: (show: boolean) => void;
|
||||
pendingNavigation: string | null;
|
||||
setPendingNavigation: (navigation: string | null) => void;
|
||||
}
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
name: string;
|
||||
@ -64,51 +101,10 @@ interface Company {
|
||||
};
|
||||
}
|
||||
|
||||
export default function CompanyManagement() {
|
||||
const { data: session, status } = useSession();
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const { toast } = useToast();
|
||||
|
||||
const companyNameFieldId = useId();
|
||||
const companyEmailFieldId = useId();
|
||||
const maxUsersFieldId = useId();
|
||||
const inviteNameFieldId = useId();
|
||||
const inviteEmailFieldId = useId();
|
||||
|
||||
const fetchCompany = useCallback(async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/platform/companies/${params.id}`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setCompany(data);
|
||||
const companyData = {
|
||||
name: data.name,
|
||||
email: data.email,
|
||||
status: data.status,
|
||||
maxUsers: data.maxUsers,
|
||||
};
|
||||
setEditData(companyData);
|
||||
setOriginalData(companyData);
|
||||
} else {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Failed to load company data",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch company:", error);
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Failed to load company data",
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [params.id, toast]);
|
||||
|
||||
/**
|
||||
* Custom hook for company management state
|
||||
*/
|
||||
function useCompanyManagementState() {
|
||||
const [company, setCompany] = useState<Company | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
@ -126,9 +122,55 @@ export default function CompanyManagement() {
|
||||
null
|
||||
);
|
||||
|
||||
// Function to check if data has been modified
|
||||
return {
|
||||
company,
|
||||
setCompany,
|
||||
isLoading,
|
||||
setIsLoading,
|
||||
isSaving,
|
||||
setIsSaving,
|
||||
editData,
|
||||
setEditData,
|
||||
originalData,
|
||||
setOriginalData,
|
||||
showInviteUser,
|
||||
setShowInviteUser,
|
||||
inviteData,
|
||||
setInviteData,
|
||||
showUnsavedChangesDialog,
|
||||
setShowUnsavedChangesDialog,
|
||||
pendingNavigation,
|
||||
setPendingNavigation,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom hook for form IDs
|
||||
*/
|
||||
function useCompanyFormIds() {
|
||||
const companyNameFieldId = useId();
|
||||
const companyEmailFieldId = useId();
|
||||
const maxUsersFieldId = useId();
|
||||
const inviteNameFieldId = useId();
|
||||
const inviteEmailFieldId = useId();
|
||||
|
||||
return {
|
||||
companyNameFieldId,
|
||||
companyEmailFieldId,
|
||||
maxUsersFieldId,
|
||||
inviteNameFieldId,
|
||||
inviteEmailFieldId,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom hook for data validation and comparison
|
||||
*/
|
||||
function useDataComparison(
|
||||
editData: Partial<Company>,
|
||||
originalData: Partial<Company>
|
||||
) {
|
||||
const hasUnsavedChanges = useCallback(() => {
|
||||
// Normalize data for comparison (handle null/undefined/empty string equivalence)
|
||||
const normalizeValue = (value: string | number | null | undefined) => {
|
||||
if (value === null || value === undefined || value === "") {
|
||||
return "";
|
||||
@ -156,24 +198,276 @@ export default function CompanyManagement() {
|
||||
);
|
||||
}, [editData, originalData]);
|
||||
|
||||
// Handle navigation protection - must be at top level
|
||||
return { hasUnsavedChanges };
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom hook for company data fetching
|
||||
*/
|
||||
function useCompanyData(
|
||||
params: { id: string | string[] },
|
||||
toast: ToastFunction,
|
||||
state: CompanyManagementState
|
||||
) {
|
||||
const fetchCompany = useCallback(async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/platform/companies/${params.id}`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
state.setCompany(data);
|
||||
const companyData = {
|
||||
name: data.name,
|
||||
email: data.email,
|
||||
status: data.status,
|
||||
maxUsers: data.maxUsers,
|
||||
};
|
||||
state.setEditData(companyData);
|
||||
state.setOriginalData(companyData);
|
||||
} else {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Failed to load company data",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch company:", error);
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Failed to load company data",
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
state.setIsLoading(false);
|
||||
}
|
||||
}, [params.id, toast, state]);
|
||||
|
||||
return { fetchCompany };
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom hook for navigation handling
|
||||
*/
|
||||
function useNavigationControl(
|
||||
router: { push: (url: string) => void },
|
||||
params: { id: string | string[] },
|
||||
hasUnsavedChanges: () => boolean,
|
||||
state: CompanyManagementState
|
||||
) {
|
||||
const handleNavigation = useCallback(
|
||||
(url: string) => {
|
||||
// Allow navigation within the same company (different tabs, etc.)
|
||||
if (url.includes(`/platform/companies/${params.id}`)) {
|
||||
router.push(url);
|
||||
return;
|
||||
}
|
||||
|
||||
// If there are unsaved changes, show confirmation dialog
|
||||
if (hasUnsavedChanges()) {
|
||||
setPendingNavigation(url);
|
||||
setShowUnsavedChangesDialog(true);
|
||||
state.setPendingNavigation(url);
|
||||
state.setShowUnsavedChangesDialog(true);
|
||||
} else {
|
||||
router.push(url);
|
||||
}
|
||||
},
|
||||
[router, params.id, hasUnsavedChanges]
|
||||
[router, params.id, hasUnsavedChanges, state]
|
||||
);
|
||||
|
||||
return { handleNavigation };
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to render company information card
|
||||
*/
|
||||
function renderCompanyInfoCard(
|
||||
state: CompanyManagementState,
|
||||
canEdit: boolean,
|
||||
companyNameFieldId: string,
|
||||
companyEmailFieldId: string,
|
||||
maxUsersFieldId: string,
|
||||
hasUnsavedChanges: () => boolean,
|
||||
handleSave: () => Promise<void>
|
||||
) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Company Information</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor={companyNameFieldId}>Company Name</Label>
|
||||
<Input
|
||||
id={companyNameFieldId}
|
||||
value={state.editData.name || ""}
|
||||
onChange={(e) =>
|
||||
state.setEditData((prev) => ({
|
||||
...prev,
|
||||
name: e.target.value,
|
||||
}))
|
||||
}
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor={companyEmailFieldId}>Contact Email</Label>
|
||||
<Input
|
||||
id={companyEmailFieldId}
|
||||
type="email"
|
||||
value={state.editData.email || ""}
|
||||
onChange={(e) =>
|
||||
state.setEditData((prev) => ({
|
||||
...prev,
|
||||
email: e.target.value,
|
||||
}))
|
||||
}
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor={maxUsersFieldId}>Max Users</Label>
|
||||
<Input
|
||||
id={maxUsersFieldId}
|
||||
type="number"
|
||||
value={state.editData.maxUsers || 0}
|
||||
onChange={(e) =>
|
||||
state.setEditData((prev) => ({
|
||||
...prev,
|
||||
maxUsers: Number.parseInt(e.target.value),
|
||||
}))
|
||||
}
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="status">Status</Label>
|
||||
<Select
|
||||
value={state.editData.status}
|
||||
onValueChange={(value) =>
|
||||
state.setEditData((prev) => ({
|
||||
...prev,
|
||||
status: value,
|
||||
}))
|
||||
}
|
||||
disabled={!canEdit}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="ACTIVE">Active</SelectItem>
|
||||
<SelectItem value="TRIAL">Trial</SelectItem>
|
||||
<SelectItem value="SUSPENDED">Suspended</SelectItem>
|
||||
<SelectItem value="ARCHIVED">Archived</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
{canEdit && hasUnsavedChanges() && (
|
||||
<div className="flex gap-2 pt-4 border-t">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
state.setEditData(state.originalData);
|
||||
}}
|
||||
>
|
||||
Cancel Changes
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={state.isSaving}>
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
{state.isSaving ? "Saving..." : "Save Changes"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to render users tab content
|
||||
*/
|
||||
function renderUsersTab(state: CompanyManagementState, canEdit: boolean) {
|
||||
return (
|
||||
<TabsContent value="users" className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
<span className="flex items-center gap-2">
|
||||
<Users className="w-5 h-5" />
|
||||
Users ({state.company?.users.length || 0})
|
||||
</span>
|
||||
{canEdit && (
|
||||
<Button size="sm" onClick={() => state.setShowInviteUser(true)}>
|
||||
<UserPlus className="w-4 h-4 mr-2" />
|
||||
Invite User
|
||||
</Button>
|
||||
)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{state.company?.users.map((user) => (
|
||||
<div
|
||||
key={user.id}
|
||||
className="flex items-center justify-between p-4 border rounded-lg"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-10 h-10 bg-blue-100 dark:bg-blue-900 rounded-full flex items-center justify-center">
|
||||
<span className="text-sm font-medium text-blue-600 dark:text-blue-300">
|
||||
{user.name?.charAt(0) ||
|
||||
user.email.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium">{user.name || "No name"}</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{user.email}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<Badge variant="outline">{user.role}</Badge>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Joined {new Date(user.createdAt).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{(state.company?.users.length || 0) === 0 && (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
No users found. Invite the first user to get started.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CompanyManagement() {
|
||||
const { data: session, status } = useSession();
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const { toast } = useToast();
|
||||
|
||||
const state = useCompanyManagementState();
|
||||
const {
|
||||
companyNameFieldId,
|
||||
companyEmailFieldId,
|
||||
maxUsersFieldId,
|
||||
inviteNameFieldId,
|
||||
inviteEmailFieldId,
|
||||
} = useCompanyFormIds();
|
||||
const { hasUnsavedChanges } = useDataComparison(
|
||||
state.editData,
|
||||
state.originalData
|
||||
);
|
||||
const { fetchCompany } = useCompanyData(params, toast, state);
|
||||
const { handleNavigation } = useNavigationControl(
|
||||
router,
|
||||
params,
|
||||
hasUnsavedChanges,
|
||||
state
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@ -188,24 +482,24 @@ export default function CompanyManagement() {
|
||||
}, [session, status, router, fetchCompany]);
|
||||
|
||||
const handleSave = async () => {
|
||||
setIsSaving(true);
|
||||
state.setIsSaving(true);
|
||||
try {
|
||||
const response = await fetch(`/api/platform/companies/${params.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(editData),
|
||||
body: JSON.stringify(state.editData),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const updatedCompany = await response.json();
|
||||
setCompany(updatedCompany);
|
||||
state.setCompany(updatedCompany);
|
||||
const companyData = {
|
||||
name: updatedCompany.name,
|
||||
email: updatedCompany.email,
|
||||
status: updatedCompany.status,
|
||||
maxUsers: updatedCompany.maxUsers,
|
||||
};
|
||||
setOriginalData(companyData);
|
||||
state.setOriginalData(companyData);
|
||||
toast({
|
||||
title: "Success",
|
||||
description: "Company updated successfully",
|
||||
@ -220,7 +514,7 @@ export default function CompanyManagement() {
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
state.setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
@ -235,8 +529,10 @@ export default function CompanyManagement() {
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
setCompany((prev) => (prev ? { ...prev, status: newStatus } : null));
|
||||
setEditData((prev) => ({ ...prev, status: newStatus }));
|
||||
state.setCompany((prev) =>
|
||||
prev ? { ...prev, status: newStatus } : null
|
||||
);
|
||||
state.setEditData((prev) => ({ ...prev, status: newStatus }));
|
||||
toast({
|
||||
title: "Success",
|
||||
description: `Company ${statusAction}d successfully`,
|
||||
@ -254,16 +550,47 @@ export default function CompanyManagement() {
|
||||
};
|
||||
|
||||
const confirmNavigation = () => {
|
||||
if (pendingNavigation) {
|
||||
router.push(pendingNavigation);
|
||||
setPendingNavigation(null);
|
||||
if (state.pendingNavigation) {
|
||||
router.push(state.pendingNavigation);
|
||||
state.setPendingNavigation(null);
|
||||
}
|
||||
setShowUnsavedChangesDialog(false);
|
||||
state.setShowUnsavedChangesDialog(false);
|
||||
};
|
||||
|
||||
const cancelNavigation = () => {
|
||||
setPendingNavigation(null);
|
||||
setShowUnsavedChangesDialog(false);
|
||||
state.setPendingNavigation(null);
|
||||
state.setShowUnsavedChangesDialog(false);
|
||||
};
|
||||
|
||||
const handleInviteUser = async () => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/platform/companies/${params.id}/users`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(state.inviteData),
|
||||
}
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
state.setShowInviteUser(false);
|
||||
state.setInviteData({ name: "", email: "", role: "USER" });
|
||||
fetchCompany();
|
||||
toast({
|
||||
title: "Success",
|
||||
description: "User invited successfully",
|
||||
});
|
||||
} else {
|
||||
throw new Error("Failed to invite user");
|
||||
}
|
||||
} catch (_error) {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Failed to invite user",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Protect against browser back/forward and other navigation
|
||||
@ -281,7 +608,6 @@ export default function CompanyManagement() {
|
||||
"You have unsaved changes. Are you sure you want to leave this page?"
|
||||
);
|
||||
if (!confirmLeave) {
|
||||
// Push the current state back to prevent navigation
|
||||
window.history.pushState(null, "", window.location.href);
|
||||
e.preventDefault();
|
||||
}
|
||||
@ -297,37 +623,6 @@ export default function CompanyManagement() {
|
||||
};
|
||||
}, [hasUnsavedChanges]);
|
||||
|
||||
const handleInviteUser = async () => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/platform/companies/${params.id}/users`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(inviteData),
|
||||
}
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
setShowInviteUser(false);
|
||||
setInviteData({ name: "", email: "", role: "USER" });
|
||||
fetchCompany(); // Refresh company data
|
||||
toast({
|
||||
title: "Success",
|
||||
description: "User invited successfully",
|
||||
});
|
||||
} else {
|
||||
throw new Error("Failed to invite user");
|
||||
}
|
||||
} catch (_error) {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Failed to invite user",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadgeVariant = (status: string) => {
|
||||
switch (status) {
|
||||
case "ACTIVE":
|
||||
@ -343,7 +638,7 @@ export default function CompanyManagement() {
|
||||
}
|
||||
};
|
||||
|
||||
if (status === "loading" || isLoading) {
|
||||
if (status === "loading" || state.isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="text-center">Loading company details...</div>
|
||||
@ -351,7 +646,7 @@ export default function CompanyManagement() {
|
||||
);
|
||||
}
|
||||
|
||||
if (!session?.user?.isPlatformUser || !company) {
|
||||
if (!session?.user?.isPlatformUser || !state.company) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -374,10 +669,10 @@ export default function CompanyManagement() {
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{company.name}
|
||||
{state.company.name}
|
||||
</h1>
|
||||
<Badge variant={getStatusBadgeVariant(company.status)}>
|
||||
{company.status}
|
||||
<Badge variant={getStatusBadgeVariant(state.company.status)}>
|
||||
{state.company.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
@ -390,7 +685,7 @@ export default function CompanyManagement() {
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowInviteUser(true)}
|
||||
onClick={() => state.setShowInviteUser(true)}
|
||||
>
|
||||
<UserPlus className="w-4 h-4 mr-2" />
|
||||
Invite User
|
||||
@ -422,10 +717,10 @@ export default function CompanyManagement() {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{company.users.length}
|
||||
{state.company.users.length}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
of {company.maxUsers} maximum
|
||||
of {state.company.maxUsers} maximum
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@ -439,7 +734,7 @@ export default function CompanyManagement() {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{company._count.sessions}
|
||||
{state.company._count.sessions}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@ -453,7 +748,7 @@ export default function CompanyManagement() {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{company._count.imports}
|
||||
{state.company._count.imports}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@ -465,160 +760,25 @@ export default function CompanyManagement() {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-sm font-bold">
|
||||
{new Date(company.createdAt).toLocaleDateString()}
|
||||
{new Date(state.company.createdAt).toLocaleDateString()}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Company Info */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Company Information</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor={companyNameFieldId}>Company Name</Label>
|
||||
<Input
|
||||
id={companyNameFieldId}
|
||||
value={editData.name || ""}
|
||||
onChange={(e) =>
|
||||
setEditData((prev) => ({
|
||||
...prev,
|
||||
name: e.target.value,
|
||||
}))
|
||||
}
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor={companyEmailFieldId}>Contact Email</Label>
|
||||
<Input
|
||||
id={companyEmailFieldId}
|
||||
type="email"
|
||||
value={editData.email || ""}
|
||||
onChange={(e) =>
|
||||
setEditData((prev) => ({
|
||||
...prev,
|
||||
email: e.target.value,
|
||||
}))
|
||||
}
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor={maxUsersFieldId}>Max Users</Label>
|
||||
<Input
|
||||
id={maxUsersFieldId}
|
||||
type="number"
|
||||
value={editData.maxUsers || 0}
|
||||
onChange={(e) =>
|
||||
setEditData((prev) => ({
|
||||
...prev,
|
||||
maxUsers: Number.parseInt(e.target.value),
|
||||
}))
|
||||
}
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="status">Status</Label>
|
||||
<Select
|
||||
value={editData.status}
|
||||
onValueChange={(value) =>
|
||||
setEditData((prev) => ({ ...prev, status: value }))
|
||||
}
|
||||
disabled={!canEdit}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="ACTIVE">Active</SelectItem>
|
||||
<SelectItem value="TRIAL">Trial</SelectItem>
|
||||
<SelectItem value="SUSPENDED">Suspended</SelectItem>
|
||||
<SelectItem value="ARCHIVED">Archived</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
{canEdit && hasUnsavedChanges() && (
|
||||
<div className="flex gap-2 pt-4 border-t">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setEditData(originalData);
|
||||
}}
|
||||
>
|
||||
Cancel Changes
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={isSaving}>
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
{isSaving ? "Saving..." : "Save Changes"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
{renderCompanyInfoCard(
|
||||
state,
|
||||
canEdit,
|
||||
companyNameFieldId,
|
||||
companyEmailFieldId,
|
||||
maxUsersFieldId,
|
||||
hasUnsavedChanges,
|
||||
handleSave
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="users" className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
<span className="flex items-center gap-2">
|
||||
<Users className="w-5 h-5" />
|
||||
Users ({company.users.length})
|
||||
</span>
|
||||
{canEdit && (
|
||||
<Button size="sm" onClick={() => setShowInviteUser(true)}>
|
||||
<UserPlus className="w-4 h-4 mr-2" />
|
||||
Invite User
|
||||
</Button>
|
||||
)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{company.users.map((user) => (
|
||||
<div
|
||||
key={user.id}
|
||||
className="flex items-center justify-between p-4 border rounded-lg"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-10 h-10 bg-blue-100 dark:bg-blue-900 rounded-full flex items-center justify-center">
|
||||
<span className="text-sm font-medium text-blue-600 dark:text-blue-300">
|
||||
{user.name?.charAt(0) ||
|
||||
user.email.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium">
|
||||
{user.name || "No name"}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{user.email}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<Badge variant="outline">{user.role}</Badge>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Joined {new Date(user.createdAt).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{company.users.length === 0 && (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
No users found. Invite the first user to get started.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
{renderUsersTab(state, canEdit)}
|
||||
|
||||
<TabsContent value="settings" className="space-y-6">
|
||||
<Card>
|
||||
@ -641,9 +801,9 @@ export default function CompanyManagement() {
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="destructive"
|
||||
disabled={company.status === "SUSPENDED"}
|
||||
disabled={state.company.status === "SUSPENDED"}
|
||||
>
|
||||
{company.status === "SUSPENDED"
|
||||
{state.company.status === "SUSPENDED"
|
||||
? "Already Suspended"
|
||||
: "Suspend"}
|
||||
</Button>
|
||||
@ -668,7 +828,7 @@ export default function CompanyManagement() {
|
||||
</AlertDialog>
|
||||
</div>
|
||||
|
||||
{company.status === "SUSPENDED" && (
|
||||
{state.company.status === "SUSPENDED" && (
|
||||
<div className="flex items-center justify-between p-4 border border-green-200 dark:border-green-800 rounded-lg">
|
||||
<div>
|
||||
<h3 className="font-medium">Reactivate Company</h3>
|
||||
@ -706,7 +866,7 @@ export default function CompanyManagement() {
|
||||
</div>
|
||||
|
||||
{/* Invite User Dialog */}
|
||||
{showInviteUser && (
|
||||
{state.showInviteUser && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<Card className="w-full max-w-md mx-4">
|
||||
<CardHeader>
|
||||
@ -717,9 +877,12 @@ export default function CompanyManagement() {
|
||||
<Label htmlFor={inviteNameFieldId}>Name</Label>
|
||||
<Input
|
||||
id={inviteNameFieldId}
|
||||
value={inviteData.name}
|
||||
value={state.inviteData.name}
|
||||
onChange={(e) =>
|
||||
setInviteData((prev) => ({ ...prev, name: e.target.value }))
|
||||
state.setInviteData((prev) => ({
|
||||
...prev,
|
||||
name: e.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="User's full name"
|
||||
/>
|
||||
@ -729,9 +892,9 @@ export default function CompanyManagement() {
|
||||
<Input
|
||||
id={inviteEmailFieldId}
|
||||
type="email"
|
||||
value={inviteData.email}
|
||||
value={state.inviteData.email}
|
||||
onChange={(e) =>
|
||||
setInviteData((prev) => ({
|
||||
state.setInviteData((prev) => ({
|
||||
...prev,
|
||||
email: e.target.value,
|
||||
}))
|
||||
@ -742,9 +905,9 @@ export default function CompanyManagement() {
|
||||
<div>
|
||||
<Label htmlFor="inviteRole">Role</Label>
|
||||
<Select
|
||||
value={inviteData.role}
|
||||
value={state.inviteData.role}
|
||||
onValueChange={(value) =>
|
||||
setInviteData((prev) => ({ ...prev, role: value }))
|
||||
state.setInviteData((prev) => ({ ...prev, role: value }))
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
@ -759,7 +922,7 @@ export default function CompanyManagement() {
|
||||
<div className="flex gap-2 pt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowInviteUser(false)}
|
||||
onClick={() => state.setShowInviteUser(false)}
|
||||
className="flex-1"
|
||||
>
|
||||
Cancel
|
||||
@ -767,7 +930,7 @@ export default function CompanyManagement() {
|
||||
<Button
|
||||
onClick={handleInviteUser}
|
||||
className="flex-1"
|
||||
disabled={!inviteData.email || !inviteData.name}
|
||||
disabled={!state.inviteData.email || !state.inviteData.name}
|
||||
>
|
||||
<Mail className="w-4 h-4 mr-2" />
|
||||
Send Invite
|
||||
@ -780,8 +943,8 @@ export default function CompanyManagement() {
|
||||
|
||||
{/* Unsaved Changes Dialog */}
|
||||
<AlertDialog
|
||||
open={showUnsavedChangesDialog}
|
||||
onOpenChange={setShowUnsavedChangesDialog}
|
||||
open={state.showUnsavedChangesDialog}
|
||||
onOpenChange={state.setShowUnsavedChangesDialog}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -10,7 +10,7 @@ import {
|
||||
Settings,
|
||||
Shield,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { SecurityConfigModal } from "@/components/security/SecurityConfigModal";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@ -51,7 +51,10 @@ interface SecurityAlert {
|
||||
acknowledged: boolean;
|
||||
}
|
||||
|
||||
export default function SecurityMonitoringPage() {
|
||||
/**
|
||||
* Custom hook for security monitoring state
|
||||
*/
|
||||
function useSecurityMonitoringState() {
|
||||
const [metrics, setMetrics] = useState<SecurityMetrics | null>(null);
|
||||
const [alerts, setAlerts] = useState<SecurityAlert[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@ -59,14 +62,29 @@ export default function SecurityMonitoringPage() {
|
||||
const [showConfig, setShowConfig] = useState(false);
|
||||
const [autoRefresh, setAutoRefresh] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
loadSecurityData();
|
||||
return {
|
||||
metrics,
|
||||
setMetrics,
|
||||
alerts,
|
||||
setAlerts,
|
||||
loading,
|
||||
setLoading,
|
||||
selectedTimeRange,
|
||||
setSelectedTimeRange,
|
||||
showConfig,
|
||||
setShowConfig,
|
||||
autoRefresh,
|
||||
setAutoRefresh,
|
||||
};
|
||||
}
|
||||
|
||||
if (autoRefresh) {
|
||||
const interval = setInterval(loadSecurityData, 30000); // Refresh every 30 seconds
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
}, [autoRefresh, loadSecurityData]);
|
||||
/**
|
||||
* Custom hook for security data fetching
|
||||
*/
|
||||
function useSecurityData(selectedTimeRange: string, autoRefresh: boolean) {
|
||||
const [metrics, setMetrics] = useState<SecurityMetrics | null>(null);
|
||||
const [alerts, setAlerts] = useState<SecurityAlert[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const loadSecurityData = useCallback(async () => {
|
||||
try {
|
||||
@ -89,6 +107,228 @@ export default function SecurityMonitoringPage() {
|
||||
}
|
||||
}, [selectedTimeRange]);
|
||||
|
||||
useEffect(() => {
|
||||
loadSecurityData();
|
||||
|
||||
if (autoRefresh) {
|
||||
const interval = setInterval(loadSecurityData, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
}, [autoRefresh, loadSecurityData]);
|
||||
|
||||
return { metrics, alerts, loading, loadSecurityData, setAlerts };
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to get date range for filtering
|
||||
*/
|
||||
function 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();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to get threat level color
|
||||
*/
|
||||
function 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";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to get severity color
|
||||
*/
|
||||
function 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";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to render dashboard header
|
||||
*/
|
||||
function renderDashboardHeader(
|
||||
autoRefresh: boolean,
|
||||
setAutoRefresh: (refresh: boolean) => void,
|
||||
setShowConfig: (show: boolean) => void,
|
||||
exportData: (format: "json" | "csv", type: "alerts" | "metrics") => void
|
||||
) {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to render time range selector
|
||||
*/
|
||||
function renderTimeRangeSelector(
|
||||
selectedTimeRange: string,
|
||||
setSelectedTimeRange: (range: string) => void
|
||||
) {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to render security overview cards
|
||||
*/
|
||||
function renderSecurityOverview(metrics: SecurityMetrics | null) {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SecurityMonitoringPage() {
|
||||
const {
|
||||
selectedTimeRange,
|
||||
setSelectedTimeRange,
|
||||
showConfig,
|
||||
setShowConfig,
|
||||
autoRefresh,
|
||||
setAutoRefresh,
|
||||
} = useSecurityMonitoringState();
|
||||
|
||||
const { metrics, alerts, loading, setAlerts, loadSecurityData } =
|
||||
useSecurityData(selectedTimeRange, autoRefresh);
|
||||
|
||||
const acknowledgeAlert = async (alertId: string) => {
|
||||
try {
|
||||
const response = await fetch("/api/admin/security-monitoring/alerts", {
|
||||
@ -135,52 +375,6 @@ export default function SecurityMonitoringPage() {
|
||||
}
|
||||
};
|
||||
|
||||
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">
|
||||
@ -191,132 +385,14 @@ export default function SecurityMonitoringPage() {
|
||||
|
||||
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>
|
||||
{renderDashboardHeader(
|
||||
autoRefresh,
|
||||
setAutoRefresh,
|
||||
setShowConfig,
|
||||
exportData
|
||||
)}
|
||||
{renderTimeRangeSelector(selectedTimeRange, setSelectedTimeRange)}
|
||||
{renderSecurityOverview(metrics)}
|
||||
|
||||
<Tabs defaultValue="alerts" className="space-y-4">
|
||||
<TabsList>
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
import { ArrowLeft, Key, Shield, User } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useId, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
@ -62,6 +62,13 @@ export default function PlatformSettings() {
|
||||
const router = useRouter();
|
||||
const { toast } = useToast();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// Generate unique IDs for form elements
|
||||
const nameId = useId();
|
||||
const emailId = useId();
|
||||
const currentPasswordId = useId();
|
||||
const newPasswordId = useId();
|
||||
const confirmPasswordId = useId();
|
||||
const [profileData, setProfileData] = useState({
|
||||
name: "",
|
||||
email: "",
|
||||
@ -223,9 +230,9 @@ export default function PlatformSettings() {
|
||||
<CardContent>
|
||||
<form onSubmit={handleProfileUpdate} className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="name">Name</Label>
|
||||
<Label htmlFor={nameId}>Name</Label>
|
||||
<Input
|
||||
id="name"
|
||||
id={nameId}
|
||||
value={profileData.name}
|
||||
onChange={(e) =>
|
||||
setProfileData({ ...profileData, name: e.target.value })
|
||||
@ -234,9 +241,9 @@ export default function PlatformSettings() {
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Label htmlFor={emailId}>Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
id={emailId}
|
||||
type="email"
|
||||
value={profileData.email}
|
||||
disabled
|
||||
@ -273,9 +280,9 @@ export default function PlatformSettings() {
|
||||
<CardContent>
|
||||
<form onSubmit={handlePasswordChange} className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="current-password">Current Password</Label>
|
||||
<Label htmlFor={currentPasswordId}>Current Password</Label>
|
||||
<Input
|
||||
id="current-password"
|
||||
id={currentPasswordId}
|
||||
type="password"
|
||||
value={passwordData.currentPassword}
|
||||
onChange={(e) =>
|
||||
@ -288,9 +295,9 @@ export default function PlatformSettings() {
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="new-password">New Password</Label>
|
||||
<Label htmlFor={newPasswordId}>New Password</Label>
|
||||
<Input
|
||||
id="new-password"
|
||||
id={newPasswordId}
|
||||
type="password"
|
||||
value={passwordData.newPassword}
|
||||
onChange={(e) =>
|
||||
@ -306,11 +313,11 @@ export default function PlatformSettings() {
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="confirm-password">
|
||||
<Label htmlFor={confirmPasswordId}>
|
||||
Confirm New Password
|
||||
</Label>
|
||||
<Input
|
||||
id="confirm-password"
|
||||
id={confirmPasswordId}
|
||||
type="password"
|
||||
value={passwordData.confirmPassword}
|
||||
onChange={(e) =>
|
||||
|
||||
Reference in New Issue
Block a user