From a0ed517047640e914014e4412df95b0620b1e639 Mon Sep 17 00:00:00 2001 From: mdares Date: Sat, 3 Jan 2026 20:18:39 +0000 Subject: [PATCH] Enrollment + almost all auth --- app/(app)/layout.tsx | 4 +- app/(app)/machines/page.tsx | 180 ++- app/(app)/settings/page.tsx | 269 +++- app/api/invites/[token]/route.ts | 140 ++ app/api/login copy/route.ts | 60 + app/api/login/route.ts | 17 +- app/api/logout copy/route.ts | 21 + app/api/machines/pair/route.ts | 54 + app/api/machines/route.ts | 81 +- app/api/me/route.ts | 15 +- app/api/org/invites/[inviteId]/route.ts | 51 + app/api/org/members/route.ts | 195 +++ app/api/signup/route.ts | 132 ++ app/api/verify-email/route.ts | 62 + app/invite/[token]/InviteAcceptForm.tsx | 131 ++ app/invite/[token]/page.tsx | 12 + app/login/LoginForm.tsx | 7 +- app/signup/SignupForm.tsx | 142 ++ app/signup/page.tsx | 12 + components/layout/Sidebar.tsx | 31 +- lib/appUrl.ts | 14 + lib/auth/requireSession.ts | 16 +- lib/auth/sessionCookie.ts | 20 + lib/email.ts | 86 + lib/pairingCode.ts | 16 + package-lock.json | 1426 +++++++++++++++++ package.json | 2 + .../20251216173800_init_auth/migration.sql | 103 ++ .../migration.sql | 39 + .../migration.sql | 11 + .../migration.sql | 66 + .../migration.sql | 27 + .../20251222235834_ingest_log/migration.sql | 55 + .../migration.sql | 0 .../migration.sql | 42 + .../migration.sql | 2 + .../20260103201628_change_name/migration.sql | 8 + prisma/migrations/migration_lock.toml | 3 + prisma/schema.prisma | 33 +- prisma/seed.ts | 5 +- 40 files changed, 3559 insertions(+), 31 deletions(-) create mode 100644 app/api/invites/[token]/route.ts create mode 100644 app/api/login copy/route.ts create mode 100644 app/api/logout copy/route.ts create mode 100644 app/api/machines/pair/route.ts create mode 100644 app/api/org/invites/[inviteId]/route.ts create mode 100644 app/api/org/members/route.ts create mode 100644 app/api/signup/route.ts create mode 100644 app/api/verify-email/route.ts create mode 100644 app/invite/[token]/InviteAcceptForm.tsx create mode 100644 app/invite/[token]/page.tsx create mode 100644 app/signup/SignupForm.tsx create mode 100644 app/signup/page.tsx create mode 100644 lib/appUrl.ts create mode 100644 lib/auth/sessionCookie.ts create mode 100644 lib/email.ts create mode 100644 lib/pairingCode.ts create mode 100644 prisma/migrations/20251216173800_init_auth/migration.sql create mode 100644 prisma/migrations/20251217000852_machines_and_heartbeats/migration.sql create mode 100644 prisma/migrations/20251217001526_machine_api_key/migration.sql create mode 100644 prisma/migrations/20251217012912_kpi_snapshots_and_events/migration.sql create mode 100644 prisma/migrations/20251218153109_add_machine_cycles/migration.sql create mode 100644 prisma/migrations/20251222235834_ingest_log/migration.sql create mode 100644 prisma/migrations/20260103184630_add_invites_email_verify/migration.sql create mode 100644 prisma/migrations/20260103184751_add_invites_email_verify/migration.sql create mode 100644 prisma/migrations/20260103192442_add_invites_email_verify/migration.sql create mode 100644 prisma/migrations/20260103201628_change_name/migration.sql create mode 100644 prisma/migrations/migration_lock.toml diff --git a/app/(app)/layout.tsx b/app/(app)/layout.tsx index 2feb873..8b5de3b 100644 --- a/app/(app)/layout.tsx +++ b/app/(app)/layout.tsx @@ -20,7 +20,9 @@ export default async function AppLayout({ children }: { children: React.ReactNod include: { user: true, org: true }, }); - if (!session) redirect("/login?next=/machines"); + if (!session || !session.user?.isActive || !session.user?.emailVerifiedAt) { + redirect("/login?next=/machines"); + } return (
diff --git a/app/(app)/machines/page.tsx b/app/(app)/machines/page.tsx index 40a14f8..b2974a8 100644 --- a/app/(app)/machines/page.tsx +++ b/app/(app)/machines/page.tsx @@ -41,6 +41,19 @@ function badgeClass(status?: string, offline?: boolean) { export default function MachinesPage() { const [machines, setMachines] = useState([]); const [loading, setLoading] = useState(true); + const [showCreate, setShowCreate] = useState(false); + const [createName, setCreateName] = useState(""); + const [createCode, setCreateCode] = useState(""); + const [createLocation, setCreateLocation] = useState(""); + const [creating, setCreating] = useState(false); + const [createError, setCreateError] = useState(null); + const [createdMachine, setCreatedMachine] = useState<{ + id: string; + name: string; + pairingCode: string; + pairingExpiresAt: string; + } | null>(null); + const [copyStatus, setCopyStatus] = useState(null); useEffect(() => { let alive = true; @@ -67,6 +80,68 @@ export default function MachinesPage() { }; }, []); + async function createMachine() { + if (!createName.trim()) { + setCreateError("Machine name is required"); + return; + } + + setCreating(true); + setCreateError(null); + + try { + const res = await fetch("/api/machines", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name: createName, + code: createCode, + location: createLocation, + }), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok || !data.ok) { + throw new Error(data.error || "Failed to create machine"); + } + + const nextMachine = { + ...data.machine, + latestHeartbeat: null, + }; + setMachines((prev) => [nextMachine, ...prev]); + setCreatedMachine({ + id: data.machine.id, + name: data.machine.name, + pairingCode: data.machine.pairingCode, + pairingExpiresAt: data.machine.pairingCodeExpiresAt, + }); + setCreateName(""); + setCreateCode(""); + setCreateLocation(""); + setShowCreate(false); + } catch (err: any) { + setCreateError(err?.message || "Failed to create machine"); + } finally { + setCreating(false); + } + } + + async function copyText(text: string) { + try { + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(text); + setCopyStatus("Copied"); + } else { + setCopyStatus("Copy not supported"); + } + } catch { + setCopyStatus("Copy failed"); + } + setTimeout(() => setCopyStatus(null), 2000); + } + + const showCreateCard = showCreate || (!loading && machines.length === 0); + return (
@@ -75,14 +150,107 @@ export default function MachinesPage() {

Select a machine to view live KPIs.

- - Back to Overview - +
+ + + Back to Overview + +
+ {showCreateCard && ( +
+
+
+
Add a machine
+
+ Generate the machine ID and API key for your Node-RED edge. +
+
+
+ +
+ + + +
+ +
+ + {createError &&
{createError}
} +
+
+ )} + + {createdMachine && ( +
+
Edge pairing code
+
+ Machine: {createdMachine.name} +
+
+
Pairing code
+
{createdMachine.pairingCode}
+
+ Expires{" "} + {createdMachine.pairingExpiresAt + ? new Date(createdMachine.pairingExpiresAt).toLocaleString() + : "soon"} +
+
+
+ Enter this code on the Node-RED Control Tower settings screen to link the edge device. +
+
+ + {copyStatus &&
{copyStatus}
} +
+
+ )} + {loading &&
Loading machines…
} {!loading && machines.length === 0 && ( diff --git a/app/(app)/settings/page.tsx b/app/(app)/settings/page.tsx index a2ade41..f8f3aa1 100644 --- a/app/(app)/settings/page.tsx +++ b/app/(app)/settings/page.tsx @@ -38,6 +38,31 @@ type SettingsPayload = { updatedBy?: string; }; +type OrgInfo = { + id: string; + name: string; + slug: string; +}; + +type MemberRow = { + id: string; + membershipId: string; + name?: string | null; + email: string; + role: string; + isActive: boolean; + joinedAt: string; +}; + +type InviteRow = { + id: string; + email: string; + role: string; + token: string; + createdAt: string; + expiresAt: string; +}; + const DEFAULT_SHIFT: Shift = { name: "Shift 1", start: "06:00", @@ -187,6 +212,15 @@ export default function SettingsPage() { const [saving, setSaving] = useState(false); const [error, setError] = useState(null); const [saveStatus, setSaveStatus] = useState(null); + const [orgInfo, setOrgInfo] = useState(null); + const [members, setMembers] = useState([]); + const [invites, setInvites] = useState([]); + const [teamLoading, setTeamLoading] = useState(true); + const [teamError, setTeamError] = useState(null); + const [inviteEmail, setInviteEmail] = useState(""); + const [inviteRole, setInviteRole] = useState("MEMBER"); + const [inviteStatus, setInviteStatus] = useState(null); + const [inviteSubmitting, setInviteSubmitting] = useState(false); const loadSettings = useCallback(async () => { setLoading(true); @@ -208,9 +242,36 @@ export default function SettingsPage() { } }, []); + const buildInviteUrl = useCallback((token: string) => { + if (typeof window === "undefined") return `/invite/${token}`; + return `${window.location.origin}/invite/${token}`; + }, []); + + const loadTeam = useCallback(async () => { + setTeamLoading(true); + setTeamError(null); + try { + const response = await fetch("/api/org/members", { cache: "no-store" }); + const { data, text } = await readResponse(response); + if (!response.ok || !data?.ok) { + const message = + data?.error || data?.message || text || `Failed to load team (${response.status})`; + throw new Error(message); + } + setOrgInfo(data.org ?? null); + setMembers(Array.isArray(data.members) ? data.members : []); + setInvites(Array.isArray(data.invites) ? data.invites : []); + } catch (err) { + setTeamError(err instanceof Error ? err.message : "Failed to load team"); + } finally { + setTeamLoading(false); + } + }, []); + useEffect(() => { loadSettings(); - }, [loadSettings]); + loadTeam(); + }, [loadSettings, loadTeam]); const updateShift = useCallback((index: number, patch: Partial) => { setDraft((prev) => { @@ -336,6 +397,77 @@ export default function SettingsPage() { [] ); + const copyInviteLink = useCallback( + async (token: string) => { + const url = buildInviteUrl(token); + try { + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(url); + setInviteStatus("Invite link copied"); + } else { + setInviteStatus(url); + } + } catch { + setInviteStatus(url); + } + }, + [buildInviteUrl] + ); + + const revokeInvite = useCallback(async (inviteId: string) => { + setInviteStatus(null); + try { + const response = await fetch(`/api/org/invites/${inviteId}`, { method: "DELETE" }); + const { data, text } = await readResponse(response); + if (!response.ok || !data?.ok) { + const message = + data?.error || data?.message || text || `Failed to revoke invite (${response.status})`; + throw new Error(message); + } + setInvites((prev) => prev.filter((invite) => invite.id !== inviteId)); + } catch (err) { + setInviteStatus(err instanceof Error ? err.message : "Failed to revoke invite"); + } + }, []); + + const createInvite = useCallback(async () => { + if (!inviteEmail.trim()) { + setInviteStatus("Email is required"); + return; + } + setInviteSubmitting(true); + setInviteStatus(null); + try { + const response = await fetch("/api/org/members", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email: inviteEmail, role: inviteRole }), + }); + const { data, text } = await readResponse(response); + if (!response.ok || !data?.ok) { + const message = + data?.error || data?.message || text || `Failed to create invite (${response.status})`; + throw new Error(message); + } + const nextInvite = data.invite; + if (nextInvite) { + setInvites((prev) => [nextInvite, ...prev.filter((invite) => invite.id !== nextInvite.id)]); + const inviteUrl = buildInviteUrl(nextInvite.token); + if (data.emailSent === false) { + setInviteStatus(`Invite created, email failed: ${inviteUrl}`); + } else { + setInviteStatus("Invite email sent"); + } + } + setInviteEmail(""); + await loadTeam(); + } catch (err) { + setInviteStatus(err instanceof Error ? err.message : "Failed to create invite"); + } finally { + setInviteSubmitting(false); + } + }, [buildInviteUrl, inviteEmail, inviteRole, loadTeam]); + const saveSettings = useCallback(async () => { if (!draft) return; setSaving(true); @@ -436,7 +568,10 @@ export default function SettingsPage() {
Plant Name
-
MIS Plant
+
{orgInfo?.name || "Loading..."}
+ {orgInfo?.slug ? ( +
Slug: {orgInfo.slug}
+ ) : null}
+ +
+
+
+
Team Members
+
{members.length} total
+
+ + {teamLoading &&
Loading team...
} + {teamError && ( +
+ {teamError} +
+ )} + + {!teamLoading && !teamError && members.length === 0 && ( +
No team members yet.
+ )} + + {!teamLoading && !teamError && members.length > 0 && ( +
+ {members.map((member) => ( +
+
+
+ {member.name || member.email} +
+
{member.email}
+
+
+ + {member.role} + + {!member.isActive ? ( + + Inactive + + ) : null} +
+
+ ))} +
+ )} +
+ +
+
Invitations
+
+ + +
+ +
+ + + {inviteStatus &&
{inviteStatus}
} +
+ +
+ {invites.length === 0 && ( +
No pending invites.
+ )} + {invites.map((invite) => ( +
+
+
+
{invite.email}
+
+ {invite.role} - Expires {new Date(invite.expiresAt).toLocaleDateString()} +
+
+
+ + +
+
+
+ {buildInviteUrl(invite.token)} +
+
+ ))} +
+
+
); } diff --git a/app/api/invites/[token]/route.ts b/app/api/invites/[token]/route.ts new file mode 100644 index 0000000..f98be40 --- /dev/null +++ b/app/api/invites/[token]/route.ts @@ -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; +} diff --git a/app/api/login copy/route.ts b/app/api/login copy/route.ts new file mode 100644 index 0000000..a32b822 --- /dev/null +++ b/app/api/login copy/route.ts @@ -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; +} diff --git a/app/api/login/route.ts b/app/api/login/route.ts index a32b822..951561a 100644 --- a/app/api/login/route.ts +++ b/app/api/login/route.ts @@ -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; } diff --git a/app/api/logout copy/route.ts b/app/api/logout copy/route.ts new file mode 100644 index 0000000..6682c25 --- /dev/null +++ b/app/api/logout copy/route.ts @@ -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; +} diff --git a/app/api/machines/pair/route.ts b/app/api/machines/pair/route.ts new file mode 100644 index 0000000..f7899a3 --- /dev/null +++ b/app/api/machines/pair/route.ts @@ -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, + }, + }); +} diff --git a/app/api/machines/route.ts b/app/api/machines/route.ts index 6cac465..70be5fc 100644 --- a/app/api/machines/route.ts +++ b/app/api/machines/route.ts @@ -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 }); +} diff --git a/app/api/me/route.ts b/app/api/me/route.ts index a8f7368..ca55f44 100644 --- a/app/api/me/route.ts +++ b/app/api/me/route.ts @@ -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 }); } diff --git a/app/api/org/invites/[inviteId]/route.ts b/app/api/org/invites/[inviteId]/route.ts new file mode 100644 index 0000000..901fe3e --- /dev/null +++ b/app/api/org/invites/[inviteId]/route.ts @@ -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 }); + } +} diff --git a/app/api/org/members/route.ts b/app/api/org/members/route.ts new file mode 100644 index 0000000..c10c83f --- /dev/null +++ b/app/api/org/members/route.ts @@ -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 }); + } +} diff --git a/app/api/signup/route.ts b/app/api/signup/route.ts new file mode 100644 index 0000000..77b0678 --- /dev/null +++ b/app/api/signup/route.ts @@ -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, + }); +} diff --git a/app/api/verify-email/route.ts b/app/api/verify-email/route.ts new file mode 100644 index 0000000..8a6b127 --- /dev/null +++ b/app/api/verify-email/route.ts @@ -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; +} diff --git a/app/invite/[token]/InviteAcceptForm.tsx b/app/invite/[token]/InviteAcceptForm.tsx new file mode 100644 index 0000000..4798f5c --- /dev/null +++ b/app/invite/[token]/InviteAcceptForm.tsx @@ -0,0 +1,131 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; + +type InviteInfo = { + email: string; + role: string; + org: { id: string; name: string; slug: string }; + expiresAt: string; +}; + +export default function InviteAcceptForm({ token }: { token: string }) { + const router = useRouter(); + const [invite, setInvite] = useState(null); + const [loading, setLoading] = useState(true); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + const [name, setName] = useState(""); + const [password, setPassword] = useState(""); + + useEffect(() => { + let alive = true; + async function loadInvite() { + setLoading(true); + setError(null); + try { + const res = await fetch(`/api/invites/${token}`, { cache: "no-store" }); + const data = await res.json().catch(() => ({})); + if (!res.ok || !data.ok) { + throw new Error(data.error || "Invite not found"); + } + if (alive) setInvite(data.invite); + } catch (err: any) { + if (alive) setError(err?.message || "Invite not found"); + } finally { + if (alive) setLoading(false); + } + } + + loadInvite(); + return () => { + alive = false; + }; + }, [token]); + + async function onSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(null); + setSubmitting(true); + try { + const res = await fetch(`/api/invites/${token}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name, password }), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok || !data.ok) { + throw new Error(data.error || "Invite acceptance failed"); + } + router.push("/machines"); + router.refresh(); + } catch (err: any) { + setError(err?.message || "Invite acceptance failed"); + } finally { + setSubmitting(false); + } + } + + if (loading) { + return ( +
+ Loading invite... +
+ ); + } + + if (!invite) { + return ( +
+
+ {error || "Invite not found."} +
+
+ ); + } + + return ( +
+
+

Join {invite.org.name}

+

+ Accept the invite for {invite.email} as {invite.role}. +

+ +
+
+ + setName(e.target.value)} + autoComplete="name" + /> +
+ +
+ + setPassword(e.target.value)} + autoComplete="new-password" + /> +
+ + {error &&
{error}
} + + +
+
+
+ ); +} diff --git a/app/invite/[token]/page.tsx b/app/invite/[token]/page.tsx new file mode 100644 index 0000000..429dba9 --- /dev/null +++ b/app/invite/[token]/page.tsx @@ -0,0 +1,12 @@ +import { cookies } from "next/headers"; +import { redirect } from "next/navigation"; +import InviteAcceptForm from "./InviteAcceptForm"; + +export default async function InvitePage({ params }: { params: { token: string } }) { + const session = (await cookies()).get("mis_session")?.value; + if (session) { + redirect("/machines"); + } + + return ; +} diff --git a/app/login/LoginForm.tsx b/app/login/LoginForm.tsx index a4eb062..8f996eb 100644 --- a/app/login/LoginForm.tsx +++ b/app/login/LoginForm.tsx @@ -78,7 +78,12 @@ export default function LoginForm() { {loading ? "Signing in..." : "Login"} -
(Dev mode) This will be replaced with JWT auth later.
+
+ New here?{" "} + + Create an account + +
diff --git a/app/signup/SignupForm.tsx b/app/signup/SignupForm.tsx new file mode 100644 index 0000000..1604533 --- /dev/null +++ b/app/signup/SignupForm.tsx @@ -0,0 +1,142 @@ +"use client"; + +import { useState } from "react"; + +export default function SignupForm() { + const [orgName, setOrgName] = useState(""); + const [name, setName] = useState(""); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [loading, setLoading] = useState(false); + const [err, setErr] = useState(null); + const [verificationSent, setVerificationSent] = useState(false); + const [emailSent, setEmailSent] = useState(true); + + async function onSubmit(e: React.FormEvent) { + e.preventDefault(); + setErr(null); + setLoading(true); + + try { + const res = await fetch("/api/signup", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ orgName, name, email, password }), + }); + + const data = await res.json().catch(() => ({})); + if (!res.ok || !data.ok) { + setErr(data.error || "Signup failed"); + return; + } + + setVerificationSent(true); + setEmailSent(data.emailSent !== false); + } catch (e: any) { + setErr(e?.message || "Network error"); + } finally { + setLoading(false); + } + } + + if (verificationSent) { + return ( +
+
+

Verify your email

+

+ We sent a verification link to {email}. +

+ {!emailSent && ( +
+ Verification email failed to send. Please contact support. +
+ )} +
+ Once verified, you can sign in and invite your team. +
+ +
+
+ ); + } + + return ( +
+
+

Create your Control Tower

+

+ Set up your organization and invite the team. +

+ +
+
+ + setOrgName(e.target.value)} + autoComplete="organization" + /> +
+ +
+
+ + setName(e.target.value)} + autoComplete="name" + /> +
+
+ + setEmail(e.target.value)} + autoComplete="email" + /> +
+
+ +
+ + setPassword(e.target.value)} + autoComplete="new-password" + /> +
+ + {err &&
{err}
} + + + +
+ Already have access?{" "} + + Sign in + +
+
+
+
+ ); +} diff --git a/app/signup/page.tsx b/app/signup/page.tsx new file mode 100644 index 0000000..0323a8c --- /dev/null +++ b/app/signup/page.tsx @@ -0,0 +1,12 @@ +import { cookies } from "next/headers"; +import { redirect } from "next/navigation"; +import SignupForm from "./SignupForm"; + +export default async function SignupPage() { + const session = (await cookies()).get("mis_session")?.value; + if (session) { + redirect("/machines"); + } + + return ; +} diff --git a/components/layout/Sidebar.tsx b/components/layout/Sidebar.tsx index b4d448f..fe1f1bd 100644 --- a/components/layout/Sidebar.tsx +++ b/components/layout/Sidebar.tsx @@ -2,6 +2,7 @@ import Link from "next/link"; import { usePathname, useRouter } from "next/navigation"; +import { useEffect, useState } from "react"; const items = [ { href: "/overview", label: "Overview", icon: "šŸ " }, @@ -13,6 +14,30 @@ const items = [ export function Sidebar() { const pathname = usePathname(); const router = useRouter(); + const [me, setMe] = useState<{ + user?: { name?: string | null; email?: string | null }; + org?: { name?: string | null }; + membership?: { role?: string | null }; + } | null>(null); + + useEffect(() => { + let alive = true; + async function loadMe() { + try { + const res = await fetch("/api/me", { cache: "no-store" }); + const data = await res.json().catch(() => ({})); + if (alive && res.ok && data?.ok) { + setMe(data); + } + } catch { + if (alive) setMe(null); + } + } + loadMe(); + return () => { + alive = false; + }; + }, []); async function onLogout() { await fetch("/api/logout", { method: "POST" }); @@ -50,8 +75,10 @@ export function Sidebar() {
-
Juan PƩrez
-
Plant Manager
+
{me?.user?.name || me?.user?.email || "User"}
+
+ {me?.org?.name ? `${me.org.name} - ${me?.membership?.role || "MEMBER"}` : "Loading..."} +