feat: update package.json scripts and add prisma seed command

refactor: improve refresh-sessions API handler for better readability and error handling

fix: enhance NextAuth configuration with session token handling and cookie settings

chore: update dashboard API handlers for consistency and improved error responses

style: format dashboard API routes for better readability

feat: implement forgot password and reset password functionality with security improvements

feat: add user registration API with email existence check and initial company creation

chore: create initial database migration and seed script for demo data

style: clean up PostCSS and Tailwind CSS configuration files

fix: update TypeScript configuration for stricter type checking

chore: add development environment variables for NextAuth

feat: create Providers component for session management in the app

chore: initialize Prisma migration and seed files for database setup
This commit is contained in:
2025-05-21 21:41:07 +02:00
parent b6b67dcd78
commit 50b2fbda55
42 changed files with 8233 additions and 7627 deletions

View File

@ -4,81 +4,95 @@ import { fetchAndParseCsv } from "../../../lib/csvFetcher";
import { prisma } from "../../../lib/prisma";
interface SessionCreateData {
id: string;
startTime: Date;
companyId: string;
sessionId?: string;
[key: string]: unknown;
id: string;
startTime: Date;
companyId: string;
sessionId?: string;
[key: string]: unknown;
}
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
// Check if this is a POST request
if (req.method !== "POST") {
return res.status(405).json({ error: "Method not allowed" });
}
export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
) {
// Check if this is a POST request
if (req.method !== "POST") {
return res.status(405).json({ error: "Method not allowed" });
}
// Get companyId from body or query
let { companyId } = req.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) {
console.error("Error fetching session:", error);
}
}
if (!companyId) {
return res.status(400).json({ error: "Company ID is required" });
}
const company = await prisma.company.findUnique({ where: { id: companyId } });
if (!company) return res.status(404).json({ error: "Company not found" });
// Get companyId from body or query
let { companyId } = req.body;
if (!companyId) {
// Try to get user from prisma based on session cookie
try {
const sessions = await fetchAndParseCsv(company.csvUrl, company.csvUsername as string | undefined, company.csvPassword as string | undefined);
const session = await prisma.session.findFirst({
orderBy: { createdAt: "desc" },
where: {
/* Add session check criteria here */
},
});
// Replace all session rows for this company (for demo simplicity)
await prisma.session.deleteMany({ where: { companyId: company.id } });
for (const session of sessions) {
const sessionData: SessionCreateData = {
...session,
companyId: company.id,
id: session.id || session.sessionId || `sess_${Date.now()}_${Math.random().toString(36).substring(2, 7)}`,
// Ensure startTime is not undefined
startTime: session.startTime || new Date()
};
// Only include fields that are properly typed for Prisma
await prisma.session.create({
data: {
id: sessionData.id,
companyId: sessionData.companyId,
startTime: sessionData.startTime,
// endTime is required in the schema, so use startTime if not available
endTime: session.endTime || new Date(),
ipAddress: session.ipAddress || null,
country: session.country || null,
language: session.language || null,
sentiment: typeof session.sentiment === 'number' ? session.sentiment : null,
messagesSent: typeof session.messagesSent === 'number' ? session.messagesSent : 0,
category: session.category || null
}
});
}
res.json({ ok: true, imported: sessions.length });
} catch (e) {
const error = e instanceof Error ? e.message : 'An unknown error occurred';
res.status(500).json({ error });
if (session) {
companyId = session.companyId;
}
} catch (error) {
console.error("Error fetching session:", error);
}
}
if (!companyId) {
return res.status(400).json({ error: "Company ID is required" });
}
const company = await prisma.company.findUnique({ where: { id: companyId } });
if (!company) return res.status(404).json({ error: "Company not found" });
try {
const sessions = await fetchAndParseCsv(
company.csvUrl,
company.csvUsername as string | undefined,
company.csvPassword as string | undefined,
);
// Replace all session rows for this company (for demo simplicity)
await prisma.session.deleteMany({ where: { companyId: company.id } });
for (const session of sessions) {
const sessionData: SessionCreateData = {
...session,
companyId: company.id,
id:
session.id ||
session.sessionId ||
`sess_${Date.now()}_${Math.random().toString(36).substring(2, 7)}`,
// Ensure startTime is not undefined
startTime: session.startTime || new Date(),
};
// Only include fields that are properly typed for Prisma
await prisma.session.create({
data: {
id: sessionData.id,
companyId: sessionData.companyId,
startTime: sessionData.startTime,
// endTime is required in the schema, so use startTime if not available
endTime: session.endTime || new Date(),
ipAddress: session.ipAddress || null,
country: session.country || null,
language: session.language || null,
sentiment:
typeof session.sentiment === "number" ? session.sentiment : null,
messagesSent:
typeof session.messagesSent === "number" ? session.messagesSent : 0,
category: session.category || null,
},
});
}
res.json({ ok: true, imported: sessions.length });
} catch (e) {
const error = e instanceof Error ? e.message : "An unknown error occurred";
res.status(500).json({ error });
}
}

