Enrollment + almost all auth
This commit is contained in:
140
app/api/invites/[token]/route.ts
Normal file
140
app/api/invites/[token]/route.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import type { NextRequest } from "next/server";
|
||||
import bcrypt from "bcrypt";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { buildSessionCookieOptions, COOKIE_NAME, SESSION_DAYS } from "@/lib/auth/sessionCookie";
|
||||
|
||||
async function loadInvite(token: string) {
|
||||
return prisma.orgInvite.findFirst({
|
||||
where: {
|
||||
token,
|
||||
revokedAt: null,
|
||||
acceptedAt: null,
|
||||
expiresAt: { gt: new Date() },
|
||||
},
|
||||
include: {
|
||||
org: { select: { id: true, name: true, slug: true } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
_req: NextRequest,
|
||||
{ params }: { params: Promise<{ token: string }> }
|
||||
) {
|
||||
const { token } = await params;
|
||||
const invite = await loadInvite(token);
|
||||
if (!invite) {
|
||||
return NextResponse.json({ ok: false, error: "Invite not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
invite: {
|
||||
email: invite.email,
|
||||
role: invite.role,
|
||||
org: invite.org,
|
||||
expiresAt: invite.expiresAt,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ token: string }> }
|
||||
) {
|
||||
const { token } = await params;
|
||||
const invite = await loadInvite(token);
|
||||
if (!invite) {
|
||||
return NextResponse.json({ ok: false, error: "Invite not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const body = await req.json().catch(() => ({}));
|
||||
const name = String(body.name || "").trim();
|
||||
const password = String(body.password || "");
|
||||
|
||||
const existingUser = await prisma.user.findUnique({
|
||||
where: { email: invite.email },
|
||||
});
|
||||
|
||||
if (!password || password.length < 8) {
|
||||
return NextResponse.json({ ok: false, error: "Password must be at least 8 characters" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!existingUser && !name) {
|
||||
return NextResponse.json({ ok: false, error: "Name is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
let userId = existingUser?.id ?? null;
|
||||
if (existingUser) {
|
||||
if (!existingUser.isActive) {
|
||||
return NextResponse.json({ ok: false, error: "User is inactive" }, { status: 403 });
|
||||
}
|
||||
const ok = await bcrypt.compare(password, existingUser.passwordHash);
|
||||
if (!ok) {
|
||||
return NextResponse.json({ ok: false, error: "Invalid credentials" }, { status: 401 });
|
||||
}
|
||||
userId = existingUser.id;
|
||||
} else {
|
||||
const passwordHash = await bcrypt.hash(password, 10);
|
||||
const created = await prisma.user.create({
|
||||
data: {
|
||||
email: invite.email,
|
||||
name,
|
||||
passwordHash,
|
||||
emailVerifiedAt: new Date(),
|
||||
},
|
||||
});
|
||||
userId = created.id;
|
||||
}
|
||||
|
||||
const expiresAt = new Date(Date.now() + SESSION_DAYS * 24 * 60 * 60 * 1000);
|
||||
|
||||
const session = await prisma.$transaction(async (tx) => {
|
||||
if (existingUser && !existingUser.emailVerifiedAt) {
|
||||
await tx.user.update({
|
||||
where: { id: existingUser.id },
|
||||
data: {
|
||||
emailVerifiedAt: new Date(),
|
||||
emailVerificationToken: null,
|
||||
emailVerificationExpiresAt: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await tx.orgUser.upsert({
|
||||
where: {
|
||||
orgId_userId: {
|
||||
orgId: invite.orgId,
|
||||
userId,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
role: invite.role,
|
||||
},
|
||||
create: {
|
||||
orgId: invite.orgId,
|
||||
userId,
|
||||
role: invite.role,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.orgInvite.update({
|
||||
where: { id: invite.id },
|
||||
data: { acceptedAt: new Date() },
|
||||
});
|
||||
|
||||
return tx.session.create({
|
||||
data: {
|
||||
userId,
|
||||
orgId: invite.orgId,
|
||||
expiresAt,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const res = NextResponse.json({ ok: true, next: "/machines" });
|
||||
res.cookies.set(COOKIE_NAME, session.id, buildSessionCookieOptions(req));
|
||||
|
||||
return res;
|
||||
}
|
||||
60
app/api/login copy/route.ts
Normal file
60
app/api/login copy/route.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import bcrypt from "bcrypt";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
const COOKIE_NAME = "mis_session";
|
||||
const SESSION_DAYS = 7;
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const body = await req.json().catch(() => ({}));
|
||||
const email = String(body.email || "").trim().toLowerCase();
|
||||
const password = String(body.password || "");
|
||||
const next = String(body.next || "/machines");
|
||||
|
||||
if (!email || !password) {
|
||||
return NextResponse.json({ ok: false, error: "Missing email/password" }, { status: 400 });
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { email } });
|
||||
if (!user || !user.isActive) {
|
||||
return NextResponse.json({ ok: false, error: "Invalid credentials" }, { status: 401 });
|
||||
}
|
||||
|
||||
const ok = await bcrypt.compare(password, user.passwordHash);
|
||||
if (!ok) {
|
||||
return NextResponse.json({ ok: false, error: "Invalid credentials" }, { status: 401 });
|
||||
}
|
||||
|
||||
// Multiple orgs per user: pick the oldest membership for now
|
||||
const membership = await prisma.orgUser.findFirst({
|
||||
where: { userId: user.id },
|
||||
orderBy: { createdAt: "asc" },
|
||||
});
|
||||
|
||||
if (!membership) {
|
||||
return NextResponse.json({ ok: false, error: "User has no organization" }, { status: 403 });
|
||||
}
|
||||
|
||||
const expiresAt = new Date(Date.now() + SESSION_DAYS * 24 * 60 * 60 * 1000);
|
||||
|
||||
const session = await prisma.session.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
orgId: membership.orgId,
|
||||
expiresAt,
|
||||
// optional fields you can add later: ip/userAgent
|
||||
},
|
||||
});
|
||||
|
||||
const res = NextResponse.json({ ok: true, next });
|
||||
|
||||
res.cookies.set(COOKIE_NAME, session.id, {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: false, // set true once HTTPS only
|
||||
path: "/",
|
||||
maxAge: SESSION_DAYS * 24 * 60 * 60,
|
||||
});
|
||||
|
||||
return res;
|
||||
}
|
||||
@@ -1,9 +1,7 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import bcrypt from "bcrypt";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
const COOKIE_NAME = "mis_session";
|
||||
const SESSION_DAYS = 7;
|
||||
import { buildSessionCookieOptions, COOKIE_NAME, SESSION_DAYS } from "@/lib/auth/sessionCookie";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const body = await req.json().catch(() => ({}));
|
||||
@@ -20,6 +18,10 @@ export async function POST(req: Request) {
|
||||
return NextResponse.json({ ok: false, error: "Invalid credentials" }, { status: 401 });
|
||||
}
|
||||
|
||||
if (!user.emailVerifiedAt) {
|
||||
return NextResponse.json({ ok: false, error: "Email not verified" }, { status: 403 });
|
||||
}
|
||||
|
||||
const ok = await bcrypt.compare(password, user.passwordHash);
|
||||
if (!ok) {
|
||||
return NextResponse.json({ ok: false, error: "Invalid credentials" }, { status: 401 });
|
||||
@@ -47,14 +49,7 @@ export async function POST(req: Request) {
|
||||
});
|
||||
|
||||
const res = NextResponse.json({ ok: true, next });
|
||||
|
||||
res.cookies.set(COOKIE_NAME, session.id, {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: false, // set true once HTTPS only
|
||||
path: "/",
|
||||
maxAge: SESSION_DAYS * 24 * 60 * 60,
|
||||
});
|
||||
res.cookies.set(COOKIE_NAME, session.id, buildSessionCookieOptions(req));
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
21
app/api/logout copy/route.ts
Normal file
21
app/api/logout copy/route.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { cookies } from "next/headers";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
const COOKIE_NAME = "mis_session";
|
||||
|
||||
export async function POST() {
|
||||
const jar = await cookies();
|
||||
const sessionId = jar.get(COOKIE_NAME)?.value;
|
||||
|
||||
if (sessionId) {
|
||||
await prisma.session.updateMany({
|
||||
where: { id: sessionId, revokedAt: null },
|
||||
data: { revokedAt: new Date() },
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
const res = NextResponse.json({ ok: true });
|
||||
res.cookies.set(COOKIE_NAME, "", { path: "/", maxAge: 0 });
|
||||
return res;
|
||||
}
|
||||
54
app/api/machines/pair/route.ts
Normal file
54
app/api/machines/pair/route.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { randomBytes } from "crypto";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { getBaseUrl } from "@/lib/appUrl";
|
||||
import { normalizePairingCode } from "@/lib/pairingCode";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const body = await req.json().catch(() => ({}));
|
||||
const rawCode = String(body.code || body.pairingCode || "").trim();
|
||||
const code = normalizePairingCode(rawCode);
|
||||
|
||||
if (!code || code.length !== 5) {
|
||||
return NextResponse.json({ ok: false, error: "Invalid pairing code" }, { status: 400 });
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
|
||||
const machine = await prisma.machine.findFirst({
|
||||
where: {
|
||||
pairingCode: code,
|
||||
pairingCodeUsedAt: null,
|
||||
pairingCodeExpiresAt: { gt: now },
|
||||
},
|
||||
select: { id: true, orgId: true, apiKey: true },
|
||||
});
|
||||
|
||||
if (!machine) {
|
||||
return NextResponse.json({ ok: false, error: "Pairing code not found or expired" }, { status: 404 });
|
||||
}
|
||||
|
||||
let apiKey = machine.apiKey;
|
||||
if (!apiKey) {
|
||||
apiKey = randomBytes(24).toString("hex");
|
||||
}
|
||||
|
||||
await prisma.machine.update({
|
||||
where: { id: machine.id },
|
||||
data: {
|
||||
apiKey,
|
||||
pairingCode: null,
|
||||
pairingCodeExpiresAt: null,
|
||||
pairingCodeUsedAt: now,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
config: {
|
||||
cloudBaseUrl: getBaseUrl(req),
|
||||
machineId: machine.id,
|
||||
apiKey,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { randomBytes } from "crypto";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { cookies } from "next/headers";
|
||||
import { generatePairingCode } from "@/lib/pairingCode";
|
||||
|
||||
const COOKIE_NAME = "mis_session";
|
||||
|
||||
@@ -8,10 +10,16 @@ async function requireSession() {
|
||||
const sessionId = (await cookies()).get(COOKIE_NAME)?.value;
|
||||
if (!sessionId) return null;
|
||||
|
||||
return prisma.session.findFirst({
|
||||
const session = await prisma.session.findFirst({
|
||||
where: { id: sessionId, revokedAt: null, expiresAt: { gt: new Date() } },
|
||||
include: { org: true, user: true },
|
||||
});
|
||||
|
||||
if (!session || !session.user?.isActive || !session.user?.emailVerifiedAt) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
@@ -65,3 +73,74 @@ export async function GET() {
|
||||
|
||||
return NextResponse.json({ ok: true, machines: out });
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const session = await requireSession();
|
||||
if (!session) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const body = await req.json().catch(() => ({}));
|
||||
const name = String(body.name || "").trim();
|
||||
const codeRaw = String(body.code || "").trim();
|
||||
const locationRaw = String(body.location || "").trim();
|
||||
|
||||
if (!name) {
|
||||
return NextResponse.json({ ok: false, error: "Machine name is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const existing = await prisma.machine.findFirst({
|
||||
where: { orgId: session.orgId, name },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
return NextResponse.json({ ok: false, error: "Machine name already exists" }, { status: 409 });
|
||||
}
|
||||
|
||||
const apiKey = randomBytes(24).toString("hex");
|
||||
const pairingExpiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000);
|
||||
|
||||
let machine = null as null | {
|
||||
id: string;
|
||||
name: string;
|
||||
code?: string | null;
|
||||
location?: string | null;
|
||||
pairingCode?: string | null;
|
||||
pairingCodeExpiresAt?: Date | null;
|
||||
};
|
||||
|
||||
for (let attempt = 0; attempt < 5; attempt += 1) {
|
||||
const pairingCode = generatePairingCode();
|
||||
try {
|
||||
machine = await prisma.machine.create({
|
||||
data: {
|
||||
orgId: session.orgId,
|
||||
name,
|
||||
code: codeRaw || null,
|
||||
location: locationRaw || null,
|
||||
apiKey,
|
||||
pairingCode,
|
||||
pairingCodeExpiresAt: pairingExpiresAt,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
code: true,
|
||||
location: true,
|
||||
pairingCode: true,
|
||||
pairingCodeExpiresAt: true,
|
||||
},
|
||||
});
|
||||
break;
|
||||
} catch (err: any) {
|
||||
if (err?.code !== "P2002") {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!machine?.pairingCode) {
|
||||
return NextResponse.json({ ok: false, error: "Failed to generate pairing code" }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true, machine });
|
||||
}
|
||||
|
||||
@@ -2,9 +2,15 @@ import { NextResponse } from "next/server";
|
||||
import { requireSession } from "@/lib/auth/requireSession";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const { userId, orgId } = await requireSession();
|
||||
const session = await requireSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ ok: false }, { status: 401 });
|
||||
}
|
||||
const { userId, orgId } = session;
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
@@ -16,7 +22,12 @@ export async function GET() {
|
||||
select: { id: true, name: true, slug: true },
|
||||
});
|
||||
|
||||
return NextResponse.json({ ok: true, user, org });
|
||||
const membership = await prisma.orgUser.findUnique({
|
||||
where: { orgId_userId: { orgId, userId } },
|
||||
select: { role: true },
|
||||
});
|
||||
|
||||
return NextResponse.json({ ok: true, user, org, membership });
|
||||
} catch {
|
||||
return NextResponse.json({ ok: false }, { status: 401 });
|
||||
}
|
||||
|
||||
51
app/api/org/invites/[inviteId]/route.ts
Normal file
51
app/api/org/invites/[inviteId]/route.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import type { NextRequest } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { requireSession } from "@/lib/auth/requireSession";
|
||||
|
||||
function canManageMembers(role?: string | null) {
|
||||
return role === "OWNER" || role === "ADMIN";
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
_req: NextRequest,
|
||||
{ params }: { params: Promise<{ inviteId: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await requireSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
const { inviteId } = await params;
|
||||
|
||||
const membership = await prisma.orgUser.findUnique({
|
||||
where: {
|
||||
orgId_userId: {
|
||||
orgId: session.orgId,
|
||||
userId: session.userId,
|
||||
},
|
||||
},
|
||||
select: { role: true },
|
||||
});
|
||||
|
||||
if (!canManageMembers(membership?.role)) {
|
||||
return NextResponse.json({ ok: false, error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
await prisma.orgInvite.updateMany({
|
||||
where: {
|
||||
id: inviteId,
|
||||
orgId: session.orgId,
|
||||
acceptedAt: null,
|
||||
revokedAt: null,
|
||||
},
|
||||
data: {
|
||||
revokedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch {
|
||||
return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
}
|
||||
195
app/api/org/members/route.ts
Normal file
195
app/api/org/members/route.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { randomBytes } from "crypto";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { requireSession } from "@/lib/auth/requireSession";
|
||||
import { buildInviteEmail, sendEmail } from "@/lib/email";
|
||||
import { getBaseUrl } from "@/lib/appUrl";
|
||||
|
||||
const INVITE_DAYS = 7;
|
||||
const ROLES = new Set(["OWNER", "ADMIN", "MEMBER"]);
|
||||
|
||||
function canManageMembers(role?: string | null) {
|
||||
return role === "OWNER" || role === "ADMIN";
|
||||
}
|
||||
|
||||
function isValidEmail(email: string) {
|
||||
return email.includes("@") && email.includes(".");
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
|
||||
const session = await requireSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const [org, members, invites] = await prisma.$transaction([
|
||||
prisma.org.findUnique({
|
||||
where: { id: session.orgId },
|
||||
select: { id: true, name: true, slug: true },
|
||||
}),
|
||||
prisma.orgUser.findMany({
|
||||
where: { orgId: session.orgId },
|
||||
orderBy: { createdAt: "asc" },
|
||||
include: {
|
||||
user: { select: { id: true, email: true, name: true, isActive: true, createdAt: true } },
|
||||
},
|
||||
}),
|
||||
prisma.orgInvite.findMany({
|
||||
where: {
|
||||
orgId: session.orgId,
|
||||
revokedAt: null,
|
||||
acceptedAt: null,
|
||||
expiresAt: { gt: new Date() },
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
role: true,
|
||||
token: true,
|
||||
createdAt: true,
|
||||
expiresAt: true,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const mappedMembers = members.map((m) => ({
|
||||
id: m.user.id,
|
||||
membershipId: m.id,
|
||||
email: m.user.email,
|
||||
name: m.user.name,
|
||||
role: m.role,
|
||||
isActive: m.user.isActive,
|
||||
joinedAt: m.createdAt,
|
||||
}));
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
org,
|
||||
members: mappedMembers,
|
||||
invites,
|
||||
});
|
||||
} catch {
|
||||
return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const session = await requireSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
const membership = await prisma.orgUser.findUnique({
|
||||
where: {
|
||||
orgId_userId: {
|
||||
orgId: session.orgId,
|
||||
userId: session.userId,
|
||||
},
|
||||
},
|
||||
select: { role: true },
|
||||
});
|
||||
|
||||
if (!canManageMembers(membership?.role)) {
|
||||
return NextResponse.json({ ok: false, error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await req.json().catch(() => ({}));
|
||||
const email = String(body.email || "").trim().toLowerCase();
|
||||
const role = String(body.role || "MEMBER").toUpperCase();
|
||||
|
||||
if (!email || !isValidEmail(email)) {
|
||||
return NextResponse.json({ ok: false, error: "Invalid email" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!ROLES.has(role)) {
|
||||
return NextResponse.json({ ok: false, error: "Invalid role" }, { status: 400 });
|
||||
}
|
||||
|
||||
const existingUser = await prisma.user.findUnique({ where: { email } });
|
||||
if (existingUser) {
|
||||
const existingMembership = await prisma.orgUser.findUnique({
|
||||
where: {
|
||||
orgId_userId: {
|
||||
orgId: session.orgId,
|
||||
userId: existingUser.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
if (existingMembership) {
|
||||
return NextResponse.json({ ok: false, error: "User already in org" }, { status: 409 });
|
||||
}
|
||||
}
|
||||
|
||||
const existingInvite = await prisma.orgInvite.findFirst({
|
||||
where: {
|
||||
orgId: session.orgId,
|
||||
email,
|
||||
acceptedAt: null,
|
||||
revokedAt: null,
|
||||
expiresAt: { gt: new Date() },
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
|
||||
if (existingInvite) {
|
||||
return NextResponse.json({ ok: true, invite: existingInvite });
|
||||
}
|
||||
|
||||
let invite = null;
|
||||
for (let i = 0; i < 3; i += 1) {
|
||||
const token = randomBytes(24).toString("hex");
|
||||
try {
|
||||
invite = await prisma.orgInvite.create({
|
||||
data: {
|
||||
orgId: session.orgId,
|
||||
email,
|
||||
role,
|
||||
token,
|
||||
invitedBy: session.userId,
|
||||
expiresAt: new Date(Date.now() + INVITE_DAYS * 24 * 60 * 60 * 1000),
|
||||
},
|
||||
});
|
||||
break;
|
||||
} catch (err: any) {
|
||||
if (err?.code !== "P2002") throw err;
|
||||
}
|
||||
}
|
||||
|
||||
if (!invite) {
|
||||
return NextResponse.json({ ok: false, error: "Failed to create invite" }, { status: 500 });
|
||||
}
|
||||
|
||||
let emailSent = true;
|
||||
let emailError: string | null = null;
|
||||
try {
|
||||
const org = await prisma.org.findUnique({
|
||||
where: { id: session.orgId },
|
||||
select: { name: true },
|
||||
});
|
||||
const baseUrl = getBaseUrl(req);
|
||||
const inviteUrl = `${baseUrl}/invite/${invite.token}`;
|
||||
const appName = "MIS Control Tower";
|
||||
const content = buildInviteEmail({
|
||||
appName,
|
||||
orgName: org?.name || "your organization",
|
||||
inviteUrl,
|
||||
});
|
||||
await sendEmail({
|
||||
to: invite.email,
|
||||
subject: content.subject,
|
||||
text: content.text,
|
||||
html: content.html,
|
||||
});
|
||||
} catch (err: any) {
|
||||
emailSent = false;
|
||||
emailError = err?.message || "Failed to send invite email";
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true, invite, emailSent, emailError });
|
||||
} catch {
|
||||
return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
}
|
||||
132
app/api/signup/route.ts
Normal file
132
app/api/signup/route.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import bcrypt from "bcrypt";
|
||||
import { randomBytes } from "crypto";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { DEFAULT_ALERTS, DEFAULT_DEFAULTS, DEFAULT_SHIFT } from "@/lib/settings";
|
||||
import { buildVerifyEmail, sendEmail } from "@/lib/email";
|
||||
import { getBaseUrl } from "@/lib/appUrl";
|
||||
|
||||
function slugify(input: string) {
|
||||
const trimmed = input.trim().toLowerCase();
|
||||
const slug = trimmed
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
return slug || "org";
|
||||
}
|
||||
|
||||
function isValidEmail(email: string) {
|
||||
return email.includes("@") && email.includes(".");
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const body = await req.json().catch(() => ({}));
|
||||
const orgName = String(body.orgName || "").trim();
|
||||
const name = String(body.name || "").trim();
|
||||
const email = String(body.email || "").trim().toLowerCase();
|
||||
const password = String(body.password || "");
|
||||
|
||||
if (!orgName || !name || !email || !password) {
|
||||
return NextResponse.json({ ok: false, error: "Missing required fields" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!isValidEmail(email)) {
|
||||
return NextResponse.json({ ok: false, error: "Invalid email" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (password.length < 8) {
|
||||
return NextResponse.json({ ok: false, error: "Password must be at least 8 characters" }, { status: 400 });
|
||||
}
|
||||
|
||||
const existing = await prisma.user.findUnique({ where: { email } });
|
||||
if (existing) {
|
||||
return NextResponse.json({ ok: false, error: "Email already in use" }, { status: 409 });
|
||||
}
|
||||
|
||||
const baseSlug = slugify(orgName);
|
||||
let slug = baseSlug;
|
||||
let counter = 1;
|
||||
while (await prisma.org.findUnique({ where: { slug } })) {
|
||||
counter += 1;
|
||||
slug = `${baseSlug}-${counter}`;
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(password, 10);
|
||||
const verificationToken = randomBytes(24).toString("hex");
|
||||
const verificationExpiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000);
|
||||
|
||||
const result = await prisma.$transaction(async (tx) => {
|
||||
const org = await tx.org.create({
|
||||
data: { name: orgName, slug },
|
||||
});
|
||||
|
||||
const user = await tx.user.create({
|
||||
data: {
|
||||
email,
|
||||
name,
|
||||
passwordHash,
|
||||
emailVerificationToken: verificationToken,
|
||||
emailVerificationExpiresAt: verificationExpiresAt,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.orgUser.create({
|
||||
data: {
|
||||
orgId: org.id,
|
||||
userId: user.id,
|
||||
role: "OWNER",
|
||||
},
|
||||
});
|
||||
|
||||
await tx.orgSettings.create({
|
||||
data: {
|
||||
orgId: org.id,
|
||||
timezone: "UTC",
|
||||
shiftChangeCompMin: 10,
|
||||
lunchBreakMin: 30,
|
||||
stoppageMultiplier: 1.5,
|
||||
oeeAlertThresholdPct: 90,
|
||||
performanceThresholdPct: 85,
|
||||
qualitySpikeDeltaPct: 5,
|
||||
alertsJson: DEFAULT_ALERTS,
|
||||
defaultsJson: DEFAULT_DEFAULTS,
|
||||
updatedBy: user.id,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.orgShift.create({
|
||||
data: {
|
||||
orgId: org.id,
|
||||
name: DEFAULT_SHIFT.name,
|
||||
startTime: DEFAULT_SHIFT.start,
|
||||
endTime: DEFAULT_SHIFT.end,
|
||||
sortOrder: 1,
|
||||
enabled: true,
|
||||
},
|
||||
});
|
||||
|
||||
return { org, user };
|
||||
});
|
||||
|
||||
const baseUrl = getBaseUrl(req);
|
||||
const verifyUrl = `${baseUrl}/api/verify-email?token=${verificationToken}`;
|
||||
const appName = "MIS Control Tower";
|
||||
const emailContent = buildVerifyEmail({ appName, verifyUrl });
|
||||
|
||||
let emailSent = true;
|
||||
try {
|
||||
await sendEmail({
|
||||
to: email,
|
||||
subject: emailContent.subject,
|
||||
text: emailContent.text,
|
||||
html: emailContent.html,
|
||||
});
|
||||
} catch {
|
||||
emailSent = false;
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
verificationRequired: true,
|
||||
emailSent,
|
||||
});
|
||||
}
|
||||
62
app/api/verify-email/route.ts
Normal file
62
app/api/verify-email/route.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { buildSessionCookieOptions, COOKIE_NAME, SESSION_DAYS } from "@/lib/auth/sessionCookie";
|
||||
import { getBaseUrl } from "@/lib/appUrl";
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const url = new URL(req.url);
|
||||
const token = url.searchParams.get("token");
|
||||
const wantsJson = req.headers.get("accept")?.includes("application/json");
|
||||
|
||||
if (!token) {
|
||||
return NextResponse.json({ ok: false, error: "Missing token" }, { status: 400 });
|
||||
}
|
||||
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
emailVerificationToken: token,
|
||||
emailVerificationExpiresAt: { gt: new Date() },
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ ok: false, error: "Invalid or expired token" }, { status: 404 });
|
||||
}
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
emailVerifiedAt: new Date(),
|
||||
emailVerificationToken: null,
|
||||
emailVerificationExpiresAt: null,
|
||||
},
|
||||
});
|
||||
|
||||
const membership = await prisma.orgUser.findFirst({
|
||||
where: { userId: user.id },
|
||||
orderBy: { createdAt: "asc" },
|
||||
});
|
||||
|
||||
if (!membership) {
|
||||
return NextResponse.json({ ok: false, error: "No organization found" }, { status: 403 });
|
||||
}
|
||||
|
||||
const expiresAt = new Date(Date.now() + SESSION_DAYS * 24 * 60 * 60 * 1000);
|
||||
const session = await prisma.session.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
orgId: membership.orgId,
|
||||
expiresAt,
|
||||
},
|
||||
});
|
||||
|
||||
if (wantsJson) {
|
||||
const res = NextResponse.json({ ok: true, next: "/machines" });
|
||||
res.cookies.set(COOKIE_NAME, session.id, buildSessionCookieOptions(req));
|
||||
return res;
|
||||
}
|
||||
|
||||
const res = NextResponse.redirect(new URL("/machines", getBaseUrl(req)));
|
||||
res.cookies.set(COOKIE_NAME, session.id, buildSessionCookieOptions(req));
|
||||
return res;
|
||||
}
|
||||
Reference in New Issue
Block a user