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"
+ />
+
-
@@ -340,14 +360,131 @@ export default function PlatformDashboard() {
{/* Companies List */}
-
-
- Companies
+
+
+
+ Companies
+ {searchTerm && (
+
+ {filteredCompanies.length} of {totalCompanies} shown
+
+ )}
+
+
+ {searchTerm && (
+
+ Search: "{searchTerm}"
+
+ )}
+
+
- {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])