mirror of
https://github.com/kjanat/livedash-node.git
synced 2026-01-16 08:52:10 +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 { NextRequest, NextResponse } from "next/server";
|
||||||
import { getServerSession } from "next-auth";
|
import { getServerSession } from "next-auth";
|
||||||
import { authOptions } from "../../auth/[...nextauth]/route";
|
import { authOptions } from "../../../../lib/auth";
|
||||||
import { prisma } from "../../../../lib/prisma";
|
import { prisma } from "../../../../lib/prisma";
|
||||||
import { processUnprocessedSessions } from "../../../../lib/processingScheduler";
|
import { processUnprocessedSessions } from "../../../../lib/processingScheduler";
|
||||||
import { ProcessingStatusManager } from "../../../../lib/processingStatusManager";
|
import { ProcessingStatusManager } from "../../../../lib/processingStatusManager";
|
||||||
|
|||||||
@ -1,106 +1,6 @@
|
|||||||
import NextAuth, { NextAuthOptions } from "next-auth";
|
import NextAuth from "next-auth";
|
||||||
import CredentialsProvider from "next-auth/providers/credentials";
|
import { authOptions } from "../../../../lib/auth";
|
||||||
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",
|
|
||||||
};
|
|
||||||
|
|
||||||
const handler = NextAuth(authOptions);
|
const handler = NextAuth(authOptions);
|
||||||
|
|
||||||
export { handler as GET, handler as POST };
|
export { handler as GET, handler as POST };
|
||||||
@ -1,7 +1,7 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { getServerSession } from "next-auth";
|
import { getServerSession } from "next-auth";
|
||||||
import { prisma } from "../../../../lib/prisma";
|
import { prisma } from "../../../../lib/prisma";
|
||||||
import { authOptions } from "../../auth/[...nextauth]/route";
|
import { authOptions } from "../../../../lib/auth";
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
const session = await getServerSession(authOptions);
|
const session = await getServerSession(authOptions);
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
|
|||||||
import { getServerSession } from "next-auth";
|
import { getServerSession } from "next-auth";
|
||||||
import { prisma } from "../../../../lib/prisma";
|
import { prisma } from "../../../../lib/prisma";
|
||||||
import { sessionMetrics } from "../../../../lib/metrics";
|
import { sessionMetrics } from "../../../../lib/metrics";
|
||||||
import { authOptions } from "../../auth/[...nextauth]/route";
|
import { authOptions } from "../../../../lib/auth";
|
||||||
import { ChatSession } from "../../../../lib/types";
|
import { ChatSession } from "../../../../lib/types";
|
||||||
|
|
||||||
interface SessionUser {
|
interface SessionUser {
|
||||||
@ -83,10 +83,7 @@ export async function GET(request: NextRequest) {
|
|||||||
|
|
||||||
// Pass company config to metrics
|
// Pass company config to metrics
|
||||||
const companyConfigForMetrics = {
|
const companyConfigForMetrics = {
|
||||||
sentimentAlert:
|
// Add company-specific configuration here in the future
|
||||||
user.company.sentimentAlert === null
|
|
||||||
? undefined
|
|
||||||
: user.company.sentimentAlert,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const metrics = sessionMetrics(chatSessions, companyConfigForMetrics);
|
const metrics = sessionMetrics(chatSessions, companyConfigForMetrics);
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { getServerSession } from "next-auth/next";
|
import { getServerSession } from "next-auth/next";
|
||||||
import { authOptions } from "../../auth/[...nextauth]/route";
|
import { authOptions } from "../../../../lib/auth";
|
||||||
import { prisma } from "../../../../lib/prisma";
|
import { prisma } from "../../../../lib/prisma";
|
||||||
import { SessionFilterOptions } from "../../../../lib/types";
|
import { SessionFilterOptions } from "../../../../lib/types";
|
||||||
|
|
||||||
|
|||||||
@ -4,9 +4,9 @@ import { ChatSession } from "../../../../../lib/types";
|
|||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
{ params }: { params: { id: string } }
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
) {
|
) {
|
||||||
const { id } = params;
|
const { id } = await params;
|
||||||
|
|
||||||
if (!id) {
|
if (!id) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { getServerSession } from "next-auth/next";
|
import { getServerSession } from "next-auth/next";
|
||||||
import { authOptions } from "../../auth/[...nextauth]/route";
|
import { authOptions } from "../../../../lib/auth";
|
||||||
import { prisma } from "../../../../lib/prisma";
|
import { prisma } from "../../../../lib/prisma";
|
||||||
import {
|
import {
|
||||||
ChatSession,
|
ChatSession,
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { getServerSession } from "next-auth";
|
import { getServerSession } from "next-auth";
|
||||||
import { prisma } from "../../../../lib/prisma";
|
import { prisma } from "../../../../lib/prisma";
|
||||||
import { authOptions } from "../../auth/[...nextauth]/route";
|
import { authOptions } from "../../../../lib/auth";
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
const session = await getServerSession(authOptions);
|
const session = await getServerSession(authOptions);
|
||||||
@ -26,9 +26,7 @@ export async function POST(request: NextRequest) {
|
|||||||
csvUrl,
|
csvUrl,
|
||||||
csvUsername,
|
csvUsername,
|
||||||
...(csvPassword ? { csvPassword } : {}),
|
...(csvPassword ? { csvPassword } : {}),
|
||||||
sentimentAlert: sentimentThreshold
|
// Remove sentimentAlert field - not in current schema
|
||||||
? parseFloat(sentimentThreshold)
|
|
||||||
: null,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import crypto from "crypto";
|
|||||||
import { getServerSession } from "next-auth";
|
import { getServerSession } from "next-auth";
|
||||||
import { prisma } from "../../../../lib/prisma";
|
import { prisma } from "../../../../lib/prisma";
|
||||||
import bcrypt from "bcryptjs";
|
import bcrypt from "bcryptjs";
|
||||||
import { authOptions } from "../../auth/[...nextauth]/route";
|
import { authOptions } from "../../../../lib/auth";
|
||||||
|
|
||||||
interface UserBasicInfo {
|
interface UserBasicInfo {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@ -28,8 +28,7 @@ function checkRateLimit(ip: string): boolean {
|
|||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
// Rate limiting check
|
// Rate limiting check
|
||||||
const ip =
|
const ip = request.headers.get("x-forwarded-for") || request.headers.get("x-real-ip") || "unknown";
|
||||||
request.ip || request.headers.get("x-forwarded-for") || "unknown";
|
|
||||||
if (!checkRateLimit(ip)) {
|
if (!checkRateLimit(ip)) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
|
|||||||
@ -1,107 +1,5 @@
|
|||||||
import NextAuth, { NextAuthOptions } from "next-auth";
|
import NextAuth from "next-auth";
|
||||||
import CredentialsProvider from "next-auth/providers/credentials";
|
import { platformAuthOptions } from "../../../../../lib/platform-auth";
|
||||||
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);
|
const handler = NextAuth(platformAuthOptions);
|
||||||
|
|
||||||
|
|||||||
@ -1,16 +1,26 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { getServerSession } from "next-auth";
|
import { getServerSession } from "next-auth";
|
||||||
import { platformAuthOptions } from "../../auth/[...nextauth]/route";
|
import { platformAuthOptions } from "../../../../../lib/platform-auth";
|
||||||
import { prisma } from "../../../../../lib/prisma";
|
import { prisma } from "../../../../../lib/prisma";
|
||||||
import { CompanyStatus } from "@prisma/client";
|
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
|
// GET /api/platform/companies/[id] - Get company details
|
||||||
export async function GET(
|
export async function GET(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
{ params }: { params: Promise<{ id: string }> }
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const session = await getServerSession(platformAuthOptions);
|
const session = await getServerSession(platformAuthOptions) as PlatformSession | null;
|
||||||
|
|
||||||
if (!session?.user?.isPlatformUser) {
|
if (!session?.user?.isPlatformUser) {
|
||||||
return NextResponse.json({ error: "Platform access required" }, { status: 401 });
|
return NextResponse.json({ error: "Platform access required" }, { status: 401 });
|
||||||
@ -24,28 +34,19 @@ export async function GET(
|
|||||||
users: {
|
users: {
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
|
name: true,
|
||||||
email: true,
|
email: true,
|
||||||
role: true,
|
role: true,
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
updatedAt: 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: {
|
_count: {
|
||||||
select: {
|
select: {
|
||||||
sessions: true,
|
sessions: true,
|
||||||
imports: 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({ error: "Company not found" }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({ company });
|
return NextResponse.json(company);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Platform company details error:", error);
|
console.error("Platform company details error:", error);
|
||||||
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
|
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
|
||||||
@ -76,10 +77,12 @@ export async function PATCH(
|
|||||||
|
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const { name, csvUrl, csvUsername, csvPassword, status } = body;
|
const { name, email, maxUsers, csvUrl, csvUsername, csvPassword, status } = body;
|
||||||
|
|
||||||
const updateData: any = {};
|
const updateData: any = {};
|
||||||
if (name !== undefined) updateData.name = name;
|
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 (csvUrl !== undefined) updateData.csvUrl = csvUrl;
|
||||||
if (csvUsername !== undefined) updateData.csvUsername = csvUsername;
|
if (csvUsername !== undefined) updateData.csvUsername = csvUsername;
|
||||||
if (csvPassword !== undefined) updateData.csvPassword = csvPassword;
|
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 { NextRequest, NextResponse } from "next/server";
|
||||||
import { getServerSession } from "next-auth";
|
import { getServerSession } from "next-auth";
|
||||||
import { platformAuthOptions } from "../auth/[...nextauth]/route";
|
import { platformAuthOptions } from "../../../../lib/platform-auth";
|
||||||
import { prisma } from "../../../../lib/prisma";
|
import { prisma } from "../../../../lib/prisma";
|
||||||
import { CompanyStatus } from "@prisma/client";
|
import { CompanyStatus } from "@prisma/client";
|
||||||
|
|
||||||
|
|||||||
@ -7,25 +7,68 @@ import { Toaster } from "@/components/ui/sonner";
|
|||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: "LiveDash - AI-Powered Customer Conversation Analytics",
|
title: "LiveDash - AI-Powered Customer Conversation Analytics",
|
||||||
description:
|
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: [
|
keywords: [
|
||||||
"customer analytics",
|
"customer analytics",
|
||||||
"AI sentiment analysis",
|
"AI sentiment analysis",
|
||||||
"conversation intelligence",
|
"conversation intelligence",
|
||||||
"customer support analytics",
|
"customer support analytics",
|
||||||
"chat 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: {
|
openGraph: {
|
||||||
title: "LiveDash - AI-Powered Customer Conversation Analytics",
|
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",
|
type: "website",
|
||||||
siteName: "LiveDash",
|
siteName: "LiveDash",
|
||||||
|
url: "/",
|
||||||
|
locale: 'en_US',
|
||||||
|
images: [
|
||||||
|
{
|
||||||
|
url: '/og-image.png',
|
||||||
|
width: 1200,
|
||||||
|
height: 630,
|
||||||
|
alt: 'LiveDash - AI-Powered Customer Conversation Analytics Platform',
|
||||||
|
}
|
||||||
|
],
|
||||||
},
|
},
|
||||||
twitter: {
|
twitter: {
|
||||||
card: "summary_large_image",
|
card: "summary_large_image",
|
||||||
title: "LiveDash - AI-Powered Customer Conversation Analytics",
|
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.",
|
||||||
|
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: {
|
icons: {
|
||||||
icon: [
|
icon: [
|
||||||
@ -35,11 +78,52 @@ export const metadata = {
|
|||||||
apple: "/icon-192.svg",
|
apple: "/icon-192.svg",
|
||||||
},
|
},
|
||||||
manifest: "/manifest.json",
|
manifest: "/manifest.json",
|
||||||
|
other: {
|
||||||
|
'msapplication-TileColor': '#2563eb',
|
||||||
|
'theme-color': '#ffffff',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({ children }: { children: ReactNode }) {
|
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 (
|
return (
|
||||||
<html lang="en" suppressHydrationWarning>
|
<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">
|
<body className="bg-background text-foreground min-h-screen font-sans antialiased">
|
||||||
{/* Skip navigation link for keyboard users */}
|
{/* Skip navigation link for keyboard users */}
|
||||||
<a
|
<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 { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
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 {
|
import {
|
||||||
Building2,
|
Building2,
|
||||||
Users,
|
Users,
|
||||||
@ -15,6 +26,7 @@ import {
|
|||||||
Settings,
|
Settings,
|
||||||
BarChart3
|
BarChart3
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
|
||||||
interface Company {
|
interface Company {
|
||||||
id: string;
|
id: string;
|
||||||
@ -39,8 +51,18 @@ interface DashboardData {
|
|||||||
export default function PlatformDashboard() {
|
export default function PlatformDashboard() {
|
||||||
const { data: session, status } = useSession();
|
const { data: session, status } = useSession();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { toast } = useToast();
|
||||||
const [dashboardData, setDashboardData] = useState<DashboardData | null>(null);
|
const [dashboardData, setDashboardData] = useState<DashboardData | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
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(() => {
|
useEffect(() => {
|
||||||
if (status === "loading") return;
|
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) => {
|
const getStatusBadgeVariant = (status: string) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case "ACTIVE": return "default";
|
case "ACTIVE": return "default";
|
||||||
@ -111,10 +181,81 @@ export default function PlatformDashboard() {
|
|||||||
<Settings className="w-4 h-4 mr-2" />
|
<Settings className="w-4 h-4 mr-2" />
|
||||||
Settings
|
Settings
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm">
|
<Dialog open={showAddCompany} onOpenChange={setShowAddCompany}>
|
||||||
<Plus className="w-4 h-4 mr-2" />
|
<DialogTrigger asChild>
|
||||||
Add Company
|
<Button size="sm">
|
||||||
</Button>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -200,7 +341,11 @@ export default function PlatformDashboard() {
|
|||||||
<BarChart3 className="w-4 h-4 mr-2" />
|
<BarChart3 className="w-4 h-4 mr-2" />
|
||||||
Analytics
|
Analytics
|
||||||
</Button>
|
</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" />
|
<Settings className="w-4 h-4 mr-2" />
|
||||||
Manage
|
Manage
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { SessionProvider } from "next-auth/react";
|
import { SessionProvider } from "next-auth/react";
|
||||||
|
import { Toaster } from "@/components/ui/toaster";
|
||||||
|
|
||||||
export default function PlatformLayout({
|
export default function PlatformLayout({
|
||||||
children,
|
children,
|
||||||
@ -10,6 +11,7 @@ export default function PlatformLayout({
|
|||||||
return (
|
return (
|
||||||
<SessionProvider>
|
<SessionProvider>
|
||||||
{children}
|
{children}
|
||||||
|
<Toaster />
|
||||||
</SessionProvider>
|
</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-slot": "^1.2.3",
|
||||||
"@radix-ui/react-switch": "^1.2.5",
|
"@radix-ui/react-switch": "^1.2.5",
|
||||||
"@radix-ui/react-tabs": "^1.1.12",
|
"@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": "^1.1.9",
|
||||||
"@radix-ui/react-toggle-group": "^1.1.10",
|
"@radix-ui/react-toggle-group": "^1.1.10",
|
||||||
"@radix-ui/react-tooltip": "^1.2.7",
|
"@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':
|
'@radix-ui/react-tabs':
|
||||||
specifier: ^1.1.12
|
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)
|
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':
|
'@radix-ui/react-toggle':
|
||||||
specifier: ^1.1.9
|
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)
|
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':
|
'@types/react-dom':
|
||||||
optional: true
|
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':
|
'@radix-ui/react-toggle-group@1.1.10':
|
||||||
resolution: {integrity: sha512-kiU694Km3WFLTC75DdqgM/3Jauf3rD9wxeS9XtyWFKsBUeZA337lC+6uUazT7I1DhanZ5gyD5Stf8uf2dbQxOQ==}
|
resolution: {integrity: sha512-kiU694Km3WFLTC75DdqgM/3Jauf3rD9wxeS9XtyWFKsBUeZA337lC+6uUazT7I1DhanZ5gyD5Stf8uf2dbQxOQ==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@ -5813,6 +5829,26 @@ snapshots:
|
|||||||
'@types/react': 19.1.8
|
'@types/react': 19.1.8
|
||||||
'@types/react-dom': 19.1.6(@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)':
|
'@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:
|
dependencies:
|
||||||
'@radix-ui/primitive': 1.1.2
|
'@radix-ui/primitive': 1.1.2
|
||||||
|
|||||||
Reference in New Issue
Block a user