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:
2026-01-20 21:09:29 +01:00
parent 7932fe7386
commit cd05fc8648
177 changed files with 5042 additions and 5541 deletions

View 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>
);
}

View File

@@ -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();

View File

@@ -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 },
});

View File

@@ -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,

View File

@@ -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 };

View 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
View 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,
});
}

View File

@@ -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();

View File

@@ -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,
});

View File

@@ -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

View File

@@ -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

View File

@@ -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");

View File

@@ -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();

View File

@@ -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.",
});
}

View File

@@ -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 }
);
}
}

View File

@@ -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 };

View File

@@ -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 }

View File

@@ -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 }

View File

@@ -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 }
);

View File

@@ -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 }
);
}
}

View File

@@ -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
View 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>
);
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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

View File

@@ -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">

View 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>
);
}

View File

@@ -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>
);
}

View File

@@ -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 {

View File

@@ -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({

View File

@@ -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;

View File

@@ -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>

View File

@@ -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>
);
}

View File

@@ -1,5 +1,6 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "@neondatabase/auth/ui/tailwind";
@custom-variant dark (&:is(.dark *));

View File

@@ -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",

View File

@@ -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&apos;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");
}

View File

@@ -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...

View 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>
);
}

View File

@@ -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>
);
}

View File

@@ -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

View File

@@ -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>
);
}

View File

@@ -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>

View File

@@ -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 };

View File

@@ -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>
);
}

View File

@@ -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>
);
}