From 36ed8259b1a7bbf269a1681bd4e1f4876dd39453 Mon Sep 17 00:00:00 2001 From: Kaj Kowalski Date: Sat, 28 Jun 2025 18:19:25 +0200 Subject: [PATCH] feat: enhance platform dashboard UX and add security controls - Move Add Company button to Companies card header for better context - Add smart Save Changes button that only appears when data is modified - Implement navigation protection with unsaved changes warnings - Add company status checks to prevent suspended companies from processing data - Fix platform dashboard showing incorrect user counts - Add dark mode toggle to platform interface - Add copy-to-clipboard for generated credentials - Fix cookie conflicts between regular and platform auth - Add invitedBy and invitedAt tracking fields to User model - Improve overall platform management workflow and security --- app/api/admin/refresh-sessions/route.ts | 11 + .../platform/companies/[id]/users/route.ts | 2 +- app/api/platform/companies/route.ts | 70 +++- app/platform/companies/[id]/page.tsx | 193 +++++++++-- app/platform/dashboard/page.tsx | 322 +++++++++++++----- app/platform/layout.tsx | 16 +- app/platform/login/page.tsx | 6 +- app/platform/page.tsx | 21 ++ lib/importProcessor.ts | 3 + lib/processingStatusManager.ts | 5 + lib/scheduler.ts | 4 +- prisma/schema.prisma | 8 + 12 files changed, 524 insertions(+), 137 deletions(-) create mode 100644 app/platform/page.tsx diff --git a/app/api/admin/refresh-sessions/route.ts b/app/api/admin/refresh-sessions/route.ts index e2a11d0..c5ae9a5 100644 --- a/app/api/admin/refresh-sessions/route.ts +++ b/app/api/admin/refresh-sessions/route.ts @@ -44,6 +44,17 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: "Company not found" }, { status: 404 }); } + // Check if company is active and can process data + if (company.status !== "ACTIVE") { + return NextResponse.json( + { + error: `Data processing is disabled for ${company.status.toLowerCase()} companies`, + companyStatus: company.status + }, + { status: 403 } + ); + } + const rawSessionData = await fetchAndParseCsv( company.csvUrl, company.csvUsername as string | undefined, diff --git a/app/api/platform/companies/[id]/users/route.ts b/app/api/platform/companies/[id]/users/route.ts index e14b577..e5fd17c 100644 --- a/app/api/platform/companies/[id]/users/route.ts +++ b/app/api/platform/companies/[id]/users/route.ts @@ -66,7 +66,7 @@ export async function POST( data: { name, email, - hashedPassword, + password: hashedPassword, role, companyId, invitedBy: session.user.email, diff --git a/app/api/platform/companies/route.ts b/app/api/platform/companies/route.ts index 01f9a11..bd5319b 100644 --- a/app/api/platform/companies/route.ts +++ b/app/api/platform/companies/route.ts @@ -40,6 +40,7 @@ export async function GET(request: NextRequest) { select: { sessions: true, imports: true, + users: true, }, }, }, @@ -75,23 +76,72 @@ export async function POST(request: NextRequest) { } const body = await request.json(); - const { name, csvUrl, csvUsername, csvPassword, status = "TRIAL" } = body; + const { + name, + csvUrl, + csvUsername, + csvPassword, + adminEmail, + adminName, + adminPassword, + maxUsers = 10, + status = "TRIAL" + } = body; if (!name || !csvUrl) { return NextResponse.json({ error: "Name and CSV URL required" }, { status: 400 }); } - const company = await prisma.company.create({ - data: { - name, - csvUrl, - csvUsername: csvUsername || null, - csvPassword: csvPassword || null, - status, - }, + if (!adminEmail || !adminName) { + return NextResponse.json({ error: "Admin email and name required" }, { status: 400 }); + } + + // Generate password if not provided + const finalAdminPassword = adminPassword || `Temp${Math.random().toString(36).slice(2, 8)}!`; + + // Hash the admin password + const bcrypt = await import("bcryptjs"); + const hashedPassword = await bcrypt.hash(finalAdminPassword, 12); + + // Create company and admin user in a transaction + const result = await prisma.$transaction(async (tx) => { + // Create the company + const company = await tx.company.create({ + data: { + name, + csvUrl, + csvUsername: csvUsername || null, + csvPassword: csvPassword || null, + maxUsers, + status, + }, + }); + + // Create the admin user + const adminUser = await tx.user.create({ + data: { + email: adminEmail, + password: hashedPassword, + name: adminName, + role: "ADMIN", + companyId: company.id, + invitedBy: session.user.email || "platform", + invitedAt: new Date(), + }, + }); + + return { company, adminUser, generatedPassword: adminPassword ? null : finalAdminPassword }; }); - return NextResponse.json({ company }, { status: 201 }); + return NextResponse.json({ + company: result.company, + adminUser: { + email: result.adminUser.email, + name: result.adminUser.name, + role: result.adminUser.role, + }, + generatedPassword: result.generatedPassword, + }, { status: 201 }); } catch (error) { console.error("Platform company creation error:", error); return NextResponse.json({ error: "Internal server error" }, { status: 500 }); diff --git a/app/platform/companies/[id]/page.tsx b/app/platform/companies/[id]/page.tsx index bd7c4d7..4b77a7f 100644 --- a/app/platform/companies/[id]/page.tsx +++ b/app/platform/companies/[id]/page.tsx @@ -1,7 +1,7 @@ "use client"; import { useSession } from "next-auth/react"; -import { useEffect, useState } from "react"; +import { useEffect, useState, useCallback } from "react"; import { useRouter, useParams } from "next/navigation"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; @@ -73,8 +73,55 @@ export default function CompanyManagement() { const [isLoading, setIsLoading] = useState(true); const [isSaving, setIsSaving] = useState(false); const [editData, setEditData] = useState>({}); + const [originalData, setOriginalData] = useState>({}); const [showInviteUser, setShowInviteUser] = useState(false); const [inviteData, setInviteData] = useState({ name: "", email: "", role: "USER" }); + const [showUnsavedChangesDialog, setShowUnsavedChangesDialog] = useState(false); + const [pendingNavigation, setPendingNavigation] = useState(null); + + // Function to check if data has been modified + const hasUnsavedChanges = useCallback(() => { + // Normalize data for comparison (handle null/undefined/empty string equivalence) + const normalizeValue = (value: any) => { + if (value === null || value === undefined || value === "") { + return ""; + } + return value; + }; + + const normalizedEditData = { + name: normalizeValue(editData.name), + email: normalizeValue(editData.email), + status: normalizeValue(editData.status), + maxUsers: editData.maxUsers || 0, + }; + + const normalizedOriginalData = { + name: normalizeValue(originalData.name), + email: normalizeValue(originalData.email), + status: normalizeValue(originalData.status), + maxUsers: originalData.maxUsers || 0, + }; + + return JSON.stringify(normalizedEditData) !== JSON.stringify(normalizedOriginalData); + }, [editData, originalData]); + + // Handle navigation protection - must be at top level + 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); + } else { + router.push(url); + } + }, [router, params.id, hasUnsavedChanges]); useEffect(() => { if (status === "loading") return; @@ -93,12 +140,14 @@ export default function CompanyManagement() { if (response.ok) { const data = await response.json(); setCompany(data); - setEditData({ + const companyData = { name: data.name, email: data.email, status: data.status, maxUsers: data.maxUsers, - }); + }; + setEditData(companyData); + setOriginalData(companyData); } else { toast({ title: "Error", @@ -130,6 +179,13 @@ export default function CompanyManagement() { if (response.ok) { const updatedCompany = await response.json(); setCompany(updatedCompany); + const companyData = { + name: updatedCompany.name, + email: updatedCompany.email, + status: updatedCompany.status, + maxUsers: updatedCompany.maxUsers, + }; + setOriginalData(companyData); toast({ title: "Success", description: "Company updated successfully", @@ -177,6 +233,50 @@ export default function CompanyManagement() { } }; + const confirmNavigation = () => { + if (pendingNavigation) { + router.push(pendingNavigation); + setPendingNavigation(null); + } + setShowUnsavedChangesDialog(false); + }; + + const cancelNavigation = () => { + setPendingNavigation(null); + setShowUnsavedChangesDialog(false); + }; + + // Protect against browser back/forward and other navigation + useEffect(() => { + const handleBeforeUnload = (e: BeforeUnloadEvent) => { + if (hasUnsavedChanges()) { + e.preventDefault(); + e.returnValue = ''; + } + }; + + const handlePopState = (e: PopStateEvent) => { + if (hasUnsavedChanges()) { + const confirmLeave = window.confirm( + '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(); + } + } + }; + + window.addEventListener('beforeunload', handleBeforeUnload); + window.addEventListener('popstate', handlePopState); + + return () => { + window.removeEventListener('beforeunload', handleBeforeUnload); + window.removeEventListener('popstate', handlePopState); + }; + }, [hasUnsavedChanges]); + const handleInviteUser = async () => { try { const response = await fetch(`/api/platform/companies/${params.id}/users`, { @@ -205,6 +305,16 @@ export default function CompanyManagement() { } }; + const getStatusBadgeVariant = (status: string) => { + switch (status) { + case "ACTIVE": return "default"; + case "TRIAL": return "secondary"; + case "SUSPENDED": return "destructive"; + case "ARCHIVED": return "outline"; + default: return "default"; + } + }; + if (status === "loading" || isLoading) { return (
@@ -217,16 +327,6 @@ export default function CompanyManagement() { return null; } - const getStatusBadgeVariant = (status: string) => { - switch (status) { - case "ACTIVE": return "default"; - case "TRIAL": return "secondary"; - case "SUSPENDED": return "destructive"; - case "ARCHIVED": return "outline"; - default: return "default"; - } - }; - const canEdit = session.user.platformRole === "SUPER_ADMIN"; return ( @@ -238,7 +338,7 @@ export default function CompanyManagement() {
{canEdit && ( - <> - - - + )}
@@ -396,6 +486,25 @@ export default function CompanyManagement() { + {canEdit && hasUnsavedChanges() && ( +
+ + +
+ )} @@ -587,6 +696,26 @@ export default function CompanyManagement() { )} + + {/* Unsaved Changes Dialog */} + + + + Unsaved Changes + + You have unsaved changes that will be lost if you leave this page. Are you sure you want to continue? + + + + + Stay on Page + + + Leave Without Saving + + + + ); } \ No newline at end of file diff --git a/app/platform/dashboard/page.tsx b/app/platform/dashboard/page.tsx index 9519a64..f3d0305 100644 --- a/app/platform/dashboard/page.tsx +++ b/app/platform/dashboard/page.tsx @@ -23,9 +23,12 @@ import { Activity, Plus, Settings, - BarChart3 + BarChart3, + Search } from "lucide-react"; +import { ThemeToggle } from "@/components/ui/theme-toggle"; import { useToast } from "@/hooks/use-toast"; +import { Copy, Check } from "lucide-react"; interface Company { id: string; @@ -86,8 +89,14 @@ export default function PlatformDashboard() { const [isLoading, setIsLoading] = useState(true); const [showAddCompany, setShowAddCompany] = useState(false); const [isCreating, setIsCreating] = useState(false); + const [copiedEmail, setCopiedEmail] = useState(false); + const [copiedPassword, setCopiedPassword] = useState(false); + const [searchTerm, setSearchTerm] = useState(""); const [newCompanyData, setNewCompanyData] = useState({ name: "", + csvUrl: "", + csvUsername: "", + csvPassword: "", adminEmail: "", adminName: "", adminPassword: "", @@ -105,6 +114,29 @@ export default function PlatformDashboard() { fetchDashboardData(); }, [session, status, router]); + const copyToClipboard = async (text: string, type: 'email' | 'password') => { + try { + await navigator.clipboard.writeText(text); + if (type === 'email') { + setCopiedEmail(true); + setTimeout(() => setCopiedEmail(false), 2000); + } else { + setCopiedPassword(true); + setTimeout(() => setCopiedPassword(false), 2000); + } + } catch (err) { + console.error('Failed to copy: ', err); + } + }; + + const getFilteredCompanies = () => { + if (!dashboardData?.companies) return []; + + return dashboardData.companies.filter(company => + company.name.toLowerCase().includes(searchTerm.toLowerCase()) + ); + }; + const fetchDashboardData = async () => { try { const response = await fetch("/api/platform/companies"); @@ -120,7 +152,7 @@ export default function PlatformDashboard() { }; const handleCreateCompany = async () => { - if (!newCompanyData.name || !newCompanyData.adminEmail || !newCompanyData.adminName) { + if (!newCompanyData.name || !newCompanyData.csvUrl || !newCompanyData.adminEmail || !newCompanyData.adminName) { toast({ title: "Error", description: "Please fill in all required fields", @@ -140,18 +172,68 @@ export default function PlatformDashboard() { if (response.ok) { const result = await response.json(); setShowAddCompany(false); + + const companyName = newCompanyData.name; setNewCompanyData({ name: "", + csvUrl: "", + csvUsername: "", + csvPassword: "", adminEmail: "", adminName: "", adminPassword: "", maxUsers: 10, }); + fetchDashboardData(); // Refresh the list - toast({ - title: "Success", - description: `Company "${newCompanyData.name}" created successfully`, - }); + + // Show success message with copyable credentials + if (result.generatedPassword) { + toast({ + title: "Company Created Successfully!", + description: ( +
+

Company "{companyName}" has been created.

+
+
+
+

Admin Email:

+

{result.adminUser.email}

+
+ +
+
+
+

Admin Password:

+

{result.generatedPassword}

+
+ +
+
+
+ ), + duration: 15000, // Longer duration for credentials + }); + } else { + toast({ + title: "Success", + description: `Company "${companyName}" created successfully`, + }); + } } else { const error = await response.json(); throw new Error(error.error || "Failed to create company"); @@ -189,6 +271,7 @@ export default function PlatformDashboard() { return null; } + const filteredCompanies = getFilteredCompanies(); const totalCompanies = dashboardData?.pagination?.total || 0; const totalUsers = dashboardData?.companies?.reduce((sum, company) => sum + company._count.users, 0) || 0; const totalSessions = dashboardData?.companies?.reduce((sum, company) => sum + company._count.sessions, 0) || 0; @@ -206,86 +289,23 @@ export default function PlatformDashboard() { Welcome back, {session.user.name || session.user.email}

-
+
+ + + {/* Search Filter */} +
+ + setSearchTerm(e.target.value)} + className="pl-10 w-64" + /> +
- - - - - - - Add New Company - - Create a new company and invite the first administrator. - - -
-
- - setNewCompanyData(prev => ({ ...prev, name: e.target.value }))} - placeholder="Acme Corporation" - /> -
-
- - setNewCompanyData(prev => ({ ...prev, adminName: e.target.value }))} - placeholder="John Doe" - /> -
-
- - setNewCompanyData(prev => ({ ...prev, adminEmail: e.target.value }))} - placeholder="admin@acme.com" - /> -
-
- - setNewCompanyData(prev => ({ ...prev, adminPassword: e.target.value }))} - placeholder="Leave empty to auto-generate" - /> -
-
- - setNewCompanyData(prev => ({ ...prev, maxUsers: parseInt(e.target.value) || 10 }))} - min="1" - max="1000" - /> -
-
- - - - -
-
@@ -340,14 +360,131 @@ export default function PlatformDashboard() { {/* Companies List */} - - - Companies + +
+ + Companies + {searchTerm && ( + + {filteredCompanies.length} of {totalCompanies} shown + + )} +
+
+ {searchTerm && ( + + Search: "{searchTerm}" + + )} + + + + + + + Add New Company + + Create a new company and invite the first administrator. + + +
+
+ + setNewCompanyData(prev => ({ ...prev, name: e.target.value }))} + placeholder="Acme Corporation" + /> +
+
+ + setNewCompanyData(prev => ({ ...prev, csvUrl: e.target.value }))} + placeholder="https://api.company.com/sessions.csv" + /> +
+
+ + setNewCompanyData(prev => ({ ...prev, csvUsername: e.target.value }))} + placeholder="Optional HTTP auth username" + /> +
+
+ + setNewCompanyData(prev => ({ ...prev, csvPassword: e.target.value }))} + placeholder="Optional HTTP auth password" + /> +
+
+ + setNewCompanyData(prev => ({ ...prev, adminName: e.target.value }))} + placeholder="John Doe" + /> +
+
+ + setNewCompanyData(prev => ({ ...prev, adminEmail: e.target.value }))} + placeholder="admin@acme.com" + /> +
+
+ + setNewCompanyData(prev => ({ ...prev, adminPassword: e.target.value }))} + placeholder="Leave empty to auto-generate" + /> +
+
+ + setNewCompanyData(prev => ({ ...prev, maxUsers: parseInt(e.target.value) || 10 }))} + min="1" + max="1000" + /> +
+
+ + + + +
+
+
- {dashboardData?.companies?.map((company) => ( + {filteredCompanies.map((company) => (
))} - {!dashboardData?.companies?.length && ( + {!filteredCompanies.length && (
- No companies found. Create your first company to get started. + {searchTerm ? ( +
+

No companies match "{searchTerm}".

+ +
+ ) : ( + "No companies found. Create your first company to get started." + )}
)}
diff --git a/app/platform/layout.tsx b/app/platform/layout.tsx index a7f9963..6daa476 100644 --- a/app/platform/layout.tsx +++ b/app/platform/layout.tsx @@ -2,6 +2,7 @@ import { SessionProvider } from "next-auth/react"; import { Toaster } from "@/components/ui/toaster"; +import { ThemeProvider } from "@/components/theme-provider"; export default function PlatformLayout({ children, @@ -9,9 +10,16 @@ export default function PlatformLayout({ children: React.ReactNode; }) { return ( - - {children} - - + + + {children} + + + ); } \ No newline at end of file diff --git a/app/platform/login/page.tsx b/app/platform/login/page.tsx index c974824..7fbad59 100644 --- a/app/platform/login/page.tsx +++ b/app/platform/login/page.tsx @@ -8,6 +8,7 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Alert, AlertDescription } from "@/components/ui/alert"; +import { ThemeToggle } from "@/components/ui/theme-toggle"; export default function PlatformLoginPage() { const [email, setEmail] = useState(""); @@ -43,7 +44,10 @@ export default function PlatformLoginPage() { }; return ( -
+
+
+ +
Platform Login diff --git a/app/platform/page.tsx b/app/platform/page.tsx new file mode 100644 index 0000000..cef9a94 --- /dev/null +++ b/app/platform/page.tsx @@ -0,0 +1,21 @@ +"use client"; + +import { useEffect } from "react"; +import { useRouter } from "next/navigation"; + +export default function PlatformIndexPage() { + const router = useRouter(); + + useEffect(() => { + // Redirect to platform dashboard + router.replace("/platform/dashboard"); + }, [router]); + + return ( +
+
+

Redirecting to platform dashboard...

+
+
+ ); +} \ No newline at end of file diff --git a/lib/importProcessor.ts b/lib/importProcessor.ts index 8b4792e..60442d1 100644 --- a/lib/importProcessor.ts +++ b/lib/importProcessor.ts @@ -366,6 +366,9 @@ export async function processQueuedImports( const unprocessedImports = await prisma.sessionImport.findMany({ where: { session: null, // No session created yet + company: { + status: "ACTIVE" // Only process imports from active companies + } }, take: batchSize, orderBy: { diff --git a/lib/processingStatusManager.ts b/lib/processingStatusManager.ts index 84be140..9ef201c 100644 --- a/lib/processingStatusManager.ts +++ b/lib/processingStatusManager.ts @@ -172,6 +172,11 @@ export class ProcessingStatusManager { where: { stage, status: ProcessingStatus.PENDING, + session: { + company: { + status: "ACTIVE" // Only process sessions from active companies + } + } }, include: { session: { diff --git a/lib/scheduler.ts b/lib/scheduler.ts index c2fe92c..0353f9d 100644 --- a/lib/scheduler.ts +++ b/lib/scheduler.ts @@ -17,7 +17,9 @@ export function startCsvImportScheduler() { ); cron.schedule(config.csvImport.interval, async () => { - const companies = await prisma.company.findMany(); + const companies = await prisma.company.findMany({ + where: { status: "ACTIVE" } // Only process active companies + }); for (const company of companies) { try { const rawSessionData = await fetchAndParseCsv( diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 4ad1bf6..d10366f 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -49,6 +49,8 @@ model Company { dashboardOpts Json? createdAt DateTime @default(now()) @db.Timestamptz(6) updatedAt DateTime @updatedAt @db.Timestamptz(6) + /// Maximum number of users allowed for this company + maxUsers Int @default(10) companyAiModels CompanyAIModel[] sessions Session[] imports SessionImport[] @@ -78,6 +80,12 @@ model User { resetTokenExpiry DateTime? @db.Timestamptz(6) createdAt DateTime @default(now()) @db.Timestamptz(6) updatedAt DateTime @updatedAt @db.Timestamptz(6) + /// Display name for the user + name String? @db.VarChar(255) + /// When this user was invited + invitedAt DateTime? @db.Timestamptz(6) + /// Email of the user who invited this user (for audit trail) + invitedBy String? @db.VarChar(255) company Company @relation("CompanyUsers", fields: [companyId], references: [id], onDelete: Cascade) @@index([companyId])