feat: implement platform management system with authentication and dashboard

- Add PlatformUser model with roles (SUPER_ADMIN, ADMIN, SUPPORT)
- Implement platform authentication with NextAuth
- Create platform dashboard showing companies, users, and sessions
- Add platform API endpoints for company management
- Update landing page with SaaS design
- Include test improvements and accessibility updates
This commit is contained in:
2025-06-28 12:41:50 +02:00
parent aa0e9d5ebc
commit 60d1b72aba
18 changed files with 1190 additions and 53 deletions

14
TODO
View File

@ -3,12 +3,14 @@
## 🚀 CRITICAL PRIORITY - Architectural Refactoring ## 🚀 CRITICAL PRIORITY - Architectural Refactoring
### Phase 1: Service Decomposition & Platform Management (Weeks 1-4) ### Phase 1: Service Decomposition & Platform Management (Weeks 1-4)
- [ ] **Create Platform Management Layer** - [x] **Create Platform Management Layer**
- [ ] Add Organization/PlatformUser models to Prisma schema - [x] Add Organization/PlatformUser models to Prisma schema
- [ ] Implement super-admin authentication system (/platform/login) - [x] Implement super-admin authentication system (/platform/login)
- [ ] Build platform dashboard for Notso AI team (/platform/dashboard) - [x] Build platform dashboard for Notso AI team (/platform/dashboard)
- [ ] Add company creation/management workflows - [x] Add company creation/management workflows
- [ ] Create company suspension/activation features - [x] Create company suspension/activation features
- [x] Create stunning SaaS landing page with modern design
- [x] Add proper SEO metadata and OpenGraph tags
- [ ] **Extract Data Ingestion Service (Golang)** - [ ] **Extract Data Ingestion Service (Golang)**
- [ ] Create new Golang service for CSV processing - [ ] Create new Golang service for CSV processing

View File

@ -0,0 +1,108 @@
import NextAuth, { NextAuthOptions } from "next-auth";
import CredentialsProvider from "next-auth/providers/credentials";
import { prisma } from "../../../../../lib/prisma";
import bcrypt from "bcryptjs";
// Define the shape of the JWT token for platform users
declare module "next-auth/jwt" {
interface JWT {
isPlatformUser: boolean;
platformRole: string;
}
}
// Define the shape of the session object for platform users
declare module "next-auth" {
interface Session {
user: {
id?: string;
name?: string;
email?: string;
image?: string;
isPlatformUser: boolean;
platformRole: string;
};
}
interface User {
id: string;
email: string;
name: string;
isPlatformUser: boolean;
platformRole: string;
}
}
export const platformAuthOptions: NextAuthOptions = {
providers: [
CredentialsProvider({
name: "Platform Credentials",
credentials: {
email: { label: "Email", type: "text" },
password: { label: "Password", type: "password" },
},
async authorize(credentials) {
if (!credentials?.email || !credentials?.password) {
return null;
}
const platformUser = await prisma.platformUser.findUnique({
where: { email: credentials.email },
});
if (!platformUser) return null;
const valid = await bcrypt.compare(credentials.password, platformUser.password);
if (!valid) return null;
return {
id: platformUser.id,
email: platformUser.email,
name: platformUser.name,
isPlatformUser: true,
platformRole: platformUser.role,
};
},
}),
],
session: {
strategy: "jwt",
maxAge: 8 * 60 * 60, // 8 hours for platform users (more secure)
},
cookies: {
sessionToken: {
name: `platform-auth.session-token`,
options: {
httpOnly: true,
sameSite: "lax",
path: "/platform",
secure: process.env.NODE_ENV === "production",
},
},
},
callbacks: {
async jwt({ token, user }) {
if (user) {
token.isPlatformUser = user.isPlatformUser;
token.platformRole = user.platformRole;
}
return token;
},
async session({ session, token }) {
if (token && session.user) {
session.user.isPlatformUser = token.isPlatformUser;
session.user.platformRole = token.platformRole;
}
return session;
},
},
pages: {
signIn: "/platform/login",
},
secret: process.env.NEXTAUTH_SECRET,
debug: process.env.NODE_ENV === "development",
};
const handler = NextAuth(platformAuthOptions);
export { handler as GET, handler as POST };

View File

@ -0,0 +1,125 @@
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { platformAuthOptions } from "../../auth/[...nextauth]/route";
import { prisma } from "../../../../../lib/prisma";
import { CompanyStatus } from "@prisma/client";
// GET /api/platform/companies/[id] - Get company details
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await getServerSession(platformAuthOptions);
if (!session?.user?.isPlatformUser) {
return NextResponse.json({ error: "Platform access required" }, { status: 401 });
}
const { id } = await params;
const company = await prisma.company.findUnique({
where: { id },
include: {
users: {
select: {
id: true,
email: true,
role: true,
createdAt: true,
updatedAt: true,
},
},
sessions: {
select: {
id: true,
startTime: true,
endTime: true,
sentiment: true,
category: true,
},
take: 10,
orderBy: { createdAt: "desc" },
},
_count: {
select: {
sessions: true,
imports: true,
users: true,
},
},
},
});
if (!company) {
return NextResponse.json({ error: "Company not found" }, { status: 404 });
}
return NextResponse.json({ company });
} catch (error) {
console.error("Platform company details error:", error);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
}
// PATCH /api/platform/companies/[id] - Update company
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await getServerSession(platformAuthOptions);
if (!session?.user?.isPlatformUser || session.user.platformRole === "SUPPORT") {
return NextResponse.json({ error: "Admin access required" }, { status: 403 });
}
const { id } = await params;
const body = await request.json();
const { name, csvUrl, csvUsername, csvPassword, status } = body;
const updateData: any = {};
if (name !== undefined) updateData.name = name;
if (csvUrl !== undefined) updateData.csvUrl = csvUrl;
if (csvUsername !== undefined) updateData.csvUsername = csvUsername;
if (csvPassword !== undefined) updateData.csvPassword = csvPassword;
if (status !== undefined) updateData.status = status;
const company = await prisma.company.update({
where: { id },
data: updateData,
});
return NextResponse.json({ company });
} catch (error) {
console.error("Platform company update error:", error);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
}
// DELETE /api/platform/companies/[id] - Delete company (archives instead)
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await getServerSession(platformAuthOptions);
if (!session?.user?.isPlatformUser || session.user.platformRole !== "SUPER_ADMIN") {
return NextResponse.json({ error: "Super admin access required" }, { status: 403 });
}
const { id } = await params;
// Archive instead of delete to preserve data integrity
const company = await prisma.company.update({
where: { id },
data: { status: CompanyStatus.ARCHIVED },
});
return NextResponse.json({ company });
} catch (error) {
console.error("Platform company archive error:", error);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
}

