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 (
+
+ );
+}
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.
+
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 (
+
+ );
+}
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..."}
+