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