Enrollment + almost all auth

This commit is contained in:
mdares
2026-01-03 20:18:39 +00:00
parent 0ad2451dd4
commit a0ed517047
40 changed files with 3559 additions and 31 deletions

View File

@@ -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 (
<div className="min-h-screen bg-black text-white">

View File

@@ -41,6 +41,19 @@ function badgeClass(status?: string, offline?: boolean) {
export default function MachinesPage() {
const [machines, setMachines] = useState<MachineRow[]>([]);
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<string | null>(null);
const [createdMachine, setCreatedMachine] = useState<{
id: string;
name: string;
pairingCode: string;
pairingExpiresAt: string;
} | null>(null);
const [copyStatus, setCopyStatus] = useState<string | null>(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 (
<div className="p-6">
<div className="mb-6 flex items-center justify-between">
@@ -75,14 +150,107 @@ export default function MachinesPage() {
<p className="text-sm text-zinc-400">Select a machine to view live KPIs.</p>
</div>
<Link
href="/overview"
className="rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-white hover:bg-white/10"
>
Back to Overview
</Link>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setShowCreate((prev) => !prev)}
className="rounded-xl border border-emerald-400/40 bg-emerald-500/20 px-4 py-2 text-sm text-emerald-100 hover:bg-emerald-500/30"
>
{showCreate ? "Cancel" : "Add Machine"}
</button>
<Link
href="/overview"
className="rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-white hover:bg-white/10"
>
Back to Overview
</Link>
</div>
</div>
{showCreateCard && (
<div className="mb-6 rounded-2xl border border-white/10 bg-white/5 p-5">
<div className="flex items-center justify-between gap-4">
<div>
<div className="text-sm font-semibold text-white">Add a machine</div>
<div className="text-xs text-zinc-400">
Generate the machine ID and API key for your Node-RED edge.
</div>
</div>
</div>
<div className="mt-4 grid grid-cols-1 gap-3 md:grid-cols-3">
<label className="rounded-xl border border-white/10 bg-black/20 p-3 text-xs text-zinc-400">
Machine Name
<input
value={createName}
onChange={(event) => setCreateName(event.target.value)}
className="mt-2 w-full rounded-lg border border-white/10 bg-black/30 px-3 py-2 text-sm text-white"
/>
</label>
<label className="rounded-xl border border-white/10 bg-black/20 p-3 text-xs text-zinc-400">
Code (optional)
<input
value={createCode}
onChange={(event) => setCreateCode(event.target.value)}
className="mt-2 w-full rounded-lg border border-white/10 bg-black/30 px-3 py-2 text-sm text-white"
/>
</label>
<label className="rounded-xl border border-white/10 bg-black/20 p-3 text-xs text-zinc-400">
Location (optional)
<input
value={createLocation}
onChange={(event) => setCreateLocation(event.target.value)}
className="mt-2 w-full rounded-lg border border-white/10 bg-black/30 px-3 py-2 text-sm text-white"
/>
</label>
</div>
<div className="mt-4 flex flex-wrap items-center gap-3">
<button
type="button"
onClick={createMachine}
disabled={creating}
className="rounded-xl border border-emerald-400/40 bg-emerald-500/20 px-4 py-2 text-sm text-emerald-100 hover:bg-emerald-500/30 disabled:opacity-60"
>
{creating ? "Creating..." : "Create Machine"}
</button>
{createError && <div className="text-xs text-red-200">{createError}</div>}
</div>
</div>
)}
{createdMachine && (
<div className="mb-6 rounded-2xl border border-emerald-500/20 bg-emerald-500/10 p-5">
<div className="text-sm font-semibold text-white">Edge pairing code</div>
<div className="mt-2 text-xs text-zinc-300">
Machine: <span className="text-white">{createdMachine.name}</span>
</div>
<div className="mt-3 rounded-xl border border-white/10 bg-black/30 p-4">
<div className="text-xs uppercase tracking-wide text-zinc-400">Pairing code</div>
<div className="mt-2 text-3xl font-semibold text-white">{createdMachine.pairingCode}</div>
<div className="mt-2 text-xs text-zinc-400">
Expires{" "}
{createdMachine.pairingExpiresAt
? new Date(createdMachine.pairingExpiresAt).toLocaleString()
: "soon"}
</div>
</div>
<div className="mt-3 text-xs text-zinc-300">
Enter this code on the Node-RED Control Tower settings screen to link the edge device.
</div>
<div className="mt-3 flex flex-wrap items-center gap-3">
<button
type="button"
onClick={() => copyText(createdMachine.pairingCode)}
className="rounded-xl border border-white/10 bg-white/5 px-3 py-2 text-sm text-white hover:bg-white/10"
>
Copy Code
</button>
{copyStatus && <div className="text-xs text-zinc-300">{copyStatus}</div>}
</div>
</div>
)}
{loading && <div className="mb-4 text-sm text-zinc-400">Loading machines</div>}
{!loading && machines.length === 0 && (

View File

@@ -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<string | null>(null);
const [saveStatus, setSaveStatus] = useState<string | null>(null);
const [orgInfo, setOrgInfo] = useState<OrgInfo | null>(null);
const [members, setMembers] = useState<MemberRow[]>([]);
const [invites, setInvites] = useState<InviteRow[]>([]);
const [teamLoading, setTeamLoading] = useState(true);
const [teamError, setTeamError] = useState<string | null>(null);
const [inviteEmail, setInviteEmail] = useState("");
const [inviteRole, setInviteRole] = useState("MEMBER");
const [inviteStatus, setInviteStatus] = useState<string | null>(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<Shift>) => {
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() {
<div className="mt-4 space-y-3">
<div className="rounded-xl border border-white/10 bg-black/20 p-3">
<div className="text-xs text-zinc-400">Plant Name</div>
<div className="mt-1 text-sm text-zinc-300">MIS Plant</div>
<div className="mt-1 text-sm text-zinc-300">{orgInfo?.name || "Loading..."}</div>
{orgInfo?.slug ? (
<div className="mt-1 text-[11px] text-zinc-500">Slug: {orgInfo.slug}</div>
) : null}
</div>
<label className="block rounded-xl border border-white/10 bg-black/20 p-3 text-xs text-zinc-400">
Time Zone
@@ -688,6 +823,136 @@ export default function SettingsPage() {
</div>
</div>
</div>
<div className="mt-6 grid grid-cols-1 gap-4 xl:grid-cols-2">
<div className="rounded-2xl border border-white/10 bg-white/5 p-5">
<div className="mb-3 flex items-center justify-between">
<div className="text-sm font-semibold text-white">Team Members</div>
<div className="text-xs text-zinc-400">{members.length} total</div>
</div>
{teamLoading && <div className="text-sm text-zinc-400">Loading team...</div>}
{teamError && (
<div className="rounded-xl border border-red-500/20 bg-red-500/10 p-3 text-sm text-red-200">
{teamError}
</div>
)}
{!teamLoading && !teamError && members.length === 0 && (
<div className="text-sm text-zinc-400">No team members yet.</div>
)}
{!teamLoading && !teamError && members.length > 0 && (
<div className="space-y-2">
{members.map((member) => (
<div
key={member.membershipId}
className="flex items-center justify-between gap-3 rounded-xl border border-white/10 bg-black/20 p-3"
>
<div className="min-w-0">
<div className="truncate text-sm font-semibold text-white">
{member.name || member.email}
</div>
<div className="truncate text-xs text-zinc-400">{member.email}</div>
</div>
<div className="flex flex-col items-end gap-1 text-xs text-zinc-400">
<span className="rounded-full border border-white/10 bg-white/5 px-2 py-0.5 text-white">
{member.role}
</span>
{!member.isActive ? (
<span className="rounded-full border border-red-500/30 bg-red-500/10 px-2 py-0.5 text-red-200">
Inactive
</span>
) : null}
</div>
</div>
))}
</div>
)}
</div>
<div className="rounded-2xl border border-white/10 bg-white/5 p-5">
<div className="mb-3 text-sm font-semibold text-white">Invitations</div>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
<label className="rounded-xl border border-white/10 bg-black/20 p-3 text-xs text-zinc-400">
Invite Email
<input
value={inviteEmail}
onChange={(event) => setInviteEmail(event.target.value)}
className="mt-2 w-full rounded-lg border border-white/10 bg-black/30 px-3 py-2 text-sm text-white"
/>
</label>
<label className="rounded-xl border border-white/10 bg-black/20 p-3 text-xs text-zinc-400">
Role
<select
value={inviteRole}
onChange={(event) => setInviteRole(event.target.value)}
className="mt-2 w-full rounded-lg border border-white/10 bg-black/30 px-3 py-2 text-sm text-white"
>
<option value="MEMBER">Member</option>
<option value="ADMIN">Admin</option>
<option value="OWNER">Owner</option>
</select>
</label>
</div>
<div className="mt-3 flex flex-wrap items-center gap-3">
<button
type="button"
onClick={createInvite}
disabled={inviteSubmitting}
className="rounded-xl border border-emerald-400/40 bg-emerald-500/20 px-4 py-2 text-sm text-emerald-100 hover:bg-emerald-500/30 disabled:opacity-60"
>
{inviteSubmitting ? "Creating..." : "Create Invite"}
</button>
<button
type="button"
onClick={loadTeam}
className="rounded-xl border border-white/10 bg-white/5 px-3 py-2 text-sm text-white hover:bg-white/10"
>
Refresh
</button>
{inviteStatus && <div className="text-xs text-zinc-400">{inviteStatus}</div>}
</div>
<div className="mt-4 space-y-3">
{invites.length === 0 && (
<div className="text-sm text-zinc-400">No pending invites.</div>
)}
{invites.map((invite) => (
<div key={invite.id} className="rounded-xl border border-white/10 bg-black/20 p-3">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="truncate text-sm font-semibold text-white">{invite.email}</div>
<div className="text-xs text-zinc-400">
{invite.role} - Expires {new Date(invite.expiresAt).toLocaleDateString()}
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
<button
type="button"
onClick={() => copyInviteLink(invite.token)}
className="rounded-lg border border-white/10 bg-white/5 px-2 py-1 text-xs text-white hover:bg-white/10"
>
Copy Link
</button>
<button
type="button"
onClick={() => revokeInvite(invite.id)}
className="rounded-lg border border-red-500/30 bg-red-500/10 px-2 py-1 text-xs text-red-200 hover:bg-red-500/20"
>
Revoke
</button>
</div>
</div>
<div className="mt-2 rounded-lg border border-white/10 bg-black/30 px-2 py-1 text-xs text-zinc-400">
{buildInviteUrl(invite.token)}
</div>
</div>
))}
</div>
</div>
</div>
</div>
);
}

View 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;
}

View 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;
}

View File

@@ -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;
}

View 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;
}

View 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,
},
});
}