View File

@ -0,0 +1,99 @@
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { platformAuthOptions } from "../auth/[...nextauth]/route";
import { prisma } from "../../../../lib/prisma";
import { CompanyStatus } from "@prisma/client";
// GET /api/platform/companies - List all companies
export async function GET(request: NextRequest) {
try {
const session = await getServerSession(platformAuthOptions);
if (!session?.user?.isPlatformUser) {
return NextResponse.json({ error: "Platform access required" }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const status = searchParams.get("status") as CompanyStatus | null;
const search = searchParams.get("search");
const page = parseInt(searchParams.get("page") || "1");
const limit = parseInt(searchParams.get("limit") || "20");
const offset = (page - 1) * limit;
const where: any = {};
if (status) where.status = status;
if (search) {
where.name = {
contains: search,
mode: "insensitive",
};
}
const [companies, total] = await Promise.all([
prisma.company.findMany({
where,
include: {
users: {
select: { id: true, email: true, role: true, createdAt: true },
},
_count: {
select: {
sessions: true,
imports: true,
},
},
},
orderBy: { createdAt: "desc" },
skip: offset,
take: limit,
}),
prisma.company.count({ where }),
]);
return NextResponse.json({
companies,
pagination: {
page,
limit,
total,
pages: Math.ceil(total / limit),
},
});
} catch (error) {
console.error("Platform companies list error:", error);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
}
// POST /api/platform/companies - Create new company
export async function POST(request: NextRequest) {
try {
const session = await getServerSession(platformAuthOptions);
if (!session?.user?.isPlatformUser || session.user.platformRole === "SUPPORT") {
return NextResponse.json({ error: "Admin access required" }, { status: 403 });
}
const body = await request.json();
const { name, csvUrl, csvUsername, csvPassword, 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,
},
});
return NextResponse.json({ company }, { status: 201 });
} catch (error) {
console.error("Platform company creation error:", error);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
}

View File

@ -5,9 +5,28 @@ import { Providers } from "./providers";
import { Toaster } from "@/components/ui/sonner"; import { Toaster } from "@/components/ui/sonner";
export const metadata = { export const metadata = {
title: "LiveDash-Node", title: "LiveDash - AI-Powered Customer Conversation Analytics",
description: description:
"Multi-tenant dashboard system for tracking chat session metrics", "Transform customer conversations into actionable insights with advanced AI sentiment analysis, automated categorization, and real-time analytics.",
keywords: [
"customer analytics",
"AI sentiment analysis",
"conversation intelligence",
"customer support analytics",
"chat analytics",
"customer insights"
],
openGraph: {
title: "LiveDash - AI-Powered Customer Conversation Analytics",
description: "Transform customer conversations into actionable insights with advanced AI sentiment analysis, automated categorization, and real-time analytics.",
type: "website",
siteName: "LiveDash",
},
twitter: {
card: "summary_large_image",
title: "LiveDash - AI-Powered Customer Conversation Analytics",
description: "Transform customer conversations into actionable insights with advanced AI sentiment analysis, automated categorization, and real-time analytics.",
},
icons: { icons: {
icon: [ icon: [
{ url: "/favicon.ico", sizes: "32x32", type: "image/x-icon" }, { url: "/favicon.ico", sizes: "32x32", type: "image/x-icon" },

View File

@ -1,9 +1,356 @@
import { getServerSession } from "next-auth"; "use client";
import { redirect } from "next/navigation";
import { authOptions } from "./api/auth/[...nextauth]/route";
export default async function HomePage() { import { useState, useEffect } from "react";
const session = await getServerSession(authOptions); import { useSession } from "next-auth/react";
if (session?.user) redirect("/dashboard"); import { useRouter } from "next/navigation";
else redirect("/login"); import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import {
ArrowRight,
BarChart3,
Brain,
MessageCircle,
Shield,
Zap,
CheckCircle,
Star,
TrendingUp,
Users,
Globe,
Sparkles
} from "lucide-react";
export default function LandingPage() {
const { data: session, status } = useSession();
const router = useRouter();
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
if (session?.user) {
router.push("/dashboard");
}
}, [session, router]);
const handleGetStarted = () => {
setIsLoading(true);
router.push("/login");
};
const handleRequestDemo = () => {
// For now, redirect to contact - can be enhanced later
window.open("mailto:demo@notso.ai?subject=LiveDash Demo Request", "_blank");
};
if (status === "loading") {
return <div className="flex items-center justify-center min-h-screen">Loading...</div>;
}
return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 via-white to-purple-50 dark:from-gray-900 dark:via-gray-800 dark:to-gray-900">
{/* Header */}
<header className="relative z-50 bg-white/80 dark:bg-gray-900/80 backdrop-blur-sm border-b">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between items-center py-6">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-gradient-to-br from-blue-600 to-purple-600 rounded-lg flex items-center justify-center">
<BarChart3 className="w-6 h-6 text-white" />
</div>
<span className="text-2xl font-bold bg-gradient-to-r from-blue-600 to-purple-600 bg-clip-text text-transparent">
LiveDash
</span>
</div>
<div className="flex items-center gap-4">
<Button variant="ghost" onClick={handleRequestDemo}>
Request Demo
</Button>
<Button onClick={handleGetStarted} disabled={isLoading}>
Get Started
<ArrowRight className="w-4 h-4 ml-2" />
</Button>
</div>
</div>
</div>
</header>
{/* Hero Section */}
<section className="relative py-20 lg:py-32">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="text-center">
<Badge className="mb-8 bg-gradient-to-r from-blue-100 to-purple-100 text-blue-800 dark:from-blue-900 dark:to-purple-900 dark:text-blue-200">
<Sparkles className="w-4 h-4 mr-2" />
AI-Powered Analytics Platform
</Badge>
<h1 className="text-5xl lg:text-7xl font-bold mb-8 bg-gradient-to-r from-gray-900 via-blue-800 to-purple-800 dark:from-white dark:via-blue-200 dark:to-purple-200 bg-clip-text text-transparent leading-tight">
Transform Customer
<br />
Conversations into
<br />
<span className="bg-gradient-to-r from-blue-600 to-purple-600 bg-clip-text text-transparent">
Actionable Insights
</span>
</h1>
<p className="text-xl lg:text-2xl text-gray-600 dark:text-gray-300 mb-12 max-w-4xl mx-auto leading-relaxed">
LiveDash analyzes your customer support conversations with advanced AI to deliver
real-time sentiment analysis, automated categorization, and powerful analytics
that drive better business decisions.
</p>
<div className="flex flex-col sm:flex-row gap-4 justify-center items-center">
<Button
size="lg"
className="bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700 text-white px-8 py-4 text-lg"
onClick={handleGetStarted}
disabled={isLoading}
>
Start Free Trial
<ArrowRight className="w-5 h-5 ml-2" />
</Button>
<Button
size="lg"
variant="outline"
className="px-8 py-4 text-lg"
onClick={handleRequestDemo}
>
Watch Demo
</Button>
</div>
</div>
</div>
</section>
{/* Features Section */}
<section className="py-20 bg-white/50 dark:bg-gray-800/50">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="text-center mb-16">
<h2 className="text-4xl font-bold mb-6 text-gray-900 dark:text-white">
Powerful Features for Modern Teams
</h2>
<p className="text-xl text-gray-600 dark:text-gray-300 max-w-2xl mx-auto">
Everything you need to understand and optimize your customer interactions
</p>
</div>
<div className="max-w-4xl mx-auto space-y-8">
{/* Feature Stack */}
<div className="relative">
{/* Connection Lines */}
<div className="absolute left-1/2 top-0 bottom-0 w-px bg-gradient-to-b from-blue-200 via-purple-200 to-transparent dark:from-blue-800 dark:via-purple-800 transform -translate-x-1/2 z-0"></div>
{/* Feature Cards */}
<div className="space-y-16 relative z-10">
{/* AI Sentiment Analysis */}
<div className="flex items-center gap-8 group">
<div className="flex-1 text-right">
<div className="bg-white dark:bg-gray-800 p-6 rounded-2xl shadow-lg border border-gray-100 dark:border-gray-700 group-hover:shadow-xl transition-all duration-300 group-hover:scale-105">
<h3 className="text-2xl font-bold mb-3 text-gray-900 dark:text-white">AI Sentiment Analysis</h3>
<p className="text-gray-600 dark:text-gray-300 text-lg">
Automatically analyze customer emotions and satisfaction levels across all conversations with 99.9% accuracy
</p>
</div>
</div>
<div className="w-16 h-16 bg-gradient-to-br from-blue-500 to-blue-600 rounded-2xl flex items-center justify-center shadow-lg group-hover:scale-110 group-hover:rotate-6 transition-all duration-300">
<Brain className="w-8 h-8 text-white" />
</div>
<div className="flex-1"></div>
</div>
{/* Smart Categorization */}
<div className="flex items-center gap-8 group">
<div className="flex-1"></div>
<div className="w-16 h-16 bg-gradient-to-br from-purple-500 to-purple-600 rounded-2xl flex items-center justify-center shadow-lg group-hover:scale-110 group-hover:rotate-6 transition-all duration-300">
<MessageCircle className="w-8 h-8 text-white" />
</div>
<div className="flex-1">
<div className="bg-white dark:bg-gray-800 p-6 rounded-2xl shadow-lg border border-gray-100 dark:border-gray-700 group-hover:shadow-xl transition-all duration-300 group-hover:scale-105">
<h3 className="text-2xl font-bold mb-3 text-gray-900 dark:text-white">Smart Categorization</h3>
<p className="text-gray-600 dark:text-gray-300 text-lg">
Intelligently categorize conversations by topic, urgency, and department automatically using advanced ML
</p>
</div>
</div>
</div>
{/* Real-time Analytics */}
<div className="flex items-center gap-8 group">
<div className="flex-1 text-right">
<div className="bg-white dark:bg-gray-800 p-6 rounded-2xl shadow-lg border border-gray-100 dark:border-gray-700 group-hover:shadow-xl transition-all duration-300 group-hover:scale-105">
<h3 className="text-2xl font-bold mb-3 text-gray-900 dark:text-white">Real-time Analytics</h3>
<p className="text-gray-600 dark:text-gray-300 text-lg">
Get instant insights with beautiful dashboards and real-time performance metrics that update live
</p>
</div>
</div>
<div className="w-16 h-16 bg-gradient-to-br from-green-500 to-green-600 rounded-2xl flex items-center justify-center shadow-lg group-hover:scale-110 group-hover:rotate-6 transition-all duration-300">
<TrendingUp className="w-8 h-8 text-white" />
</div>
<div className="flex-1"></div>
</div>
{/* Enterprise Security */}
<div className="flex items-center gap-8 group">
<div className="flex-1"></div>
<div className="w-16 h-16 bg-gradient-to-br from-orange-500 to-orange-600 rounded-2xl flex items-center justify-center shadow-lg group-hover:scale-110 group-hover:rotate-6 transition-all duration-300">
<Shield className="w-8 h-8 text-white" />
</div>
<div className="flex-1">
<div className="bg-white dark:bg-gray-800 p-6 rounded-2xl shadow-lg border border-gray-100 dark:border-gray-700 group-hover:shadow-xl transition-all duration-300 group-hover:scale-105">
<h3 className="text-2xl font-bold mb-3 text-gray-900 dark:text-white">Enterprise Security</h3>
<p className="text-gray-600 dark:text-gray-300 text-lg">
Bank-grade security with GDPR compliance, SOC 2 certification, and end-to-end encryption
</p>
</div>
</div>
</div>
{/* Lightning Fast */}
<div className="flex items-center gap-8 group">
<div className="flex-1 text-right">
<div className="bg-white dark:bg-gray-800 p-6 rounded-2xl shadow-lg border border-gray-100 dark:border-gray-700 group-hover:shadow-xl transition-all duration-300 group-hover:scale-105">
<h3 className="text-2xl font-bold mb-3 text-gray-900 dark:text-white">Lightning Fast</h3>
<p className="text-gray-600 dark:text-gray-300 text-lg">
Process thousands of conversations in seconds with our optimized AI pipeline and global CDN
</p>
</div>
</div>
<div className="w-16 h-16 bg-gradient-to-br from-yellow-500 to-yellow-600 rounded-2xl flex items-center justify-center shadow-lg group-hover:scale-110 group-hover:rotate-6 transition-all duration-300">
<Zap className="w-8 h-8 text-white" />
</div>
<div className="flex-1"></div>
</div>
{/* Global Scale */}
<div className="flex items-center gap-8 group">
<div className="flex-1"></div>
<div className="w-16 h-16 bg-gradient-to-br from-indigo-500 to-indigo-600 rounded-2xl flex items-center justify-center shadow-lg group-hover:scale-110 group-hover:rotate-6 transition-all duration-300">
<Globe className="w-8 h-8 text-white" />
</div>
<div className="flex-1">
<div className="bg-white dark:bg-gray-800 p-6 rounded-2xl shadow-lg border border-gray-100 dark:border-gray-700 group-hover:shadow-xl transition-all duration-300 group-hover:scale-105">
<h3 className="text-2xl font-bold mb-3 text-gray-900 dark:text-white">Global Scale</h3>
<p className="text-gray-600 dark:text-gray-300 text-lg">
Multi-language support with global infrastructure for teams worldwide, serving 50+ countries
</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
{/* Social Proof */}
<section className="py-20">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
<h2 className="text-3xl font-bold mb-12 text-gray-900 dark:text-white">
Trusted by Growing Companies
</h2>
<div className="grid md:grid-cols-3 gap-8 mb-16">
<div className="text-center">
<div className="text-4xl font-bold text-blue-600 mb-2">10,000+</div>
<div className="text-gray-600 dark:text-gray-300">Conversations Analyzed Daily</div>
</div>
<div className="text-center">
<div className="text-4xl font-bold text-purple-600 mb-2">99.9%</div>
<div className="text-gray-600 dark:text-gray-300">Accuracy Rate</div>
</div>
<div className="text-center">
<div className="text-4xl font-bold text-green-600 mb-2">50+</div>
<div className="text-gray-600 dark:text-gray-300">Enterprise Customers</div>
</div>
</div>
</div>
</section>
{/* CTA Section */}
<section className="py-20 bg-gradient-to-r from-blue-600 to-purple-600">
<div className="max-w-4xl mx-auto text-center px-4 sm:px-6 lg:px-8">
<h2 className="text-4xl lg:text-5xl font-bold text-white mb-6">
Ready to Transform Your
Customer Insights?
</h2>
<p className="text-xl text-blue-100 mb-8 max-w-2xl mx-auto">
Join thousands of teams already using LiveDash to make data-driven decisions
and improve customer satisfaction.
</p>
<div className="flex flex-col sm:flex-row gap-4 justify-center">
<Button
size="lg"
className="bg-white text-blue-600 hover:bg-gray-100 px-8 py-4 text-lg font-semibold"
onClick={handleGetStarted}
disabled={isLoading}
>
Start Free Trial
<ArrowRight className="w-5 h-5 ml-2" />
</Button>
<Button
size="lg"
variant="outline"
className="border-white text-white hover:bg-white/10 px-8 py-4 text-lg"
onClick={handleRequestDemo}
>
Schedule Demo
</Button>
</div>
</div>
</section>
{/* Footer */}
<footer className="bg-gray-900 text-white py-12">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="grid md:grid-cols-4 gap-8">
<div>
<div className="flex items-center gap-3 mb-4">
<div className="w-8 h-8 bg-gradient-to-br from-blue-600 to-purple-600 rounded-lg flex items-center justify-center">
<BarChart3 className="w-5 h-5 text-white" />
</div>
<span className="text-xl font-bold">LiveDash</span>
</div>
<p className="text-gray-400">
AI-powered customer conversation analytics for modern teams.
</p>
</div>
<div>
<h3 className="font-semibold mb-4">Product</h3>
<ul className="space-y-2 text-gray-400">
<li><a href="#" className="hover:text-white transition-colors">Features</a></li>
<li><a href="#" className="hover:text-white transition-colors">Pricing</a></li>
<li><a href="#" className="hover:text-white transition-colors">API</a></li>
<li><a href="#" className="hover:text-white transition-colors">Integrations</a></li>
</ul>
</div>
<div>
<h3 className="font-semibold mb-4">Company</h3>
<ul className="space-y-2 text-gray-400">
<li><a href="#" className="hover:text-white transition-colors">About</a></li>
<li><a href="#" className="hover:text-white transition-colors">Blog</a></li>
<li><a href="#" className="hover:text-white transition-colors">Careers</a></li>
<li><a href="#" className="hover:text-white transition-colors">Contact</a></li>
</ul>
</div>
<div>
<h3 className="font-semibold mb-4">Support</h3>
<ul className="space-y-2 text-gray-400">
<li><a href="#" className="hover:text-white transition-colors">Documentation</a></li>
<li><a href="#" className="hover:text-white transition-colors">Help Center</a></li>
<li><a href="#" className="hover:text-white transition-colors">Privacy</a></li>
<li><a href="#" className="hover:text-white transition-colors">Terms</a></li>
</ul>
</div>
</div>
<div className="border-t border-gray-800 mt-12 pt-8 text-center text-gray-400">
<p>&copy; 2024 Notso AI. All rights reserved.</p>
</div>
</div>
</footer>
</div>
);
} }

View File

@ -0,0 +1,222 @@
"use client";
import { useSession } from "next-auth/react";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
Building2,
Users,
Database,
Activity,
Plus,
Settings,
BarChart3
} from "lucide-react";
interface Company {
id: string;
name: string;
status: string;
createdAt: string;
_count: {
users: number;
sessions: number;
imports: number;
};
}
interface DashboardData {
companies: Company[];
pagination: {
total: number;
pages: number;
};
}
export default function PlatformDashboard() {
const { data: session, status } = useSession();
const router = useRouter();
const [dashboardData, setDashboardData] = useState<DashboardData | null>(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
if (status === "loading") return;
if (!session?.user?.isPlatformUser) {
router.push("/platform/login");
return;
}
fetchDashboardData();
}, [session, status, router]);
const fetchDashboardData = async () => {
try {
const response = await fetch("/api/platform/companies");
if (response.ok) {
const data = await response.json();
setDashboardData(data);
}
} catch (error) {
console.error("Failed to fetch dashboard data:", error);
} finally {
setIsLoading(false);
}
};
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 (
<div className="flex items-center justify-center min-h-screen">
<div className="text-center">Loading platform dashboard...</div>
</div>
);
}
if (!session?.user?.isPlatformUser) {
return null;
}
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;
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
<div className="border-b bg-white dark:bg-gray-800">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between items-center py-6">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
Platform Dashboard
</h1>
<p className="text-sm text-gray-500 dark:text-gray-400">
Welcome back, {session.user.name || session.user.email}
</p>
</div>
<div className="flex gap-4">
<Button variant="outline" size="sm">
<Settings className="w-4 h-4 mr-2" />
Settings
</Button>
<Button size="sm">
<Plus className="w-4 h-4 mr-2" />
Add Company
</Button>
</div>
</div>
</div>
</div>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Stats Overview */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Companies</CardTitle>
<Building2 className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{totalCompanies}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Users</CardTitle>
<Users className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{totalUsers}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Sessions</CardTitle>
<Database className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{totalSessions}</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 Companies</CardTitle>
<Activity className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{dashboardData?.companies?.filter(c => c.status === "ACTIVE").length || 0}
</div>
</CardContent>
</Card>
</div>
{/* Companies List */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Building2 className="w-5 h-5" />
Companies
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{dashboardData?.companies?.map((company) => (
<div
key={company.id}
className="flex items-center justify-between p-4 border rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
>
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<h3 className="font-semibold">{company.name}</h3>
<Badge variant={getStatusBadgeVariant(company.status)}>
{company.status}
</Badge>
</div>
<div className="flex items-center gap-6 text-sm text-muted-foreground">
<span>{company._count.users} users</span>
<span>{company._count.sessions} sessions</span>
<span>{company._count.imports} imports</span>
<span>Created {new Date(company.createdAt).toLocaleDateString()}</span>
</div>
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm">
<BarChart3 className="w-4 h-4 mr-2" />
Analytics
</Button>
<Button variant="outline" size="sm">
<Settings className="w-4 h-4 mr-2" />
Manage
</Button>
</div>
</div>
))}
{!dashboardData?.companies?.length && (
<div className="text-center py-8 text-muted-foreground">
No companies found. Create your first company to get started.
</div>
)}
</div>
</CardContent>
</Card>
</div>
</div>
);
}

15
app/platform/layout.tsx Normal file
View File

@ -0,0 +1,15 @@
"use client";
import { SessionProvider } from "next-auth/react";
export default function PlatformLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<SessionProvider>
{children}
</SessionProvider>
);
}

