mirror of
https://github.com/kjanat/livedash-node.git
synced 2026-01-16 07:52:10 +01:00
feat: comprehensive security and architecture improvements
- Add Zod validation schemas with strong password requirements (12+ chars, complexity) - Implement rate limiting for authentication endpoints (registration, password reset) - Remove duplicate MetricCard component, consolidate to ui/metric-card.tsx - Update README.md to use pnpm commands consistently - Enhance authentication security with 12-round bcrypt hashing - Add comprehensive input validation for all API endpoints - Fix security vulnerabilities in user registration and password reset flows 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@ -37,12 +37,11 @@ export async function POST(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
const company = await prisma.company.findUnique({ where: { id: companyId } });
|
||||
const company = await prisma.company.findUnique({
|
||||
where: { id: companyId },
|
||||
});
|
||||
if (!company) {
|
||||
return NextResponse.json(
|
||||
{ error: "Company not found" },
|
||||
{ status: 404 }
|
||||
);
|
||||
return NextResponse.json({ error: "Company not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const rawSessionData = await fetchAndParseCsv(
|
||||
@ -114,12 +113,12 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
// Immediately process the queued imports to create Session records
|
||||
console.log('[Refresh API] Processing queued imports...');
|
||||
console.log("[Refresh API] Processing queued imports...");
|
||||
await processQueuedImports(100); // Process up to 100 imports immediately
|
||||
|
||||
// Count how many sessions were created
|
||||
const sessionCount = await prisma.session.count({
|
||||
where: { companyId: company.id }
|
||||
where: { companyId: company.id },
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
@ -127,7 +126,7 @@ export async function POST(request: NextRequest) {
|
||||
imported: importedCount,
|
||||
total: rawSessionData.length,
|
||||
sessions: sessionCount,
|
||||
message: `Successfully imported ${importedCount} records and processed them into sessions. Total sessions: ${sessionCount}`
|
||||
message: `Successfully imported ${importedCount} records and processed them into sessions. Total sessions: ${sessionCount}`,
|
||||
});
|
||||
} catch (e) {
|
||||
const error = e instanceof Error ? e.message : "An unknown error occurred";
|
||||
|
||||
@ -45,18 +45,21 @@ export async function POST(request: NextRequest) {
|
||||
const { batchSize, maxConcurrency } = body;
|
||||
|
||||
// Validate parameters
|
||||
const validatedBatchSize = batchSize && batchSize > 0 ? parseInt(batchSize) : null;
|
||||
const validatedMaxConcurrency = maxConcurrency && maxConcurrency > 0 ? parseInt(maxConcurrency) : 5;
|
||||
const validatedBatchSize =
|
||||
batchSize && batchSize > 0 ? parseInt(batchSize) : null;
|
||||
const validatedMaxConcurrency =
|
||||
maxConcurrency && maxConcurrency > 0 ? parseInt(maxConcurrency) : 5;
|
||||
|
||||
// Check how many sessions need AI processing using the new status system
|
||||
const sessionsNeedingAI = await ProcessingStatusManager.getSessionsNeedingProcessing(
|
||||
ProcessingStage.AI_ANALYSIS,
|
||||
1000 // Get count only
|
||||
);
|
||||
const sessionsNeedingAI =
|
||||
await ProcessingStatusManager.getSessionsNeedingProcessing(
|
||||
ProcessingStage.AI_ANALYSIS,
|
||||
1000 // Get count only
|
||||
);
|
||||
|
||||
// Filter to sessions for this company
|
||||
const companySessionsNeedingAI = sessionsNeedingAI.filter(
|
||||
statusRecord => statusRecord.session.companyId === user.companyId
|
||||
(statusRecord) => statusRecord.session.companyId === user.companyId
|
||||
);
|
||||
|
||||
const unprocessedCount = companySessionsNeedingAI.length;
|
||||
@ -77,10 +80,15 @@ export async function POST(request: NextRequest) {
|
||||
// The processing will continue in the background
|
||||
processUnprocessedSessions(validatedBatchSize, validatedMaxConcurrency)
|
||||
.then(() => {
|
||||
console.log(`[Manual Trigger] Processing completed for company ${user.companyId}`);
|
||||
console.log(
|
||||
`[Manual Trigger] Processing completed for company ${user.companyId}`
|
||||
);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(`[Manual Trigger] Processing failed for company ${user.companyId}:`, error);
|
||||
console.error(
|
||||
`[Manual Trigger] Processing failed for company ${user.companyId}:`,
|
||||
error
|
||||
);
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
@ -91,7 +99,6 @@ export async function POST(request: NextRequest) {
|
||||
maxConcurrency: validatedMaxConcurrency,
|
||||
startedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error("[Manual Trigger] Error:", error);
|
||||
return NextResponse.json(
|
||||
|
||||
@ -42,7 +42,7 @@ export async function GET(request: NextRequest) {
|
||||
if (startDate && endDate) {
|
||||
whereClause.startTime = {
|
||||
gte: new Date(startDate),
|
||||
lte: new Date(endDate + 'T23:59:59.999Z'), // Include full end date
|
||||
lte: new Date(endDate + "T23:59:59.999Z"), // Include full end date
|
||||
};
|
||||
}
|
||||
|
||||
@ -94,10 +94,12 @@ export async function GET(request: NextRequest) {
|
||||
// Calculate date range from sessions
|
||||
let dateRange: { minDate: string; maxDate: string } | null = null;
|
||||
if (prismaSessions.length > 0) {
|
||||
const dates = prismaSessions.map(s => new Date(s.startTime)).sort((a, b) => a.getTime() - b.getTime());
|
||||
const dates = prismaSessions
|
||||
.map((s) => new Date(s.startTime))
|
||||
.sort((a, b) => a.getTime() - b.getTime());
|
||||
dateRange = {
|
||||
minDate: dates[0].toISOString().split('T')[0], // First session date
|
||||
maxDate: dates[dates.length - 1].toISOString().split('T')[0] // Last session date
|
||||
minDate: dates[0].toISOString().split("T")[0], // First session date
|
||||
maxDate: dates[dates.length - 1].toISOString().split("T")[0], // Last session date
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -53,9 +53,9 @@ export async function GET(request: NextRequest) {
|
||||
.map((s) => s.language)
|
||||
.filter(Boolean) as string[]; // Filter out any nulls and assert as string[]
|
||||
|
||||
return NextResponse.json({
|
||||
categories: distinctCategories,
|
||||
languages: distinctLanguages
|
||||
return NextResponse.json({
|
||||
categories: distinctCategories,
|
||||
languages: distinctLanguages,
|
||||
});
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
|
||||
@ -26,10 +26,7 @@ export async function GET(
|
||||
});
|
||||
|
||||
if (!prismaSession) {
|
||||
return NextResponse.json(
|
||||
{ error: "Session not found" },
|
||||
{ status: 404 }
|
||||
);
|
||||
return NextResponse.json({ error: "Session not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
// Map Prisma session object to ChatSession type
|
||||
|
||||
@ -18,7 +18,7 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
const companyId = authSession.user.companyId;
|
||||
const { searchParams } = new URL(request.url);
|
||||
|
||||
|
||||
const searchTerm = searchParams.get("searchTerm");
|
||||
const category = searchParams.get("category");
|
||||
const language = searchParams.get("language");
|
||||
@ -87,9 +87,7 @@ export async function GET(request: NextRequest) {
|
||||
| Prisma.SessionOrderByWithRelationInput[];
|
||||
|
||||
const primarySortField =
|
||||
sortKey && validSortKeys[sortKey]
|
||||
? validSortKeys[sortKey]
|
||||
: "startTime"; // Default to startTime field if sortKey is invalid/missing
|
||||
sortKey && validSortKeys[sortKey] ? validSortKeys[sortKey] : "startTime"; // Default to startTime field if sortKey is invalid/missing
|
||||
|
||||
const primarySortOrder =
|
||||
sortOrder === "asc" || sortOrder === "desc" ? sortOrder : "desc"; // Default to desc order
|
||||
|
||||
@ -65,7 +65,7 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
const tempPassword = crypto.randomBytes(12).toString("base64").slice(0, 12); // secure random initial password
|
||||
|
||||
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
email,
|
||||
|
||||
@ -1,28 +1,92 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "../../../lib/prisma";
|
||||
import { sendEmail } from "../../../lib/sendEmail";
|
||||
import { forgotPasswordSchema, validateInput } from "../../../lib/validation";
|
||||
import crypto from "crypto";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const body = await request.json();
|
||||
const { email } = body as { email: string };
|
||||
// In-memory rate limiting for password reset requests
|
||||
const resetAttempts = new Map<string, { count: number; resetTime: number }>();
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { email } });
|
||||
if (!user) {
|
||||
// Always return 200 for privacy (don't reveal if email exists)
|
||||
return NextResponse.json({ success: true }, { status: 200 });
|
||||
function checkRateLimit(ip: string): boolean {
|
||||
const now = Date.now();
|
||||
const attempts = resetAttempts.get(ip);
|
||||
|
||||
if (!attempts || now > attempts.resetTime) {
|
||||
resetAttempts.set(ip, { count: 1, resetTime: now + 15 * 60 * 1000 }); // 15 minute window
|
||||
return true;
|
||||
}
|
||||
|
||||
const token = crypto.randomBytes(32).toString("hex");
|
||||
const expiry = new Date(Date.now() + 1000 * 60 * 30); // 30 min expiry
|
||||
|
||||
await prisma.user.update({
|
||||
where: { email },
|
||||
data: { resetToken: token, resetTokenExpiry: expiry },
|
||||
});
|
||||
if (attempts.count >= 5) {
|
||||
// Max 5 reset requests per 15 minutes per IP
|
||||
return false;
|
||||
}
|
||||
|
||||
const resetUrl = `${process.env.NEXTAUTH_URL || "http://localhost:3000"}/reset-password?token=${token}`;
|
||||
await sendEmail(email, "Password Reset", `Reset your password: ${resetUrl}`);
|
||||
|
||||
return NextResponse.json({ success: true }, { status: 200 });
|
||||
attempts.count++;
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
// Rate limiting check
|
||||
const ip =
|
||||
request.ip || request.headers.get("x-forwarded-for") || "unknown";
|
||||
if (!checkRateLimit(ip)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: "Too many password reset attempts. Please try again later.",
|
||||
},
|
||||
{ status: 429 }
|
||||
);
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
|
||||
// Validate input
|
||||
const validation = validateInput(forgotPasswordSchema, body);
|
||||
if (!validation.success) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: "Invalid email format",
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const { email } = validation.data;
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { email } });
|
||||
|
||||
// Always return success for privacy (don't reveal if email exists)
|
||||
// But only send email if user exists
|
||||
if (user) {
|
||||
const token = crypto.randomBytes(32).toString("hex");
|
||||
const tokenHash = crypto.createHash("sha256").update(token).digest("hex");
|
||||
const expiry = new Date(Date.now() + 1000 * 60 * 30); // 30 min expiry
|
||||
|
||||
await prisma.user.update({
|
||||
where: { email },
|
||||
data: { resetToken: tokenHash, resetTokenExpiry: expiry },
|
||||
});
|
||||
|
||||
const resetUrl = `${process.env.NEXTAUTH_URL || "http://localhost:3000"}/reset-password?token=${token}`;
|
||||
await sendEmail(
|
||||
email,
|
||||
"Password Reset",
|
||||
`Reset your password: ${resetUrl}`
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true }, { status: 200 });
|
||||
} catch (error) {
|
||||
console.error("Forgot password error:", error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: "Internal server error",
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,63 +1,136 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "../../../lib/prisma";
|
||||
import { registerSchema, validateInput } from "../../../lib/validation";
|
||||
import bcrypt from "bcryptjs";
|
||||
|
||||
interface RegisterRequestBody {
|
||||
email: string;
|
||||
password: string;
|
||||
company: string;
|
||||
csvUrl?: string;
|
||||
// In-memory rate limiting (for production, use Redis or similar)
|
||||
const registrationAttempts = new Map<
|
||||
string,
|
||||
{ count: number; resetTime: number }
|
||||
>();
|
||||
|
||||
function checkRateLimit(ip: string): boolean {
|
||||
const now = Date.now();
|
||||
const attempts = registrationAttempts.get(ip);
|
||||
|
||||
if (!attempts || now > attempts.resetTime) {
|
||||
registrationAttempts.set(ip, { count: 1, resetTime: now + 60 * 60 * 1000 }); // 1 hour window
|
||||
return true;
|
||||
}
|
||||
|
||||
if (attempts.count >= 3) {
|
||||
// Max 3 registrations per hour per IP
|
||||
return false;
|
||||
}
|
||||
|
||||
attempts.count++;
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const body = await request.json();
|
||||
const { email, password, company, csvUrl } = body as RegisterRequestBody;
|
||||
try {
|
||||
// Rate limiting check
|
||||
const ip =
|
||||
request.ip || request.headers.get("x-forwarded-for") || "unknown";
|
||||
if (!checkRateLimit(ip)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: "Too many registration attempts. Please try again later.",
|
||||
},
|
||||
{ status: 429 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!email || !password || !company) {
|
||||
const body = await request.json();
|
||||
|
||||
// Validate input with Zod schema
|
||||
const validation = validateInput(registerSchema, body);
|
||||
if (!validation.success) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: "Validation failed",
|
||||
details: validation.errors,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const { email, password, company } = validation.data;
|
||||
|
||||
// Check if email exists
|
||||
const existingUser = await prisma.user.findUnique({
|
||||
where: { email },
|
||||
});
|
||||
|
||||
if (existingUser) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: "Email already exists",
|
||||
},
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
// Check if company name already exists
|
||||
const existingCompany = await prisma.company.findFirst({
|
||||
where: { name: company },
|
||||
});
|
||||
|
||||
if (existingCompany) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: "Company name already exists",
|
||||
},
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
// Create company and user in a transaction
|
||||
const result = await prisma.$transaction(async (tx) => {
|
||||
const newCompany = await tx.company.create({
|
||||
data: {
|
||||
name: company,
|
||||
csvUrl: "", // Empty by default, can be set later in settings
|
||||
},
|
||||
});
|
||||
|
||||
const hashedPassword = await bcrypt.hash(password, 12); // Increased rounds for better security
|
||||
|
||||
const newUser = await tx.user.create({
|
||||
data: {
|
||||
email,
|
||||
password: hashedPassword,
|
||||
companyId: newCompany.id,
|
||||
role: "USER", // Changed from ADMIN - users should be promoted by existing admins
|
||||
},
|
||||
});
|
||||
|
||||
return { company: newCompany, user: newUser };
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: true,
|
||||
data: {
|
||||
message: "Registration successful",
|
||||
userId: result.user.id,
|
||||
companyId: result.company.id,
|
||||
},
|
||||
},
|
||||
{ status: 201 }
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Registration error:", error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: "Missing required fields",
|
||||
error: "Internal server error",
|
||||
},
|
||||
{ status: 400 }
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
// Check if email exists
|
||||
const exists = await prisma.user.findUnique({
|
||||
where: { email },
|
||||
});
|
||||
|
||||
if (exists) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: "Email already exists",
|
||||
},
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
const newCompany = await prisma.company.create({
|
||||
data: { name: company, csvUrl: csvUrl || "" },
|
||||
});
|
||||
|
||||
const hashed = await bcrypt.hash(password, 10);
|
||||
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
email,
|
||||
password: hashed,
|
||||
companyId: newCompany.id,
|
||||
role: "ADMIN",
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: true,
|
||||
data: { success: true },
|
||||
},
|
||||
{ status: 201 }
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,29 +1,34 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "../../../lib/prisma";
|
||||
import { resetPasswordSchema, validateInput } from "../../../lib/validation";
|
||||
import bcrypt from "bcryptjs";
|
||||
import crypto from "crypto";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const body = await request.json();
|
||||
const { token, password } = body as { token?: string; password?: string };
|
||||
|
||||
if (!token || !password) {
|
||||
return NextResponse.json(
|
||||
{ error: "Token and password are required." },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (password.length < 8) {
|
||||
return NextResponse.json(
|
||||
{ error: "Password must be at least 8 characters long." },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
|
||||
// Validate input with strong password requirements
|
||||
const validation = validateInput(resetPasswordSchema, body);
|
||||
if (!validation.success) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: "Validation failed",
|
||||
details: validation.errors,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const { token, password } = validation.data;
|
||||
|
||||
// Hash the token to compare with stored hash
|
||||
const tokenHash = crypto.createHash("sha256").update(token).digest("hex");
|
||||
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
resetToken: token,
|
||||
resetToken: tokenHash,
|
||||
resetTokenExpiry: { gte: new Date() },
|
||||
},
|
||||
});
|
||||
@ -31,30 +36,38 @@ export async function POST(request: NextRequest) {
|
||||
if (!user) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "Invalid or expired token. Please request a new password reset.",
|
||||
success: false,
|
||||
error:
|
||||
"Invalid or expired token. Please request a new password reset.",
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const hash = await bcrypt.hash(password, 10);
|
||||
// Hash password with higher rounds for better security
|
||||
const hashedPassword = await bcrypt.hash(password, 12);
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
password: hash,
|
||||
password: hashedPassword,
|
||||
resetToken: null,
|
||||
resetTokenExpiry: null,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
{ message: "Password has been reset successfully." },
|
||||
{
|
||||
success: true,
|
||||
message: "Password has been reset successfully.",
|
||||
},
|
||||
{ status: 200 }
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Reset password error:", error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: "An internal server error occurred. Please try again later.",
|
||||
},
|
||||
{ status: 500 }
|
||||
|
||||
@ -48,7 +48,10 @@ function DashboardContent() {
|
||||
const [company, setCompany] = useState<Company | null>(null);
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
const [refreshing, setRefreshing] = useState<boolean>(false);
|
||||
const [dateRange, setDateRange] = useState<{ minDate: string; maxDate: string } | null>(null);
|
||||
const [dateRange, setDateRange] = useState<{
|
||||
minDate: string;
|
||||
maxDate: string;
|
||||
} | null>(null);
|
||||
const [selectedStartDate, setSelectedStartDate] = useState<string>("");
|
||||
const [selectedEndDate, setSelectedEndDate] = useState<string>("");
|
||||
const [isInitialLoad, setIsInitialLoad] = useState<boolean>(true);
|
||||
@ -56,7 +59,11 @@ function DashboardContent() {
|
||||
const isAuditor = session?.user?.role === "AUDITOR";
|
||||
|
||||
// Function to fetch metrics with optional date range
|
||||
const fetchMetrics = async (startDate?: string, endDate?: string, isInitial = false) => {
|
||||
const fetchMetrics = async (
|
||||
startDate?: string,
|
||||
endDate?: string,
|
||||
isInitial = false
|
||||
) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
let url = "/api/dashboard/metrics";
|
||||
@ -85,11 +92,14 @@ function DashboardContent() {
|
||||
};
|
||||
|
||||
// Handle date range changes
|
||||
const handleDateRangeChange = useCallback((startDate: string, endDate: string) => {
|
||||
setSelectedStartDate(startDate);
|
||||
setSelectedEndDate(endDate);
|
||||
fetchMetrics(startDate, endDate);
|
||||
}, []);
|
||||
const handleDateRangeChange = useCallback(
|
||||
(startDate: string, endDate: string) => {
|
||||
setSelectedStartDate(startDate);
|
||||
setSelectedEndDate(endDate);
|
||||
fetchMetrics(startDate, endDate);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// Redirect if not authenticated
|
||||
@ -216,33 +226,48 @@ function DashboardContent() {
|
||||
};
|
||||
|
||||
return [
|
||||
{ name: "Positive", value: sentimentData.positive, color: "hsl(var(--chart-1))" },
|
||||
{ name: "Neutral", value: sentimentData.neutral, color: "hsl(var(--chart-2))" },
|
||||
{ name: "Negative", value: sentimentData.negative, color: "hsl(var(--chart-3))" },
|
||||
{
|
||||
name: "Positive",
|
||||
value: sentimentData.positive,
|
||||
color: "hsl(var(--chart-1))",
|
||||
},
|
||||
{
|
||||
name: "Neutral",
|
||||
value: sentimentData.neutral,
|
||||
color: "hsl(var(--chart-2))",
|
||||
},
|
||||
{
|
||||
name: "Negative",
|
||||
value: sentimentData.negative,
|
||||
color: "hsl(var(--chart-3))",
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
const getSessionsOverTimeData = () => {
|
||||
if (!metrics?.days) return [];
|
||||
|
||||
|
||||
return Object.entries(metrics.days).map(([date, value]) => ({
|
||||
date: new Date(date).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }),
|
||||
date: new Date(date).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
}),
|
||||
value: value as number,
|
||||
}));
|
||||
};
|
||||
|
||||
const getCategoriesData = () => {
|
||||
if (!metrics?.categories) return [];
|
||||
|
||||
|
||||
return Object.entries(metrics.categories).map(([name, value]) => ({
|
||||
name: name.length > 15 ? name.substring(0, 15) + '...' : name,
|
||||
name: name.length > 15 ? name.substring(0, 15) + "..." : name,
|
||||
value: value as number,
|
||||
}));
|
||||
};
|
||||
|
||||
const getLanguagesData = () => {
|
||||
if (!metrics?.languages) return [];
|
||||
|
||||
|
||||
return Object.entries(metrics.languages).map(([name, value]) => ({
|
||||
name,
|
||||
value: value as number,
|
||||
@ -287,7 +312,9 @@ function DashboardContent() {
|
||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-3xl font-bold tracking-tight">{company.name}</h1>
|
||||
<h1 className="text-3xl font-bold tracking-tight">
|
||||
{company.name}
|
||||
</h1>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
Analytics Dashboard
|
||||
</Badge>
|
||||
@ -299,7 +326,7 @@ function DashboardContent() {
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
onClick={handleRefresh}
|
||||
@ -307,10 +334,12 @@ function DashboardContent() {
|
||||
size="sm"
|
||||
className="gap-2"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${refreshing ? 'animate-spin' : ''}`} />
|
||||
<RefreshCw
|
||||
className={`h-4 w-4 ${refreshing ? "animate-spin" : ""}`}
|
||||
/>
|
||||
{refreshing ? "Refreshing..." : "Refresh"}
|
||||
</Button>
|
||||
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
@ -318,7 +347,9 @@ function DashboardContent() {
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => signOut({ callbackUrl: "/login" })}>
|
||||
<DropdownMenuItem
|
||||
onClick={() => signOut({ callbackUrl: "/login" })}
|
||||
>
|
||||
<LogOut className="h-4 w-4 mr-2" />
|
||||
Sign out
|
||||
</DropdownMenuItem>
|
||||
@ -352,7 +383,7 @@ function DashboardContent() {
|
||||
}}
|
||||
variant="primary"
|
||||
/>
|
||||
|
||||
|
||||
<MetricCard
|
||||
title="Unique Users"
|
||||
value={metrics.uniqueUsers?.toLocaleString()}
|
||||
@ -363,7 +394,7 @@ function DashboardContent() {
|
||||
}}
|
||||
variant="success"
|
||||
/>
|
||||
|
||||
|
||||
<MetricCard
|
||||
title="Avg. Session Time"
|
||||
value={`${Math.round(metrics.avgSessionLength || 0)}s`}
|
||||
@ -373,7 +404,7 @@ function DashboardContent() {
|
||||
isPositive: (metrics.avgSessionTimeTrend ?? 0) >= 0,
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
<MetricCard
|
||||
title="Avg. Response Time"
|
||||
value={`${metrics.avgResponseTime?.toFixed(1) || 0}s`}
|
||||
@ -384,32 +415,37 @@ function DashboardContent() {
|
||||
}}
|
||||
variant="warning"
|
||||
/>
|
||||
|
||||
|
||||
<MetricCard
|
||||
title="Daily Costs"
|
||||
value={`€${metrics.avgDailyCosts?.toFixed(4) || '0.0000'}`}
|
||||
value={`€${metrics.avgDailyCosts?.toFixed(4) || "0.0000"}`}
|
||||
icon={<Euro className="h-5 w-5" />}
|
||||
description="Average per day"
|
||||
/>
|
||||
|
||||
|
||||
<MetricCard
|
||||
title="Peak Usage"
|
||||
value={metrics.peakUsageTime || 'N/A'}
|
||||
value={metrics.peakUsageTime || "N/A"}
|
||||
icon={<TrendingUp className="h-5 w-5" />}
|
||||
description="Busiest hour"
|
||||
/>
|
||||
|
||||
|
||||
<MetricCard
|
||||
title="Resolution Rate"
|
||||
value={`${metrics.resolvedChatsPercentage?.toFixed(1) || '0.0'}%`}
|
||||
value={`${metrics.resolvedChatsPercentage?.toFixed(1) || "0.0"}%`}
|
||||
icon={<CheckCircle className="h-5 w-5" />}
|
||||
trend={{
|
||||
value: metrics.resolvedChatsPercentage ?? 0,
|
||||
isPositive: (metrics.resolvedChatsPercentage ?? 0) >= 80,
|
||||
}}
|
||||
variant={metrics.resolvedChatsPercentage && metrics.resolvedChatsPercentage >= 80 ? "success" : "warning"}
|
||||
variant={
|
||||
metrics.resolvedChatsPercentage &&
|
||||
metrics.resolvedChatsPercentage >= 80
|
||||
? "success"
|
||||
: "warning"
|
||||
}
|
||||
/>
|
||||
|
||||
|
||||
<MetricCard
|
||||
title="Active Languages"
|
||||
value={Object.keys(metrics.languages || {}).length}
|
||||
@ -426,7 +462,7 @@ function DashboardContent() {
|
||||
className="lg:col-span-2"
|
||||
height={350}
|
||||
/>
|
||||
|
||||
|
||||
<ModernDonutChart
|
||||
data={getSentimentData()}
|
||||
title="Conversation Sentiment"
|
||||
@ -444,7 +480,7 @@ function DashboardContent() {
|
||||
title="Sessions by Category"
|
||||
height={350}
|
||||
/>
|
||||
|
||||
|
||||
<ModernDonutChart
|
||||
data={getLanguagesData()}
|
||||
title="Languages Used"
|
||||
@ -508,8 +544,8 @@ function DashboardContent() {
|
||||
{metrics.totalTokens?.toLocaleString() || 0}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="gap-1">
|
||||
<span className="font-semibold">Total Cost:</span>
|
||||
€{metrics.totalTokensEur?.toFixed(4) || 0}
|
||||
<span className="font-semibold">Total Cost:</span>€
|
||||
{metrics.totalTokensEur?.toFixed(4) || 0}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -46,7 +46,8 @@ const DashboardPage: FC = () => {
|
||||
const navigationCards = [
|
||||
{
|
||||
title: "Analytics Overview",
|
||||
description: "View comprehensive metrics, charts, and insights from your chat sessions",
|
||||
description:
|
||||
"View comprehensive metrics, charts, and insights from your chat sessions",
|
||||
icon: <BarChart3 className="h-6 w-6" />,
|
||||
href: "/dashboard/overview",
|
||||
variant: "primary" as const,
|
||||
@ -54,7 +55,8 @@ const DashboardPage: FC = () => {
|
||||
},
|
||||
{
|
||||
title: "Session Browser",
|
||||
description: "Browse, search, and analyze individual conversation sessions",
|
||||
description:
|
||||
"Browse, search, and analyze individual conversation sessions",
|
||||
icon: <MessageSquare className="h-6 w-6" />,
|
||||
href: "/dashboard/sessions",
|
||||
variant: "success" as const,
|
||||
@ -64,16 +66,22 @@ const DashboardPage: FC = () => {
|
||||
? [
|
||||
{
|
||||
title: "Company Settings",
|
||||
description: "Configure company settings, integrations, and API connections",
|
||||
description:
|
||||
"Configure company settings, integrations, and API connections",
|
||||
icon: <Settings className="h-6 w-6" />,
|
||||
href: "/dashboard/company",
|
||||
variant: "warning" as const,
|
||||
features: ["API configuration", "Integration settings", "Data management"],
|
||||
features: [
|
||||
"API configuration",
|
||||
"Integration settings",
|
||||
"Data management",
|
||||
],
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
title: "User Management",
|
||||
description: "Invite team members and manage user accounts and permissions",
|
||||
description:
|
||||
"Invite team members and manage user accounts and permissions",
|
||||
icon: <Users className="h-6 w-6" />,
|
||||
href: "/dashboard/users",
|
||||
variant: "default" as const,
|
||||
@ -129,7 +137,7 @@ const DashboardPage: FC = () => {
|
||||
Choose a section below to explore your analytics dashboard
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1 text-sm text-muted-foreground">
|
||||
<Shield className="h-4 w-4" />
|
||||
@ -152,7 +160,7 @@ const DashboardPage: FC = () => {
|
||||
>
|
||||
{/* Subtle gradient overlay */}
|
||||
<div className="absolute inset-0 bg-linear-to-br from-white/50 to-transparent dark:from-white/5 pointer-events-none" />
|
||||
|
||||
|
||||
<CardHeader className="relative">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="space-y-3">
|
||||
@ -186,7 +194,10 @@ const DashboardPage: FC = () => {
|
||||
{/* Features List */}
|
||||
<div className="space-y-2">
|
||||
{card.features.map((feature, featureIndex) => (
|
||||
<div key={featureIndex} className="flex items-center gap-2 text-sm">
|
||||
<div
|
||||
key={featureIndex}
|
||||
className="flex items-center gap-2 text-sm"
|
||||
>
|
||||
<div className="h-1.5 w-1.5 rounded-full bg-current opacity-60" />
|
||||
<span className="text-muted-foreground">{feature}</span>
|
||||
</div>
|
||||
@ -232,7 +243,7 @@ const DashboardPage: FC = () => {
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">Data updates</p>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="text-center space-y-2">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Shield className="h-5 w-5 text-green-600" />
|
||||
@ -240,7 +251,7 @@ const DashboardPage: FC = () => {
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">Data protection</p>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="text-center space-y-2">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<BarChart3 className="h-5 w-5 text-blue-600" />
|
||||
|
||||
Reference in New Issue
Block a user