View File

@@ -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 });
}

View File

@@ -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 });
}

View 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 });
}
}

View 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
View 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,
});
}

View 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;
}

View File

@@ -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<InviteInfo | null>(null);
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(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 (
<div className="min-h-screen bg-black flex items-center justify-center p-6 text-zinc-300">
Loading invite...
</div>
);
}
if (!invite) {
return (
<div className="min-h-screen bg-black flex items-center justify-center p-6">
<div className="max-w-md rounded-2xl border border-red-500/30 bg-red-500/10 p-6 text-sm text-red-200">
{error || "Invite not found."}
</div>
</div>
);
}
return (
<div className="min-h-screen bg-black flex items-center justify-center p-6">
<form onSubmit={onSubmit} className="w-full max-w-lg rounded-2xl border border-white/10 bg-white/5 p-8">
<h1 className="text-2xl font-semibold text-white">Join {invite.org.name}</h1>
<p className="mt-1 text-sm text-zinc-400">
Accept the invite for {invite.email} as {invite.role}.
</p>
<div className="mt-6 space-y-4">
<div>
<label className="text-sm text-zinc-300">Your name</label>
<input
className="mt-1 w-full rounded-xl border border-white/10 bg-black/40 px-4 py-3 text-white outline-none"
value={name}
onChange={(e) => setName(e.target.value)}
autoComplete="name"
/>
</div>
<div>
<label className="text-sm text-zinc-300">Password</label>
<input
type="password"
className="mt-1 w-full rounded-xl border border-white/10 bg-black/40 px-4 py-3 text-white outline-none"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="new-password"
/>
</div>
{error && <div className="text-sm text-red-400">{error}</div>}
<button
type="submit"
disabled={submitting}
className="mt-2 w-full rounded-xl bg-emerald-400 py-3 font-semibold text-black disabled:opacity-70"
>
{submitting ? "Joining..." : "Join organization"}
</button>
</div>
</form>
</div>
);
}

