mirror of
https://github.com/kjanat/livedash-node.git
synced 2026-01-16 11:32:13 +01:00
feat: initialize project with Next.js, Prisma, and Tailwind CSS
- Add package.json with dependencies and scripts for Next.js and Prisma - Implement API routes for session management, user authentication, and company configuration - Create database schema for Company, User, and Session models in Prisma - Set up authentication with NextAuth and JWT - Add password reset functionality and user registration endpoint - Configure Tailwind CSS and PostCSS for styling - Implement metrics and dashboard settings API endpoints
This commit is contained in:
60
pages/api/admin/refresh-sessions.ts
Normal file
60
pages/api/admin/refresh-sessions.ts
Normal file
@ -0,0 +1,60 @@
|
||||
// API route to refresh (fetch+parse+update) session data for a company
|
||||
import { NextApiRequest, NextApiResponse } from "next";
|
||||
import { fetchAndParseCsv } from "../../../lib/csvFetcher";
|
||||
import { prisma } from "../../../lib/prisma";
|
||||
|
||||
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" });
|
||||
|
||||
try {
|
||||
// @ts-expect-error - Handle type conversion on session import
|
||||
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) {
|
||||
// @ts-expect-error - Proper data mapping would be needed for production
|
||||
await prisma.session.create({
|
||||
// @ts-expect-error - We ensure id is present but TypeScript doesn't know
|
||||
data: {
|
||||
...session,
|
||||
id: session.id || session.sessionId || `sess_${Date.now()}_${Math.random().toString(36).substring(2, 7)}`,
|
||||
companyId: company.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
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 });
|
||||
}
|
||||
}
|
||||
89
pages/api/auth/[...nextauth].ts
Normal file
89
pages/api/auth/[...nextauth].ts
Normal file
@ -0,0 +1,89 @@
|
||||
import NextAuth, { NextAuthOptions } from "next-auth";
|
||||
import CredentialsProvider from "next-auth/providers/credentials";
|
||||
import { prisma } from "../../../lib/prisma";
|
||||
import bcrypt from "bcryptjs";
|
||||
|
||||
// Define the shape of the JWT token
|
||||
declare module "next-auth/jwt" {
|
||||
interface JWT {
|
||||
companyId: string;
|
||||
role: string;
|
||||
}
|
||||
}
|
||||
|
||||
// Define the shape of the session object
|
||||
declare module "next-auth" {
|
||||
interface Session {
|
||||
user: {
|
||||
id?: string;
|
||||
name?: string;
|
||||
email?: string;
|
||||
image?: string;
|
||||
companyId: string;
|
||||
role: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
companyId: string;
|
||||
role: string;
|
||||
}
|
||||
}
|
||||
|
||||
export const authOptions: NextAuthOptions = {
|
||||
providers: [
|
||||
CredentialsProvider({
|
||||
name: "Credentials",
|
||||
credentials: {
|
||||
email: { label: "Email", type: "text" },
|
||||
password: { label: "Password", type: "password" },
|
||||
},
|
||||
async authorize(credentials) {
|
||||
if (!credentials?.email || !credentials?.password) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email: credentials.email }
|
||||
});
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
const valid = await bcrypt.compare(credentials.password, user.password);
|
||||
if (!valid) return null;
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
companyId: user.companyId,
|
||||
role: user.role,
|
||||
};
|
||||
},
|
||||
}),
|
||||
],
|
||||
session: { strategy: "jwt" },
|
||||
callbacks: {
|
||||
async jwt({ token, user }) {
|
||||
if (user) {
|
||||
token.companyId = user.companyId;
|
||||
token.role = user.role;
|
||||
}
|
||||
return token;
|
||||
},
|
||||
async session({ session, token }) {
|
||||
if (token && session.user) {
|
||||
session.user.companyId = token.companyId;
|
||||
session.user.role = token.role;
|
||||
}
|
||||
return session;
|
||||
},
|
||||
},
|
||||
pages: {
|
||||
signIn: "/login",
|
||||
},
|
||||
secret: process.env.NEXTAUTH_SECRET || "fallback-secret-key-change-in-production",
|
||||
};
|
||||
|
||||
export default NextAuth(authOptions);
|
||||
27
pages/api/dashboard/config.ts
Normal file
27
pages/api/dashboard/config.ts
Normal file
@ -0,0 +1,27 @@
|
||||
// API endpoint: update company CSV URL config
|
||||
import { NextApiRequest, NextApiResponse } from "next";
|
||||
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" });
|
||||
|
||||
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 }
|
||||
});
|
||||
res.json({ ok: true });
|
||||
} else {
|
||||
res.status(405).end();
|
||||
}
|
||||
}
|
||||
44
pages/api/dashboard/metrics.ts
Normal file
44
pages/api/dashboard/metrics.ts
Normal file
@ -0,0 +1,44 @@
|
||||
// API endpoint: return metrics for current company
|
||||
import { NextApiRequest, NextApiResponse } from "next";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { prisma } from "../../../lib/prisma";
|
||||
import { sessionMetrics } from "../../../lib/metrics";
|
||||
import { authOptions } from "../auth/[...nextauth]";
|
||||
|
||||
interface SessionUser {
|
||||
email: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
interface SessionData {
|
||||
user: SessionUser;
|
||||
}
|
||||
|
||||
export default async function handler(
|
||||
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 user = await prisma.user.findUnique({
|
||||
where: { email: session.user.email },
|
||||
include: { company: true }
|
||||
});
|
||||
|
||||
if (!user) return res.status(401).json({ error: "No user" });
|
||||
|
||||
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);
|
||||
|
||||
res.json({
|
||||
metrics,
|
||||
csvUrl: user.company.csvUrl,
|
||||
company: user.company
|
||||
});
|
||||
}
|
||||
32
pages/api/dashboard/settings.ts
Normal file
32
pages/api/dashboard/settings.ts
Normal file
@ -0,0 +1,32 @@
|
||||
import { NextApiRequest, NextApiResponse } from "next";
|
||||
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" });
|
||||
|
||||
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,
|
||||
}
|
||||
});
|
||||
res.json({ ok: true });
|
||||
} else {
|
||||
res.status(405).end();
|
||||
}
|
||||
}
|
||||
56
pages/api/dashboard/users.ts
Normal file
56
pages/api/dashboard/users.ts
Normal file
@ -0,0 +1,56 @@
|
||||
import { NextApiRequest, NextApiResponse } from "next";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { prisma } from "../../../lib/prisma";
|
||||
import bcrypt from "bcryptjs";
|
||||
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;
|
||||
}
|
||||
|
||||
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 }
|
||||
});
|
||||
|
||||
if (!user) return res.status(401).json({ error: "No user" });
|
||||
|
||||
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();
|
||||
}
|
||||
35
pages/api/forgot-password.ts
Normal file
35
pages/api/forgot-password.ts
Normal file
@ -0,0 +1,35 @@
|
||||
import { prisma } from "../../lib/prisma";
|
||||
import { sendEmail } from "../../lib/sendEmail";
|
||||
import crypto from "crypto";
|
||||
import type { IncomingMessage, ServerResponse } from 'http';
|
||||
|
||||
type NextApiRequest = IncomingMessage & {
|
||||
body: {
|
||||
email: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
};
|
||||
|
||||
type NextApiResponse = ServerResponse & {
|
||||
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
|
||||
|
||||
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();
|
||||
}
|
||||
53
pages/api/register.ts
Normal file
53
pages/api/register.ts
Normal file
@ -0,0 +1,53 @@
|
||||
import { NextApiRequest, NextApiResponse } from "next";
|
||||
import { prisma } from "../../lib/prisma";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { ApiResponse } from "../../lib/types";
|
||||
|
||||
interface RegisterRequestBody {
|
||||
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();
|
||||
|
||||
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 (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 }
|
||||
});
|
||||
}
|
||||
40
pages/api/reset-password.ts
Normal file
40
pages/api/reset-password.ts
Normal file
@ -0,0 +1,40 @@
|
||||
import { prisma } from "../../lib/prisma";
|
||||
import bcrypt from "bcryptjs";
|
||||
import type { IncomingMessage, ServerResponse } from 'http';
|
||||
|
||||
type NextApiRequest = IncomingMessage & {
|
||||
body: {
|
||||
token: string;
|
||||
password: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
};
|
||||
|
||||
type NextApiResponse = ServerResponse & {
|
||||
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" });
|
||||
|
||||
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();
|
||||
}
|
||||
Reference in New Issue
Block a user