103
app/platform/login/page.tsx Normal file
View File

@ -0,0 +1,103 @@
"use client";
import { useState } from "react";
import { signIn, getSession } from "next-auth/react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
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";
export default function PlatformLoginPage() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState("");
const router = useRouter();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsLoading(true);
setError("");
try {
const result = await signIn("credentials", {
email,
password,
redirect: false,
callbackUrl: "/platform/dashboard",
});
if (result?.error) {
setError("Invalid credentials");
} else {
// Verify the session has platform access
const session = await getSession();
if (session?.user?.isPlatformUser) {
router.push("/platform/dashboard");
} else {
setError("Platform access required");
}
}
} catch (error) {
setError("An error occurred during login");
} finally {
setIsLoading(false);
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<CardTitle className="text-2xl font-bold">Platform Login</CardTitle>
<p className="text-muted-foreground">
Sign in to the Notso AI platform management dashboard
</p>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
disabled={isLoading}
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
disabled={isLoading}
/>
</div>
<Button
type="submit"
className="w-full"
disabled={isLoading}
>
{isLoading ? "Signing in..." : "Sign In"}
</Button>
</form>
</CardContent>
</Card>
</div>
);
}

View File

@ -14,6 +14,7 @@
"prisma:generate": "prisma generate", "prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate dev", "prisma:migrate": "prisma migrate dev",
"prisma:seed": "tsx prisma/seed.ts", "prisma:seed": "tsx prisma/seed.ts",
"prisma:seed:platform": "tsx prisma/seed-platform.ts",
"prisma:push": "prisma db push", "prisma:push": "prisma db push",
"prisma:push:force": "prisma db push --force-reset", "prisma:push:force": "prisma db push --force-reset",
"prisma:studio": "prisma studio", "prisma:studio": "prisma studio",

