mirror of
https://github.com/kjanat/livedash-node.git
synced 2026-03-02 21:01:28 +01:00
fix: resolve Prettier markdown code block parsing errors
- Fix syntax errors in skills markdown files (.github/skills, .opencode/skills) - Change typescript to tsx for code blocks with JSX - Replace ellipsis (...) in array examples with valid syntax - Separate CSS from TypeScript into distinct code blocks - Convert JavaScript object examples to valid JSON in docs - Fix enum definitions with proper comma separation
This commit is contained in:
20
app/account/[path]/page.tsx
Normal file
20
app/account/[path]/page.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { AccountView } from "@neondatabase/auth/react";
|
||||
import { accountViewPaths } from "@neondatabase/auth/react/ui/server";
|
||||
|
||||
export function generateStaticParams() {
|
||||
return Object.values(accountViewPaths).map((path) => ({ path }));
|
||||
}
|
||||
|
||||
export default async function AccountPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ path: string }>;
|
||||
}) {
|
||||
const { path } = await params;
|
||||
|
||||
return (
|
||||
<main className="container p-4 md:p-6">
|
||||
<AccountView path={path} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -2,16 +2,14 @@
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { checkDatabaseConnection, prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
// Check if user has admin access (you may want to add proper auth here)
|
||||
const authHeader = request.headers.get("authorization");
|
||||
if (!authHeader || !authHeader.startsWith("Bearer ")) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
// Check if user has admin access (you may want to add proper auth here)
|
||||
const authHeader = request.headers.get("authorization");
|
||||
if (!authHeader || !authHeader.startsWith("Bearer ")) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
// Basic database connectivity check
|
||||
const isConnected = await checkDatabaseConnection();
|
||||
|
||||
|
||||
@@ -1,44 +1,43 @@
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { fetchAndParseCsv } from "../../../../lib/csvFetcher";
|
||||
import { processQueuedImports } from "../../../../lib/importProcessor";
|
||||
import { prisma } from "../../../../lib/prisma";
|
||||
import { neonAuth } from "@/lib/auth/server";
|
||||
import { fetchAndParseCsv } from "@/lib/csvFetcher";
|
||||
import { processQueuedImports } from "@/lib/importProcessor";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
// MIGRATED: Removed "export const dynamic = 'force-dynamic'" - dynamic by default with Cache Components
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
let { companyId } = body;
|
||||
|
||||
if (!companyId) {
|
||||
// Try to get user from prisma based on session cookie
|
||||
try {
|
||||
const session = await prisma.session.findFirst({
|
||||
orderBy: { createdAt: "desc" },
|
||||
where: {
|
||||
/* Add session check criteria here */
|
||||
},
|
||||
});
|
||||
|
||||
if (session) {
|
||||
companyId = session.companyId;
|
||||
}
|
||||
} catch (error) {
|
||||
// Log error for server-side debugging
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
// Use a server-side logging approach instead of console
|
||||
process.stderr.write(`Error fetching session: ${errorMessage}\n`);
|
||||
}
|
||||
// Authenticate user
|
||||
const { session: authSession, user: authUser } = await neonAuth();
|
||||
if (!authSession || !authUser?.email) {
|
||||
return NextResponse.json({ error: "Not logged in" }, { status: 401 });
|
||||
}
|
||||
|
||||
if (!companyId) {
|
||||
// Look up user to get companyId and role
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email: authUser.email },
|
||||
select: { companyId: true, role: true },
|
||||
});
|
||||
|
||||
if (!user || !user.companyId) {
|
||||
return NextResponse.json(
|
||||
{ error: "Company ID is required" },
|
||||
{ status: 400 }
|
||||
{ error: "User not found or no company" },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
// Check if user has ADMIN role
|
||||
if (user.role !== "ADMIN") {
|
||||
return NextResponse.json(
|
||||
{ error: "Admin access required" },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
// Use user's companyId from auth (ignore any body params for security)
|
||||
const companyId = user.companyId;
|
||||
|
||||
const company = await prisma.company.findUnique({
|
||||
where: { id: companyId },
|
||||
});
|
||||
|
||||
@@ -1,31 +1,20 @@
|
||||
import { ProcessingStage } from "@prisma/client";
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "../../../../lib/auth";
|
||||
import { prisma } from "../../../../lib/prisma";
|
||||
import { processUnprocessedSessions } from "../../../../lib/processingScheduler";
|
||||
import { getSessionsNeedingProcessing } from "../../../../lib/processingStatusManager";
|
||||
import { neonAuth } from "@/lib/auth/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { processUnprocessedSessions } from "@/lib/processingScheduler";
|
||||
import { getSessionsNeedingProcessing } from "@/lib/processingStatusManager";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
interface SessionUser {
|
||||
email: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
interface SessionData {
|
||||
user: SessionUser;
|
||||
}
|
||||
// MIGRATED: Removed "export const dynamic = 'force-dynamic'" - dynamic by default with Cache Components
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const session = (await getServerSession(authOptions)) as SessionData | null;
|
||||
|
||||
if (!session?.user) {
|
||||
const { session: authSession, user: authUser } = await neonAuth();
|
||||
if (!authSession || !authUser?.email) {
|
||||
return NextResponse.json({ error: "Not logged in" }, { status: 401 });
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email: session.user.email },
|
||||
where: { email: authUser.email },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
import NextAuth from "next-auth";
|
||||
import { authOptions } from "../../../../lib/auth";
|
||||
|
||||
// Prevent static generation - this route needs runtime env vars
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const handler = NextAuth(authOptions);
|
||||
|
||||
export { handler as GET, handler as POST };
|
||||
3
app/api/auth/[...path]/route.ts
Normal file
3
app/api/auth/[...path]/route.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { authApiHandler } from "@neondatabase/auth/next/server";
|
||||
|
||||
export const { GET, POST } = authApiHandler();
|
||||
23
app/api/auth/me/route.ts
Normal file
23
app/api/auth/me/route.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getAuthenticatedUser } from "@/lib/auth/server";
|
||||
|
||||
/**
|
||||
* GET /api/auth/me
|
||||
* Returns the current user's data from our User table
|
||||
*/
|
||||
export async function GET() {
|
||||
const { user } = await getAuthenticatedUser();
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
role: user.role,
|
||||
companyId: user.companyId,
|
||||
company: user.company,
|
||||
});
|
||||
}
|
||||
@@ -1,22 +1,21 @@
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "../../../../lib/auth";
|
||||
import { prisma } from "../../../../lib/prisma";
|
||||
import { neonAuth } from "@/lib/auth/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
// MIGRATED: Removed "export const dynamic = 'force-dynamic'" - dynamic by default with Cache Components
|
||||
|
||||
export async function GET(_request: NextRequest) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user) {
|
||||
const { session: authSession, user: authUser } = await neonAuth();
|
||||
if (!authSession || !authUser?.email) {
|
||||
return NextResponse.json({ error: "Not logged in" }, { status: 401 });
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email: session.user.email as string },
|
||||
where: { email: authUser.email },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "No user" }, { status: 401 });
|
||||
if (!user || !user.companyId) {
|
||||
return NextResponse.json({ error: "No user or company" }, { status: 401 });
|
||||
}
|
||||
|
||||
// Get company data
|
||||
@@ -28,17 +27,17 @@ export async function GET(_request: NextRequest) {
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user) {
|
||||
const { session: authSession, user: authUser } = await neonAuth();
|
||||
if (!authSession || !authUser?.email) {
|
||||
return NextResponse.json({ error: "Not logged in" }, { status: 401 });
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email: session.user.email as string },
|
||||
where: { email: authUser.email },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "No user" }, { status: 401 });
|
||||
if (!user || !user.companyId) {
|
||||
return NextResponse.json({ error: "No user or company" }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
|
||||
@@ -1,29 +1,19 @@
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "../../../../lib/auth";
|
||||
import { sessionMetrics } from "../../../../lib/metrics";
|
||||
import { prisma } from "../../../../lib/prisma";
|
||||
import type { ChatSession } from "../../../../lib/types";
|
||||
import { neonAuth } from "@/lib/auth/server";
|
||||
import { sessionMetrics } from "@/lib/metrics";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import type { ChatSession } from "@/lib/types";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
interface SessionUser {
|
||||
email: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
interface SessionData {
|
||||
user: SessionUser;
|
||||
}
|
||||
// MIGRATED: Removed "export const dynamic = 'force-dynamic'" - dynamic by default with Cache Components
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const session = (await getServerSession(authOptions)) as SessionData | null;
|
||||
if (!session?.user) {
|
||||
const { session: authSession, user: authUser } = await neonAuth();
|
||||
if (!authSession || !authUser?.email) {
|
||||
return NextResponse.json({ error: "Not logged in" }, { status: 401 });
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email: session.user.email },
|
||||
where: { email: authUser.email },
|
||||
select: {
|
||||
id: true,
|
||||
companyId: true,
|
||||
@@ -38,8 +28,8 @@ export async function GET(request: NextRequest) {
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "No user" }, { status: 401 });
|
||||
if (!user || !user.companyId) {
|
||||
return NextResponse.json({ error: "No user or company" }, { status: 401 });
|
||||
}
|
||||
|
||||
// Get date range from query parameters
|
||||
@@ -171,7 +161,7 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
return NextResponse.json({
|
||||
metrics,
|
||||
csvUrl: user.company.csvUrl,
|
||||
csvUrl: user.company?.csvUrl,
|
||||
company: user.company,
|
||||
dateRange,
|
||||
});
|
||||
|
||||
@@ -1,18 +1,29 @@
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { getServerSession } from "next-auth/next";
|
||||
import { authOptions } from "../../../../lib/auth";
|
||||
import { prisma } from "../../../../lib/prisma";
|
||||
import { neonAuth } from "@/lib/auth/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
// MIGRATED: Removed "export const dynamic = 'force-dynamic'" - dynamic by default with Cache Components
|
||||
|
||||
export async function GET(_request: NextRequest) {
|
||||
const authSession = await getServerSession(authOptions);
|
||||
|
||||
if (!authSession || !authSession.user?.companyId) {
|
||||
const { session: authSession, user: authUser } = await neonAuth();
|
||||
if (!authSession || !authUser?.email) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const companyId = authSession.user.companyId;
|
||||
// Look up user in our database to get companyId
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email: authUser.email },
|
||||
select: { companyId: true },
|
||||
});
|
||||
|
||||
if (!user || !user.companyId) {
|
||||
return NextResponse.json(
|
||||
{ error: "User not found or no company" },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const companyId = user.companyId;
|
||||
|
||||
try {
|
||||
// Use groupBy for better performance with distinct values
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "../../../../../lib/prisma";
|
||||
import type { ChatSession } from "../../../../../lib/types";
|
||||
import { neonAuth } from "@/lib/auth/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import type { ChatSession } from "@/lib/types";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
// MIGRATED: Removed "export const dynamic = 'force-dynamic'" - dynamic by default with Cache Components
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
@@ -17,6 +18,22 @@ export async function GET(
|
||||
);
|
||||
}
|
||||
|
||||
// Verify user is authenticated
|
||||
const { session: authSession, user: authUser } = await neonAuth();
|
||||
if (!authSession || !authUser?.email) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
// Look up user in our database to get companyId for authorization
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email: authUser.email },
|
||||
select: { companyId: true },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "User not found" }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const prismaSession = await prisma.session.findUnique({
|
||||
where: { id },
|
||||
@@ -31,6 +48,11 @@ export async function GET(
|
||||
return NextResponse.json({ error: "Session not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
// Verify user has access to this session (same company)
|
||||
if (prismaSession.companyId !== user.companyId) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
// Map Prisma session object to ChatSession type
|
||||
const session: ChatSession = {
|
||||
// Spread prismaSession to include all its properties
|
||||
|
||||
@@ -1,20 +1,31 @@
|
||||
import type { Prisma, SessionCategory } from "@prisma/client";
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { getServerSession } from "next-auth/next";
|
||||
import { authOptions } from "../../../../lib/auth";
|
||||
import { prisma } from "../../../../lib/prisma";
|
||||
import type { ChatSession } from "../../../../lib/types";
|
||||
import { neonAuth } from "@/lib/auth/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import type { ChatSession } from "@/lib/types";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
// MIGRATED: Removed "export const dynamic = 'force-dynamic'" - dynamic by default with Cache Components
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const authSession = await getServerSession(authOptions);
|
||||
|
||||
if (!authSession || !authSession.user?.companyId) {
|
||||
const { session: authSession, user: authUser } = await neonAuth();
|
||||
if (!authSession || !authUser?.email) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const companyId = authSession.user.companyId;
|
||||
// Look up user in our database to get companyId
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email: authUser.email },
|
||||
select: { companyId: true },
|
||||
});
|
||||
|
||||
if (!user || !user.companyId) {
|
||||
return NextResponse.json(
|
||||
{ error: "User not found or no company" },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const companyId = user.companyId;
|
||||
const { searchParams } = new URL(request.url);
|
||||
|
||||
const searchTerm = searchParams.get("searchTerm");
|
||||
|
||||
@@ -1,22 +1,31 @@
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "../../../../lib/auth";
|
||||
import { prisma } from "../../../../lib/prisma";
|
||||
import { neonAuth } from "@/lib/auth/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
// MIGRATED: Removed "export const dynamic = 'force-dynamic'" - dynamic by default with Cache Components
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user || session.user.role !== "ADMIN") {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
const { session: authSession, user: authUser } = await neonAuth();
|
||||
if (!authSession || !authUser?.email) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
// Look up user in our database to get companyId and role
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email: session.user.email as string },
|
||||
where: { email: authUser.email },
|
||||
select: { companyId: true, role: true },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "No user" }, { status: 401 });
|
||||
if (!user || !user.companyId) {
|
||||
return NextResponse.json(
|
||||
{ error: "User not found or no company" },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
// Check for admin role
|
||||
if (user.role !== "ADMIN") {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
|
||||
@@ -1,30 +1,38 @@
|
||||
import crypto from "node:crypto";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "../../../../lib/auth";
|
||||
import { prisma } from "../../../../lib/prisma";
|
||||
import { neonAuth } from "@/lib/auth/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
// MIGRATED: Removed "export const dynamic = 'force-dynamic'" - dynamic by default with Cache Components
|
||||
|
||||
interface UserBasicInfo {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string | null;
|
||||
role: string;
|
||||
}
|
||||
|
||||
export async function GET(_request: NextRequest) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user || session.user.role !== "ADMIN") {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
const { session: authSession, user: authUser } = await neonAuth();
|
||||
if (!authSession || !authUser?.email) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
// Look up user in our database to get companyId and role
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email: session.user.email as string },
|
||||
where: { email: authUser.email },
|
||||
select: { companyId: true, role: true },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "No user" }, { status: 401 });
|
||||
if (!user || !user.companyId) {
|
||||
return NextResponse.json(
|
||||
{ error: "User not found or no company" },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
// Check for admin role
|
||||
if (user.role !== "ADMIN") {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const users = await prisma.user.findMany({
|
||||
@@ -34,28 +42,44 @@ export async function GET(_request: NextRequest) {
|
||||
const mappedUsers: UserBasicInfo[] = users.map((u) => ({
|
||||
id: u.id,
|
||||
email: u.email,
|
||||
name: u.name,
|
||||
role: u.role,
|
||||
}));
|
||||
|
||||
return NextResponse.json({ users: mappedUsers });
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/dashboard/users
|
||||
* Invite a new user to the company (creates a placeholder record)
|
||||
* The user will need to sign up via Neon Auth using the same email
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user || session.user.role !== "ADMIN") {
|
||||
const { session: authSession, user: authUser } = await neonAuth();
|
||||
if (!authSession || !authUser?.email) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
// Look up user in our database to get companyId and role
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email: authUser.email },
|
||||
select: { companyId: true, role: true, email: true },
|
||||
});
|
||||
|
||||
if (!user || !user.companyId) {
|
||||
return NextResponse.json(
|
||||
{ error: "User not found or no company" },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
// Check for admin role
|
||||
if (user.role !== "ADMIN") {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email: session.user.email as string },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "No user" }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { email, role } = body;
|
||||
const { email, name, role } = body;
|
||||
|
||||
if (!email || !role) {
|
||||
return NextResponse.json({ error: "Missing fields" }, { status: 400 });
|
||||
@@ -63,20 +87,28 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
const exists = await prisma.user.findUnique({ where: { email } });
|
||||
if (exists) {
|
||||
return NextResponse.json({ error: "Email exists" }, { status: 409 });
|
||||
return NextResponse.json(
|
||||
{ error: "Email already exists" },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
const tempPassword = crypto.randomBytes(12).toString("base64").slice(0, 12); // secure random initial password
|
||||
|
||||
// Create user record (they'll complete signup via Neon Auth)
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
email,
|
||||
password: await bcrypt.hash(tempPassword, 10),
|
||||
name: name || null,
|
||||
companyId: user.companyId,
|
||||
role,
|
||||
invitedBy: user.email,
|
||||
invitedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
// TODO: Email user their temp password (stub, for demo) - Implement a robust and secure email sending mechanism. Consider using a transactional email service.
|
||||
return NextResponse.json({ ok: true, tempPassword });
|
||||
// TODO: Send invitation email with sign-up link
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
message:
|
||||
"User invited. They should sign up at /auth/sign-up with this email.",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
import crypto from "node:crypto";
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "../../../lib/prisma";
|
||||
import { sendEmail } from "../../../lib/sendEmail";
|
||||
import { forgotPasswordSchema, validateInput } from "../../../lib/validation";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// In-memory rate limiting for password reset requests
|
||||
const resetAttempts = new Map<string, { count: number; resetTime: number }>();
|
||||
|
||||
function checkRateLimit(ip: string): boolean {
|
||||
const now = Date.now();
|
||||
const attempts = resetAttempts.get(ip);
|
||||
|
||||
if (!attempts || now > attempts.resetTime) {
|
||||
resetAttempts.set(ip, { count: 1, resetTime: now + 15 * 60 * 1000 }); // 15 minute window
|
||||
return true;
|
||||
}
|
||||
|
||||
if (attempts.count >= 5) {
|
||||
// Max 5 reset requests per 15 minutes per IP
|
||||
return false;
|
||||
}
|
||||
|
||||
attempts.count++;
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
// Rate limiting check
|
||||
const ip =
|
||||
request.headers.get("x-forwarded-for") ||
|
||||
request.headers.get("x-real-ip") ||
|
||||
"unknown";
|
||||
if (!checkRateLimit(ip)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: "Too many password reset attempts. Please try again later.",
|
||||
},
|
||||
{ status: 429 }
|
||||
);
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
|
||||
// Validate input
|
||||
const validation = validateInput(forgotPasswordSchema, body);
|
||||
if (!validation.success) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: "Invalid email format",
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const { email } = validation.data;
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { email } });
|
||||
|
||||
// Always return success for privacy (don't reveal if email exists)
|
||||
// But only send email if user exists
|
||||
if (user) {
|
||||
const token = crypto.randomBytes(32).toString("hex");
|
||||
const tokenHash = crypto.createHash("sha256").update(token).digest("hex");
|
||||
const expiry = new Date(Date.now() + 1000 * 60 * 30); // 30 min expiry
|
||||
|
||||
await prisma.user.update({
|
||||
where: { email },
|
||||
data: { resetToken: tokenHash, resetTokenExpiry: expiry },
|
||||
});
|
||||
|
||||
const resetUrl = `${process.env.NEXTAUTH_URL || "http://localhost:3000"}/reset-password?token=${token}`;
|
||||
await sendEmail(
|
||||
email,
|
||||
"Password Reset",
|
||||
`Reset your password: ${resetUrl}`
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true }, { status: 200 });
|
||||
} catch (error) {
|
||||
console.error("Forgot password error:", error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: "Internal server error",
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
import NextAuth from "next-auth";
|
||||
import { platformAuthOptions } from "../../../../../lib/platform-auth";
|
||||
|
||||
const handler = NextAuth(platformAuthOptions);
|
||||
|
||||
export { handler as GET, handler as POST };
|
||||
@@ -1,10 +1,10 @@
|
||||
import { CompanyStatus } from "@prisma/client";
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { platformAuthOptions } from "../../../../../lib/platform-auth";
|
||||
import { prisma } from "../../../../../lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
import {
|
||||
getAuthenticatedPlatformUser,
|
||||
hasPlatformAccess,
|
||||
} from "@/lib/auth/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
// GET /api/platform/companies/[id] - Get company details
|
||||
export async function GET(
|
||||
@@ -12,9 +12,9 @@ export async function GET(
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await getServerSession(platformAuthOptions);
|
||||
const { user } = await getAuthenticatedPlatformUser();
|
||||
|
||||
if (!session?.user?.isPlatformUser) {
|
||||
if (!user) {
|
||||
return NextResponse.json(
|
||||
{ error: "Platform access required" },
|
||||
{ status: 401 }
|
||||
@@ -67,12 +67,9 @@ export async function PATCH(
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await getServerSession(platformAuthOptions);
|
||||
const { user } = await getAuthenticatedPlatformUser();
|
||||
|
||||
if (
|
||||
!session?.user?.isPlatformUser ||
|
||||
session.user.platformRole === "SUPPORT"
|
||||
) {
|
||||
if (!user || !hasPlatformAccess(user.role, "PLATFORM_ADMIN")) {
|
||||
return NextResponse.json(
|
||||
{ error: "Admin access required" },
|
||||
{ status: 403 }
|
||||
@@ -81,12 +78,10 @@ export async function PATCH(
|
||||
|
||||
const { id } = await params;
|
||||
const body = await request.json();
|
||||
const { name, email, maxUsers, csvUrl, csvUsername, csvPassword, status } =
|
||||
body;
|
||||
const { name, maxUsers, csvUrl, csvUsername, csvPassword, status } = body;
|
||||
|
||||
const updateData: {
|
||||
name?: string;
|
||||
email?: string;
|
||||
maxUsers?: number;
|
||||
csvUrl?: string;
|
||||
csvUsername?: string;
|
||||
@@ -94,7 +89,6 @@ export async function PATCH(
|
||||
status?: CompanyStatus;
|
||||
} = {};
|
||||
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;
|
||||
@@ -122,12 +116,9 @@ export async function DELETE(
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await getServerSession(platformAuthOptions);
|
||||
const { user } = await getAuthenticatedPlatformUser();
|
||||
|
||||
if (
|
||||
!session?.user?.isPlatformUser ||
|
||||
session.user.platformRole !== "SUPER_ADMIN"
|
||||
) {
|
||||
if (!user || !hasPlatformAccess(user.role, "PLATFORM_SUPER_ADMIN")) {
|
||||
return NextResponse.json(
|
||||
{ error: "Super admin access required" },
|
||||
{ status: 403 }
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { hash } from "bcryptjs";
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { platformAuthOptions } from "../../../../../../lib/platform-auth";
|
||||
import { prisma } from "../../../../../../lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
import {
|
||||
getAuthenticatedPlatformUser,
|
||||
hasPlatformAccess,
|
||||
} from "@/lib/auth/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
// POST /api/platform/companies/[id]/users - Invite user to company
|
||||
export async function POST(
|
||||
@@ -12,11 +11,11 @@ export async function POST(
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await getServerSession(platformAuthOptions);
|
||||
const { user: platformUser } = await getAuthenticatedPlatformUser();
|
||||
|
||||
if (
|
||||
!session?.user?.isPlatformUser ||
|
||||
session.user.platformRole === "SUPPORT"
|
||||
!platformUser ||
|
||||
!hasPlatformAccess(platformUser.role, "PLATFORM_ADMIN")
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{ error: "Admin access required" },
|
||||
@@ -53,34 +52,26 @@ export async function POST(
|
||||
);
|
||||
}
|
||||
|
||||
// Check if user already exists in this company
|
||||
const existingUser = await prisma.user.findFirst({
|
||||
where: {
|
||||
email,
|
||||
companyId,
|
||||
},
|
||||
// Check if user already exists
|
||||
const existingUser = await prisma.user.findUnique({
|
||||
where: { email },
|
||||
});
|
||||
|
||||
if (existingUser) {
|
||||
return NextResponse.json(
|
||||
{ error: "User already exists in this company" },
|
||||
{ error: "User with this email already exists" },
|
||||
{ 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
|
||||
// Create the user placeholder (they'll complete signup via Neon Auth)
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
name,
|
||||
email,
|
||||
password: hashedPassword,
|
||||
role,
|
||||
companyId,
|
||||
invitedBy: session.user.email,
|
||||
invitedBy: platformUser.email,
|
||||
invitedAt: new Date(),
|
||||
},
|
||||
select: {
|
||||
@@ -94,13 +85,12 @@ export async function POST(
|
||||
},
|
||||
});
|
||||
|
||||
// In a real application, you would send an email with login credentials
|
||||
// For now, we'll return the temporary password
|
||||
// TODO: Send invitation email with sign-up link
|
||||
return NextResponse.json({
|
||||
user,
|
||||
tempPassword, // Remove this in production and send via email
|
||||
signupUrl: "/auth/sign-up",
|
||||
message:
|
||||
"User invited successfully. In production, credentials would be sent via email.",
|
||||
"User invited. They should sign up at /auth/sign-up with this email.",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Platform user invitation error:", error);
|
||||
@@ -117,9 +107,9 @@ export async function GET(
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await getServerSession(platformAuthOptions);
|
||||
const { user } = await getAuthenticatedPlatformUser();
|
||||
|
||||
if (!session?.user?.isPlatformUser) {
|
||||
if (!user) {
|
||||
return NextResponse.json(
|
||||
{ error: "Platform access required" },
|
||||
{ status: 401 }
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import type { CompanyStatus } from "@prisma/client";
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { platformAuthOptions } from "../../../../lib/platform-auth";
|
||||
import { prisma } from "../../../../lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
import {
|
||||
getAuthenticatedPlatformUser,
|
||||
hasPlatformAccess,
|
||||
} from "@/lib/auth/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
// GET /api/platform/companies - List all companies
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const session = await getServerSession(platformAuthOptions);
|
||||
const { user } = await getAuthenticatedPlatformUser();
|
||||
|
||||
if (!session?.user?.isPlatformUser) {
|
||||
if (!user) {
|
||||
return NextResponse.json(
|
||||
{ error: "Platform access required" },
|
||||
{ status: 401 }
|
||||
@@ -86,12 +86,9 @@ export async function GET(request: NextRequest) {
|
||||
// POST /api/platform/companies - Create new company
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const session = await getServerSession(platformAuthOptions);
|
||||
const { user } = await getAuthenticatedPlatformUser();
|
||||
|
||||
if (
|
||||
!session?.user?.isPlatformUser ||
|
||||
session.user.platformRole === "SUPPORT"
|
||||
) {
|
||||
if (!user || !hasPlatformAccess(user.role, "PLATFORM_ADMIN")) {
|
||||
return NextResponse.json(
|
||||
{ error: "Admin access required" },
|
||||
{ status: 403 }
|
||||
@@ -106,7 +103,6 @@ export async function POST(request: NextRequest) {
|
||||
csvPassword,
|
||||
adminEmail,
|
||||
adminName,
|
||||
adminPassword,
|
||||
maxUsers = 10,
|
||||
status = "TRIAL",
|
||||
} = body;
|
||||
@@ -125,15 +121,7 @@ export async function POST(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
// Generate password if not provided
|
||||
const finalAdminPassword =
|
||||
adminPassword || `Temp${Math.random().toString(36).slice(2, 8)}!`;
|
||||
|
||||
// Hash the admin password
|
||||
const bcrypt = await import("bcryptjs");
|
||||
const hashedPassword = await bcrypt.hash(finalAdminPassword, 12);
|
||||
|
||||
// Create company and admin user in a transaction
|
||||
// Create company and admin user placeholder in a transaction
|
||||
const result = await prisma.$transaction(async (tx) => {
|
||||
// Create the company
|
||||
const company = await tx.company.create({
|
||||
@@ -147,24 +135,19 @@ export async function POST(request: NextRequest) {
|
||||
},
|
||||
});
|
||||
|
||||
// Create the admin user
|
||||
// Create the admin user placeholder (they'll complete signup via Neon Auth)
|
||||
const adminUser = await tx.user.create({
|
||||
data: {
|
||||
email: adminEmail,
|
||||
password: hashedPassword,
|
||||
name: adminName,
|
||||
role: "ADMIN",
|
||||
companyId: company.id,
|
||||
invitedBy: session.user.email || "platform",
|
||||
invitedBy: user.email,
|
||||
invitedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
company,
|
||||
adminUser,
|
||||
generatedPassword: adminPassword ? null : finalAdminPassword,
|
||||
};
|
||||
return { company, adminUser };
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
@@ -175,7 +158,8 @@ export async function POST(request: NextRequest) {
|
||||
name: result.adminUser.name,
|
||||
role: result.adminUser.role,
|
||||
},
|
||||
generatedPassword: result.generatedPassword,
|
||||
// User should sign up via /auth/sign-up with this email
|
||||
signupUrl: "/auth/sign-up",
|
||||
},
|
||||
{ status: 201 }
|
||||
);
|
||||
|
||||
@@ -1,137 +0,0 @@
|
||||
import bcrypt from "bcryptjs";
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "../../../lib/prisma";
|
||||
import { registerSchema, validateInput } from "../../../lib/validation";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// In-memory rate limiting (for production, use Redis or similar)
|
||||
const registrationAttempts = new Map<
|
||||
string,
|
||||
{ count: number; resetTime: number }
|
||||
>();
|
||||
|
||||
function checkRateLimit(ip: string): boolean {
|
||||
const now = Date.now();
|
||||
const attempts = registrationAttempts.get(ip);
|
||||
|
||||
if (!attempts || now > attempts.resetTime) {
|
||||
registrationAttempts.set(ip, { count: 1, resetTime: now + 60 * 60 * 1000 }); // 1 hour window
|
||||
return true;
|
||||
}
|
||||
|
||||
if (attempts.count >= 3) {
|
||||
// Max 3 registrations per hour per IP
|
||||
return false;
|
||||
}
|
||||
|
||||
attempts.count++;
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
// Rate limiting check
|
||||
const ip = request.headers.get("x-forwarded-for") || "unknown";
|
||||
if (!checkRateLimit(ip)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: "Too many registration attempts. Please try again later.",
|
||||
},
|
||||
{ status: 429 }
|
||||
);
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
|
||||
// Validate input with Zod schema
|
||||
const validation = validateInput(registerSchema, body);
|
||||
if (!validation.success) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: "Validation failed",
|
||||
details: validation.errors,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const { email, password, company } = validation.data;
|
||||
|
||||
// Check if email exists
|
||||
const existingUser = await prisma.user.findUnique({
|
||||
where: { email },
|
||||
});
|
||||
|
||||
if (existingUser) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: "Email already exists",
|
||||
},
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
// Check if company name already exists
|
||||
const existingCompany = await prisma.company.findFirst({
|
||||
where: { name: company },
|
||||
});
|
||||
|
||||
if (existingCompany) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: "Company name already exists",
|
||||
},
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
// Create company and user in a transaction
|
||||
const result = await prisma.$transaction(async (tx) => {
|
||||
const newCompany = await tx.company.create({
|
||||
data: {
|
||||
name: company,
|
||||
csvUrl: "", // Empty by default, can be set later in settings
|
||||
},
|
||||
});
|
||||
|
||||
const hashedPassword = await bcrypt.hash(password, 12); // Increased rounds for better security
|
||||
|
||||
const newUser = await tx.user.create({
|
||||
data: {
|
||||
email,
|
||||
password: hashedPassword,
|
||||
companyId: newCompany.id,
|
||||
role: "USER", // Changed from ADMIN - users should be promoted by existing admins
|
||||
},
|
||||
});
|
||||
|
||||
return { company: newCompany, user: newUser };
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: true,
|
||||
data: {
|
||||
message: "Registration successful",
|
||||
userId: result.user.id,
|
||||
companyId: result.company.id,
|
||||
},
|
||||
},
|
||||
{ status: 201 }
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Registration error:", error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: "Internal server error",
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
import crypto from "node:crypto";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "../../../lib/prisma";
|
||||
import { resetPasswordSchema, validateInput } from "../../../lib/validation";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
|
||||
// Validate input with strong password requirements
|
||||
const validation = validateInput(resetPasswordSchema, body);
|
||||
if (!validation.success) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: "Validation failed",
|
||||
details: validation.errors,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const { token, password } = validation.data;
|
||||
|
||||
// Hash the token to compare with stored hash
|
||||
const tokenHash = crypto.createHash("sha256").update(token).digest("hex");
|
||||
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
resetToken: tokenHash,
|
||||
resetTokenExpiry: { gte: new Date() },
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error:
|
||||
"Invalid or expired token. Please request a new password reset.",
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Hash password with higher rounds for better security
|
||||
const hashedPassword = await bcrypt.hash(password, 12);
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
password: hashedPassword,
|
||||
resetToken: null,
|
||||
resetTokenExpiry: null,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: true,
|
||||
message: "Password has been reset successfully.",
|
||||
},
|
||||
{ status: 200 }
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Reset password error:", error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: "An internal server error occurred. Please try again later.",
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
19
app/auth/[path]/page.tsx
Normal file
19
app/auth/[path]/page.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { AuthView } from "@neondatabase/auth/react";
|
||||
|
||||
export function generateStaticParams() {
|
||||
return [{ path: "sign-in" }, { path: "sign-up" }, { path: "sign-out" }];
|
||||
}
|
||||
|
||||
export default async function AuthPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ path: string }>;
|
||||
}) {
|
||||
const { path } = await params;
|
||||
|
||||
return (
|
||||
<main className="container mx-auto flex grow flex-col items-center justify-center gap-3 self-center p-4 md:p-6">
|
||||
<AuthView path={path} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,20 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { Database, Save, Settings, ShieldX } from "lucide-react";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useEffect, useId, useState } from "react";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import type { Company } from "../../../lib/types";
|
||||
import { authClient } from "@/lib/auth/client";
|
||||
import type { Company } from "@/lib/types";
|
||||
|
||||
export default function CompanySettingsPage() {
|
||||
const csvUrlId = useId();
|
||||
const csvUsernameId = useId();
|
||||
const csvPasswordId = useId();
|
||||
const { data: session, status } = useSession();
|
||||
const { data, isPending } = authClient.useSession();
|
||||
// We store the full company object for future use and updates after save operations
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars, no-unused-vars
|
||||
const [_company, setCompany] = useState<Company | null>(null);
|
||||
@@ -25,7 +25,7 @@ export default function CompanySettingsPage() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (status === "authenticated") {
|
||||
if (!isPending && data?.session) {
|
||||
const fetchCompany = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
@@ -46,7 +46,7 @@ export default function CompanySettingsPage() {
|
||||
};
|
||||
fetchCompany();
|
||||
}
|
||||
}, [status]);
|
||||
}, [isPending, data]);
|
||||
|
||||
async function handleSave() {
|
||||
setMessage("");
|
||||
@@ -99,8 +99,8 @@ export default function CompanySettingsPage() {
|
||||
);
|
||||
}
|
||||
|
||||
// Check for ADMIN access
|
||||
if (session?.user?.role !== "ADMIN") {
|
||||
// Check for admin access
|
||||
if (data?.user?.role !== "admin") {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { type ReactNode, useCallback, useEffect, useId, useState } from "react";
|
||||
import Sidebar from "../../components/Sidebar";
|
||||
import Sidebar from "@/components/Sidebar";
|
||||
import { authClient } from "@/lib/auth/client";
|
||||
|
||||
export default function DashboardLayout({ children }: { children: ReactNode }) {
|
||||
const mainContentId = useId();
|
||||
const { status } = useSession();
|
||||
const { data, isPending } = authClient.useSession();
|
||||
const router = useRouter();
|
||||
|
||||
const [isSidebarExpanded, setIsSidebarExpanded] = useState(true);
|
||||
@@ -40,19 +40,21 @@ export default function DashboardLayout({ children }: { children: ReactNode }) {
|
||||
}
|
||||
}, [isMobile]);
|
||||
|
||||
if (status === "unauthenticated") {
|
||||
router.push("/login");
|
||||
// Redirect handled by middleware, but show loading state
|
||||
if (isPending) {
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center">
|
||||
<div className="text-center">Redirecting to login...</div>
|
||||
<div className="text-center">Loading session...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === "loading") {
|
||||
// If no session after loading, redirect to sign-in
|
||||
if (!data?.session) {
|
||||
router.push("/auth/sign-in");
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center">
|
||||
<div className="text-center">Loading session...</div>
|
||||
<div className="text-center">Redirecting to login...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -76,7 +78,6 @@ export default function DashboardLayout({ children }: { children: ReactNode }) {
|
||||
}
|
||||
sm:pr-6 md:py-6 md:pr-10`}
|
||||
>
|
||||
{/* <div className="w-full mx-auto">{children}</div> */}
|
||||
<div className="max-w-7xl mx-auto">{children}</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -16,8 +16,13 @@ import {
|
||||
} from "lucide-react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { signOut, useSession } from "next-auth/react";
|
||||
import { useCallback, useEffect, useId, useMemo, useState } from "react";
|
||||
import ModernBarChart from "@/components/charts/bar-chart";
|
||||
import ModernDonutChart from "@/components/charts/donut-chart";
|
||||
import ModernLineChart from "@/components/charts/line-chart";
|
||||
import GeographicMap from "@/components/GeographicMap";
|
||||
import ResponseTimeDistribution from "@/components/ResponseTimeDistribution";
|
||||
import TopQuestionsChart from "@/components/TopQuestionsChart";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
@@ -27,19 +32,14 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import MetricCard from "@/components/ui/metric-card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { authClient } from "@/lib/auth/client";
|
||||
import { formatEnumValue } from "@/lib/format-enums";
|
||||
import ModernBarChart from "../../../components/charts/bar-chart";
|
||||
import ModernDonutChart from "../../../components/charts/donut-chart";
|
||||
import ModernLineChart from "../../../components/charts/line-chart";
|
||||
import GeographicMap from "../../../components/GeographicMap";
|
||||
import ResponseTimeDistribution from "../../../components/ResponseTimeDistribution";
|
||||
import TopQuestionsChart from "../../../components/TopQuestionsChart";
|
||||
import MetricCard from "../../../components/ui/metric-card";
|
||||
import type { Company, MetricsResult } from "../../../lib/types";
|
||||
import type { Company, MetricsResult } from "@/lib/types";
|
||||
|
||||
// Dynamic import for heavy D3-based WordCloud component (~50KB)
|
||||
const WordCloud = dynamic(() => import("../../../components/WordCloud"), {
|
||||
const WordCloud = dynamic(() => import("@/components/WordCloud"), {
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div className="h-[300px] flex items-center justify-center">
|
||||
@@ -50,7 +50,7 @@ const WordCloud = dynamic(() => import("../../../components/WordCloud"), {
|
||||
|
||||
// Safely wrapped component with useSession
|
||||
function DashboardContent() {
|
||||
const { data: session, status } = useSession();
|
||||
const { data, isPending } = authClient.useSession();
|
||||
const router = useRouter();
|
||||
const [metrics, setMetrics] = useState<MetricsResult | null>(null);
|
||||
const [company, setCompany] = useState<Company | null>(null);
|
||||
@@ -59,7 +59,7 @@ function DashboardContent() {
|
||||
const [isInitialLoad, setIsInitialLoad] = useState<boolean>(true);
|
||||
|
||||
const refreshStatusId = useId();
|
||||
const isAuditor = session?.user?.role === "AUDITOR";
|
||||
// Role-based restrictions removed - all authenticated users have same access
|
||||
|
||||
// Function to fetch metrics with optional date range
|
||||
const fetchMetrics = useCallback(
|
||||
@@ -92,19 +92,18 @@ function DashboardContent() {
|
||||
|
||||
useEffect(() => {
|
||||
// Redirect if not authenticated
|
||||
if (status === "unauthenticated") {
|
||||
router.push("/login");
|
||||
if (!isPending && !data?.session) {
|
||||
router.push("/auth/sign-in");
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch metrics and company on mount if authenticated
|
||||
if (status === "authenticated" && isInitialLoad) {
|
||||
if (!isPending && data?.session && isInitialLoad) {
|
||||
fetchMetrics(undefined, undefined, true);
|
||||
}
|
||||
}, [status, router, isInitialLoad, fetchMetrics]);
|
||||
}, [isPending, data, router, isInitialLoad, fetchMetrics]);
|
||||
|
||||
const handleRefresh = useCallback(async () => {
|
||||
if (isAuditor) return;
|
||||
if (!company?.id) {
|
||||
alert("Cannot refresh: Company ID is missing");
|
||||
return;
|
||||
@@ -132,7 +131,7 @@ function DashboardContent() {
|
||||
} finally {
|
||||
setRefreshing(false);
|
||||
}
|
||||
}, [isAuditor, company?.id]);
|
||||
}, [company?.id]);
|
||||
|
||||
// Memoized data preparation - must be before any early returns (Rules of Hooks)
|
||||
const sentimentData = useMemo(() => {
|
||||
@@ -222,7 +221,7 @@ function DashboardContent() {
|
||||
}, [metrics?.avgResponseTime]);
|
||||
|
||||
// Show loading state while session status is being determined
|
||||
if (status === "loading") {
|
||||
if (isPending) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[60vh]">
|
||||
<div className="text-center space-y-4">
|
||||
@@ -233,7 +232,7 @@ function DashboardContent() {
|
||||
);
|
||||
}
|
||||
|
||||
if (status === "unauthenticated") {
|
||||
if (!isPending && !data?.session) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[60vh]">
|
||||
<div className="text-center">
|
||||
@@ -335,7 +334,7 @@ function DashboardContent() {
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
onClick={handleRefresh}
|
||||
disabled={refreshing || isAuditor}
|
||||
disabled={refreshing}
|
||||
size="sm"
|
||||
className="gap-2"
|
||||
aria-label={
|
||||
@@ -369,7 +368,15 @@ function DashboardContent() {
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={() => signOut({ callbackUrl: "/login" })}
|
||||
onClick={async () => {
|
||||
await authClient.signOut({
|
||||
fetchOptions: {
|
||||
onSuccess: () => {
|
||||
window.location.href = "/auth/sign-in";
|
||||
},
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
<LogOut className="h-4 w-4 mr-2" aria-hidden="true" />
|
||||
Sign out
|
||||
|
||||
@@ -11,25 +11,25 @@ import {
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { type FC, useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { authClient } from "@/lib/auth/client";
|
||||
|
||||
const DashboardPage: FC = () => {
|
||||
const { data: session, status } = useSession();
|
||||
const { data, isPending } = authClient.useSession();
|
||||
const router = useRouter();
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
// Once session is loaded, redirect appropriately
|
||||
if (status === "unauthenticated") {
|
||||
router.push("/login");
|
||||
} else if (status === "authenticated") {
|
||||
if (!isPending && !data?.session) {
|
||||
router.push("/auth/sign-in");
|
||||
} else if (!isPending && data?.session) {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [status, router]);
|
||||
}, [isPending, data, router]);
|
||||
|
||||
// Memoize navigation cards - only recalculates when user role changes
|
||||
const navigationCards = useMemo(() => {
|
||||
@@ -56,7 +56,7 @@ const DashboardPage: FC = () => {
|
||||
},
|
||||
];
|
||||
|
||||
if (session?.user?.role === "ADMIN") {
|
||||
if (data?.user?.role === "admin") {
|
||||
return [
|
||||
...baseCards,
|
||||
{
|
||||
@@ -86,7 +86,7 @@ const DashboardPage: FC = () => {
|
||||
];
|
||||
}
|
||||
return baseCards;
|
||||
}, [session?.user?.role]);
|
||||
}, [data?.user?.role]);
|
||||
|
||||
// Memoize class getter functions
|
||||
const getCardClasses = useCallback((variant: string) => {
|
||||
@@ -150,13 +150,13 @@ const DashboardPage: FC = () => {
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-4xl font-bold tracking-tight bg-clip-text text-transparent bg-linear-to-r from-foreground to-foreground/70">
|
||||
Welcome back, {session?.user?.name || "User"}!
|
||||
Welcome back, {data?.user?.name || "User"}!
|
||||
</h1>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-xs px-3 py-1 bg-primary/10 text-primary border-primary/20"
|
||||
>
|
||||
{session?.user?.role}
|
||||
{data?.user?.role}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-lg">
|
||||
|
||||
326
app/dashboard/sessions/[id]/SessionViewClient.tsx
Normal file
326
app/dashboard/sessions/[id]/SessionViewClient.tsx
Normal file
@@ -0,0 +1,326 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Activity,
|
||||
AlertCircle,
|
||||
ArrowLeft,
|
||||
Clock,
|
||||
ExternalLink,
|
||||
FileText,
|
||||
Globe,
|
||||
MessageSquare,
|
||||
User,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import MessageViewer from "@/components/MessageViewer";
|
||||
import SessionDetails from "@/components/SessionDetails";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { authClient } from "@/lib/auth/client";
|
||||
import { formatCategory } from "@/lib/format-enums";
|
||||
import type { ChatSession } from "@/lib/types";
|
||||
|
||||
export default function SessionViewClient() {
|
||||
const params = useParams();
|
||||
const router = useRouter(); // Initialize useRouter
|
||||
const { data: authData, isPending } = authClient.useSession(); // Get session status
|
||||
const id = params?.id as string;
|
||||
const [session, setSession] = useState<ChatSession | null>(null);
|
||||
const [loading, setLoading] = useState(true); // This will now primarily be for data fetching
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPending && !authData?.session) {
|
||||
router.push("/auth/sign-in");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isPending && authData?.session && id) {
|
||||
const fetchSession = async () => {
|
||||
setLoading(true); // Always set loading before fetch
|
||||
setError(null);
|
||||
try {
|
||||
const response = await fetch(`/api/dashboard/session/${id}`);
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(
|
||||
errorData.error ||
|
||||
`Failed to fetch session: ${response.statusText}`
|
||||
);
|
||||
}
|
||||
const data = await response.json();
|
||||
setSession(data.session);
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error ? err.message : "An unknown error occurred"
|
||||
);
|
||||
setSession(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchSession();
|
||||
} else if (!isPending && authData?.session && !id) {
|
||||
setError("Session ID is missing.");
|
||||
setLoading(false);
|
||||
}
|
||||
}, [id, isPending, authData, router]);
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
Loading session...
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isPending && !authData?.session) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
Redirecting to login...
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading && !isPending && authData?.session) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
Loading session details...
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-center py-8">
|
||||
<AlertCircle className="h-12 w-12 text-destructive mx-auto mb-4" />
|
||||
<p className="text-destructive text-lg mb-4">Error: {error}</p>
|
||||
<Link href="/dashboard/sessions">
|
||||
<Button variant="outline" className="gap-2">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back to Sessions List
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-center py-8">
|
||||
<MessageSquare className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
||||
<p className="text-muted-foreground text-lg mb-4">
|
||||
Session not found.
|
||||
</p>
|
||||
<Link href="/dashboard/sessions">
|
||||
<Button variant="outline" className="gap-2">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back to Sessions List
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-6xl mx-auto">
|
||||
{/* Header */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
||||
<div className="space-y-2">
|
||||
<Link href="/dashboard/sessions">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="gap-2 p-0 h-auto focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2"
|
||||
aria-label="Return to sessions list"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" aria-hidden="true" />
|
||||
Back to Sessions List
|
||||
</Button>
|
||||
</Link>
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-3xl font-bold">Session Details</h1>
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge variant="outline" className="font-mono text-xs">
|
||||
ID
|
||||
</Badge>
|
||||
<code className="text-sm text-muted-foreground font-mono">
|
||||
{(session.sessionId || session.id).slice(0, 8)}...
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{session.category && (
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
<Activity className="h-3 w-3" />
|
||||
{formatCategory(session.category)}
|
||||
</Badge>
|
||||
)}
|
||||
{session.language && (
|
||||
<Badge variant="outline" className="gap-1">
|
||||
<Globe className="h-3 w-3" />
|
||||
{session.language.toUpperCase()}
|
||||
</Badge>
|
||||
)}
|
||||
{session.sentiment && (
|
||||
<Badge
|
||||
variant={
|
||||
session.sentiment === "positive"
|
||||
? "default"
|
||||
: session.sentiment === "negative"
|
||||
? "destructive"
|
||||
: "secondary"
|
||||
}
|
||||
className="gap-1"
|
||||
>
|
||||
{session.sentiment.charAt(0).toUpperCase() +
|
||||
session.sentiment.slice(1)}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Session Overview */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Clock className="h-8 w-8 text-blue-500" />
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Start Time</p>
|
||||
<p className="font-semibold">
|
||||
{new Date(session.startTime).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<MessageSquare className="h-8 w-8 text-green-500" />
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Messages</p>
|
||||
<p className="font-semibold">{session.messages?.length || 0}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<User className="h-8 w-8 text-purple-500" />
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">User ID</p>
|
||||
<p className="font-semibold truncate">
|
||||
{session.userId || "N/A"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Activity className="h-8 w-8 text-orange-500" />
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Duration</p>
|
||||
<p className="font-semibold">
|
||||
{session.endTime && session.startTime
|
||||
? `${Math.round(
|
||||
(new Date(session.endTime).getTime() -
|
||||
new Date(session.startTime).getTime()) /
|
||||
60000
|
||||
)} min`
|
||||
: "N/A"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Session Details */}
|
||||
<SessionDetails session={session} />
|
||||
|
||||
{/* Messages */}
|
||||
{session.messages && session.messages.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<MessageSquare className="h-5 w-5" />
|
||||
Conversation ({session.messages.length} messages)
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<MessageViewer messages={session.messages} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Transcript URL */}
|
||||
{session.fullTranscriptUrl && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FileText className="h-5 w-5" />
|
||||
Source Transcript
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<a
|
||||
href={session.fullTranscriptUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 text-primary hover:underline focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 rounded-sm"
|
||||
aria-label="Open original transcript in new tab"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" aria-hidden="true" />
|
||||
View Original Transcript
|
||||
</a>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,326 +1,36 @@
|
||||
"use client";
|
||||
import { Suspense } from "react";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import SessionViewClient from "./SessionViewClient";
|
||||
|
||||
import {
|
||||
Activity,
|
||||
AlertCircle,
|
||||
ArrowLeft,
|
||||
Clock,
|
||||
ExternalLink,
|
||||
FileText,
|
||||
Globe,
|
||||
MessageSquare,
|
||||
User,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { formatCategory } from "@/lib/format-enums";
|
||||
import MessageViewer from "../../../../components/MessageViewer";
|
||||
import SessionDetails from "../../../../components/SessionDetails";
|
||||
import type { ChatSession } from "../../../../lib/types";
|
||||
export const metadata = {
|
||||
title: "Session Details | LiveDash",
|
||||
description: "View detailed session information and conversation history",
|
||||
};
|
||||
|
||||
export default function SessionViewPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter(); // Initialize useRouter
|
||||
const { status } = useSession(); // Get session status, removed unused sessionData
|
||||
const id = params?.id as string;
|
||||
const [session, setSession] = useState<ChatSession | null>(null);
|
||||
const [loading, setLoading] = useState(true); // This will now primarily be for data fetching
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (status === "unauthenticated") {
|
||||
router.push("/login");
|
||||
return;
|
||||
}
|
||||
|
||||
if (status === "authenticated" && id) {
|
||||
const fetchSession = async () => {
|
||||
setLoading(true); // Always set loading before fetch
|
||||
setError(null);
|
||||
try {
|
||||
const response = await fetch(`/api/dashboard/session/${id}`);
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(
|
||||
errorData.error ||
|
||||
`Failed to fetch session: ${response.statusText}`
|
||||
);
|
||||
}
|
||||
const data = await response.json();
|
||||
setSession(data.session);
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error ? err.message : "An unknown error occurred"
|
||||
);
|
||||
setSession(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchSession();
|
||||
} else if (status === "authenticated" && !id) {
|
||||
setError("Session ID is missing.");
|
||||
setLoading(false);
|
||||
}
|
||||
}, [id, status, router]); // session removed from dependencies
|
||||
|
||||
if (status === "loading") {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
Loading session...
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === "unauthenticated") {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
Redirecting to login...
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading && status === "authenticated") {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
Loading session details...
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-center py-8">
|
||||
<AlertCircle className="h-12 w-12 text-destructive mx-auto mb-4" />
|
||||
<p className="text-destructive text-lg mb-4">Error: {error}</p>
|
||||
<Link href="/dashboard/sessions">
|
||||
<Button variant="outline" className="gap-2">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back to Sessions List
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-center py-8">
|
||||
<MessageSquare className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
||||
<p className="text-muted-foreground text-lg mb-4">
|
||||
Session not found.
|
||||
</p>
|
||||
<Link href="/dashboard/sessions">
|
||||
<Button variant="outline" className="gap-2">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back to Sessions List
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// Provide at least one sample param for build-time validation
|
||||
// Runtime params not in this list will be handled dynamically
|
||||
export async function generateStaticParams() {
|
||||
return [{ id: "sample" }];
|
||||
}
|
||||
|
||||
function SessionLoadingFallback() {
|
||||
return (
|
||||
<div className="space-y-6 max-w-6xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
||||
<div className="space-y-2">
|
||||
<Link href="/dashboard/sessions">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="gap-2 p-0 h-auto focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2"
|
||||
aria-label="Return to sessions list"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" aria-hidden="true" />
|
||||
Back to Sessions List
|
||||
</Button>
|
||||
</Link>
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-3xl font-bold">Session Details</h1>
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge variant="outline" className="font-mono text-xs">
|
||||
ID
|
||||
</Badge>
|
||||
<code className="text-sm text-muted-foreground font-mono">
|
||||
{(session.sessionId || session.id).slice(0, 8)}...
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{session.category && (
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
<Activity className="h-3 w-3" />
|
||||
{formatCategory(session.category)}
|
||||
</Badge>
|
||||
)}
|
||||
{session.language && (
|
||||
<Badge variant="outline" className="gap-1">
|
||||
<Globe className="h-3 w-3" />
|
||||
{session.language.toUpperCase()}
|
||||
</Badge>
|
||||
)}
|
||||
{session.sentiment && (
|
||||
<Badge
|
||||
variant={
|
||||
session.sentiment === "positive"
|
||||
? "default"
|
||||
: session.sentiment === "negative"
|
||||
? "destructive"
|
||||
: "secondary"
|
||||
}
|
||||
className="gap-1"
|
||||
>
|
||||
{session.sentiment.charAt(0).toUpperCase() +
|
||||
session.sentiment.slice(1)}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
Loading session...
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Session Overview */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Clock className="h-8 w-8 text-blue-500" />
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Start Time</p>
|
||||
<p className="font-semibold">
|
||||
{new Date(session.startTime).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<MessageSquare className="h-8 w-8 text-green-500" />
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Messages</p>
|
||||
<p className="font-semibold">{session.messages?.length || 0}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<User className="h-8 w-8 text-purple-500" />
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">User ID</p>
|
||||
<p className="font-semibold truncate">
|
||||
{session.userId || "N/A"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Activity className="h-8 w-8 text-orange-500" />
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Duration</p>
|
||||
<p className="font-semibold">
|
||||
{session.endTime && session.startTime
|
||||
? `${Math.round(
|
||||
(new Date(session.endTime).getTime() -
|
||||
new Date(session.startTime).getTime()) /
|
||||
60000
|
||||
)} min`
|
||||
: "N/A"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Session Details */}
|
||||
<SessionDetails session={session} />
|
||||
|
||||
{/* Messages */}
|
||||
{session.messages && session.messages.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<MessageSquare className="h-5 w-5" />
|
||||
Conversation ({session.messages.length} messages)
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<MessageViewer messages={session.messages} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Transcript URL */}
|
||||
{session.fullTranscriptUrl && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FileText className="h-5 w-5" />
|
||||
Source Transcript
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<a
|
||||
href={session.fullTranscriptUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 text-primary hover:underline focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 rounded-sm"
|
||||
aria-label="Open original transcript in new tab"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" aria-hidden="true" />
|
||||
View Original Transcript
|
||||
</a>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SessionViewPage() {
|
||||
return (
|
||||
<Suspense fallback={<SessionLoadingFallback />}>
|
||||
<SessionViewClient />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { formatCategory } from "@/lib/format-enums";
|
||||
import type { ChatSession } from "../../../lib/types";
|
||||
import type { ChatSession } from "@/lib/types";
|
||||
|
||||
// State types
|
||||
interface FilterState {
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
"use client";
|
||||
import type { Session } from "next-auth";
|
||||
import { useState } from "react";
|
||||
import type { Company } from "../../lib/types";
|
||||
import type { Company, UserSession } from "@/lib/types";
|
||||
|
||||
interface DashboardSettingsProps {
|
||||
company: Company;
|
||||
session: Session;
|
||||
session: UserSession;
|
||||
}
|
||||
|
||||
export default function DashboardSettings({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
import { useEffect, useState } from "react";
|
||||
import type { UserSession } from "../../lib/types";
|
||||
import type { UserSession } from "@/lib/types";
|
||||
|
||||
interface UserItem {
|
||||
id: string;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { AlertCircle, Eye, Shield, UserPlus, Users } from "lucide-react";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { AlertCircle, Shield, UserPlus, Users } from "lucide-react";
|
||||
import { useCallback, useEffect, useId, useState } from "react";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -24,6 +23,7 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { authClient } from "@/lib/auth/client";
|
||||
|
||||
interface UserItem {
|
||||
id: string;
|
||||
@@ -32,10 +32,10 @@ interface UserItem {
|
||||
}
|
||||
|
||||
export default function UserManagementPage() {
|
||||
const { data: session, status } = useSession();
|
||||
const { data, isPending } = authClient.useSession();
|
||||
const [users, setUsers] = useState<UserItem[]>([]);
|
||||
const [email, setEmail] = useState<string>("");
|
||||
const [role, setRole] = useState<string>("USER");
|
||||
const [role, setRole] = useState<string>("user");
|
||||
const [message, setMessage] = useState<string>("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const emailId = useId();
|
||||
@@ -55,16 +55,16 @@ export default function UserManagementPage() {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (status === "authenticated") {
|
||||
if (session?.user?.role === "ADMIN") {
|
||||
if (!isPending && data?.session) {
|
||||
if (data?.user?.role === "admin") {
|
||||
fetchUsers();
|
||||
} else {
|
||||
setLoading(false); // Stop loading for non-admin users
|
||||
}
|
||||
} else if (status === "unauthenticated") {
|
||||
} else if (!isPending && !data?.session) {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [status, session?.user?.role, fetchUsers]);
|
||||
}, [isPending, data, fetchUsers]);
|
||||
|
||||
async function inviteUser() {
|
||||
setMessage("");
|
||||
@@ -108,7 +108,7 @@ export default function UserManagementPage() {
|
||||
}
|
||||
|
||||
// Check for admin access
|
||||
if (session?.user?.role !== "ADMIN") {
|
||||
if (data?.user?.role !== "admin") {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
@@ -185,9 +185,8 @@ export default function UserManagementPage() {
|
||||
<SelectValue placeholder="Select role" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="USER">User</SelectItem>
|
||||
<SelectItem value="ADMIN">Admin</SelectItem>
|
||||
<SelectItem value="AUDITOR">Auditor</SelectItem>
|
||||
<SelectItem value="user">User</SelectItem>
|
||||
<SelectItem value="admin">Admin</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -237,21 +236,14 @@ export default function UserManagementPage() {
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={
|
||||
user.role === "ADMIN"
|
||||
? "default"
|
||||
: user.role === "AUDITOR"
|
||||
? "secondary"
|
||||
: "outline"
|
||||
user.role === "admin" ? "default" : "outline"
|
||||
}
|
||||
className="gap-1"
|
||||
data-testid="role-badge"
|
||||
>
|
||||
{user.role === "ADMIN" && (
|
||||
{user.role === "admin" && (
|
||||
<Shield className="h-3 w-3" />
|
||||
)}
|
||||
{user.role === "AUDITOR" && (
|
||||
<Eye className="h-3 w-3" />
|
||||
)}
|
||||
{user.role}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
"use client";
|
||||
import { useState } from "react";
|
||||
|
||||
export default function ForgotPasswordPage() {
|
||||
const [email, setEmail] = useState<string>("");
|
||||
const [message, setMessage] = useState<string>("");
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
const res = await fetch("/api/forgot-password", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email }),
|
||||
});
|
||||
if (res.ok) setMessage("If that email exists, a reset link has been sent.");
|
||||
else setMessage("Failed. Try again.");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-md mx-auto mt-24 bg-white rounded-xl p-8 shadow">
|
||||
<h1 className="text-2xl font-bold mb-6">Forgot Password</h1>
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||
<input
|
||||
className="border px-3 py-2 rounded"
|
||||
type="email"
|
||||
placeholder="Email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<button className="bg-blue-600 text-white rounded py-2" type="submit">
|
||||
Send Reset Link
|
||||
</button>
|
||||
</form>
|
||||
<div className="mt-4 text-green-700">{message}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@import "@neondatabase/auth/ui/tailwind";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
// Main app layout with basic global style
|
||||
import "./globals.css";
|
||||
import type { Metadata } from "next";
|
||||
import type { ReactNode } from "react";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { Providers } from "./providers";
|
||||
|
||||
export const metadata = {
|
||||
// Use production URL as default for metadataBase (required for static generation)
|
||||
const baseUrl = "https://livedash.notso.ai";
|
||||
|
||||
export const metadata: 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. Turn every conversation into competitive intelligence.",
|
||||
@@ -31,9 +35,7 @@ export const metadata = {
|
||||
address: false,
|
||||
telephone: false,
|
||||
},
|
||||
metadataBase: new URL(
|
||||
process.env.NEXTAUTH_URL || "https://livedash.notso.ai"
|
||||
),
|
||||
metadataBase: new URL(baseUrl),
|
||||
alternates: {
|
||||
canonical: "/",
|
||||
},
|
||||
@@ -95,7 +97,7 @@ export default function RootLayout({ children }: { children: ReactNode }) {
|
||||
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",
|
||||
url: baseUrl,
|
||||
author: {
|
||||
"@type": "Organization",
|
||||
name: "Notso AI",
|
||||
|
||||
@@ -1,271 +1,6 @@
|
||||
"use client";
|
||||
import { BarChart3, Loader2, Shield, Zap } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { signIn } from "next-auth/react";
|
||||
import { useId, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { ThemeToggle } from "@/components/ui/theme-toggle";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
// Redirect to Neon Auth sign-in page
|
||||
export default function LoginPage() {
|
||||
const emailId = useId();
|
||||
const emailHelpId = useId();
|
||||
const passwordId = useId();
|
||||
const passwordHelpId = useId();
|
||||
const loadingStatusId = useId();
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
async function handleLogin(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
setError("");
|
||||
|
||||
try {
|
||||
const res = await signIn("credentials", {
|
||||
email,
|
||||
password,
|
||||
redirect: false,
|
||||
});
|
||||
|
||||
if (res?.ok) {
|
||||
toast.success("Login successful! Redirecting...");
|
||||
router.push("/dashboard");
|
||||
} else {
|
||||
setError("Invalid email or password. Please try again.");
|
||||
toast.error("Login failed. Please check your credentials.");
|
||||
}
|
||||
} catch {
|
||||
setError("An error occurred. Please try again.");
|
||||
toast.error("An unexpected error occurred.");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex">
|
||||
{/* Left side - Branding and Features */}
|
||||
<div className="hidden lg:flex lg:flex-1 bg-linear-to-br from-primary/10 via-primary/5 to-background relative overflow-hidden">
|
||||
<div className="absolute inset-0 bg-linear-to-br from-primary/5 to-transparent" />
|
||||
<div className="absolute -top-24 -left-24 h-96 w-96 rounded-full bg-primary/10 blur-3xl" />
|
||||
<div className="absolute -bottom-24 -right-24 h-96 w-96 rounded-full bg-primary/5 blur-3xl" />
|
||||
|
||||
<div className="relative flex flex-col justify-center px-12 py-24">
|
||||
<div className="max-w-md">
|
||||
<Link href="/" className="flex items-center gap-3 mb-8">
|
||||
<div className="relative w-12 h-12">
|
||||
<Image
|
||||
src="/favicon.svg"
|
||||
alt="LiveDash Logo"
|
||||
fill
|
||||
className="object-contain"
|
||||
/>
|
||||
</div>
|
||||
<span className="text-2xl font-bold text-primary">LiveDash</span>
|
||||
</Link>
|
||||
|
||||
<h1 className="text-4xl font-bold tracking-tight mb-6">
|
||||
Welcome back to your analytics dashboard
|
||||
</h1>
|
||||
<p className="text-xl text-muted-foreground mb-8">
|
||||
Monitor, analyze, and optimize your customer conversations with
|
||||
AI-powered insights.
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-primary/10 text-primary">
|
||||
<BarChart3 className="h-5 w-5" />
|
||||
</div>
|
||||
<span className="text-muted-foreground">
|
||||
Real-time analytics and insights
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-green-500/10 text-green-600">
|
||||
<Shield className="h-5 w-5" />
|
||||
</div>
|
||||
<span className="text-muted-foreground">
|
||||
Enterprise-grade security
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-blue-500/10 text-blue-600">
|
||||
<Zap className="h-5 w-5" />
|
||||
</div>
|
||||
<span className="text-muted-foreground">
|
||||
AI-powered conversation analysis
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right side - Login Form */}
|
||||
<div className="flex-1 flex flex-col justify-center px-8 py-12 lg:px-12">
|
||||
<div className="absolute top-4 right-4">
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
|
||||
<div className="mx-auto w-full max-w-sm">
|
||||
{/* Mobile logo */}
|
||||
<div className="lg:hidden flex justify-center mb-8">
|
||||
<Link href="/" className="flex items-center gap-3">
|
||||
<div className="relative w-10 h-10">
|
||||
<Image
|
||||
src="/favicon.svg"
|
||||
alt="LiveDash Logo"
|
||||
fill
|
||||
className="object-contain"
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xl font-bold text-primary">LiveDash</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<Card className="border-border/50 shadow-xl">
|
||||
<CardHeader className="space-y-1">
|
||||
<CardTitle className="text-2xl font-bold">Sign in</CardTitle>
|
||||
<CardDescription>
|
||||
Enter your email and password to access your dashboard
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{/* Live region for screen reader announcements */}
|
||||
<output aria-live="polite" className="sr-only">
|
||||
{isLoading && "Signing in, please wait..."}
|
||||
{error && `Error: ${error}`}
|
||||
</output>
|
||||
|
||||
{error && (
|
||||
<Alert variant="destructive" className="mb-6" role="alert">
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleLogin} className="space-y-4" noValidate>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={emailId}>Email</Label>
|
||||
<Input
|
||||
id={emailId}
|
||||
type="email"
|
||||
placeholder="name@company.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
disabled={isLoading}
|
||||
required
|
||||
aria-describedby={emailHelpId}
|
||||
aria-invalid={!!error}
|
||||
className="transition-all duration-200 focus:ring-2 focus:ring-primary/20"
|
||||
/>
|
||||
<div id={emailHelpId} className="sr-only">
|
||||
Enter your company email address
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={passwordId}>Password</Label>
|
||||
<Input
|
||||
id={passwordId}
|
||||
type="password"
|
||||
placeholder="Enter your password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
disabled={isLoading}
|
||||
required
|
||||
aria-describedby={passwordHelpId}
|
||||
aria-invalid={!!error}
|
||||
className="transition-all duration-200 focus:ring-2 focus:ring-primary/20"
|
||||
/>
|
||||
<div id={passwordHelpId} className="sr-only">
|
||||
Enter your account password
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full mt-6 h-11 bg-linear-to-r from-primary to-primary/90 hover:from-primary/90 hover:to-primary/80 transition-all duration-200"
|
||||
disabled={isLoading || !email || !password}
|
||||
aria-describedby={isLoading ? "loading-status" : undefined}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2
|
||||
className="mr-2 h-4 w-4 animate-spin"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
Signing in...
|
||||
</>
|
||||
) : (
|
||||
"Sign in"
|
||||
)}
|
||||
</Button>
|
||||
{isLoading && (
|
||||
<div
|
||||
id={loadingStatusId}
|
||||
className="sr-only"
|
||||
aria-live="polite"
|
||||
>
|
||||
Authentication in progress, please wait
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
|
||||
<div className="mt-6 space-y-4">
|
||||
<div className="text-center">
|
||||
<Link
|
||||
href="/register"
|
||||
className="text-sm text-primary hover:underline transition-colors focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 rounded-sm"
|
||||
>
|
||||
Don't have a company account? Register here
|
||||
</Link>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<Link
|
||||
href="/forgot-password"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 rounded-sm"
|
||||
>
|
||||
Forgot your password?
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<p className="mt-8 text-center text-xs text-muted-foreground">
|
||||
By signing in, you agree to our{" "}
|
||||
<Link
|
||||
href="/terms"
|
||||
className="text-primary hover:underline focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 rounded-sm"
|
||||
>
|
||||
Terms of Service
|
||||
</Link>{" "}
|
||||
and{" "}
|
||||
<Link
|
||||
href="/privacy"
|
||||
className="text-primary hover:underline focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 rounded-sm"
|
||||
>
|
||||
Privacy Policy
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
redirect("/auth/sign-in");
|
||||
}
|
||||
|
||||
12
app/page.tsx
12
app/page.tsx
@@ -12,25 +12,25 @@ import {
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { authClient } from "@/lib/auth/client";
|
||||
|
||||
export default function LandingPage() {
|
||||
const { data: session, status } = useSession();
|
||||
const { data, isPending } = authClient.useSession();
|
||||
const router = useRouter();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (session?.user) {
|
||||
if (data?.user) {
|
||||
router.push("/dashboard");
|
||||
}
|
||||
}, [session, router]);
|
||||
}, [data, router]);
|
||||
|
||||
const handleGetStarted = () => {
|
||||
setIsLoading(true);
|
||||
router.push("/login");
|
||||
router.push("/auth/sign-in");
|
||||
};
|
||||
|
||||
const handleRequestDemo = () => {
|
||||
@@ -38,7 +38,7 @@ export default function LandingPage() {
|
||||
window.open("mailto:demo@notso.ai?subject=LiveDash Demo Request", "_blank");
|
||||
};
|
||||
|
||||
if (status === "loading") {
|
||||
if (isPending) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
Loading...
|
||||
|
||||
857
app/platform/companies/[id]/CompanyManagementClient.tsx
Normal file
857
app/platform/companies/[id]/CompanyManagementClient.tsx
Normal file
@@ -0,0 +1,857 @@
|
||||
"use client";
|
||||
|
||||
import type { UserRole } from "@prisma/client";
|
||||
import {
|
||||
Activity,
|
||||
ArrowLeft,
|
||||
Calendar,
|
||||
Database,
|
||||
Mail,
|
||||
Save,
|
||||
UserPlus,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useCallback, useEffect, useId, useState } from "react";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { authClient } from "@/lib/auth/client";
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
role: string;
|
||||
createdAt: string;
|
||||
invitedBy: string | null;
|
||||
invitedAt: string | null;
|
||||
}
|
||||
|
||||
interface Company {
|
||||
id: string;
|
||||
name: string;
|
||||
status: string;
|
||||
maxUsers: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
csvUrl?: string;
|
||||
users: User[];
|
||||
_count: {
|
||||
sessions: number;
|
||||
imports: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface PlatformUserData {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string | null;
|
||||
role: UserRole;
|
||||
}
|
||||
|
||||
const PLATFORM_ROLES: UserRole[] = [
|
||||
"PLATFORM_SUPER_ADMIN",
|
||||
"PLATFORM_ADMIN",
|
||||
"PLATFORM_SUPPORT",
|
||||
];
|
||||
|
||||
function isPlatformRole(role: UserRole): boolean {
|
||||
return PLATFORM_ROLES.includes(role);
|
||||
}
|
||||
|
||||
function usePlatformUser() {
|
||||
const { data: sessionData, isPending } = authClient.useSession();
|
||||
const [platformUser, setPlatformUser] = useState<PlatformUserData | null>(
|
||||
null
|
||||
);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (isPending) return;
|
||||
|
||||
if (!sessionData?.session) {
|
||||
setPlatformUser(null);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const fetchUser = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/auth/me");
|
||||
if (response.ok) {
|
||||
const userData = await response.json();
|
||||
if (isPlatformRole(userData.role)) {
|
||||
setPlatformUser(userData);
|
||||
} else {
|
||||
setPlatformUser(null);
|
||||
}
|
||||
} else {
|
||||
setPlatformUser(null);
|
||||
}
|
||||
} catch {
|
||||
setPlatformUser(null);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchUser();
|
||||
}, [sessionData, isPending]);
|
||||
|
||||
return {
|
||||
user: platformUser,
|
||||
isLoading: isPending || isLoading,
|
||||
isAuthenticated: !!platformUser,
|
||||
};
|
||||
}
|
||||
|
||||
export default function CompanyManagementClient() {
|
||||
const {
|
||||
user: platformUser,
|
||||
isLoading: userLoading,
|
||||
isAuthenticated,
|
||||
} = usePlatformUser();
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const { toast } = useToast();
|
||||
|
||||
const companyNameFieldId = useId();
|
||||
const maxUsersFieldId = useId();
|
||||
const inviteNameFieldId = useId();
|
||||
const inviteEmailFieldId = useId();
|
||||
|
||||
const fetchCompany = useCallback(async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/platform/companies/${params.id}`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setCompany(data);
|
||||
const companyData = {
|
||||
name: data.name,
|
||||
status: data.status,
|
||||
maxUsers: data.maxUsers,
|
||||
};
|
||||
setEditData(companyData);
|
||||
setOriginalData(companyData);
|
||||
} 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);
|
||||
}
|
||||
}, [params.id, toast]);
|
||||
|
||||
const [company, setCompany] = useState<Company | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [editData, setEditData] = useState<Partial<Company>>({});
|
||||
const [originalData, setOriginalData] = useState<Partial<Company>>({});
|
||||
const [showInviteUser, setShowInviteUser] = useState(false);
|
||||
const [inviteData, setInviteData] = useState({
|
||||
name: "",
|
||||
email: "",
|
||||
role: "USER",
|
||||
});
|
||||
const [showUnsavedChangesDialog, setShowUnsavedChangesDialog] =
|
||||
useState(false);
|
||||
const [pendingNavigation, setPendingNavigation] = useState<string | null>(
|
||||
null
|
||||
);
|
||||
|
||||
// Function to check if data has been modified
|
||||
const hasUnsavedChanges = useCallback(() => {
|
||||
// Normalize data for comparison (handle null/undefined/empty string equivalence)
|
||||
const normalizeValue = (value: string | number | null | undefined) => {
|
||||
if (value === null || value === undefined || value === "") {
|
||||
return "";
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
const normalizedEditData = {
|
||||
name: normalizeValue(editData.name),
|
||||
status: normalizeValue(editData.status),
|
||||
maxUsers: editData.maxUsers || 0,
|
||||
};
|
||||
|
||||
const normalizedOriginalData = {
|
||||
name: normalizeValue(originalData.name),
|
||||
status: normalizeValue(originalData.status),
|
||||
maxUsers: originalData.maxUsers || 0,
|
||||
};
|
||||
|
||||
return (
|
||||
JSON.stringify(normalizedEditData) !==
|
||||
JSON.stringify(normalizedOriginalData)
|
||||
);
|
||||
}, [editData, originalData]);
|
||||
|
||||
// Handle navigation protection - must be at top level
|
||||
const handleNavigation = useCallback(
|
||||
(url: string) => {
|
||||
// Allow navigation within the same company (different tabs, etc.)
|
||||
if (url.includes(`/platform/companies/${params.id}`)) {
|
||||
router.push(url);
|
||||
return;
|
||||
}
|
||||
|
||||
// If there are unsaved changes, show confirmation dialog
|
||||
if (hasUnsavedChanges()) {
|
||||
setPendingNavigation(url);
|
||||
setShowUnsavedChangesDialog(true);
|
||||
} else {
|
||||
router.push(url);
|
||||
}
|
||||
},
|
||||
[router, params.id, hasUnsavedChanges]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (userLoading) return;
|
||||
|
||||
if (!isAuthenticated) {
|
||||
router.push("/platform/login");
|
||||
return;
|
||||
}
|
||||
|
||||
fetchCompany();
|
||||
}, [userLoading, isAuthenticated, router, fetchCompany]);
|
||||
|
||||
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);
|
||||
const companyData = {
|
||||
name: updatedCompany.name,
|
||||
status: updatedCompany.status,
|
||||
maxUsers: updatedCompany.maxUsers,
|
||||
};
|
||||
setOriginalData(companyData);
|
||||
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 confirmNavigation = () => {
|
||||
if (pendingNavigation) {
|
||||
router.push(pendingNavigation);
|
||||
setPendingNavigation(null);
|
||||
}
|
||||
setShowUnsavedChangesDialog(false);
|
||||
};
|
||||
|
||||
const cancelNavigation = () => {
|
||||
setPendingNavigation(null);
|
||||
setShowUnsavedChangesDialog(false);
|
||||
};
|
||||
|
||||
// Protect against browser back/forward and other navigation
|
||||
useEffect(() => {
|
||||
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
|
||||
if (hasUnsavedChanges()) {
|
||||
e.preventDefault();
|
||||
e.returnValue = "";
|
||||
}
|
||||
};
|
||||
|
||||
const handlePopState = (e: PopStateEvent) => {
|
||||
if (hasUnsavedChanges()) {
|
||||
const confirmLeave = window.confirm(
|
||||
"You have unsaved changes. Are you sure you want to leave this page?"
|
||||
);
|
||||
if (!confirmLeave) {
|
||||
// Push the current state back to prevent navigation
|
||||
window.history.pushState(null, "", window.location.href);
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("beforeunload", handleBeforeUnload);
|
||||
window.addEventListener("popstate", handlePopState);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("beforeunload", handleBeforeUnload);
|
||||
window.removeEventListener("popstate", handlePopState);
|
||||
};
|
||||
}, [hasUnsavedChanges]);
|
||||
|
||||
const handleInviteUser = async () => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/platform/companies/${params.id}/users`,
|
||||
{
|
||||
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",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadgeVariant = (status: string) => {
|
||||
switch (status) {
|
||||
case "ACTIVE":
|
||||
return "default";
|
||||
case "TRIAL":
|
||||
return "secondary";
|
||||
case "SUSPENDED":
|
||||
return "destructive";
|
||||
case "ARCHIVED":
|
||||
return "outline";
|
||||
default:
|
||||
return "default";
|
||||
}
|
||||
};
|
||||
|
||||
if (userLoading || isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="text-center">Loading company details...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthenticated || !company) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const canEdit =
|
||||
platformUser?.role === "PLATFORM_SUPER_ADMIN" ||
|
||||
platformUser?.role === "PLATFORM_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={() => handleNavigation("/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>
|
||||
)}
|
||||
</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={companyNameFieldId}>Company Name</Label>
|
||||
<Input
|
||||
id={companyNameFieldId}
|
||||
value={editData.name || ""}
|
||||
onChange={(e) =>
|
||||
setEditData((prev) => ({
|
||||
...prev,
|
||||
name: e.target.value,
|
||||
}))
|
||||
}
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor={maxUsersFieldId}>Max Users</Label>
|
||||
<Input
|
||||
id={maxUsersFieldId}
|
||||
type="number"
|
||||
value={editData.maxUsers || 0}
|
||||
onChange={(e) =>
|
||||
setEditData((prev) => ({
|
||||
...prev,
|
||||
maxUsers: Number.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>
|
||||
{canEdit && hasUnsavedChanges() && (
|
||||
<div className="flex gap-2 pt-4 border-t">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setEditData(originalData);
|
||||
}}
|
||||
>
|
||||
Cancel Changes
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={isSaving}>
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
{isSaving ? "Saving..." : "Save Changes"}
|
||||
</Button>
|
||||
</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={inviteNameFieldId}>Name</Label>
|
||||
<Input
|
||||
id={inviteNameFieldId}
|
||||
value={inviteData.name}
|
||||
onChange={(e) =>
|
||||
setInviteData((prev) => ({ ...prev, name: e.target.value }))
|
||||
}
|
||||
placeholder="User's full name"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor={inviteEmailFieldId}>Email</Label>
|
||||
<Input
|
||||
id={inviteEmailFieldId}
|
||||
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>
|
||||
)}
|
||||
|
||||
{/* Unsaved Changes Dialog */}
|
||||
<AlertDialog
|
||||
open={showUnsavedChangesDialog}
|
||||
onOpenChange={setShowUnsavedChangesDialog}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Unsaved Changes</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
You have unsaved changes that will be lost if you leave this page.
|
||||
Are you sure you want to continue?
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel onClick={cancelNavigation}>
|
||||
Stay on Page
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={confirmNavigation}>
|
||||
Leave Without Saving
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,806 +1,29 @@
|
||||
"use client";
|
||||
import { Suspense } from "react";
|
||||
import CompanyManagementClient from "./CompanyManagementClient";
|
||||
|
||||
import {
|
||||
Activity,
|
||||
ArrowLeft,
|
||||
Calendar,
|
||||
Database,
|
||||
Mail,
|
||||
Save,
|
||||
UserPlus,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useCallback, useEffect, useId, useState } from "react";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
export const metadata = {
|
||||
title: "Company Management | LiveDash Platform",
|
||||
description: "Manage company settings, users, and analytics",
|
||||
};
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
role: string;
|
||||
createdAt: string;
|
||||
invitedBy: string | null;
|
||||
invitedAt: string | null;
|
||||
// Provide at least one sample param for build-time validation
|
||||
// Runtime params not in this list will be handled dynamically
|
||||
export async function generateStaticParams() {
|
||||
return [{ id: "sample" }];
|
||||
}
|
||||
|
||||
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 companyNameFieldId = useId();
|
||||
const companyEmailFieldId = useId();
|
||||
const maxUsersFieldId = useId();
|
||||
const inviteNameFieldId = useId();
|
||||
const inviteEmailFieldId = useId();
|
||||
|
||||
const fetchCompany = useCallback(async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/platform/companies/${params.id}`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setCompany(data);
|
||||
const companyData = {
|
||||
name: data.name,
|
||||
email: data.email,
|
||||
status: data.status,
|
||||
maxUsers: data.maxUsers,
|
||||
};
|
||||
setEditData(companyData);
|
||||
setOriginalData(companyData);
|
||||
} 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);
|
||||
}
|
||||
}, [params.id, toast]);
|
||||
|
||||
const [company, setCompany] = useState<Company | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [editData, setEditData] = useState<Partial<Company>>({});
|
||||
const [originalData, setOriginalData] = useState<Partial<Company>>({});
|
||||
const [showInviteUser, setShowInviteUser] = useState(false);
|
||||
const [inviteData, setInviteData] = useState({
|
||||
name: "",
|
||||
email: "",
|
||||
role: "USER",
|
||||
});
|
||||
const [showUnsavedChangesDialog, setShowUnsavedChangesDialog] =
|
||||
useState(false);
|
||||
const [pendingNavigation, setPendingNavigation] = useState<string | null>(
|
||||
null
|
||||
);
|
||||
|
||||
// Function to check if data has been modified
|
||||
const hasUnsavedChanges = useCallback(() => {
|
||||
// Normalize data for comparison (handle null/undefined/empty string equivalence)
|
||||
const normalizeValue = (value: string | number | null | undefined) => {
|
||||
if (value === null || value === undefined || value === "") {
|
||||
return "";
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
const normalizedEditData = {
|
||||
name: normalizeValue(editData.name),
|
||||
email: normalizeValue(editData.email),
|
||||
status: normalizeValue(editData.status),
|
||||
maxUsers: editData.maxUsers || 0,
|
||||
};
|
||||
|
||||
const normalizedOriginalData = {
|
||||
name: normalizeValue(originalData.name),
|
||||
email: normalizeValue(originalData.email),
|
||||
status: normalizeValue(originalData.status),
|
||||
maxUsers: originalData.maxUsers || 0,
|
||||
};
|
||||
|
||||
return (
|
||||
JSON.stringify(normalizedEditData) !==
|
||||
JSON.stringify(normalizedOriginalData)
|
||||
);
|
||||
}, [editData, originalData]);
|
||||
|
||||
// Handle navigation protection - must be at top level
|
||||
const handleNavigation = useCallback(
|
||||
(url: string) => {
|
||||
// Allow navigation within the same company (different tabs, etc.)
|
||||
if (url.includes(`/platform/companies/${params.id}`)) {
|
||||
router.push(url);
|
||||
return;
|
||||
}
|
||||
|
||||
// If there are unsaved changes, show confirmation dialog
|
||||
if (hasUnsavedChanges()) {
|
||||
setPendingNavigation(url);
|
||||
setShowUnsavedChangesDialog(true);
|
||||
} else {
|
||||
router.push(url);
|
||||
}
|
||||
},
|
||||
[router, params.id, hasUnsavedChanges]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (status === "loading") return;
|
||||
|
||||
if (!session?.user?.isPlatformUser) {
|
||||
router.push("/platform/login");
|
||||
return;
|
||||
}
|
||||
|
||||
fetchCompany();
|
||||
}, [session, status, router, fetchCompany]);
|
||||
|
||||
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);
|
||||
const companyData = {
|
||||
name: updatedCompany.name,
|
||||
email: updatedCompany.email,
|
||||
status: updatedCompany.status,
|
||||
maxUsers: updatedCompany.maxUsers,
|
||||
};
|
||||
setOriginalData(companyData);
|
||||
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 confirmNavigation = () => {
|
||||
if (pendingNavigation) {
|
||||
router.push(pendingNavigation);
|
||||
setPendingNavigation(null);
|
||||
}
|
||||
setShowUnsavedChangesDialog(false);
|
||||
};
|
||||
|
||||
const cancelNavigation = () => {
|
||||
setPendingNavigation(null);
|
||||
setShowUnsavedChangesDialog(false);
|
||||
};
|
||||
|
||||
// Protect against browser back/forward and other navigation
|
||||
useEffect(() => {
|
||||
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
|
||||
if (hasUnsavedChanges()) {
|
||||
e.preventDefault();
|
||||
e.returnValue = "";
|
||||
}
|
||||
};
|
||||
|
||||
const handlePopState = (e: PopStateEvent) => {
|
||||
if (hasUnsavedChanges()) {
|
||||
const confirmLeave = window.confirm(
|
||||
"You have unsaved changes. Are you sure you want to leave this page?"
|
||||
);
|
||||
if (!confirmLeave) {
|
||||
// Push the current state back to prevent navigation
|
||||
window.history.pushState(null, "", window.location.href);
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("beforeunload", handleBeforeUnload);
|
||||
window.addEventListener("popstate", handlePopState);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("beforeunload", handleBeforeUnload);
|
||||
window.removeEventListener("popstate", handlePopState);
|
||||
};
|
||||
}, [hasUnsavedChanges]);
|
||||
|
||||
const handleInviteUser = async () => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/platform/companies/${params.id}/users`,
|
||||
{
|
||||
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",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadgeVariant = (status: string) => {
|
||||
switch (status) {
|
||||
case "ACTIVE":
|
||||
return "default";
|
||||
case "TRIAL":
|
||||
return "secondary";
|
||||
case "SUSPENDED":
|
||||
return "destructive";
|
||||
case "ARCHIVED":
|
||||
return "outline";
|
||||
default:
|
||||
return "default";
|
||||
}
|
||||
};
|
||||
|
||||
if (status === "loading" || isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="text-center">Loading company details...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!session?.user?.isPlatformUser || !company) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const canEdit = session.user.platformRole === "SUPER_ADMIN";
|
||||
|
||||
function CompanyLoadingFallback() {
|
||||
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={() => handleNavigation("/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>
|
||||
)}
|
||||
</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={companyNameFieldId}>Company Name</Label>
|
||||
<Input
|
||||
id={companyNameFieldId}
|
||||
value={editData.name || ""}
|
||||
onChange={(e) =>
|
||||
setEditData((prev) => ({
|
||||
...prev,
|
||||
name: e.target.value,
|
||||
}))
|
||||
}
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor={companyEmailFieldId}>Contact Email</Label>
|
||||
<Input
|
||||
id={companyEmailFieldId}
|
||||
type="email"
|
||||
value={editData.email || ""}
|
||||
onChange={(e) =>
|
||||
setEditData((prev) => ({
|
||||
...prev,
|
||||
email: e.target.value,
|
||||
}))
|
||||
}
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor={maxUsersFieldId}>Max Users</Label>
|
||||
<Input
|
||||
id={maxUsersFieldId}
|
||||
type="number"
|
||||
value={editData.maxUsers || 0}
|
||||
onChange={(e) =>
|
||||
setEditData((prev) => ({
|
||||
...prev,
|
||||
maxUsers: Number.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>
|
||||
{canEdit && hasUnsavedChanges() && (
|
||||
<div className="flex gap-2 pt-4 border-t">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setEditData(originalData);
|
||||
}}
|
||||
>
|
||||
Cancel Changes
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={isSaving}>
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
{isSaving ? "Saving..." : "Save Changes"}
|
||||
</Button>
|
||||
</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={inviteNameFieldId}>Name</Label>
|
||||
<Input
|
||||
id={inviteNameFieldId}
|
||||
value={inviteData.name}
|
||||
onChange={(e) =>
|
||||
setInviteData((prev) => ({ ...prev, name: e.target.value }))
|
||||
}
|
||||
placeholder="User's full name"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor={inviteEmailFieldId}>Email</Label>
|
||||
<Input
|
||||
id={inviteEmailFieldId}
|
||||
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>
|
||||
)}
|
||||
|
||||
{/* Unsaved Changes Dialog */}
|
||||
<AlertDialog
|
||||
open={showUnsavedChangesDialog}
|
||||
onOpenChange={setShowUnsavedChangesDialog}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Unsaved Changes</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
You have unsaved changes that will be lost if you leave this page.
|
||||
Are you sure you want to continue?
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel onClick={cancelNavigation}>
|
||||
Stay on Page
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={confirmNavigation}>
|
||||
Leave Without Saving
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="text-center">Loading company details...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CompanyManagementPage() {
|
||||
return (
|
||||
<Suspense fallback={<CompanyLoadingFallback />}>
|
||||
<CompanyManagementClient />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import type { PlatformUserRole } from "@prisma/client";
|
||||
import type { UserRole } from "@prisma/client";
|
||||
import {
|
||||
Activity,
|
||||
BarChart3,
|
||||
@@ -31,6 +31,7 @@ import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { ThemeToggle } from "@/components/ui/theme-toggle";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { authClient } from "@/lib/auth/client";
|
||||
|
||||
interface Company {
|
||||
id: string;
|
||||
@@ -52,51 +53,74 @@ interface DashboardData {
|
||||
};
|
||||
}
|
||||
|
||||
interface PlatformSession {
|
||||
user: {
|
||||
id: string;
|
||||
email: string;
|
||||
name?: string;
|
||||
isPlatformUser: boolean;
|
||||
platformRole: PlatformUserRole;
|
||||
};
|
||||
interface PlatformUserData {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string | null;
|
||||
role: UserRole;
|
||||
}
|
||||
|
||||
// Custom hook for platform session
|
||||
function usePlatformSession() {
|
||||
const [session, setSession] = useState<PlatformSession | null>(null);
|
||||
const [status, setStatus] = useState<
|
||||
"loading" | "authenticated" | "unauthenticated"
|
||||
>("loading");
|
||||
// Platform roles for checking
|
||||
const PLATFORM_ROLES: UserRole[] = [
|
||||
"PLATFORM_SUPER_ADMIN",
|
||||
"PLATFORM_ADMIN",
|
||||
"PLATFORM_SUPPORT",
|
||||
];
|
||||
|
||||
function isPlatformRole(role: UserRole): boolean {
|
||||
return PLATFORM_ROLES.includes(role);
|
||||
}
|
||||
|
||||
// Custom hook for platform user data
|
||||
function usePlatformUser() {
|
||||
const { data: sessionData, isPending } = authClient.useSession();
|
||||
const [platformUser, setPlatformUser] = useState<PlatformUserData | null>(
|
||||
null
|
||||
);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchSession = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/platform/auth/session");
|
||||
const sessionData = await response.json();
|
||||
if (isPending) return;
|
||||
|
||||
if (sessionData?.user?.isPlatformUser) {
|
||||
setSession(sessionData);
|
||||
setStatus("authenticated");
|
||||
if (!sessionData?.session) {
|
||||
setPlatformUser(null);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch user data from our API to get role
|
||||
const fetchUser = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/auth/me");
|
||||
if (response.ok) {
|
||||
const userData = await response.json();
|
||||
if (isPlatformRole(userData.role)) {
|
||||
setPlatformUser(userData);
|
||||
} else {
|
||||
setPlatformUser(null);
|
||||
}
|
||||
} else {
|
||||
setSession(null);
|
||||
setStatus("unauthenticated");
|
||||
setPlatformUser(null);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Platform session fetch error:", error);
|
||||
setSession(null);
|
||||
setStatus("unauthenticated");
|
||||
} catch {
|
||||
setPlatformUser(null);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchSession();
|
||||
}, []);
|
||||
fetchUser();
|
||||
}, [sessionData, isPending]);
|
||||
|
||||
return { data: session, status };
|
||||
return {
|
||||
user: platformUser,
|
||||
isLoading: isPending || isLoading,
|
||||
isAuthenticated: !!platformUser,
|
||||
};
|
||||
}
|
||||
|
||||
export default function PlatformDashboard() {
|
||||
const { data: session, status } = usePlatformSession();
|
||||
const { user, isLoading: userLoading, isAuthenticated } = usePlatformUser();
|
||||
const router = useRouter();
|
||||
const { toast } = useToast();
|
||||
const [dashboardData, setDashboardData] = useState<DashboardData | null>(
|
||||
@@ -115,7 +139,6 @@ export default function PlatformDashboard() {
|
||||
csvPassword: "",
|
||||
adminEmail: "",
|
||||
adminName: "",
|
||||
adminPassword: "",
|
||||
maxUsers: 10,
|
||||
});
|
||||
|
||||
@@ -125,7 +148,6 @@ export default function PlatformDashboard() {
|
||||
const csvPasswordId = useId();
|
||||
const adminNameId = useId();
|
||||
const adminEmailId = useId();
|
||||
const adminPasswordId = useId();
|
||||
const maxUsersId = useId();
|
||||
|
||||
const fetchDashboardData = useCallback(async () => {
|
||||
@@ -143,15 +165,15 @@ export default function PlatformDashboard() {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (status === "loading") return;
|
||||
if (userLoading) return;
|
||||
|
||||
if (status === "unauthenticated" || !session?.user?.isPlatformUser) {
|
||||
if (!isAuthenticated) {
|
||||
router.push("/platform/login");
|
||||
return;
|
||||
}
|
||||
|
||||
fetchDashboardData();
|
||||
}, [session, status, router, fetchDashboardData]);
|
||||
}, [userLoading, isAuthenticated, router, fetchDashboardData]);
|
||||
|
||||
const copyToClipboard = async (text: string, type: "email" | "password") => {
|
||||
try {
|
||||
@@ -211,14 +233,12 @@ export default function PlatformDashboard() {
|
||||
csvPassword: "",
|
||||
adminEmail: "",
|
||||
adminName: "",
|
||||
adminPassword: "",
|
||||
maxUsers: 10,
|
||||
});
|
||||
|
||||
fetchDashboardData(); // Refresh the list
|
||||
fetchDashboardData();
|
||||
|
||||
// Show success message with copyable credentials
|
||||
if (result.generatedPassword) {
|
||||
if (result.adminUser) {
|
||||
toast({
|
||||
title: "Company Created Successfully!",
|
||||
description: (
|
||||
@@ -251,34 +271,36 @@ export default function PlatformDashboard() {
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center justify-between bg-muted p-2 rounded">
|
||||
<div className="flex-1">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Admin Password:
|
||||
</p>
|
||||
<p className="font-mono text-sm">
|
||||
{result.generatedPassword}
|
||||
</p>
|
||||
{result.inviteLink && (
|
||||
<div className="flex items-center justify-between bg-muted p-2 rounded">
|
||||
<div className="flex-1">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Invite Link:
|
||||
</p>
|
||||
<p className="font-mono text-sm truncate">
|
||||
{result.inviteLink}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
copyToClipboard(result.inviteLink, "password")
|
||||
}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
{copiedPassword ? (
|
||||
<Check className="h-3 w-3" />
|
||||
) : (
|
||||
<Copy className="h-3 w-3" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
copyToClipboard(result.generatedPassword, "password")
|
||||
}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
{copiedPassword ? (
|
||||
<Check className="h-3 w-3" />
|
||||
) : (
|
||||
<Copy className="h-3 w-3" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
duration: 15000, // Longer duration for credentials
|
||||
duration: 15000,
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
@@ -317,7 +339,7 @@ export default function PlatformDashboard() {
|
||||
}
|
||||
};
|
||||
|
||||
if (status === "loading" || isLoading) {
|
||||
if (userLoading || isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="text-center">Loading platform dashboard...</div>
|
||||
@@ -325,7 +347,7 @@ export default function PlatformDashboard() {
|
||||
);
|
||||
}
|
||||
|
||||
if (status === "unauthenticated" || !session?.user?.isPlatformUser) {
|
||||
if (!isAuthenticated || !user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -352,13 +374,12 @@ export default function PlatformDashboard() {
|
||||
Platform Dashboard
|
||||
</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Welcome back, {session.user.name || session.user.email}
|
||||
Welcome back, {user.name || user.email}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-4 items-center">
|
||||
<ThemeToggle />
|
||||
|
||||
{/* Search Filter */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
|
||||
<Input
|
||||
@@ -378,7 +399,6 @@ export default function PlatformDashboard() {
|
||||
</div>
|
||||
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{/* Stats Overview */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
@@ -430,7 +450,6 @@ export default function PlatformDashboard() {
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Companies List */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
@@ -550,21 +569,6 @@ export default function PlatformDashboard() {
|
||||
placeholder="admin@acme.com"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={adminPasswordId}>Admin Password</Label>
|
||||
<Input
|
||||
id={adminPasswordId}
|
||||
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={maxUsersId}>Max Users</Label>
|
||||
<Input
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { SessionProvider } from "next-auth/react";
|
||||
import { NeonAuthUIProvider } from "@neondatabase/auth/react";
|
||||
import { ThemeProvider } from "@/components/theme-provider";
|
||||
import { Toaster } from "@/components/ui/toaster";
|
||||
import { authClient } from "@/lib/auth/client";
|
||||
|
||||
export default function PlatformLayout({
|
||||
children,
|
||||
@@ -16,10 +17,15 @@ export default function PlatformLayout({
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
>
|
||||
<SessionProvider basePath="/api/platform/auth">
|
||||
<NeonAuthUIProvider
|
||||
authClient={authClient}
|
||||
redirectTo="/platform/dashboard"
|
||||
emailOTP
|
||||
credentials={{ forgotPassword: true }}
|
||||
>
|
||||
{children}
|
||||
<Toaster />
|
||||
</SessionProvider>
|
||||
</NeonAuthUIProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,49 +1,29 @@
|
||||
"use client";
|
||||
|
||||
import { AuthView } from "@neondatabase/auth/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { signIn } from "next-auth/react";
|
||||
import { useId, useState } from "react";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useEffect } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { ThemeToggle } from "@/components/ui/theme-toggle";
|
||||
import { authClient } from "@/lib/auth/client";
|
||||
|
||||
export default function PlatformLoginPage() {
|
||||
const emailId = useId();
|
||||
const passwordId = useId();
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const router = useRouter();
|
||||
const { data, isPending } = authClient.useSession();
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
setError("");
|
||||
|
||||
try {
|
||||
const result = await signIn("credentials", {
|
||||
email,
|
||||
password,
|
||||
redirect: false,
|
||||
callbackUrl: "/platform/dashboard",
|
||||
});
|
||||
|
||||
if (result?.error) {
|
||||
setError("Invalid credentials");
|
||||
} else if (result?.ok) {
|
||||
// Login successful, redirect to dashboard
|
||||
router.push("/platform/dashboard");
|
||||
}
|
||||
} catch (_error) {
|
||||
setError("An error occurred during login");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
useEffect(() => {
|
||||
if (!isPending && data?.session) {
|
||||
router.push("/platform/dashboard");
|
||||
}
|
||||
};
|
||||
}, [data, isPending, router]);
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900">
|
||||
<div className="text-center">Loading...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900 relative">
|
||||
@@ -58,43 +38,7 @@ export default function PlatformLoginPage() {
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={emailId}>Email</Label>
|
||||
<Input
|
||||
id={emailId}
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
disabled={isLoading}
|
||||
autoComplete="email"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={passwordId}>Password</Label>
|
||||
<Input
|
||||
id={passwordId}
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
disabled={isLoading}
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||
{isLoading ? "Signing in..." : "Sign In"}
|
||||
</Button>
|
||||
</form>
|
||||
<AuthView pathname="sign-in" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { SessionProvider } from "next-auth/react";
|
||||
import { NeonAuthUIProvider, UserButton } from "@neondatabase/auth/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { ThemeProvider } from "@/components/theme-provider";
|
||||
import { authClient } from "@/lib/auth/client";
|
||||
|
||||
export function Providers({ children }: { children: ReactNode }) {
|
||||
// Including error handling and refetch interval for better user experience
|
||||
return (
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
@@ -13,13 +13,17 @@ export function Providers({ children }: { children: ReactNode }) {
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
>
|
||||
<SessionProvider
|
||||
// Re-fetch session every 30 minutes (reduced from 10)
|
||||
refetchInterval={30 * 60}
|
||||
refetchOnWindowFocus={false}
|
||||
<NeonAuthUIProvider
|
||||
authClient={authClient}
|
||||
redirectTo="/dashboard"
|
||||
emailOTP
|
||||
credentials={{ forgotPassword: true }}
|
||||
>
|
||||
{children}
|
||||
</SessionProvider>
|
||||
</NeonAuthUIProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
// Export UserButton for use in layouts
|
||||
export { UserButton };
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
"use client";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
||||
export default function RegisterPage() {
|
||||
const [email, setEmail] = useState<string>("");
|
||||
const [company, setCompany] = useState<string>("");
|
||||
const [password, setPassword] = useState<string>("");
|
||||
const [csvUrl, setCsvUrl] = useState<string>("");
|
||||
const [role, setRole] = useState<string>("ADMIN"); // Default to ADMIN for company registration
|
||||
const [error, setError] = useState<string>("");
|
||||
const router = useRouter();
|
||||
|
||||
async function handleRegister(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
const res = await fetch("/api/register", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email, password, company, csvUrl, role }),
|
||||
});
|
||||
if (res.ok) router.push("/login");
|
||||
else setError("Registration failed.");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-md mx-auto mt-24 bg-white rounded-xl p-8 shadow">
|
||||
<h1 className="text-2xl font-bold mb-6">Register Company</h1>
|
||||
{error && <div className="text-red-600 mb-3">{error}</div>}
|
||||
<form onSubmit={handleRegister} className="flex flex-col gap-4">
|
||||
<input
|
||||
className="border px-3 py-2 rounded"
|
||||
type="text"
|
||||
placeholder="Company Name"
|
||||
value={company}
|
||||
onChange={(e) => setCompany(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<input
|
||||
className="border px-3 py-2 rounded"
|
||||
type="email"
|
||||
placeholder="Admin Email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<input
|
||||
className="border px-3 py-2 rounded"
|
||||
type="password"
|
||||
placeholder="Password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<input
|
||||
className="border px-3 py-2 rounded"
|
||||
type="text"
|
||||
placeholder="CSV URL"
|
||||
value={csvUrl}
|
||||
onChange={(e) => setCsvUrl(e.target.value)}
|
||||
/>
|
||||
<select
|
||||
className="border px-3 py-2 rounded"
|
||||
value={role}
|
||||
onChange={(e) => setRole(e.target.value)}
|
||||
required
|
||||
>
|
||||
<option value="admin">Admin</option>
|
||||
<option value="user">User</option>
|
||||
<option value="AUDITOR">Auditor</option>
|
||||
</select>
|
||||
<button className="bg-blue-600 text-white rounded py-2" type="submit">
|
||||
Register & Continue
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
"use client";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { Suspense, useState } from "react";
|
||||
|
||||
// Component that uses useSearchParams wrapped in Suspense
|
||||
function ResetPasswordForm() {
|
||||
const searchParams = useSearchParams();
|
||||
const token = searchParams?.get("token");
|
||||
const [password, setPassword] = useState<string>("");
|
||||
const [message, setMessage] = useState<string>("");
|
||||
const router = useRouter();
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
const res = await fetch("/api/reset-password", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ token, password }),
|
||||
});
|
||||
if (res.ok) {
|
||||
setMessage("Password reset! Redirecting to login...");
|
||||
setTimeout(() => router.push("/login"), 2000);
|
||||
} else setMessage("Invalid or expired link.");
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||
<input
|
||||
className="border px-3 py-2 rounded"
|
||||
type="password"
|
||||
placeholder="New Password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<button className="bg-blue-600 text-white rounded py-2" type="submit">
|
||||
Reset Password
|
||||
</button>
|
||||
<div className="mt-4 text-green-700">{message}</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
// Loading fallback component
|
||||
function LoadingForm() {
|
||||
return <div className="text-center py-4">Loading...</div>;
|
||||
}
|
||||
|
||||
export default function ResetPasswordPage() {
|
||||
return (
|
||||
<div className="max-w-md mx-auto mt-24 bg-white rounded-xl p-8 shadow">
|
||||
<h1 className="text-2xl font-bold mb-6">Reset Password</h1>
|
||||
<Suspense fallback={<LoadingForm />}>
|
||||
<ResetPasswordForm />
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user