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 }, include: { user: true, org: true },
}); });
if (!session) redirect("/login?next=/machines"); if (!session || !session.user?.isActive || !session.user?.emailVerifiedAt) {
redirect("/login?next=/machines");
}
return ( return (
<div className="min-h-screen bg-black text-white"> <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() { export default function MachinesPage() {
const [machines, setMachines] = useState<MachineRow[]>([]); const [machines, setMachines] = useState<MachineRow[]>([]);
const [loading, setLoading] = useState(true); 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(() => { useEffect(() => {
let alive = true; 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 ( return (
<div className="p-6"> <div className="p-6">
<div className="mb-6 flex items-center justify-between"> <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> <p className="text-sm text-zinc-400">Select a machine to view live KPIs.</p>
</div> </div>
<Link <div className="flex items-center gap-2">
href="/overview" <button
className="rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-white hover:bg-white/10" type="button"
> onClick={() => setShowCreate((prev) => !prev)}
Back to Overview 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"
</Link> >
{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> </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 && <div className="mb-4 text-sm text-zinc-400">Loading machines</div>}
{!loading && machines.length === 0 && ( {!loading && machines.length === 0 && (

View File

@@ -38,6 +38,31 @@ type SettingsPayload = {
updatedBy?: string; 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 = { const DEFAULT_SHIFT: Shift = {
name: "Shift 1", name: "Shift 1",
start: "06:00", start: "06:00",
@@ -187,6 +212,15 @@ export default function SettingsPage() {
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [saveStatus, setSaveStatus] = 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 () => { const loadSettings = useCallback(async () => {
setLoading(true); 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(() => { useEffect(() => {
loadSettings(); loadSettings();
}, [loadSettings]); loadTeam();
}, [loadSettings, loadTeam]);
const updateShift = useCallback((index: number, patch: Partial<Shift>) => { const updateShift = useCallback((index: number, patch: Partial<Shift>) => {
setDraft((prev) => { 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 () => { const saveSettings = useCallback(async () => {
if (!draft) return; if (!draft) return;
setSaving(true); setSaving(true);
@@ -436,7 +568,10 @@ export default function SettingsPage() {
<div className="mt-4 space-y-3"> <div className="mt-4 space-y-3">
<div className="rounded-xl border border-white/10 bg-black/20 p-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="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> </div>
<label className="block rounded-xl border border-white/10 bg-black/20 p-3 text-xs text-zinc-400"> <label className="block rounded-xl border border-white/10 bg-black/20 p-3 text-xs text-zinc-400">
Time Zone Time Zone
@@ -688,6 +823,136 @@ export default function SettingsPage() {
</div> </div>
</div> </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> </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 { NextResponse } from "next/server";
import bcrypt from "bcrypt"; import bcrypt from "bcrypt";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { buildSessionCookieOptions, COOKIE_NAME, SESSION_DAYS } from "@/lib/auth/sessionCookie";
const COOKIE_NAME = "mis_session";
const SESSION_DAYS = 7;
export async function POST(req: Request) { export async function POST(req: Request) {
const body = await req.json().catch(() => ({})); 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 }); 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); const ok = await bcrypt.compare(password, user.passwordHash);
if (!ok) { if (!ok) {
return NextResponse.json({ ok: false, error: "Invalid credentials" }, { status: 401 }); 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 }); const res = NextResponse.json({ ok: true, next });
res.cookies.set(COOKIE_NAME, session.id, buildSessionCookieOptions(req));
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; 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 { NextResponse } from "next/server";
import { randomBytes } from "crypto";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { cookies } from "next/headers"; import { cookies } from "next/headers";
import { generatePairingCode } from "@/lib/pairingCode";
const COOKIE_NAME = "mis_session"; const COOKIE_NAME = "mis_session";
@@ -8,10 +10,16 @@ async function requireSession() {
const sessionId = (await cookies()).get(COOKIE_NAME)?.value; const sessionId = (await cookies()).get(COOKIE_NAME)?.value;
if (!sessionId) return null; if (!sessionId) return null;
return prisma.session.findFirst({ const session = await prisma.session.findFirst({
where: { id: sessionId, revokedAt: null, expiresAt: { gt: new Date() } }, where: { id: sessionId, revokedAt: null, expiresAt: { gt: new Date() } },
include: { org: true, user: true }, include: { org: true, user: true },
}); });
if (!session || !session.user?.isActive || !session.user?.emailVerifiedAt) {
return null;
}
return session;
} }
export async function GET() { export async function GET() {
@@ -65,3 +73,74 @@ export async function GET() {
return NextResponse.json({ ok: true, machines: out }); 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 { requireSession } from "@/lib/auth/requireSession";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
export async function GET() { export async function GET() {
try { 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({ const user = await prisma.user.findUnique({
where: { id: userId }, where: { id: userId },
@@ -16,7 +22,12 @@ export async function GET() {
select: { id: true, name: true, slug: true }, 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 { } catch {
return NextResponse.json({ ok: false }, { status: 401 }); 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"} {loading ? "Signing in..." : "Login"}
</button> </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> </div>
</form> </form>
</div> </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 />;
}

View File

@@ -2,6 +2,7 @@
import Link from "next/link"; import Link from "next/link";
import { usePathname, useRouter } from "next/navigation"; import { usePathname, useRouter } from "next/navigation";
import { useEffect, useState } from "react";
const items = [ const items = [
{ href: "/overview", label: "Overview", icon: "🏠" }, { href: "/overview", label: "Overview", icon: "🏠" },
@@ -13,6 +14,30 @@ const items = [
export function Sidebar() { export function Sidebar() {
const pathname = usePathname(); const pathname = usePathname();
const router = useRouter(); 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() { async function onLogout() {
await fetch("/api/logout", { method: "POST" }); await fetch("/api/logout", { method: "POST" });
@@ -50,8 +75,10 @@ export function Sidebar() {
<div className="px-5 py-4 border-t border-white/10 space-y-3"> <div className="px-5 py-4 border-t border-white/10 space-y-3">
<div> <div>
<div className="text-sm text-white">Juan Pérez</div> <div className="text-sm text-white">{me?.user?.name || me?.user?.email || "User"}</div>
<div className="text-xs text-zinc-500">Plant Manager</div> <div className="text-xs text-zinc-500">
{me?.org?.name ? `${me.org.name} - ${me?.membership?.role || "MEMBER"}` : "Loading..."}
</div>
</div> </div>
<button <button

14
lib/appUrl.ts Normal file
View File

@@ -0,0 +1,14 @@
export function getBaseUrl(req?: Request) {
const envUrl = process.env.APP_BASE_URL || process.env.NEXT_PUBLIC_APP_URL;
if (envUrl) return String(envUrl).replace(/\/+$/, "");
if (!req) return "http://localhost:3000";
const forwardedProto = req.headers.get("x-forwarded-proto");
const proto = forwardedProto ? forwardedProto.split(",")[0].trim() : new URL(req.url).protocol.replace(":", "");
const host =
req.headers.get("x-forwarded-host") ||
req.headers.get("host") ||
new URL(req.url).host;
return `${proto}://${host}`;
}

View File

@@ -6,7 +6,7 @@ const COOKIE_NAME = "mis_session";
export async function requireSession() { export async function requireSession() {
const jar = await cookies(); const jar = await cookies();
const sessionId = jar.get(COOKIE_NAME)?.value; const sessionId = jar.get(COOKIE_NAME)?.value;
if (!sessionId) throw new Error("UNAUTHORIZED"); if (!sessionId) return null;
const session = await prisma.session.findFirst({ const session = await prisma.session.findFirst({
where: { where: {
@@ -14,9 +14,21 @@ export async function requireSession() {
revokedAt: null, revokedAt: null,
expiresAt: { gt: new Date() }, expiresAt: { gt: new Date() },
}, },
include: {
user: {
select: { isActive: true, emailVerifiedAt: true },
},
},
}); });
if (!session) throw new Error("UNAUTHORIZED"); if (!session) return null;
if (!session.user?.isActive || !session.user?.emailVerifiedAt) {
await prisma.session
.update({ where: { id: session.id }, data: { revokedAt: new Date() } })
.catch(() => {});
return null;
}
// Optional: update lastSeenAt (useful later) // Optional: update lastSeenAt (useful later)
await prisma.session await prisma.session

20
lib/auth/sessionCookie.ts Normal file
View File

@@ -0,0 +1,20 @@
export const COOKIE_NAME = "mis_session";
export const SESSION_DAYS = 7;
export function isSecureRequest(req: Request) {
const forwardedProto = req.headers.get("x-forwarded-proto");
if (forwardedProto) {
return forwardedProto.split(",")[0].trim() === "https";
}
return new URL(req.url).protocol === "https:";
}
export function buildSessionCookieOptions(req: Request) {
return {
httpOnly: true,
sameSite: "lax" as const,
secure: isSecureRequest(req),
path: "/",
maxAge: SESSION_DAYS * 24 * 60 * 60,
};
}

86
lib/email.ts Normal file
View File

@@ -0,0 +1,86 @@
import nodemailer from "nodemailer";
type EmailPayload = {
to: string;
subject: string;
text: string;
html: string;
};
let cachedTransport: nodemailer.Transporter | null = null;
function getTransporter() {
if (cachedTransport) return cachedTransport;
const host = process.env.SMTP_HOST;
const port = Number(process.env.SMTP_PORT || 465);
const user = process.env.SMTP_USER;
const pass = process.env.SMTP_PASS;
const secure =
process.env.SMTP_SECURE !== undefined
? process.env.SMTP_SECURE === "true"
: port === 465;
if (!host || !user || !pass) {
throw new Error("SMTP not configured");
}
cachedTransport = nodemailer.createTransport({
host,
port,
secure,
auth: { user, pass },
});
return cachedTransport;
}
export async function sendEmail(payload: EmailPayload) {
const from = process.env.SMTP_FROM || process.env.SMTP_USER;
if (!from) {
throw new Error("SMTP_FROM not configured");
}
const transporter = getTransporter();
return transporter.sendMail({
from,
to: payload.to,
subject: payload.subject,
text: payload.text,
html: payload.html,
});
}
export function buildVerifyEmail(params: { appName: string; verifyUrl: string }) {
const subject = `Verify your ${params.appName} account`;
const text =
`Welcome to ${params.appName}.\n\n` +
`Verify your email to activate your account:\n${params.verifyUrl}\n\n` +
`If you did not request this, ignore this email.`;
const html =
`<p>Welcome to ${params.appName}.</p>` +
`<p>Verify your email to activate your account:</p>` +
`<p><a href="${params.verifyUrl}">${params.verifyUrl}</a></p>` +
`<p>If you did not request this, ignore this email.</p>`;
return { subject, text, html };
}
export function buildInviteEmail(params: {
appName: string;
orgName: string;
inviteUrl: string;
}) {
const subject = `You're invited to ${params.orgName} on ${params.appName}`;
const text =
`You have been invited to join ${params.orgName} on ${params.appName}.\n\n` +
`Accept the invite here:\n${params.inviteUrl}\n\n` +
`If you did not expect this invite, you can ignore this email.`;
const html =
`<p>You have been invited to join ${params.orgName} on ${params.appName}.</p>` +
`<p>Accept the invite here:</p>` +
`<p><a href="${params.inviteUrl}">${params.inviteUrl}</a></p>` +
`<p>If you did not expect this invite, you can ignore this email.</p>`;
return { subject, text, html };
}

16
lib/pairingCode.ts Normal file
View File

@@ -0,0 +1,16 @@
import { randomBytes } from "crypto";
const PAIRING_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
export function generatePairingCode(length = 5) {
const bytes = randomBytes(length);
let code = "";
for (let i = 0; i < length; i += 1) {
code += PAIRING_ALPHABET[bytes[i] % PAIRING_ALPHABET.length];
}
return code;
}
export function normalizePairingCode(input: string) {
return input.trim().toUpperCase().replace(/[^A-Z0-9]/g, "");
}

1426
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -13,6 +13,7 @@
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
"lucide-react": "^0.561.0", "lucide-react": "^0.561.0",
"next": "16.0.10", "next": "16.0.10",
"nodemailer": "^7.0.12",
"react": "19.2.1", "react": "19.2.1",
"react-dom": "19.2.1", "react-dom": "19.2.1",
"recharts": "^3.6.0", "recharts": "^3.6.0",
@@ -22,6 +23,7 @@
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@types/bcrypt": "^6.0.0", "@types/bcrypt": "^6.0.0",
"@types/node": "^20", "@types/node": "^20",
"@types/nodemailer": "^7.0.4",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"dotenv-cli": "^11.0.0", "dotenv-cli": "^11.0.0",

View File

@@ -0,0 +1,103 @@
-- CreateTable
CREATE TABLE "Org" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Org_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL,
"email" TEXT NOT NULL,
"name" TEXT,
"passwordHash" TEXT NOT NULL,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "OrgUser" (
"id" TEXT NOT NULL,
"orgId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"role" TEXT NOT NULL DEFAULT 'MEMBER',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "OrgUser_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Session" (
"id" TEXT NOT NULL,
"orgId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"lastSeenAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"expiresAt" TIMESTAMP(3) NOT NULL,
"revokedAt" TIMESTAMP(3),
"ip" TEXT,
"userAgent" TEXT,
CONSTRAINT "Session_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Machine" (
"id" TEXT NOT NULL,
"orgId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"code" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Machine_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Org_slug_key" ON "Org"("slug");
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
-- CreateIndex
CREATE INDEX "OrgUser_userId_idx" ON "OrgUser"("userId");
-- CreateIndex
CREATE INDEX "OrgUser_orgId_idx" ON "OrgUser"("orgId");
-- CreateIndex
CREATE UNIQUE INDEX "OrgUser_orgId_userId_key" ON "OrgUser"("orgId", "userId");
-- CreateIndex
CREATE INDEX "Session_userId_idx" ON "Session"("userId");
-- CreateIndex
CREATE INDEX "Session_orgId_idx" ON "Session"("orgId");
-- CreateIndex
CREATE INDEX "Session_expiresAt_idx" ON "Session"("expiresAt");
-- CreateIndex
CREATE INDEX "Machine_orgId_idx" ON "Machine"("orgId");
-- CreateIndex
CREATE UNIQUE INDEX "Machine_orgId_code_key" ON "Machine"("orgId", "code");
-- AddForeignKey
ALTER TABLE "OrgUser" ADD CONSTRAINT "OrgUser_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "Org"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "OrgUser" ADD CONSTRAINT "OrgUser_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Session" ADD CONSTRAINT "Session_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "Org"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Machine" ADD CONSTRAINT "Machine_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "Org"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,39 @@
/*
Warnings:
- A unique constraint covering the columns `[orgId,name]` on the table `Machine` will be added. If there are existing duplicate values, this will fail.
- Added the required column `updatedAt` to the `Machine` table without a default value. This is not possible if the table is not empty.
*/
-- DropIndex
DROP INDEX "Machine_orgId_code_key";
-- AlterTable
ALTER TABLE "Machine" ADD COLUMN "location" TEXT,
ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL;
-- CreateTable
CREATE TABLE "MachineHeartbeat" (
"id" TEXT NOT NULL,
"orgId" TEXT NOT NULL,
"machineId" TEXT NOT NULL,
"ts" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"status" TEXT NOT NULL,
"message" TEXT,
"ip" TEXT,
"fwVersion" TEXT,
CONSTRAINT "MachineHeartbeat_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "MachineHeartbeat_orgId_machineId_ts_idx" ON "MachineHeartbeat"("orgId", "machineId", "ts");
-- CreateIndex
CREATE UNIQUE INDEX "Machine_orgId_name_key" ON "Machine"("orgId", "name");
-- AddForeignKey
ALTER TABLE "MachineHeartbeat" ADD CONSTRAINT "MachineHeartbeat_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "Org"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "MachineHeartbeat" ADD CONSTRAINT "MachineHeartbeat_machineId_fkey" FOREIGN KEY ("machineId") REFERENCES "Machine"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,11 @@
/*
Warnings:
- A unique constraint covering the columns `[apiKey]` on the table `Machine` will be added. If there are existing duplicate values, this will fail.
*/
-- AlterTable
ALTER TABLE "Machine" ADD COLUMN "apiKey" TEXT;
-- CreateIndex
CREATE UNIQUE INDEX "Machine_apiKey_key" ON "Machine"("apiKey");

View File

@@ -0,0 +1,66 @@
-- CreateTable
CREATE TABLE "MachineKpiSnapshot" (
"id" TEXT NOT NULL,
"orgId" TEXT NOT NULL,
"machineId" TEXT NOT NULL,
"ts" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"workOrderId" TEXT,
"sku" TEXT,
"target" INTEGER,
"good" INTEGER,
"scrap" INTEGER,
"cycleCount" INTEGER,
"goodParts" INTEGER,
"scrapParts" INTEGER,
"cavities" INTEGER,
"cycleTime" DOUBLE PRECISION,
"actualCycle" DOUBLE PRECISION,
"availability" DOUBLE PRECISION,
"performance" DOUBLE PRECISION,
"quality" DOUBLE PRECISION,
"oee" DOUBLE PRECISION,
"trackingEnabled" BOOLEAN,
"productionStarted" BOOLEAN,
CONSTRAINT "MachineKpiSnapshot_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "MachineEvent" (
"id" TEXT NOT NULL,
"orgId" TEXT NOT NULL,
"machineId" TEXT NOT NULL,
"ts" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"topic" TEXT NOT NULL,
"eventType" TEXT NOT NULL,
"severity" TEXT NOT NULL,
"requiresAck" BOOLEAN NOT NULL DEFAULT false,
"title" TEXT NOT NULL,
"description" TEXT,
"data" JSONB,
"workOrderId" TEXT,
"sku" TEXT,
CONSTRAINT "MachineEvent_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "MachineKpiSnapshot_orgId_machineId_ts_idx" ON "MachineKpiSnapshot"("orgId", "machineId", "ts");
-- CreateIndex
CREATE INDEX "MachineEvent_orgId_machineId_ts_idx" ON "MachineEvent"("orgId", "machineId", "ts");
-- CreateIndex
CREATE INDEX "MachineEvent_orgId_machineId_eventType_ts_idx" ON "MachineEvent"("orgId", "machineId", "eventType", "ts");
-- AddForeignKey
ALTER TABLE "MachineKpiSnapshot" ADD CONSTRAINT "MachineKpiSnapshot_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "Org"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "MachineKpiSnapshot" ADD CONSTRAINT "MachineKpiSnapshot_machineId_fkey" FOREIGN KEY ("machineId") REFERENCES "Machine"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "MachineEvent" ADD CONSTRAINT "MachineEvent_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "Org"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "MachineEvent" ADD CONSTRAINT "MachineEvent_machineId_fkey" FOREIGN KEY ("machineId") REFERENCES "Machine"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,27 @@
-- CreateTable
CREATE TABLE "MachineCycle" (
"id" TEXT NOT NULL,
"orgId" TEXT NOT NULL,
"machineId" TEXT NOT NULL,
"ts" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"cycleCount" INTEGER,
"actualCycleTime" DOUBLE PRECISION NOT NULL,
"theoreticalCycleTime" DOUBLE PRECISION,
"workOrderId" TEXT,
"sku" TEXT,
"cavities" INTEGER,
"goodDelta" INTEGER,
"scrapDelta" INTEGER,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "MachineCycle_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "MachineCycle_orgId_machineId_ts_idx" ON "MachineCycle"("orgId", "machineId", "ts");
-- CreateIndex
CREATE INDEX "MachineCycle_orgId_machineId_cycleCount_idx" ON "MachineCycle"("orgId", "machineId", "cycleCount");
-- AddForeignKey
ALTER TABLE "MachineCycle" ADD CONSTRAINT "MachineCycle_machineId_fkey" FOREIGN KEY ("machineId") REFERENCES "Machine"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@@ -0,0 +1,55 @@
-- AlterTable
ALTER TABLE "Machine" ADD COLUMN "schema_version" TEXT,
ADD COLUMN "seq" BIGINT,
ADD COLUMN "ts" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
ADD COLUMN "ts_server" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
-- AlterTable
ALTER TABLE "MachineCycle" ADD COLUMN "schema_version" TEXT,
ADD COLUMN "seq" BIGINT,
ADD COLUMN "ts_server" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
-- AlterTable
ALTER TABLE "MachineEvent" ADD COLUMN "schema_version" TEXT,
ADD COLUMN "seq" BIGINT,
ADD COLUMN "ts_server" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
-- AlterTable
ALTER TABLE "MachineHeartbeat" ADD COLUMN "schema_version" TEXT,
ADD COLUMN "seq" BIGINT,
ADD COLUMN "ts_server" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
-- AlterTable
ALTER TABLE "MachineKpiSnapshot" ADD COLUMN "schema_version" TEXT,
ADD COLUMN "seq" BIGINT,
ADD COLUMN "ts_server" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
-- CreateTable
CREATE TABLE "IngestLog" (
"id" TEXT NOT NULL,
"orgId" TEXT,
"machineId" TEXT,
"endpoint" TEXT NOT NULL,
"schemaVersion" TEXT,
"seq" BIGINT,
"tsDevice" TIMESTAMP(3),
"tsServer" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"ok" BOOLEAN NOT NULL,
"status" INTEGER NOT NULL,
"errorCode" TEXT,
"errorMsg" TEXT,
"body" JSONB,
"ip" TEXT,
"userAgent" TEXT,
CONSTRAINT "IngestLog_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "IngestLog_endpoint_tsServer_idx" ON "IngestLog"("endpoint", "tsServer");
-- CreateIndex
CREATE INDEX "IngestLog_machineId_tsServer_idx" ON "IngestLog"("machineId", "tsServer");
-- CreateIndex
CREATE INDEX "IngestLog_machineId_seq_idx" ON "IngestLog"("machineId", "seq");

View File

@@ -0,0 +1,42 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "email_verification_expires_at" TIMESTAMP(3),
ADD COLUMN "email_verification_token" TEXT,
ADD COLUMN "email_verified_at" TIMESTAMP(3);
-- CreateTable
CREATE TABLE "org_invites" (
"id" TEXT NOT NULL,
"org_id" TEXT NOT NULL,
"email" TEXT NOT NULL,
"role" TEXT NOT NULL DEFAULT 'MEMBER',
"token" TEXT NOT NULL,
"invited_by" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"expires_at" TIMESTAMP(3) NOT NULL,
"accepted_at" TIMESTAMP(3),
"revoked_at" TIMESTAMP(3),
CONSTRAINT "org_invites_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "org_invites_token_key" ON "org_invites"("token");
-- CreateIndex
CREATE INDEX "org_invites_org_id_idx" ON "org_invites"("org_id");
-- CreateIndex
CREATE INDEX "org_invites_org_id_email_idx" ON "org_invites"("org_id", "email");
-- CreateIndex
CREATE INDEX "org_invites_expires_at_idx" ON "org_invites"("expires_at");
-- CreateIndex
CREATE UNIQUE INDEX "User_email_verification_token_key" ON "User"("email_verification_token");
-- AddForeignKey
ALTER TABLE "org_invites" ADD CONSTRAINT "org_invites_org_id_fkey" FOREIGN KEY ("org_id") REFERENCES "Org"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "org_invites" ADD CONSTRAINT "org_invites_invited_by_fkey" FOREIGN KEY ("invited_by") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -0,0 +1,2 @@
-- This is an empty migration.

View File

@@ -0,0 +1,8 @@
-- AlterTable
ALTER TABLE "Machine" ADD COLUMN "pairing_code" TEXT,
ADD COLUMN "pairing_code_expires_at" TIMESTAMP(3),
ADD COLUMN "pairing_code_used_at" TIMESTAMP(3);
-- CreateIndex
CREATE UNIQUE INDEX "Machine_pairing_code_key" ON "Machine"("pairing_code");

View File

@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"

View File

@@ -23,6 +23,7 @@ model Org {
shifts OrgShift[] shifts OrgShift[]
machineSettings MachineSettings[] machineSettings MachineSettings[]
settingsAudits SettingsAudit[] settingsAudits SettingsAudit[]
invites OrgInvite[]
} }
model User { model User {
@@ -32,9 +33,13 @@ model User {
passwordHash String passwordHash String
isActive Boolean @default(true) isActive Boolean @default(true)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
emailVerifiedAt DateTime? @map("email_verified_at")
emailVerificationToken String? @unique @map("email_verification_token")
emailVerificationExpiresAt DateTime? @map("email_verification_expires_at")
orgs OrgUser[] orgs OrgUser[]
sessions Session[] sessions Session[]
sentInvites OrgInvite[] @relation("OrgInviteInviter")
} }
model OrgUser { model OrgUser {
@@ -52,6 +57,27 @@ model OrgUser {
@@index([orgId]) @@index([orgId])
} }
model OrgInvite {
id String @id @default(uuid())
orgId String @map("org_id")
email String
role String @default("MEMBER") // OWNER | ADMIN | MEMBER
token String @unique
invitedBy String? @map("invited_by")
createdAt DateTime @default(now()) @map("created_at")
expiresAt DateTime @map("expires_at")
acceptedAt DateTime? @map("accepted_at")
revokedAt DateTime? @map("revoked_at")
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
inviter User? @relation("OrgInviteInviter", fields: [invitedBy], references: [id], onDelete: SetNull)
@@index([orgId])
@@index([orgId, email])
@@index([expiresAt])
@@map("org_invites")
}
model Session { model Session {
id String @id @default(uuid()) // cookie value id String @id @default(uuid()) // cookie value
orgId String orgId String
@@ -84,6 +110,9 @@ model Machine {
tsServer DateTime @default(now()) @map("ts_server") tsServer DateTime @default(now()) @map("ts_server")
schemaVersion String? @map("schema_version") schemaVersion String? @map("schema_version")
seq BigInt? @map("seq") seq BigInt? @map("seq")
pairingCode String? @unique @map("pairing_code")
pairingCodeExpiresAt DateTime? @map("pairing_code_expires_at")
pairingCodeUsedAt DateTime? @map("pairing_code_used_at")
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade) org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
heartbeats MachineHeartbeat[] heartbeats MachineHeartbeat[]

View File

@@ -17,11 +17,14 @@ async function main() {
const user = await prisma.user.upsert({ const user = await prisma.user.upsert({
where: { email: "admin@maliountech.com" }, where: { email: "admin@maliountech.com" },
update: {}, update: {
emailVerifiedAt: new Date(),
},
create: { create: {
email: "admin@maliountech.com", email: "admin@maliountech.com",
name: "Admin", name: "Admin",
passwordHash, passwordHash,
emailVerifiedAt: new Date(),
}, },
}); });