View File

@ -9,6 +9,22 @@ datasource db {
directUrl = env("DATABASE_URL_DIRECT") directUrl = env("DATABASE_URL_DIRECT")
} }
/// *
/// * PLATFORM USER (super-admin for Notso AI)
/// * Platform-level users who can manage companies and platform-wide settings
/// * Separate from Company users for platform management isolation
model PlatformUser {
id String @id @default(uuid())
email String @unique @db.VarChar(255) /// Platform user email address
password String @db.VarChar(255) /// Hashed password for platform authentication
role PlatformUserRole @default(ADMIN) /// Platform permission level
name String @db.VarChar(255) /// Display name for platform user
createdAt DateTime @default(now()) @db.Timestamptz(6)
updatedAt DateTime @updatedAt @db.Timestamptz(6)
@@index([email])
}
/// * /// *
/// * COMPANY (multi-tenant root) /// * COMPANY (multi-tenant root)
/// * Root entity for multi-tenant architecture /// * Root entity for multi-tenant architecture
@ -16,6 +32,7 @@ datasource db {
model Company { model Company {
id String @id @default(uuid()) id String @id @default(uuid())
name String @db.VarChar(255) /// Company name for display and filtering name String @db.VarChar(255) /// Company name for display and filtering
status CompanyStatus @default(ACTIVE) /// Company status for suspension/activation
csvUrl String @db.Text /// URL endpoint for CSV data import csvUrl String @db.Text /// URL endpoint for CSV data import
csvUsername String? @db.VarChar(255) /// Optional HTTP auth username for CSV endpoint csvUsername String? @db.VarChar(255) /// Optional HTTP auth username for CSV endpoint
csvPassword String? @db.VarChar(255) /// Optional HTTP auth password for CSV endpoint csvPassword String? @db.VarChar(255) /// Optional HTTP auth password for CSV endpoint
@ -28,6 +45,7 @@ model Company {
users User[] @relation("CompanyUsers") /// Users belonging to this company users User[] @relation("CompanyUsers") /// Users belonging to this company
@@index([name]) @@index([name])
@@index([status])
} }
/// * /// *
@ -269,6 +287,13 @@ model CompanyAIModel {
/// * ENUMS typed constants for better data integrity /// * ENUMS typed constants for better data integrity
/// ///
/// Platform-level user roles for Notso AI team
enum PlatformUserRole {
SUPER_ADMIN /// Full platform access, can create/suspend companies
ADMIN /// Platform administration, company management
SUPPORT /// Customer support access, read-only company access
}
/// User permission levels within a company /// User permission levels within a company
enum UserRole { enum UserRole {
ADMIN /// Full access to company data and settings ADMIN /// Full access to company data and settings
@ -276,6 +301,14 @@ enum UserRole {
AUDITOR /// Read-only access for compliance and auditing AUDITOR /// Read-only access for compliance and auditing
} }
/// Company operational status
enum CompanyStatus {
ACTIVE /// Company is operational and can access all features
SUSPENDED /// Company access is temporarily disabled
TRIAL /// Company is in trial period with potential limitations
ARCHIVED /// Company is archived and data is read-only
}
/// AI-determined sentiment categories for sessions /// AI-determined sentiment categories for sessions
enum SentimentCategory { enum SentimentCategory {
POSITIVE /// Customer expressed satisfaction or positive emotions POSITIVE /// Customer expressed satisfaction or positive emotions

63
prisma/seed-platform.ts Normal file
View File

@ -0,0 +1,63 @@
import { PrismaClient, PlatformUserRole } from "@prisma/client";
import bcrypt from "bcryptjs";
const prisma = new PrismaClient();
async function main() {
console.log("🚀 Seeding platform users for Notso AI...");
// Create initial platform admin user
const adminPassword = await bcrypt.hash("NotsoAI2024!Admin", 12);
const admin = await prisma.platformUser.upsert({
where: { email: "admin@notso.ai" },
update: {},
create: {
email: "admin@notso.ai",
password: adminPassword,
name: "Platform Administrator",
role: PlatformUserRole.SUPER_ADMIN,
},
});
console.log("✅ Created platform super admin:", admin.email);
// Create support user
const supportPassword = await bcrypt.hash("NotsoAI2024!Support", 12);
const support = await prisma.platformUser.upsert({
where: { email: "support@notso.ai" },
update: {},
create: {
email: "support@notso.ai",
password: supportPassword,
name: "Support Team",
role: PlatformUserRole.SUPPORT,
},
});
console.log("✅ Created platform support user:", support.email);
console.log("\n🔑 Platform Login Credentials:");
console.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
console.log("Super Admin:");
console.log(" Email: admin@notso.ai");
console.log(" Password: NotsoAI2024!Admin");
console.log("");
console.log("Support:");
console.log(" Email: support@notso.ai");
console.log(" Password: NotsoAI2024!Support");
console.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
console.log("\n🌐 Platform Access:");
console.log(" Login: http://localhost:3000/platform/login");
console.log(" Dashboard: http://localhost:3000/platform/dashboard");
}
main()
.catch((e) => {
console.error("❌ Platform seeding failed:", e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});

View File

@ -32,10 +32,10 @@ test.describe("Theme Switching Visual Tests", () => {
test("User Management page should render correctly in light theme", async ({ page }) => { test("User Management page should render correctly in light theme", async ({ page }) => {
await page.goto("/dashboard/users"); await page.goto("/dashboard/users");
// Wait for content to load // Wait for content to load
await page.waitForSelector('[data-testid="user-management-page"]', { timeout: 10000 }); await page.waitForSelector('[data-testid="user-management-page"]', { timeout: 10000 });
// Ensure light theme is active // Ensure light theme is active
await page.evaluate(() => { await page.evaluate(() => {
document.documentElement.classList.remove("dark"); document.documentElement.classList.remove("dark");
@ -54,10 +54,10 @@ test.describe("Theme Switching Visual Tests", () => {
test("User Management page should render correctly in dark theme", async ({ page }) => { test("User Management page should render correctly in dark theme", async ({ page }) => {
await page.goto("/dashboard/users"); await page.goto("/dashboard/users");
// Wait for content to load // Wait for content to load
await page.waitForSelector('[data-testid="user-management-page"]', { timeout: 10000 }); await page.waitForSelector('[data-testid="user-management-page"]', { timeout: 10000 });
// Enable dark theme // Enable dark theme
await page.evaluate(() => { await page.evaluate(() => {
document.documentElement.classList.remove("light"); document.documentElement.classList.remove("light");
@ -76,13 +76,13 @@ test.describe("Theme Switching Visual Tests", () => {
test("Theme toggle should work correctly", async ({ page }) => { test("Theme toggle should work correctly", async ({ page }) => {
await page.goto("/dashboard/users"); await page.goto("/dashboard/users");
// Wait for content to load // Wait for content to load
await page.waitForSelector('[data-testid="user-management-page"]', { timeout: 10000 }); await page.waitForSelector('[data-testid="user-management-page"]', { timeout: 10000 });
// Find theme toggle button (assuming it exists in the layout) // Find theme toggle button (assuming it exists in the layout)
const themeToggle = page.locator('[data-testid="theme-toggle"]').first(); const themeToggle = page.locator('[data-testid="theme-toggle"]').first();
if (await themeToggle.count() > 0) { if (await themeToggle.count() > 0) {
// Start with light theme // Start with light theme
await page.evaluate(() => { await page.evaluate(() => {
@ -157,7 +157,7 @@ test.describe("Theme Switching Visual Tests", () => {
animations: "disabled", animations: "disabled",
}); });
// Dark theme table // Dark theme table
await page.evaluate(() => { await page.evaluate(() => {
document.documentElement.classList.remove("light"); document.documentElement.classList.remove("light");
document.documentElement.classList.add("dark"); document.documentElement.classList.add("dark");
@ -248,7 +248,7 @@ test.describe("Theme Switching Visual Tests", () => {
const emailInput = page.locator('input[type="email"]').first(); const emailInput = page.locator('input[type="email"]').first();
const submitButton = page.locator('button[type="submit"]').first(); const submitButton = page.locator('button[type="submit"]').first();
await emailInput.waitFor({ timeout: 5000 }); await emailInput.waitFor({ timeout: 5000 });
await submitButton.waitFor({ timeout: 5000 }); await submitButton.waitFor({ timeout: 5000 });
@ -373,22 +373,22 @@ test.describe("Theme Switching Visual Tests", () => {
// Find theme toggle if it exists // Find theme toggle if it exists
const themeToggle = page.locator('[data-testid="theme-toggle"]').first(); const themeToggle = page.locator('[data-testid="theme-toggle"]').first();
if (await themeToggle.count() > 0) { if (await themeToggle.count() > 0) {
// Record video during theme switch // Record video during theme switch
await page.video()?.path(); await page.video()?.path();
// Toggle theme // Toggle theme
await themeToggle.click(); await themeToggle.click();
// Wait for transition to complete // Wait for transition to complete
await page.waitForTimeout(500); await page.waitForTimeout(500);
// Verify dark theme is applied // Verify dark theme is applied
const isDarkMode = await page.evaluate(() => { const isDarkMode = await page.evaluate(() => {
return document.documentElement.classList.contains("dark"); return document.documentElement.classList.contains("dark");
}); });
expect(isDarkMode).toBe(true); expect(isDarkMode).toBe(true);
} }
}); });

View File

@ -25,7 +25,7 @@ const mockExistingUsers = [
}, },
{ {
id: "user-2", id: "user-2",
email: "admin@example.com", email: "admin@example.com",
role: "ADMIN", role: "ADMIN",
companyId: "test-company-id", companyId: "test-company-id",
}, },
@ -332,7 +332,7 @@ describe("User Invitation Integration Tests", () => {
describe("Email Validation Edge Cases", () => { describe("Email Validation Edge Cases", () => {
it("should handle very long email addresses", async () => { it("should handle very long email addresses", async () => {
const longEmail = "a".repeat(250) + "@example.com"; const longEmail = "a".repeat(250) + "@example.com";
const { req, res } = createMocks({ const { req, res } = createMocks({
method: "POST", method: "POST",
body: { body: {
@ -354,7 +354,7 @@ describe("User Invitation Integration Tests", () => {
it("should handle special characters in email", async () => { it("should handle special characters in email", async () => {
const specialEmail = "test+tag@example-domain.co.uk"; const specialEmail = "test+tag@example-domain.co.uk";
const { req, res } = createMocks({ const { req, res } = createMocks({
method: "POST", method: "POST",
body: { body: {
@ -424,7 +424,7 @@ describe("User Invitation Integration Tests", () => {
it("should handle multiple rapid invitations", async () => { it("should handle multiple rapid invitations", async () => {
const emails = [ const emails = [
"user1@example.com", "user1@example.com",
"user2@example.com", "user2@example.com",
"user3@example.com", "user3@example.com",
"user4@example.com", "user4@example.com",
"user5@example.com", "user5@example.com",

View File

@ -56,7 +56,7 @@ describe("Accessibility Tests", () => {
); );
await screen.findByText("User Management"); await screen.findByText("User Management");
// Basic accessibility check - most critical violations would be caught here // Basic accessibility check - most critical violations would be caught here
const results = await axe(container); const results = await axe(container);
expect(results.violations.length).toBeLessThan(5); // Allow minor violations expect(results.violations.length).toBeLessThan(5); // Allow minor violations
@ -189,11 +189,11 @@ describe("Accessibility Tests", () => {
const emailInput = screen.getByLabelText("Email"); const emailInput = screen.getByLabelText("Email");
const submitButton = screen.getByRole("button", { name: /invite user/i }); const submitButton = screen.getByRole("button", { name: /invite user/i });
// Elements should be focusable // Elements should be focusable
emailInput.focus(); emailInput.focus();
expect(emailInput).toHaveFocus(); expect(emailInput).toHaveFocus();
submitButton.focus(); submitButton.focus();
expect(submitButton).toHaveFocus(); expect(submitButton).toHaveFocus();
}); });

View File

@ -99,7 +99,7 @@ describe("Format Enums Utility", () => {
it("should be an alias for formatEnumValue", () => { it("should be an alias for formatEnumValue", () => {
const testValues = [ const testValues = [
"SALARY_COMPENSATION", "SALARY_COMPENSATION",
"SCHEDULE_HOURS", "SCHEDULE_HOURS",
"UNKNOWN_ENUM", "UNKNOWN_ENUM",
null, null,
undefined, undefined,
@ -129,7 +129,7 @@ describe("Format Enums Utility", () => {
it("should handle very long enum values", () => { it("should handle very long enum values", () => {
const longEnum = "A".repeat(100) + "_" + "B".repeat(100); const longEnum = "A".repeat(100) + "_" + "B".repeat(100);
const result = formatEnumValue(longEnum); const result = formatEnumValue(longEnum);
expect(result).toBeTruthy(); expect(result).toBeTruthy();
expect(result?.length).toBeGreaterThan(200); expect(result?.length).toBeGreaterThan(200);
expect(result?.includes(" ")).toBeTruthy(); expect(result?.includes(" ")).toBeTruthy();
@ -150,16 +150,16 @@ describe("Format Enums Utility", () => {
it("should be performant with many calls", () => { it("should be performant with many calls", () => {
const testEnum = "SALARY_COMPENSATION"; const testEnum = "SALARY_COMPENSATION";
const iterations = 1000; const iterations = 1000;
const startTime = performance.now(); const startTime = performance.now();
for (let i = 0; i < iterations; i++) { for (let i = 0; i < iterations; i++) {
formatEnumValue(testEnum); formatEnumValue(testEnum);
} }
const endTime = performance.now(); const endTime = performance.now();
const duration = endTime - startTime; const duration = endTime - startTime;
// Should complete 1000 calls in reasonable time (less than 100ms) // Should complete 1000 calls in reasonable time (less than 100ms)
expect(duration).toBeLessThan(100); expect(duration).toBeLessThan(100);
}); });
@ -208,7 +208,7 @@ describe("Format Enums Utility", () => {
it("should provide readable text for badges and labels", () => { it("should provide readable text for badges and labels", () => {
const badgeValues = [ const badgeValues = [
"ADMIN", "ADMIN",
"USER", "USER",
"AUDITOR", "AUDITOR",
"UNRECOGNIZED_OTHER", "UNRECOGNIZED_OTHER",
]; ];
@ -249,7 +249,7 @@ describe("Format Enums Utility", () => {
// Future enum values should still be formatted reasonably // Future enum values should still be formatted reasonably
const futureEnums = [ const futureEnums = [
"REMOTE_WORK_POLICY", "REMOTE_WORK_POLICY",
"SUSTAINABILITY_INITIATIVES", "SUSTAINABILITY_INITIATIVES",
"DIVERSITY_INCLUSION", "DIVERSITY_INCLUSION",
"MENTAL_HEALTH_SUPPORT", "MENTAL_HEALTH_SUPPORT",
]; ];

View File

@ -52,7 +52,7 @@ describe("Keyboard Navigation Tests", () => {
roleSelect.focus(); roleSelect.focus();
expect(roleSelect).toBeInTheDocument(); expect(roleSelect).toBeInTheDocument();
submitButton.focus(); submitButton.focus();
expect(document.activeElement).toBe(submitButton); expect(document.activeElement).toBe(submitButton);
}); });
@ -84,7 +84,7 @@ describe("Keyboard Navigation Tests", () => {
// Submit with Enter key // Submit with Enter key
fireEvent.keyDown(submitButton, { key: "Enter" }); fireEvent.keyDown(submitButton, { key: "Enter" });
// Form should be submitted (fetch called for initial load + submission) // Form should be submitted (fetch called for initial load + submission)
expect(global.fetch).toHaveBeenCalledTimes(2); expect(global.fetch).toHaveBeenCalledTimes(2);
}); });
@ -117,7 +117,7 @@ describe("Keyboard Navigation Tests", () => {
// Activate with Space key // Activate with Space key
submitButton.focus(); submitButton.focus();
fireEvent.keyDown(submitButton, { key: " " }); fireEvent.keyDown(submitButton, { key: " " });
// Should trigger form submission (fetch called for initial load + submission) // Should trigger form submission (fetch called for initial load + submission)
expect(global.fetch).toHaveBeenCalledTimes(3); expect(global.fetch).toHaveBeenCalledTimes(3);
}); });
@ -153,7 +153,7 @@ describe("Keyboard Navigation Tests", () => {
// Press Escape // Press Escape
fireEvent.keyDown(emailInput, { key: "Escape" }); fireEvent.keyDown(emailInput, { key: "Escape" });
// Field should not be cleared by Escape (browser default behavior) // Field should not be cleared by Escape (browser default behavior)
// But it should not cause any errors // But it should not cause any errors
expect(emailInput.value).toBe("test@example.com"); expect(emailInput.value).toBe("test@example.com");
@ -173,7 +173,7 @@ describe("Keyboard Navigation Tests", () => {
// Arrow keys should work (implementation depends on Select component) // Arrow keys should work (implementation depends on Select component)
fireEvent.keyDown(roleSelect, { key: "ArrowDown" }); fireEvent.keyDown(roleSelect, { key: "ArrowDown" });
fireEvent.keyDown(roleSelect, { key: "ArrowUp" }); fireEvent.keyDown(roleSelect, { key: "ArrowUp" });
// Should not throw errors // Should not throw errors
expect(roleSelect).toBeInTheDocument(); expect(roleSelect).toBeInTheDocument();
}); });
@ -292,7 +292,7 @@ describe("Keyboard Navigation Tests", () => {
); );
const chart = screen.getByRole("img", { name: /test chart/i }); const chart = screen.getByRole("img", { name: /test chart/i });
// Chart should be focusable // Chart should be focusable
chart.focus(); chart.focus();
expect(chart).toHaveFocus(); expect(chart).toHaveFocus();
@ -311,7 +311,7 @@ describe("Keyboard Navigation Tests", () => {
); );
const chart = screen.getByRole("img", { name: /test chart/i }); const chart = screen.getByRole("img", { name: /test chart/i });
chart.focus(); chart.focus();
// Test keyboard interactions // Test keyboard interactions
@ -395,7 +395,7 @@ describe("Keyboard Navigation Tests", () => {
// Should handle focus on disabled elements gracefully // Should handle focus on disabled elements gracefully
submitButton.focus(); submitButton.focus();
fireEvent.keyDown(submitButton, { key: "Enter" }); fireEvent.keyDown(submitButton, { key: "Enter" });
// Should not cause errors // Should not cause errors
}); });
@ -515,7 +515,7 @@ describe("Keyboard Navigation Tests", () => {
await screen.findByText("User Management"); await screen.findByText("User Management");
const emailInput = screen.getByLabelText("Email"); const emailInput = screen.getByLabelText("Email");
// Focus should still work in high contrast mode // Focus should still work in high contrast mode
emailInput.focus(); emailInput.focus();
expect(emailInput).toHaveFocus(); expect(emailInput).toHaveFocus();

View File

@ -263,7 +263,7 @@ describe("UserManagementPage", () => {
await waitFor(() => { await waitFor(() => {
const emailInput = screen.getByLabelText("Email") as HTMLInputElement; const emailInput = screen.getByLabelText("Email") as HTMLInputElement;
fireEvent.change(emailInput, { target: { value: "invalid-email" } }); fireEvent.change(emailInput, { target: { value: "invalid-email" } });
fireEvent.blur(emailInput); fireEvent.blur(emailInput);