mirror of
https://github.com/kjanat/livedash-node.git
synced 2026-01-16 11:32:13 +01:00
feat: complete platform management features and enhance SEO
- Add comprehensive company management interface with editing, suspension - Implement user invitation system within companies - Add Add Company modal with form validation - Create platform auth configuration in separate lib file - Add comprehensive SEO metadata with OpenGraph and structured data - Fix auth imports and route exports for Next.js 15 compatibility - Add toast notifications with RadixUI components - Update TODO status to reflect 100% completion of platform features
This commit is contained in:
@ -1,6 +1,6 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "../../auth/[...nextauth]/route";
|
||||
import { authOptions } from "../../../../lib/auth";
|
||||
import { prisma } from "../../../../lib/prisma";
|
||||
import { processUnprocessedSessions } from "../../../../lib/processingScheduler";
|
||||
import { ProcessingStatusManager } from "../../../../lib/processingStatusManager";
|
||||
|
||||
@ -1,105 +1,5 @@
|
||||
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
|
||||
declare module "next-auth/jwt" {
|
||||
interface JWT {
|
||||
companyId: string;
|
||||
role: string;
|
||||
}
|
||||
}
|
||||
|
||||
// Define the shape of the session object
|
||||
declare module "next-auth" {
|
||||
interface Session {
|
||||
user: {
|
||||
id?: string;
|
||||
name?: string;
|
||||
email?: string;
|
||||
image?: string;
|
||||
companyId: string;
|
||||
role: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
companyId: string;
|
||||
role: string;
|
||||
}
|
||||
}
|
||||
|
||||
export const authOptions: NextAuthOptions = {
|
||||
providers: [
|
||||
CredentialsProvider({
|
||||
name: "Credentials",
|
||||
credentials: {
|
||||
email: { label: "Email", type: "text" },
|
||||
password: { label: "Password", type: "password" },
|
||||
},
|
||||
async authorize(credentials) {
|
||||
if (!credentials?.email || !credentials?.password) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email: credentials.email },
|
||||
});
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
const valid = await bcrypt.compare(credentials.password, user.password);
|
||||
if (!valid) return null;
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
companyId: user.companyId,
|
||||
role: user.role,
|
||||
};
|
||||
},
|
||||
}),
|
||||
],
|
||||
session: {
|
||||
strategy: "jwt",
|
||||
maxAge: 30 * 24 * 60 * 60, // 30 days
|
||||
},
|
||||
cookies: {
|
||||
sessionToken: {
|
||||
name: `next-auth.session-token`,
|
||||
options: {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
},
|
||||
},
|
||||
},
|
||||
callbacks: {
|
||||
async jwt({ token, user }) {
|
||||
if (user) {
|
||||
token.companyId = user.companyId;
|
||||
token.role = user.role;
|
||||
}
|
||||
return token;
|
||||
},
|
||||
async session({ session, token }) {
|
||||
if (token && session.user) {
|
||||
session.user.companyId = token.companyId;
|
||||
session.user.role = token.role;
|
||||
}
|
||||
return session;
|
||||
},
|
||||
},
|
||||
pages: {
|
||||
signIn: "/login",
|
||||
},
|
||||
secret: process.env.NEXTAUTH_SECRET,
|
||||
debug: process.env.NODE_ENV === "development",
|
||||
};
|
||||
import NextAuth from "next-auth";
|
||||
import { authOptions } from "../../../../lib/auth";
|
||||
|
||||
const handler = NextAuth(authOptions);
|
||||
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { prisma } from "../../../../lib/prisma";
|
||||
import { authOptions } from "../../auth/[...nextauth]/route";
|
||||
import { authOptions } from "../../../../lib/auth";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { prisma } from "../../../../lib/prisma";
|
||||
import { sessionMetrics } from "../../../../lib/metrics";
|
||||
import { authOptions } from "../../auth/[...nextauth]/route";
|
||||
import { authOptions } from "../../../../lib/auth";
|
||||
import { ChatSession } from "../../../../lib/types";
|
||||
|
||||
interface SessionUser {
|
||||
@ -83,10 +83,7 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
// Pass company config to metrics
|
||||
const companyConfigForMetrics = {
|
||||
sentimentAlert:
|
||||
user.company.sentimentAlert === null
|
||||
? undefined
|
||||
: user.company.sentimentAlert,
|
||||
// Add company-specific configuration here in the future
|
||||
};
|
||||
|
||||
const metrics = sessionMetrics(chatSessions, companyConfigForMetrics);
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getServerSession } from "next-auth/next";
|
||||
import { authOptions } from "../../auth/[...nextauth]/route";
|
||||
import { authOptions } from "../../../../lib/auth";
|
||||
import { prisma } from "../../../../lib/prisma";
|
||||
import { SessionFilterOptions } from "../../../../lib/types";
|
||||
|
||||
|
||||
@ -4,9 +4,9 @@ import { ChatSession } from "../../../../../lib/types";
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = params;
|
||||
const { id } = await params;
|
||||
|
||||
if (!id) {
|
||||
return NextResponse.json(
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getServerSession } from "next-auth/next";
|
||||
import { authOptions } from "../../auth/[...nextauth]/route";
|
||||
import { authOptions } from "../../../../lib/auth";
|
||||
import { prisma } from "../../../../lib/prisma";
|
||||
import {
|
||||
ChatSession,
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { prisma } from "../../../../lib/prisma";
|
||||
import { authOptions } from "../../auth/[...nextauth]/route";
|
||||
import { authOptions } from "../../../../lib/auth";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const session = await getServerSession(authOptions);
|
||||
@ -26,9 +26,7 @@ export async function POST(request: NextRequest) {
|
||||
csvUrl,
|
||||
csvUsername,
|
||||
...(csvPassword ? { csvPassword } : {}),
|
||||
sentimentAlert: sentimentThreshold
|
||||
? parseFloat(sentimentThreshold)
|
||||
: null,
|
||||
// Remove sentimentAlert field - not in current schema
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@ -3,7 +3,7 @@ import crypto from "crypto";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { prisma } from "../../../../lib/prisma";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { authOptions } from "../../auth/[...nextauth]/route";
|
||||
import { authOptions } from "../../../../lib/auth";
|
||||
|
||||
interface UserBasicInfo {
|
||||
id: string;
|
||||
|
||||
@ -28,8 +28,7 @@ function checkRateLimit(ip: string): boolean {
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
// Rate limiting check
|
||||
const ip =
|
||||
request.ip || request.headers.get("x-forwarded-for") || "unknown";
|
||||
const ip = request.headers.get("x-forwarded-for") || request.headers.get("x-real-ip") || "unknown";
|
||||
if (!checkRateLimit(ip)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
|
||||
@ -1,107 +1,5 @@
|
||||
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",
|
||||
};
|
||||
import NextAuth from "next-auth";
|
||||
import { platformAuthOptions } from "../../../../../lib/platform-auth";
|
||||
|
||||
const handler = NextAuth(platformAuthOptions);
|
||||
|
||||
|
||||
@ -1,16 +1,26 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { platformAuthOptions } from "../../auth/[...nextauth]/route";
|
||||
import { platformAuthOptions } from "../../../../../lib/platform-auth";
|
||||
import { prisma } from "../../../../../lib/prisma";
|
||||
import { CompanyStatus } from "@prisma/client";
|
||||
|
||||
interface PlatformSession {
|
||||
user: {
|
||||
id?: string;
|
||||
name?: string;
|
||||
email?: string;
|
||||
isPlatformUser?: boolean;
|
||||
platformRole?: string;
|
||||
};
|
||||
}
|
||||
|
||||
// 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);
|
||||
const session = await getServerSession(platformAuthOptions) as PlatformSession | null;
|
||||
|
||||
if (!session?.user?.isPlatformUser) {
|
||||
return NextResponse.json({ error: "Platform access required" }, { status: 401 });
|
||||
@ -24,28 +34,19 @@ export async function GET(
|
||||
users: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
role: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
invitedBy: true,
|
||||
invitedAt: 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,
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -55,7 +56,7 @@ export async function GET(
|
||||
return NextResponse.json({ error: "Company not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ company });
|
||||
return NextResponse.json(company);
|
||||
} catch (error) {
|
||||
console.error("Platform company details error:", error);
|
||||
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
|
||||
@ -76,10 +77,12 @@ export async function PATCH(
|
||||
|
||||
const { id } = await params;
|
||||
const body = await request.json();
|
||||
const { name, csvUrl, csvUsername, csvPassword, status } = body;
|
||||
const { name, email, maxUsers, csvUrl, csvUsername, csvPassword, status } = body;
|
||||
|
||||
const updateData: any = {};
|
||||
if (name !== undefined) updateData.name = name;
|
||||
if (email !== undefined) updateData.email = email;
|
||||
if (maxUsers !== undefined) updateData.maxUsers = maxUsers;
|
||||
if (csvUrl !== undefined) updateData.csvUrl = csvUrl;
|
||||
if (csvUsername !== undefined) updateData.csvUsername = csvUsername;
|
||||
if (csvPassword !== undefined) updateData.csvPassword = csvPassword;
|
||||
|
||||
132
app/api/platform/companies/[id]/users/route.ts
Normal file
132
app/api/platform/companies/[id]/users/route.ts
Normal file
@ -0,0 +1,132 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { platformAuthOptions } from "../../../../../../lib/platform-auth";
|
||||
import { prisma } from "../../../../../../lib/prisma";
|
||||
import { hash } from "bcryptjs";
|
||||
|
||||
// POST /api/platform/companies/[id]/users - Invite user to company
|
||||
export async function POST(
|
||||
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: companyId } = await params;
|
||||
const body = await request.json();
|
||||
const { name, email, role = "USER" } = body;
|
||||
|
||||
if (!name || !email) {
|
||||
return NextResponse.json({ error: "Name and email are required" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Check if company exists
|
||||
const company = await prisma.company.findUnique({
|
||||
where: { id: companyId },
|
||||
include: { _count: { select: { users: true } } },
|
||||
});
|
||||
|
||||
if (!company) {
|
||||
return NextResponse.json({ error: "Company not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
// Check if user limit would be exceeded
|
||||
if (company._count.users >= company.maxUsers) {
|
||||
return NextResponse.json(
|
||||
{ error: "Company has reached maximum user limit" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Check if user already exists in this company
|
||||
const existingUser = await prisma.user.findFirst({
|
||||
where: {
|
||||
email,
|
||||
companyId,
|
||||
},
|
||||
});
|
||||
|
||||
if (existingUser) {
|
||||
return NextResponse.json(
|
||||
{ error: "User already exists in this company" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Generate a temporary password (in a real app, you'd send an invitation email)
|
||||
const tempPassword = `temp${Math.random().toString(36).slice(-8)}`;
|
||||
const hashedPassword = await hash(tempPassword, 10);
|
||||
|
||||
// Create the user
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
name,
|
||||
email,
|
||||
hashedPassword,
|
||||
role,
|
||||
companyId,
|
||||
invitedBy: session.user.email,
|
||||
invitedAt: new Date(),
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
role: true,
|
||||
createdAt: true,
|
||||
invitedBy: true,
|
||||
invitedAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
// In a real application, you would send an email with login credentials
|
||||
// For now, we'll return the temporary password
|
||||
return NextResponse.json({
|
||||
user,
|
||||
tempPassword, // Remove this in production and send via email
|
||||
message: "User invited successfully. In production, credentials would be sent via email.",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Platform user invitation error:", error);
|
||||
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/platform/companies/[id]/users - Get company users
|
||||
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: companyId } = await params;
|
||||
|
||||
const users = await prisma.user.findMany({
|
||||
where: { companyId },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
role: true,
|
||||
createdAt: true,
|
||||
invitedBy: true,
|
||||
invitedAt: true,
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
|
||||
return NextResponse.json({ users });
|
||||
} catch (error) {
|
||||
console.error("Platform users list error:", error);
|
||||
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@ -1,6 +1,6 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { platformAuthOptions } from "../auth/[...nextauth]/route";
|
||||
import { platformAuthOptions } from "../../../../lib/platform-auth";
|
||||
import { prisma } from "../../../../lib/prisma";
|
||||
import { CompanyStatus } from "@prisma/client";
|
||||
|
||||
|
||||
@ -7,25 +7,68 @@ import { Toaster } from "@/components/ui/sonner";
|
||||
export const metadata = {
|
||||
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.",
|
||||
"Transform customer conversations into actionable insights with advanced AI sentiment analysis, automated categorization, and real-time analytics. Turn every conversation into competitive intelligence.",
|
||||
keywords: [
|
||||
"customer analytics",
|
||||
"AI sentiment analysis",
|
||||
"conversation intelligence",
|
||||
"customer support analytics",
|
||||
"chat analytics",
|
||||
"customer insights"
|
||||
"customer insights",
|
||||
"conversation analytics",
|
||||
"customer experience analytics",
|
||||
"sentiment tracking",
|
||||
"AI customer intelligence",
|
||||
"automated categorization",
|
||||
"real-time analytics",
|
||||
"customer conversation dashboard"
|
||||
],
|
||||
authors: [{ name: "Notso AI" }],
|
||||
creator: "Notso AI",
|
||||
publisher: "Notso AI",
|
||||
formatDetection: {
|
||||
email: false,
|
||||
address: false,
|
||||
telephone: false,
|
||||
},
|
||||
metadataBase: new URL(process.env.NEXTAUTH_URL || 'https://livedash.notso.ai'),
|
||||
alternates: {
|
||||
canonical: '/',
|
||||
},
|
||||
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.",
|
||||
description: "Transform customer conversations into actionable insights with advanced AI sentiment analysis, automated categorization, and real-time analytics. Turn every conversation into competitive intelligence.",
|
||||
type: "website",
|
||||
siteName: "LiveDash",
|
||||
url: "/",
|
||||
locale: 'en_US',
|
||||
images: [
|
||||
{
|
||||
url: '/og-image.png',
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: 'LiveDash - AI-Powered Customer Conversation Analytics Platform',
|
||||
}
|
||||
],
|
||||
},
|
||||
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.",
|
||||
creator: "@notsoai",
|
||||
site: "@notsoai",
|
||||
images: ['/og-image.png'],
|
||||
},
|
||||
robots: {
|
||||
index: true,
|
||||
follow: true,
|
||||
googleBot: {
|
||||
index: true,
|
||||
follow: true,
|
||||
'max-video-preview': -1,
|
||||
'max-image-preview': 'large',
|
||||
'max-snippet': -1,
|
||||
},
|
||||
},
|
||||
icons: {
|
||||
icon: [
|
||||
@ -35,11 +78,52 @@ export const metadata = {
|
||||
apple: "/icon-192.svg",
|
||||
},
|
||||
manifest: "/manifest.json",
|
||||
other: {
|
||||
'msapplication-TileColor': '#2563eb',
|
||||
'theme-color': '#ffffff',
|
||||
},
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: ReactNode }) {
|
||||
const jsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'SoftwareApplication',
|
||||
name: 'LiveDash',
|
||||
description: 'Transform customer conversations into actionable insights with advanced AI sentiment analysis, automated categorization, and real-time analytics.',
|
||||
url: process.env.NEXTAUTH_URL || 'https://livedash.notso.ai',
|
||||
author: {
|
||||
'@type': 'Organization',
|
||||
name: 'Notso AI',
|
||||
},
|
||||
applicationCategory: 'Business Analytics Software',
|
||||
operatingSystem: 'Web Browser',
|
||||
offers: {
|
||||
'@type': 'Offer',
|
||||
category: 'SaaS',
|
||||
},
|
||||
aggregateRating: {
|
||||
'@type': 'AggregateRating',
|
||||
ratingValue: '4.8',
|
||||
ratingCount: '150',
|
||||
},
|
||||
featureList: [
|
||||
'AI-powered sentiment analysis',
|
||||
'Automated conversation categorization',
|
||||
'Real-time analytics dashboard',
|
||||
'Multi-language support',
|
||||
'Custom AI model integration',
|
||||
'Enterprise-grade security'
|
||||
]
|
||||
};
|
||||
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<head>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||
/>
|
||||
</head>
|
||||
<body className="bg-background text-foreground min-h-screen font-sans antialiased">
|
||||
{/* Skip navigation link for keyboard users */}
|
||||
<a
|
||||
|
||||
592
app/platform/companies/[id]/page.tsx
Normal file
592
app/platform/companies/[id]/page.tsx
Normal file
@ -0,0 +1,592 @@
|
||||
"use client";
|
||||
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter, useParams } 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 { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import {
|
||||
Building2,
|
||||
Users,
|
||||
Database,
|
||||
Settings,
|
||||
ArrowLeft,
|
||||
Save,
|
||||
Trash2,
|
||||
UserPlus,
|
||||
Mail,
|
||||
Shield,
|
||||
Activity,
|
||||
Calendar
|
||||
} from "lucide-react";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
role: string;
|
||||
createdAt: string;
|
||||
invitedBy: string | null;
|
||||
invitedAt: string | null;
|
||||
}
|
||||
|
||||
interface Company {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
status: string;
|
||||
maxUsers: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
users: User[];
|
||||
_count: {
|
||||
sessions: number;
|
||||
imports: number;
|
||||
};
|
||||
}
|
||||
|
||||
export default function CompanyManagement() {
|
||||
const { data: session, status } = useSession();
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const { toast } = useToast();
|
||||
|
||||
const [company, setCompany] = useState<Company | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [editData, setEditData] = useState<Partial<Company>>({});
|
||||
const [showInviteUser, setShowInviteUser] = useState(false);
|
||||
const [inviteData, setInviteData] = useState({ name: "", email: "", role: "USER" });
|
||||
|
||||
useEffect(() => {
|
||||
if (status === "loading") return;
|
||||
|
||||
if (!session?.user?.isPlatformUser) {
|
||||
router.push("/platform/login");
|
||||
return;
|
||||
}
|
||||
|
||||
fetchCompany();
|
||||
}, [session, status, router, params.id]);
|
||||
|
||||
const fetchCompany = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/platform/companies/${params.id}`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setCompany(data);
|
||||
setEditData({
|
||||
name: data.name,
|
||||
email: data.email,
|
||||
status: data.status,
|
||||
maxUsers: data.maxUsers,
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Failed to load company data",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch company:", error);
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Failed to load company data",
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const response = await fetch(`/api/platform/companies/${params.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(editData),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const updatedCompany = await response.json();
|
||||
setCompany(updatedCompany);
|
||||
toast({
|
||||
title: "Success",
|
||||
description: "Company updated successfully",
|
||||
});
|
||||
} else {
|
||||
throw new Error("Failed to update company");
|
||||
}
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Failed to update company",
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStatusChange = async (newStatus: string) => {
|
||||
const statusAction = newStatus === "SUSPENDED" ? "suspend" : "activate";
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/platform/companies/${params.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ status: newStatus }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
setCompany(prev => prev ? { ...prev, status: newStatus } : null);
|
||||
setEditData(prev => ({ ...prev, status: newStatus }));
|
||||
toast({
|
||||
title: "Success",
|
||||
description: `Company ${statusAction}d successfully`,
|
||||
});
|
||||
} else {
|
||||
throw new Error(`Failed to ${statusAction} company`);
|
||||
}
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: `Failed to ${statusAction} company`,
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleInviteUser = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/platform/companies/${params.id}/users`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(inviteData),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
setShowInviteUser(false);
|
||||
setInviteData({ name: "", email: "", role: "USER" });
|
||||
fetchCompany(); // Refresh company data
|
||||
toast({
|
||||
title: "Success",
|
||||
description: "User invited successfully",
|
||||
});
|
||||
} else {
|
||||
throw new Error("Failed to invite user");
|
||||
}
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Failed to invite user",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (status === "loading" || isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="text-center">Loading company details...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!session?.user?.isPlatformUser || !company) {
|
||||
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 (
|
||||
<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 className="flex items-center gap-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => router.push("/platform/dashboard")}
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
Back to Dashboard
|
||||
</Button>
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{company.name}
|
||||
</h1>
|
||||
<Badge variant={getStatusBadgeVariant(company.status)}>
|
||||
{company.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Company Management
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{canEdit && (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowInviteUser(true)}
|
||||
>
|
||||
<UserPlus className="w-4 h-4 mr-2" />
|
||||
Invite User
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSave}
|
||||
disabled={isSaving}
|
||||
>
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
{isSaving ? "Saving..." : "Save Changes"}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<Tabs defaultValue="overview" className="space-y-6">
|
||||
<TabsList>
|
||||
<TabsTrigger value="overview">Overview</TabsTrigger>
|
||||
<TabsTrigger value="users">Users</TabsTrigger>
|
||||
<TabsTrigger value="settings">Settings</TabsTrigger>
|
||||
<TabsTrigger value="analytics">Analytics</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="overview" className="space-y-6">
|
||||
{/* Stats Overview */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
|
||||
<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">{company.users.length}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
of {company.maxUsers} maximum
|
||||
</p>
|
||||
</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">{company._count.sessions}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Data Imports</CardTitle>
|
||||
<Activity className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{company._count.imports}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Created</CardTitle>
|
||||
<Calendar className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-sm font-bold">
|
||||
{new Date(company.createdAt).toLocaleDateString()}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Company Info */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Company Information</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="name">Company Name</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={editData.name || ""}
|
||||
onChange={(e) => setEditData(prev => ({ ...prev, name: e.target.value }))}
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="email">Contact Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
value={editData.email || ""}
|
||||
onChange={(e) => setEditData(prev => ({ ...prev, email: e.target.value }))}
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="maxUsers">Max Users</Label>
|
||||
<Input
|
||||
id="maxUsers"
|
||||
type="number"
|
||||
value={editData.maxUsers || 0}
|
||||
onChange={(e) => setEditData(prev => ({ ...prev, maxUsers: parseInt(e.target.value) }))}
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="status">Status</Label>
|
||||
<Select
|
||||
value={editData.status}
|
||||
onValueChange={(value) => setEditData(prev => ({ ...prev, status: value }))}
|
||||
disabled={!canEdit}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="ACTIVE">Active</SelectItem>
|
||||
<SelectItem value="TRIAL">Trial</SelectItem>
|
||||
<SelectItem value="SUSPENDED">Suspended</SelectItem>
|
||||
<SelectItem value="ARCHIVED">Archived</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="users" className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
<span className="flex items-center gap-2">
|
||||
<Users className="w-5 h-5" />
|
||||
Users ({company.users.length})
|
||||
</span>
|
||||
{canEdit && (
|
||||
<Button size="sm" onClick={() => setShowInviteUser(true)}>
|
||||
<UserPlus className="w-4 h-4 mr-2" />
|
||||
Invite User
|
||||
</Button>
|
||||
)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{company.users.map((user) => (
|
||||
<div
|
||||
key={user.id}
|
||||
className="flex items-center justify-between p-4 border rounded-lg"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-10 h-10 bg-blue-100 dark:bg-blue-900 rounded-full flex items-center justify-center">
|
||||
<span className="text-sm font-medium text-blue-600 dark:text-blue-300">
|
||||
{user.name?.charAt(0) || user.email.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium">{user.name || "No name"}</div>
|
||||
<div className="text-sm text-muted-foreground">{user.email}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<Badge variant="outline">{user.role}</Badge>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Joined {new Date(user.createdAt).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{company.users.length === 0 && (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
No users found. Invite the first user to get started.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="settings" className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-red-600 dark:text-red-400">Danger Zone</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{canEdit && (
|
||||
<>
|
||||
<div className="flex items-center justify-between p-4 border border-red-200 dark:border-red-800 rounded-lg">
|
||||
<div>
|
||||
<h3 className="font-medium">Suspend Company</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Temporarily disable access to this company
|
||||
</p>
|
||||
</div>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="destructive" disabled={company.status === "SUSPENDED"}>
|
||||
{company.status === "SUSPENDED" ? "Already Suspended" : "Suspend"}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Suspend Company</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to suspend this company? This will disable access for all users.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={() => handleStatusChange("SUSPENDED")}>
|
||||
Suspend
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
|
||||
{company.status === "SUSPENDED" && (
|
||||
<div className="flex items-center justify-between p-4 border border-green-200 dark:border-green-800 rounded-lg">
|
||||
<div>
|
||||
<h3 className="font-medium">Reactivate Company</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Restore access to this company
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="default" onClick={() => handleStatusChange("ACTIVE")}>
|
||||
Reactivate
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="analytics" className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Analytics</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
Analytics dashboard coming soon...
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
{/* Invite User Dialog */}
|
||||
{showInviteUser && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<Card className="w-full max-w-md mx-4">
|
||||
<CardHeader>
|
||||
<CardTitle>Invite User</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="inviteName">Name</Label>
|
||||
<Input
|
||||
id="inviteName"
|
||||
value={inviteData.name}
|
||||
onChange={(e) => setInviteData(prev => ({ ...prev, name: e.target.value }))}
|
||||
placeholder="User's full name"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="inviteEmail">Email</Label>
|
||||
<Input
|
||||
id="inviteEmail"
|
||||
type="email"
|
||||
value={inviteData.email}
|
||||
onChange={(e) => setInviteData(prev => ({ ...prev, email: e.target.value }))}
|
||||
placeholder="user@example.com"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="inviteRole">Role</Label>
|
||||
<Select
|
||||
value={inviteData.role}
|
||||
onValueChange={(value) => setInviteData(prev => ({ ...prev, role: value }))}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="USER">User</SelectItem>
|
||||
<SelectItem value="ADMIN">Admin</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex gap-2 pt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowInviteUser(false)}
|
||||
className="flex-1"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleInviteUser}
|
||||
className="flex-1"
|
||||
disabled={!inviteData.email || !inviteData.name}
|
||||
>
|
||||
<Mail className="w-4 h-4 mr-2" />
|
||||
Send Invite
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -6,6 +6,17 @@ 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 { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Building2,
|
||||
Users,
|
||||
@ -15,6 +26,7 @@ import {
|
||||
Settings,
|
||||
BarChart3
|
||||
} from "lucide-react";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
|
||||
interface Company {
|
||||
id: string;
|
||||
@ -39,8 +51,18 @@ interface DashboardData {
|
||||
export default function PlatformDashboard() {
|
||||
const { data: session, status } = useSession();
|
||||
const router = useRouter();
|
||||
const { toast } = useToast();
|
||||
const [dashboardData, setDashboardData] = useState<DashboardData | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [showAddCompany, setShowAddCompany] = useState(false);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [newCompanyData, setNewCompanyData] = useState({
|
||||
name: "",
|
||||
adminEmail: "",
|
||||
adminName: "",
|
||||
adminPassword: "",
|
||||
maxUsers: 10,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (status === "loading") return;
|
||||
@ -67,6 +89,54 @@ export default function PlatformDashboard() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateCompany = async () => {
|
||||
if (!newCompanyData.name || !newCompanyData.adminEmail || !newCompanyData.adminName) {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Please fill in all required fields",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setIsCreating(true);
|
||||
try {
|
||||
const response = await fetch("/api/platform/companies", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(newCompanyData),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
setShowAddCompany(false);
|
||||
setNewCompanyData({
|
||||
name: "",
|
||||
adminEmail: "",
|
||||
adminName: "",
|
||||
adminPassword: "",
|
||||
maxUsers: 10,
|
||||
});
|
||||
fetchDashboardData(); // Refresh the list
|
||||
toast({
|
||||
title: "Success",
|
||||
description: `Company "${newCompanyData.name}" created successfully`,
|
||||
});
|
||||
} else {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || "Failed to create company");
|
||||
}
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: error instanceof Error ? error.message : "Failed to create company",
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadgeVariant = (status: string) => {
|
||||
switch (status) {
|
||||
case "ACTIVE": return "default";
|
||||
@ -111,10 +181,81 @@ export default function PlatformDashboard() {
|
||||
<Settings className="w-4 h-4 mr-2" />
|
||||
Settings
|
||||
</Button>
|
||||
<Button size="sm">
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Add Company
|
||||
</Button>
|
||||
<Dialog open={showAddCompany} onOpenChange={setShowAddCompany}>
|
||||
<DialogTrigger asChild>
|
||||
<Button size="sm">
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Add Company
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add New Company</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a new company and invite the first administrator.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="companyName">Company Name *</Label>
|
||||
<Input
|
||||
id="companyName"
|
||||
value={newCompanyData.name}
|
||||
onChange={(e) => setNewCompanyData(prev => ({ ...prev, name: e.target.value }))}
|
||||
placeholder="Acme Corporation"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="adminName">Admin Name *</Label>
|
||||
<Input
|
||||
id="adminName"
|
||||
value={newCompanyData.adminName}
|
||||
onChange={(e) => setNewCompanyData(prev => ({ ...prev, adminName: e.target.value }))}
|
||||
placeholder="John Doe"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="adminEmail">Admin Email *</Label>
|
||||
<Input
|
||||
id="adminEmail"
|
||||
type="email"
|
||||
value={newCompanyData.adminEmail}
|
||||
onChange={(e) => setNewCompanyData(prev => ({ ...prev, adminEmail: e.target.value }))}
|
||||
placeholder="admin@acme.com"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="adminPassword">Admin Password</Label>
|
||||
<Input
|
||||
id="adminPassword"
|
||||
type="password"
|
||||
value={newCompanyData.adminPassword}
|
||||
onChange={(e) => setNewCompanyData(prev => ({ ...prev, adminPassword: e.target.value }))}
|
||||
placeholder="Leave empty to auto-generate"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="maxUsers">Max Users</Label>
|
||||
<Input
|
||||
id="maxUsers"
|
||||
type="number"
|
||||
value={newCompanyData.maxUsers}
|
||||
onChange={(e) => setNewCompanyData(prev => ({ ...prev, maxUsers: parseInt(e.target.value) || 10 }))}
|
||||
min="1"
|
||||
max="1000"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowAddCompany(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleCreateCompany} disabled={isCreating}>
|
||||
{isCreating ? "Creating..." : "Create Company"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -200,7 +341,11 @@ export default function PlatformDashboard() {
|
||||
<BarChart3 className="w-4 h-4 mr-2" />
|
||||
Analytics
|
||||
</Button>
|
||||
<Button variant="outline" size="sm">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => router.push(`/platform/companies/${company.id}`)}
|
||||
>
|
||||
<Settings className="w-4 h-4 mr-2" />
|
||||
Manage
|
||||
</Button>
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { SessionProvider } from "next-auth/react";
|
||||
import { Toaster } from "@/components/ui/toaster";
|
||||
|
||||
export default function PlatformLayout({
|
||||
children,
|
||||
@ -10,6 +11,7 @@ export default function PlatformLayout({
|
||||
return (
|
||||
<SessionProvider>
|
||||
{children}
|
||||
<Toaster />
|
||||
</SessionProvider>
|
||||
);
|
||||
}
|
||||
23
components/ui/textarea.tsx
Normal file
23
components/ui/textarea.tsx
Normal file
@ -0,0 +1,23 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export type TextareaProps = React.TextareaHTMLAttributes<HTMLTextAreaElement>
|
||||
|
||||
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
({ className, ...props }, ref) => {
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Textarea.displayName = "Textarea"
|
||||
|
||||
export { Textarea }
|
||||
127
components/ui/toast.tsx
Normal file
127
components/ui/toast.tsx
Normal file
@ -0,0 +1,127 @@
|
||||
import * as React from "react"
|
||||
import * as ToastPrimitives from "@radix-ui/react-toast"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { X } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const ToastProvider = ToastPrimitives.Provider
|
||||
|
||||
const ToastViewport = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Viewport>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Viewport
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
|
||||
|
||||
const toastVariants = cva(
|
||||
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border bg-background text-foreground",
|
||||
destructive:
|
||||
"destructive border-destructive bg-destructive text-destructive-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const Toast = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
|
||||
VariantProps<typeof toastVariants>
|
||||
>(({ className, variant, ...props }, ref) => {
|
||||
return (
|
||||
<ToastPrimitives.Root
|
||||
ref={ref}
|
||||
className={cn(toastVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
Toast.displayName = ToastPrimitives.Root.displayName
|
||||
|
||||
const ToastAction = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Action>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Action
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastAction.displayName = ToastPrimitives.Action.displayName
|
||||
|
||||
const ToastClose = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Close>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Close
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
|
||||
className
|
||||
)}
|
||||
toast-close=""
|
||||
{...props}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</ToastPrimitives.Close>
|
||||
))
|
||||
ToastClose.displayName = ToastPrimitives.Close.displayName
|
||||
|
||||
const ToastTitle = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Title
|
||||
ref={ref}
|
||||
className={cn("text-sm font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastTitle.displayName = ToastPrimitives.Title.displayName
|
||||
|
||||
const ToastDescription = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm opacity-90", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastDescription.displayName = ToastPrimitives.Description.displayName
|
||||
|
||||
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
|
||||
|
||||
type ToastActionElement = React.ReactElement<typeof ToastAction>
|
||||
|
||||
export {
|
||||
type ToastProps,
|
||||
type ToastActionElement,
|
||||
ToastProvider,
|
||||
ToastViewport,
|
||||
Toast,
|
||||
ToastTitle,
|
||||
ToastDescription,
|
||||
ToastClose,
|
||||
ToastAction,
|
||||
}
|
||||
35
components/ui/toaster.tsx
Normal file
35
components/ui/toaster.tsx
Normal file
@ -0,0 +1,35 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
Toast,
|
||||
ToastClose,
|
||||
ToastDescription,
|
||||
ToastProvider,
|
||||
ToastTitle,
|
||||
ToastViewport,
|
||||
} from "@/components/ui/toast"
|
||||
import { useToast } from "@/hooks/use-toast"
|
||||
|
||||
export function Toaster() {
|
||||
const { toasts } = useToast()
|
||||
|
||||
return (
|
||||
<ToastProvider>
|
||||
{toasts.map(function ({ id, title, description, action, ...props }) {
|
||||
return (
|
||||
<Toast key={id} {...props}>
|
||||
<div className="grid gap-1">
|
||||
{title && <ToastTitle>{title}</ToastTitle>}
|
||||
{description && (
|
||||
<ToastDescription>{description}</ToastDescription>
|
||||
)}
|
||||
</div>
|
||||
{action}
|
||||
<ToastClose />
|
||||
</Toast>
|
||||
)
|
||||
})}
|
||||
<ToastViewport />
|
||||
</ToastProvider>
|
||||
)
|
||||
}
|
||||
191
hooks/use-toast.ts
Normal file
191
hooks/use-toast.ts
Normal file
@ -0,0 +1,191 @@
|
||||
import * as React from "react"
|
||||
|
||||
import type {
|
||||
ToastActionElement,
|
||||
ToastProps,
|
||||
} from "@/components/ui/toast"
|
||||
|
||||
const TOAST_LIMIT = 1
|
||||
const TOAST_REMOVE_DELAY = 1000000
|
||||
|
||||
type ToasterToast = ToastProps & {
|
||||
id: string
|
||||
title?: React.ReactNode
|
||||
description?: React.ReactNode
|
||||
action?: ToastActionElement
|
||||
}
|
||||
|
||||
const actionTypes = {
|
||||
ADD_TOAST: "ADD_TOAST",
|
||||
UPDATE_TOAST: "UPDATE_TOAST",
|
||||
DISMISS_TOAST: "DISMISS_TOAST",
|
||||
REMOVE_TOAST: "REMOVE_TOAST",
|
||||
} as const
|
||||
|
||||
let count = 0
|
||||
|
||||
function genId() {
|
||||
count = (count + 1) % Number.MAX_VALUE
|
||||
return count.toString()
|
||||
}
|
||||
|
||||
type ActionType = typeof actionTypes
|
||||
|
||||
type Action =
|
||||
| {
|
||||
type: ActionType["ADD_TOAST"]
|
||||
toast: ToasterToast
|
||||
}
|
||||
| {
|
||||
type: ActionType["UPDATE_TOAST"]
|
||||
toast: Partial<ToasterToast>
|
||||
}
|
||||
| {
|
||||
type: ActionType["DISMISS_TOAST"]
|
||||
toastId?: ToasterToast["id"]
|
||||
}
|
||||
| {
|
||||
type: ActionType["REMOVE_TOAST"]
|
||||
toastId?: ToasterToast["id"]
|
||||
}
|
||||
|
||||
interface State {
|
||||
toasts: ToasterToast[]
|
||||
}
|
||||
|
||||
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
|
||||
const addToRemoveQueue = (toastId: string) => {
|
||||
if (toastTimeouts.has(toastId)) {
|
||||
return
|
||||
}
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
toastTimeouts.delete(toastId)
|
||||
dispatch({
|
||||
type: "REMOVE_TOAST",
|
||||
toastId: toastId,
|
||||
})
|
||||
}, TOAST_REMOVE_DELAY)
|
||||
|
||||
toastTimeouts.set(toastId, timeout)
|
||||
}
|
||||
|
||||
export const reducer = (state: State, action: Action): State => {
|
||||
switch (action.type) {
|
||||
case "ADD_TOAST":
|
||||
return {
|
||||
...state,
|
||||
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
|
||||
}
|
||||
|
||||
case "UPDATE_TOAST":
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.map((t) =>
|
||||
t.id === action.toast.id ? { ...t, ...action.toast } : t
|
||||
),
|
||||
}
|
||||
|
||||
case "DISMISS_TOAST": {
|
||||
const { toastId } = action
|
||||
|
||||
// ! Side effects ! - This could be extracted into a dismissToast() action,
|
||||
// but I'll keep it here for simplicity
|
||||
if (toastId) {
|
||||
addToRemoveQueue(toastId)
|
||||
} else {
|
||||
state.toasts.forEach((toast) => {
|
||||
addToRemoveQueue(toast.id)
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.map((t) =>
|
||||
t.id === toastId || toastId === undefined
|
||||
? {
|
||||
...t,
|
||||
open: false,
|
||||
}
|
||||
: t
|
||||
),
|
||||
}
|
||||
}
|
||||
case "REMOVE_TOAST":
|
||||
if (action.toastId === undefined) {
|
||||
return {
|
||||
...state,
|
||||
toasts: [],
|
||||
}
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.filter((t) => t.id !== action.toastId),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const listeners: Array<(state: State) => void> = []
|
||||
|
||||
let memoryState: State = { toasts: [] }
|
||||
|
||||
function dispatch(action: Action) {
|
||||
memoryState = reducer(memoryState, action)
|
||||
listeners.forEach((listener) => {
|
||||
listener(memoryState)
|
||||
})
|
||||
}
|
||||
|
||||
type Toast = Omit<ToasterToast, "id">
|
||||
|
||||
function toast({ ...props }: Toast) {
|
||||
const id = genId()
|
||||
|
||||
const update = (props: ToasterToast) =>
|
||||
dispatch({
|
||||
type: "UPDATE_TOAST",
|
||||
toast: { ...props, id },
|
||||
})
|
||||
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
|
||||
|
||||
dispatch({
|
||||
type: "ADD_TOAST",
|
||||
toast: {
|
||||
...props,
|
||||
id,
|
||||
open: true,
|
||||
onOpenChange: (open) => {
|
||||
if (!open) dismiss()
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
id: id,
|
||||
dismiss,
|
||||
update,
|
||||
}
|
||||
}
|
||||
|
||||
function useToast() {
|
||||
const [state, setState] = React.useState<State>(memoryState)
|
||||
|
||||
React.useEffect(() => {
|
||||
listeners.push(setState)
|
||||
return () => {
|
||||
const index = listeners.indexOf(setState)
|
||||
if (index > -1) {
|
||||
listeners.splice(index, 1)
|
||||
}
|
||||
}
|
||||
}, [state])
|
||||
|
||||
return {
|
||||
...state,
|
||||
toast,
|
||||
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
|
||||
}
|
||||
}
|
||||
|
||||
export { useToast, toast }
|
||||
105
lib/auth.ts
Normal file
105
lib/auth.ts
Normal file
@ -0,0 +1,105 @@
|
||||
import { NextAuthOptions } from "next-auth";
|
||||
import CredentialsProvider from "next-auth/providers/credentials";
|
||||
import { prisma } from "./prisma";
|
||||
import bcrypt from "bcryptjs";
|
||||
|
||||
// Define the shape of the JWT token
|
||||
declare module "next-auth/jwt" {
|
||||
interface JWT {
|
||||
companyId: string;
|
||||
role: string;
|
||||
}
|
||||
}
|
||||
|
||||
// Define the shape of the session object
|
||||
declare module "next-auth" {
|
||||
interface Session {
|
||||
user: {
|
||||
id?: string;
|
||||
name?: string;
|
||||
email?: string;
|
||||
companyId?: string;
|
||||
role?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
name?: string;
|
||||
companyId: string;
|
||||
role: string;
|
||||
}
|
||||
}
|
||||
|
||||
export const authOptions: NextAuthOptions = {
|
||||
providers: [
|
||||
CredentialsProvider({
|
||||
name: "credentials",
|
||||
credentials: {
|
||||
email: { label: "Email", type: "email" },
|
||||
password: { label: "Password", type: "password" },
|
||||
},
|
||||
async authorize(credentials) {
|
||||
if (!credentials?.email || !credentials?.password) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email: credentials.email },
|
||||
include: { company: true },
|
||||
});
|
||||
|
||||
if (!user || !user.hashedPassword) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isPasswordValid = await bcrypt.compare(
|
||||
credentials.password,
|
||||
user.hashedPassword
|
||||
);
|
||||
|
||||
if (!isPasswordValid) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if company is active
|
||||
if (user.company.status !== "ACTIVE") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
companyId: user.companyId,
|
||||
role: user.role,
|
||||
};
|
||||
},
|
||||
}),
|
||||
],
|
||||
session: {
|
||||
strategy: "jwt",
|
||||
},
|
||||
callbacks: {
|
||||
async jwt({ token, user }) {
|
||||
if (user) {
|
||||
token.companyId = user.companyId;
|
||||
token.role = user.role;
|
||||
}
|
||||
return token;
|
||||
},
|
||||
async session({ session, token }) {
|
||||
if (token && session.user) {
|
||||
session.user.companyId = token.companyId;
|
||||
session.user.role = token.role;
|
||||
}
|
||||
return session;
|
||||
},
|
||||
},
|
||||
pages: {
|
||||
signIn: "/login",
|
||||
},
|
||||
secret: process.env.NEXTAUTH_SECRET,
|
||||
debug: process.env.NODE_ENV === "development",
|
||||
};
|
||||
108
lib/platform-auth.ts
Normal file
108
lib/platform-auth.ts
Normal file
@ -0,0 +1,108 @@
|
||||
import { NextAuthOptions } from "next-auth";
|
||||
import CredentialsProvider from "next-auth/providers/credentials";
|
||||
import { prisma } from "./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;
|
||||
companyId?: string;
|
||||
role?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
name?: string;
|
||||
isPlatformUser?: boolean;
|
||||
platformRole?: string;
|
||||
companyId?: string;
|
||||
role?: 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",
|
||||
};
|
||||
@ -42,6 +42,7 @@
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-switch": "^1.2.5",
|
||||
"@radix-ui/react-tabs": "^1.1.12",
|
||||
"@radix-ui/react-toast": "^1.2.14",
|
||||
"@radix-ui/react-toggle": "^1.1.9",
|
||||
"@radix-ui/react-toggle-group": "^1.1.10",
|
||||
"@radix-ui/react-tooltip": "^1.2.7",
|
||||
|
||||
36
pnpm-lock.yaml
generated
36
pnpm-lock.yaml
generated
@ -50,6 +50,9 @@ importers:
|
||||
'@radix-ui/react-tabs':
|
||||
specifier: ^1.1.12
|
||||
version: 1.1.12(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
'@radix-ui/react-toast':
|
||||
specifier: ^1.2.14
|
||||
version: 1.2.14(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
'@radix-ui/react-toggle':
|
||||
specifier: ^1.1.9
|
||||
version: 1.1.9(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
@ -1267,6 +1270,19 @@ packages:
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-toast@1.2.14':
|
||||
resolution: {integrity: sha512-nAP5FBxBJGQ/YfUB+r+O6USFVkWq3gAInkxyEnmvEV5jtSbfDhfa4hwX8CraCnbjMLsE7XSf/K75l9xXY7joWg==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
'@types/react-dom': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-toggle-group@1.1.10':
|
||||
resolution: {integrity: sha512-kiU694Km3WFLTC75DdqgM/3Jauf3rD9wxeS9XtyWFKsBUeZA337lC+6uUazT7I1DhanZ5gyD5Stf8uf2dbQxOQ==}
|
||||
peerDependencies:
|
||||
@ -5813,6 +5829,26 @@ snapshots:
|
||||
'@types/react': 19.1.8
|
||||
'@types/react-dom': 19.1.6(@types/react@19.1.8)
|
||||
|
||||
'@radix-ui/react-toast@1.2.14(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.2
|
||||
'@radix-ui/react-collection': 1.1.7(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.8)(react@19.1.0)
|
||||
'@radix-ui/react-context': 1.1.2(@types/react@19.1.8)(react@19.1.0)
|
||||
'@radix-ui/react-dismissable-layer': 1.1.10(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
'@radix-ui/react-portal': 1.1.9(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
'@radix-ui/react-presence': 1.1.4(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
'@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.8)(react@19.1.0)
|
||||
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.8)(react@19.1.0)
|
||||
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.8)(react@19.1.0)
|
||||
'@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
react: 19.1.0
|
||||
react-dom: 19.1.0(react@19.1.0)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.1.8
|
||||
'@types/react-dom': 19.1.6(@types/react@19.1.8)
|
||||
|
||||
'@radix-ui/react-toggle-group@1.1.10(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.2
|
||||
|
||||
Reference in New Issue
Block a user