View File

@ -5,85 +5,100 @@ import bcrypt from "bcryptjs";
// Define the shape of the JWT token
declare module "next-auth/jwt" {
interface JWT {
companyId: string;
role: string;
}
interface JWT {
companyId: string;
role: string;
}
}
// Define the shape of the session object
declare module "next-auth" {
interface Session {
user: {
id?: string;
name?: string;
email?: string;
image?: string;
companyId: string;
role: string;
};
}
interface Session {
user: {
id?: string;
name?: string;
email?: string;
image?: string;
companyId: string;
role: string;
};
}
interface User {
id: string;
email: string;
companyId: string;
role: string;
}
interface User {
id: string;
email: string;
companyId: string;
role: string;
}
}
export const authOptions: NextAuthOptions = {
providers: [
CredentialsProvider({
name: "Credentials",
credentials: {
email: { label: "Email", type: "text" },
password: { label: "Password", type: "password" },
},
async authorize(credentials) {
if (!credentials?.email || !credentials?.password) {
return null;
}
providers: [
CredentialsProvider({
name: "Credentials",
credentials: {
email: { label: "Email", type: "text" },
password: { label: "Password", type: "password" },
},
async authorize(credentials) {
if (!credentials?.email || !credentials?.password) {
return null;
}
const user = await prisma.user.findUnique({
where: { email: credentials.email }
});
const user = await prisma.user.findUnique({
where: { email: credentials.email },
});
if (!user) return null;
if (!user) return null;
const valid = await bcrypt.compare(credentials.password, user.password);
if (!valid) return null;
const valid = await bcrypt.compare(credentials.password, user.password);
if (!valid) return null;
return {
id: user.id,
email: user.email,
companyId: user.companyId,
role: user.role,
};
},
}),
],
session: { strategy: "jwt" },
callbacks: {
async jwt({ token, user }) {
if (user) {
token.companyId = user.companyId;
token.role = user.role;
}
return token;
},
async session({ session, token }) {
if (token && session.user) {
session.user.companyId = token.companyId;
session.user.role = token.role;
}
return session;
},
return {
id: user.id,
email: user.email,
companyId: user.companyId,
role: user.role,
};
},
}),
],
session: {
strategy: "jwt",
maxAge: 30 * 24 * 60 * 60, // 30 days
},
cookies: {
sessionToken: {
name: `next-auth.session-token`,
options: {
httpOnly: true,
sameSite: "lax",
path: "/",
secure: process.env.NODE_ENV === "production",
},
},
pages: {
signIn: "/login",
},
callbacks: {
async jwt({ token, user }) {
if (user) {
token.companyId = user.companyId;
token.role = user.role;
}
return token;
},
secret: process.env.NEXTAUTH_SECRET || "fallback-secret-key-change-in-production",
async session({ session, token }) {
if (token && session.user) {
session.user.companyId = token.companyId;
session.user.role = token.role;
}
return session;
},
},
pages: {
signIn: "/login",
},
secret: process.env.NEXTAUTH_SECRET,
debug: process.env.NODE_ENV === "development",
};
export default NextAuth(authOptions);

View File

@ -4,24 +4,27 @@ import { getServerSession } from "next-auth";
import { prisma } from "../../../lib/prisma";
import { authOptions } from "../auth/[...nextauth]";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const session = await getServerSession(req, res, authOptions);
if (!session?.user) return res.status(401).json({ error: "Not logged in" });
export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
) {
const session = await getServerSession(req, res, authOptions);
if (!session?.user) return res.status(401).json({ error: "Not logged in" });
const user = await prisma.user.findUnique({
where: { email: session.user.email as string }
const user = await prisma.user.findUnique({
where: { email: session.user.email as string },
});
if (!user) return res.status(401).json({ error: "No user" });
if (req.method === "POST") {
const { csvUrl } = req.body;
await prisma.company.update({
where: { id: user.companyId },
data: { csvUrl },
});
if (!user) return res.status(401).json({ error: "No user" });
if (req.method === "POST") {
const { csvUrl } = req.body;
await prisma.company.update({
where: { id: user.companyId },
data: { csvUrl }
});
res.json({ ok: true });
} else {
res.status(405).end();
}
res.json({ ok: true });
} else {
res.status(405).end();
}
}

View File

@ -6,39 +6,43 @@ import { sessionMetrics } from "../../../lib/metrics";
import { authOptions } from "../auth/[...nextauth]";
interface SessionUser {
email: string;
name?: string;
email: string;
name?: string;
}
interface SessionData {
user: SessionUser;
user: SessionUser;
}
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
req: NextApiRequest,
res: NextApiResponse,
) {
const session = await getServerSession(req, res, authOptions) as SessionData | null;
if (!session?.user) return res.status(401).json({ error: "Not logged in" });
const session = (await getServerSession(
req,
res,
authOptions,
)) as SessionData | null;
if (!session?.user) return res.status(401).json({ error: "Not logged in" });
const user = await prisma.user.findUnique({
where: { email: session.user.email },
include: { company: true }
});
const user = await prisma.user.findUnique({
where: { email: session.user.email },
include: { company: true },
});
if (!user) return res.status(401).json({ error: "No user" });
if (!user) return res.status(401).json({ error: "No user" });
const sessions = await prisma.session.findMany({
where: { companyId: user.companyId }
});
const sessions = await prisma.session.findMany({
where: { companyId: user.companyId },
});
// Pass company config to metrics
// @ts-expect-error - Type conversion is needed between prisma session and ChatSession
const metrics = sessionMetrics(sessions, user.company);
// Pass company config to metrics
// @ts-expect-error - Type conversion is needed between prisma session and ChatSession
const metrics = sessionMetrics(sessions, user.company);
res.json({
metrics,
csvUrl: user.company.csvUrl,
company: user.company
});
res.json({
metrics,
csvUrl: user.company.csvUrl,
company: user.company,
});
}

View File

@ -3,30 +3,35 @@ import { getServerSession } from "next-auth";
import { prisma } from "../../../lib/prisma";
import { authOptions } from "../auth/[...nextauth]";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const session = await getServerSession(req, res, authOptions);
if (!session?.user || session.user.role !== "admin")
return res.status(403).json({ error: "Forbidden" });
export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
) {
const session = await getServerSession(req, res, authOptions);
if (!session?.user || session.user.role !== "admin")
return res.status(403).json({ error: "Forbidden" });
const user = await prisma.user.findUnique({
where: { email: session.user.email as string }
const user = await prisma.user.findUnique({
where: { email: session.user.email as string },
});
if (!user) return res.status(401).json({ error: "No user" });
if (req.method === "POST") {
const { csvUrl, csvUsername, csvPassword, sentimentThreshold } = req.body;
await prisma.company.update({
where: { id: user.companyId },
data: {
csvUrl,
csvUsername,
...(csvPassword ? { csvPassword } : {}),
sentimentAlert: sentimentThreshold
? parseFloat(sentimentThreshold)
: null,
},
});
if (!user) return res.status(401).json({ error: "No user" });
if (req.method === "POST") {
const { csvUrl, csvUsername, csvPassword, sentimentThreshold } = req.body;
await prisma.company.update({
where: { id: user.companyId },
data: {
csvUrl,
csvUsername,
...(csvPassword ? { csvPassword } : {}),
sentimentAlert: sentimentThreshold ? parseFloat(sentimentThreshold) : null,
}
});
res.json({ ok: true });
} else {
res.status(405).end();
}
res.json({ ok: true });
} else {
res.status(405).end();
}
}

View File

@ -6,51 +6,53 @@ import { authOptions } from "../auth/[...nextauth]";
// User type from prisma is used instead of the one in lib/types
interface UserBasicInfo {
id: string;
email: string;
role: string;
id: string;
email: string;
role: string;
}
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const session = await getServerSession(req, res, authOptions);
if (!session?.user || session.user.role !== "admin")
return res.status(403).json({ error: "Forbidden" });
export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
) {
const session = await getServerSession(req, res, authOptions);
if (!session?.user || session.user.role !== "admin")
return res.status(403).json({ error: "Forbidden" });
const user = await prisma.user.findUnique({
where: { email: session.user.email as string }
const user = await prisma.user.findUnique({
where: { email: session.user.email as string },
});
if (!user) return res.status(401).json({ error: "No user" });
if (req.method === "GET") {
const users = await prisma.user.findMany({
where: { companyId: user.companyId },
});
if (!user) return res.status(401).json({ error: "No user" });
const mappedUsers: UserBasicInfo[] = users.map((u) => ({
id: u.id,
email: u.email,
role: u.role,
}));
if (req.method === "GET") {
const users = await prisma.user.findMany({
where: { companyId: user.companyId }
});
const mappedUsers: UserBasicInfo[] = users.map(u => ({
id: u.id,
email: u.email,
role: u.role
}));
res.json({ users: mappedUsers });
}
else if (req.method === "POST") {
const { email, role } = req.body;
if (!email || !role) return res.status(400).json({ error: "Missing fields" });
const exists = await prisma.user.findUnique({ where: { email } });
if (exists) return res.status(409).json({ error: "Email exists" });
const tempPassword = Math.random().toString(36).slice(-8); // random initial password
await prisma.user.create({
data: {
email,
password: await bcrypt.hash(tempPassword, 10),
companyId: user.companyId,
role,
}
});
// TODO: Email user their temp password (stub, for demo)
res.json({ ok: true, tempPassword });
}
else res.status(405).end();
res.json({ users: mappedUsers });
} else if (req.method === "POST") {
const { email, role } = req.body;
if (!email || !role)
return res.status(400).json({ error: "Missing fields" });
const exists = await prisma.user.findUnique({ where: { email } });
if (exists) return res.status(409).json({ error: "Email exists" });
const tempPassword = Math.random().toString(36).slice(-8); // random initial password
await prisma.user.create({
data: {
email,
password: await bcrypt.hash(tempPassword, 10),
companyId: user.companyId,
role,
},
});
// TODO: Email user their temp password (stub, for demo)
res.json({ ok: true, tempPassword });
} else res.status(405).end();
}

View File

@ -1,35 +1,38 @@
import { prisma } from "../../lib/prisma";
import { sendEmail } from "../../lib/sendEmail";
import crypto from "crypto";
import type { IncomingMessage, ServerResponse } from 'http';
import type { IncomingMessage, ServerResponse } from "http";
type NextApiRequest = IncomingMessage & {
body: {
email: string;
[key: string]: unknown;
};
body: {
email: string;
[key: string]: unknown;
};
};
type NextApiResponse = ServerResponse & {
status: (code: number) => NextApiResponse;
json: (data: Record<string, unknown>) => void;
end: () => void;
status: (code: number) => NextApiResponse;
json: (data: Record<string, unknown>) => void;
end: () => void;
};
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== "POST") return res.status(405).end();
const { email } = req.body;
const user = await prisma.user.findUnique({ where: { email } });
if (!user) return res.status(200).end(); // always 200 for privacy
export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
) {
if (req.method !== "POST") return res.status(405).end();
const { email } = req.body;
const user = await prisma.user.findUnique({ where: { email } });
if (!user) return res.status(200).end(); // always 200 for privacy
const token = crypto.randomBytes(32).toString("hex");
const expiry = new Date(Date.now() + 1000 * 60 * 30); // 30 min expiry
await prisma.user.update({
where: { email },
data: { resetToken: token, resetTokenExpiry: expiry },
});
const token = crypto.randomBytes(32).toString("hex");
const expiry = new Date(Date.now() + 1000 * 60 * 30); // 30 min expiry
await prisma.user.update({
where: { email },
data: { resetToken: token, resetTokenExpiry: expiry },
});
const resetUrl = `${process.env.NEXTAUTH_URL || "http://localhost:3000"}/reset-password?token=${token}`;
await sendEmail(email, "Password Reset", `Reset your password: ${resetUrl}`);
res.status(200).end();
const resetUrl = `${process.env.NEXTAUTH_URL || "http://localhost:3000"}/reset-password?token=${token}`;
await sendEmail(email, "Password Reset", `Reset your password: ${resetUrl}`);
res.status(200).end();
}

View File

@ -4,50 +4,53 @@ import bcrypt from "bcryptjs";
import { ApiResponse } from "../../lib/types";
interface RegisterRequestBody {
email: string;
password: string;
company: string;
csvUrl?: string;
email: string;
password: string;
company: string;
csvUrl?: string;
}
export default async function handler(req: NextApiRequest, res: NextApiResponse<ApiResponse<{ success: boolean; } | { error: string; }>>) {
if (req.method !== "POST") return res.status(405).end();
export default async function handler(
req: NextApiRequest,
res: NextApiResponse<ApiResponse<{ success: boolean } | { error: string }>>,
) {
if (req.method !== "POST") return res.status(405).end();
const { email, password, company, csvUrl } = req.body as RegisterRequestBody;
const { email, password, company, csvUrl } = req.body as RegisterRequestBody;
if (!email || !password || !company) {
return res.status(400).json({
success: false,
error: "Missing required fields"
});
}
// Check if email exists
const exists = await prisma.user.findUnique({
where: { email }
if (!email || !password || !company) {
return res.status(400).json({
success: false,
error: "Missing required fields",
});
}
if (exists) {
return res.status(409).json({
success: false,
error: "Email already exists"
});
}
// Check if email exists
const exists = await prisma.user.findUnique({
where: { email },
});
const newCompany = await prisma.company.create({
data: { name: company, csvUrl: csvUrl || "" },
});
const hashed = await bcrypt.hash(password, 10);
await prisma.user.create({
data: {
email,
password: hashed,
companyId: newCompany.id,
role: "admin",
},
});
res.status(201).json({
success: true,
data: { success: true }
if (exists) {
return res.status(409).json({
success: false,
error: "Email already exists",
});
}
const newCompany = await prisma.company.create({
data: { name: company, csvUrl: csvUrl || "" },
});
const hashed = await bcrypt.hash(password, 10);
await prisma.user.create({
data: {
email,
password: hashed,
companyId: newCompany.id,
role: "admin",
},
});
res.status(201).json({
success: true,
data: { success: true },
});
}

View File

@ -1,40 +1,43 @@
import { prisma } from "../../lib/prisma";
import bcrypt from "bcryptjs";
import type { IncomingMessage, ServerResponse } from 'http';
import type { IncomingMessage, ServerResponse } from "http";
type NextApiRequest = IncomingMessage & {
body: {
token: string;
password: string;
[key: string]: unknown;
};
body: {
token: string;
password: string;
[key: string]: unknown;
};
};
type NextApiResponse = ServerResponse & {
status: (code: number) => NextApiResponse;
json: (data: Record<string, unknown>) => void;
end: () => void;
status: (code: number) => NextApiResponse;
json: (data: Record<string, unknown>) => void;
end: () => void;
};
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== "POST") return res.status(405).end();
const { token, password } = req.body;
const user = await prisma.user.findFirst({
where: {
resetToken: token,
resetTokenExpiry: { gte: new Date() }
}
});
if (!user) return res.status(400).json({ error: "Invalid or expired token" });
export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
) {
if (req.method !== "POST") return res.status(405).end();
const { token, password } = req.body;
const user = await prisma.user.findFirst({
where: {
resetToken: token,
resetTokenExpiry: { gte: new Date() },
},
});
if (!user) return res.status(400).json({ error: "Invalid or expired token" });
const hash = await bcrypt.hash(password, 10);
await prisma.user.update({
where: { id: user.id },
data: {
password: hash,
resetToken: null,
resetTokenExpiry: null,
}
});
res.status(200).end();
const hash = await bcrypt.hash(password, 10);
await prisma.user.update({
where: { id: user.id },
data: {
password: hash,
resetToken: null,
resetTokenExpiry: null,
},
});
res.status(200).end();
}