View File

@@ -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 <InviteAcceptForm token={params.token} />;
}

View File

@@ -78,7 +78,12 @@ export default function LoginForm() {
{loading ? "Signing in..." : "Login"}
</button>
<div className="text-xs text-zinc-500">(Dev mode) This will be replaced with JWT auth later.</div>
<div className="text-xs text-zinc-500">
New here?{" "}
<a href="/signup" className="text-emerald-300 hover:text-emerald-200">
Create an account
</a>
</div>
</div>
</form>
</div>

142
app/signup/SignupForm.tsx Normal file
View File

@@ -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<string | null>(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 (
<div className="min-h-screen bg-black flex items-center justify-center p-6">
<div className="w-full max-w-lg rounded-2xl border border-white/10 bg-white/5 p-8">
<h1 className="text-2xl font-semibold text-white">Verify your email</h1>
<p className="mt-2 text-sm text-zinc-300">
We sent a verification link to <span className="text-white">{email}</span>.
</p>
{!emailSent && (
<div className="mt-3 rounded-xl border border-red-500/30 bg-red-500/10 p-3 text-xs text-red-200">
Verification email failed to send. Please contact support.
</div>
)}
<div className="mt-4 text-xs text-zinc-500">
Once verified, you can sign in and invite your team.
</div>
<div className="mt-6">
<a
href="/login"
className="inline-flex rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-white hover:bg-white/10"
>
Back to login
</a>
</div>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-black flex items-center justify-center p-6">
<form onSubmit={onSubmit} className="w-full max-w-lg rounded-2xl border border-white/10 bg-white/5 p-8">
<h1 className="text-2xl font-semibold text-white">Create your Control Tower</h1>
<p className="mt-1 text-sm text-zinc-400">
Set up your organization and invite the team.
</p>
<div className="mt-6 space-y-4">
<div>
<label className="text-sm text-zinc-300">Organization name</label>
<input
className="mt-1 w-full rounded-xl border border-white/10 bg-black/40 px-4 py-3 text-white outline-none"
value={orgName}
onChange={(e) => setOrgName(e.target.value)}
autoComplete="organization"
/>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<label className="text-sm text-zinc-300">Your name</label>
<input
className="mt-1 w-full rounded-xl border border-white/10 bg-black/40 px-4 py-3 text-white outline-none"
value={name}
onChange={(e) => setName(e.target.value)}
autoComplete="name"
/>
</div>
<div>
<label className="text-sm text-zinc-300">Email</label>
<input
className="mt-1 w-full rounded-xl border border-white/10 bg-black/40 px-4 py-3 text-white outline-none"
value={email}
onChange={(e) => setEmail(e.target.value)}
autoComplete="email"
/>
</div>
</div>
<div>
<label className="text-sm text-zinc-300">Password</label>
<input
type="password"
className="mt-1 w-full rounded-xl border border-white/10 bg-black/40 px-4 py-3 text-white outline-none"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="new-password"
/>
</div>
{err && <div className="text-sm text-red-400">{err}</div>}
<button
type="submit"
disabled={loading}
className="mt-2 w-full rounded-xl bg-emerald-400 py-3 font-semibold text-black disabled:opacity-70"
>
{loading ? "Creating account..." : "Create account"}
</button>
<div className="text-xs text-zinc-500">
Already have access?{" "}
<a href="/login" className="text-emerald-300 hover:text-emerald-200">
Sign in
</a>
</div>
</div>
</form>
</div>
);
}

12
app/signup/page.tsx Normal file
View File

@@ -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 <SignupForm />;
}