Merge branch 'master' of ssh://stash.dyson.global.corp:7999/~thowitz/tremor-tracker

This commit is contained in:
Emily Neighbour 2025-05-31 21:30:12 +01:00
commit 8d3575591d
12 changed files with 1561 additions and 1004 deletions

154
package-lock.json generated
View File

@ -28,6 +28,7 @@
"lodash": "^4.17.21", "lodash": "^4.17.21",
"mapbox-gl": "^3.10.0", "mapbox-gl": "^3.10.0",
"next": "^15.1.7", "next": "^15.1.7",
"next-auth": "^4.24.11",
"path": "^0.12.7", "path": "^0.12.7",
"prisma": "^6.4.1", "prisma": "^6.4.1",
"react": "^19.1.0", "react": "^19.1.0",
@ -1070,6 +1071,15 @@
"node": ">=12.4.0" "node": ">=12.4.0"
} }
}, },
"node_modules/@panva/hkdf": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@panva/hkdf/-/hkdf-1.2.1.tgz",
"integrity": "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/@pkgjs/parseargs": { "node_modules/@pkgjs/parseargs": {
"version": "0.11.0", "version": "0.11.0",
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
@ -5670,6 +5680,47 @@
} }
} }
}, },
"node_modules/next-auth": {
"version": "4.24.11",
"resolved": "https://registry.npmjs.org/next-auth/-/next-auth-4.24.11.tgz",
"integrity": "sha512-pCFXzIDQX7xmHFs4KVH4luCjaCbuPRtZ9oBUjUhOk84mZ9WVPf94n87TxYI4rSRf9HmfHEF8Yep3JrYDVOo3Cw==",
"license": "ISC",
"dependencies": {
"@babel/runtime": "^7.20.13",
"@panva/hkdf": "^1.0.2",
"cookie": "^0.7.0",
"jose": "^4.15.5",
"oauth": "^0.9.15",
"openid-client": "^5.4.0",
"preact": "^10.6.3",
"preact-render-to-string": "^5.1.19",
"uuid": "^8.3.2"
},
"peerDependencies": {
"@auth/core": "0.34.2",
"next": "^12.2.5 || ^13 || ^14 || ^15",
"nodemailer": "^6.6.5",
"react": "^17.0.2 || ^18 || ^19",
"react-dom": "^17.0.2 || ^18 || ^19"
},
"peerDependenciesMeta": {
"@auth/core": {
"optional": true
},
"nodemailer": {
"optional": true
}
}
},
"node_modules/next-auth/node_modules/jose": {
"version": "4.15.9",
"resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz",
"integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/next/node_modules/postcss": { "node_modules/next/node_modules/postcss": {
"version": "8.4.31", "version": "8.4.31",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
@ -5708,6 +5759,12 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/oauth": {
"version": "0.9.15",
"resolved": "https://registry.npmjs.org/oauth/-/oauth-0.9.15.tgz",
"integrity": "sha512-a5ERWK1kh38ExDEfoO6qUHJb32rd7aYmPHuyCu3Fta/cnICvYmgd2uhuKXvPD+PXB+gCEYYEaQdIRAjCOwAKNA==",
"license": "MIT"
},
"node_modules/object-assign": { "node_modules/object-assign": {
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@ -5840,6 +5897,15 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/oidc-token-hash": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.1.0.tgz",
"integrity": "sha512-y0W+X7Ppo7oZX6eovsRkuzcSM40Bicg2JEJkDJ4irIt1wsYAP5MLSNv+QAogO8xivMffw/9OvV3um1pxXgt1uA==",
"license": "MIT",
"engines": {
"node": "^10.13.0 || >=12.0.0"
}
},
"node_modules/on-finished": { "node_modules/on-finished": {
"version": "2.4.1", "version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
@ -5861,6 +5927,51 @@
"wrappy": "1" "wrappy": "1"
} }
}, },
"node_modules/openid-client": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.7.1.tgz",
"integrity": "sha512-jDBPgSVfTnkIh71Hg9pRvtJc6wTwqjRkN88+gCFtYWrlP4Yx2Dsrow8uPi3qLr/aeymPF3o2+dS+wOpglK04ew==",
"license": "MIT",
"dependencies": {
"jose": "^4.15.9",
"lru-cache": "^6.0.0",
"object-hash": "^2.2.0",
"oidc-token-hash": "^5.0.3"
},
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/openid-client/node_modules/jose": {
"version": "4.15.9",
"resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz",
"integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/openid-client/node_modules/lru-cache": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
"license": "ISC",
"dependencies": {
"yallist": "^4.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/openid-client/node_modules/object-hash": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz",
"integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==",
"license": "MIT",
"engines": {
"node": ">= 6"
}
},
"node_modules/optionator": { "node_modules/optionator": {
"version": "0.9.4", "version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@ -6212,6 +6323,28 @@
"integrity": "sha512-Q+/tYsFU9r7xoOJ+y/ZTtdVQwTWfzjbiXBDMM/JKUux3+QPP02iUuIoeBQ+Ot6oEDlC+/PGjB/5A3K7KKb7hcw==", "integrity": "sha512-Q+/tYsFU9r7xoOJ+y/ZTtdVQwTWfzjbiXBDMM/JKUux3+QPP02iUuIoeBQ+Ot6oEDlC+/PGjB/5A3K7KKb7hcw==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/preact": {
"version": "10.26.8",
"resolved": "https://registry.npmjs.org/preact/-/preact-10.26.8.tgz",
"integrity": "sha512-1nMfdFjucm5hKvq0IClqZwK4FJkGXhRrQstOQ3P4vp8HxKrJEMFcY6RdBRVTdfQS/UlnX6gfbPuTvaqx/bDoeQ==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/preact"
}
},
"node_modules/preact-render-to-string": {
"version": "5.2.6",
"resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-5.2.6.tgz",
"integrity": "sha512-JyhErpYOvBV1hEPwIxc/fHWXPfnEGdRKxc8gFdAZ7XV4tlzyzG847XAyEZqoDnynP88akM4eaHcSOzNcLWFguw==",
"license": "MIT",
"dependencies": {
"pretty-format": "^3.8.0"
},
"peerDependencies": {
"preact": ">=10"
}
},
"node_modules/prelude-ls": { "node_modules/prelude-ls": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
@ -6222,6 +6355,12 @@
"node": ">= 0.8.0" "node": ">= 0.8.0"
} }
}, },
"node_modules/pretty-format": {
"version": "3.8.0",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-3.8.0.tgz",
"integrity": "sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==",
"license": "MIT"
},
"node_modules/prisma": { "node_modules/prisma": {
"version": "6.8.2", "version": "6.8.2",
"resolved": "https://registry.npmjs.org/prisma/-/prisma-6.8.2.tgz", "resolved": "https://registry.npmjs.org/prisma/-/prisma-6.8.2.tgz",
@ -7927,6 +8066,15 @@
"integrity": "sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw==", "integrity": "sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/uuid": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
"license": "MIT",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/vary": { "node_modules/vary": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
@ -8163,6 +8311,12 @@
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/yallist": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
"license": "ISC"
},
"node_modules/yaml": { "node_modules/yaml": {
"version": "2.8.0", "version": "2.8.0",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.0.tgz", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.0.tgz",

View File

@ -31,6 +31,7 @@
"lodash": "^4.17.21", "lodash": "^4.17.21",
"mapbox-gl": "^3.10.0", "mapbox-gl": "^3.10.0",
"next": "^15.1.7", "next": "^15.1.7",
"next-auth": "^4.24.11",
"path": "^0.12.7", "path": "^0.12.7",
"prisma": "^6.4.1", "prisma": "^6.4.1",
"react": "^19.1.0", "react": "^19.1.0",

View File

@ -1,386 +1,569 @@
"use client"; "use client";
import React, { useRef, useState } from "react"; import React, { useRef, useState, useEffect } from "react";
import { useStoreState } from "@hooks/store";
// --- Types and labels ---
type Role = "ADMIN" | "GUEST" | "SCIENTIST"; type Role = "ADMIN" | "GUEST" | "SCIENTIST";
const roleLabels: Record<Role, string> = { const roleLabels: Record<Role, string> = {
ADMIN: "Admin", ADMIN: "Admin",
GUEST: "Guest", GUEST: "Guest",
SCIENTIST: "Scientist", SCIENTIST: "Scientist",
}; };
type User = { type User = {
id: number; id: number;
email: string; email: string;
name: string; name: string;
role: Role; role: Role;
password: string; createdAt: string;
createdAt: string;
}; };
// todo add fulfilling of requests
// todo create api route to get users, with auth for only admin
// todo add management of only junior scientists if senior scientist
// todo (optional) add display of each user's previous orders when selecting them
const initialUsers: User[] = [ const initialUsers: User[] = [
{ email: "john@example.com", name: "John Doe", role: "ADMIN", password: "secret1", createdAt: "2024-06-21T09:15:01Z", id: 1 }, {
{ email: "jane@example.com", name: "Jane Smith", role: "GUEST", password: "secret2", createdAt: "2024-06-21T10:01:09Z", id: 2 }, email: "users-loading@admin.api",
{ name: "Loading Users",
email: "bob@example.com", role: "ADMIN",
name: "Bob Brown", createdAt: "Check admin api and frontend",
role: "SCIENTIST", id: 0,
password: "secret3", },
createdAt: "2024-06-21T12:13:45Z",
id: 3,
},
{
email: "alice@example.com",
name: "Alice Johnson",
role: "GUEST",
password: "secret4",
createdAt: "2024-06-20T18:43:20Z",
id: 4,
},
{ email: "eve@example.com", name: "Eve Black", role: "ADMIN", password: "secret5", createdAt: "2024-06-20T19:37:10Z", id: 5 },
{ email: "dave@example.com", name: "Dave Clark", role: "GUEST", password: "pw", createdAt: "2024-06-19T08:39:10Z", id: 6 },
{ email: "fred@example.com", name: "Fred Fox", role: "GUEST", password: "pw", createdAt: "2024-06-19T09:11:52Z", id: 7 },
{ email: "ginny@example.com", name: "Ginny Hall", role: "SCIENTIST", password: "pw", createdAt: "2024-06-17T14:56:27Z", id: 8 },
{ email: "harry@example.com", name: "Harry Lee", role: "ADMIN", password: "pw", createdAt: "2024-06-16T19:28:11Z", id: 9 },
{ email: "ivy@example.com", name: "Ivy Volt", role: "ADMIN", password: "pw", createdAt: "2024-06-15T21:04:05Z", id: 10 },
{ email: "kate@example.com", name: "Kate Moss", role: "SCIENTIST", password: "pw", createdAt: "2024-06-14T11:16:35Z", id: 11 },
{ email: "leo@example.com", name: "Leo Garrison", role: "GUEST", password: "pw", createdAt: "2024-06-12T08:02:51Z", id: 12 },
{ email: "isaac@example.com", name: "Isaac Yang", role: "GUEST", password: "pw", createdAt: "2024-06-12T15:43:29Z", id: 13 },
]; ];
const sortFields = [ const sortFields = [
// Sort box options { label: "Name", value: "name" },
{ label: "Name", value: "name" }, { label: "Email", value: "email" },
{ label: "Email", value: "email" },
] as const; ] as const;
type SortField = (typeof sortFields)[number]["value"]; type SortField = (typeof sortFields)[number]["value"];
type SortDir = "asc" | "desc"; type SortDir = "asc" | "desc";
const dirLabels: Record<SortDir, string> = { asc: "ascending", desc: "descending" }; const dirLabels: Record<SortDir, string> = { asc: "ascending", desc: "descending" };
const fieldLabels: Record<SortField, string> = { name: "Name", email: "Email" }; const fieldLabels: Record<SortField, string> = { name: "Name", email: "Email" };
// =========== THE PAGE =============
export default function AdminPage() { export default function AdminPage() {
const [users, setUsers] = useState<User[]>(initialUsers); // ---- All hooks at the top!
const [selectedEmail, setSelectedEmail] = useState<string | null>(null); const user = useStoreState((state) => state.user);
// Local edit state for SCIENTIST form const [selectedEmail, setSelectedEmail] = useState<string | null>(null);
const [editUser, setEditUser] = useState<User | null>(null); const [users, setUsers] = useState<User[]>(initialUsers);
// Reset editUser when the selected user changes const [addOpen, setAddOpen] = useState(false);
React.useEffect(() => { const [addForm, setAddForm] = useState<{ name: string; email: string; role: Role; password: string }>({
if (!selectedEmail) setEditUser(null); name: "",
else { email: "",
const user = users.find((u) => u.email === selectedEmail); role: "SCIENTIST",
setEditUser(user ? { ...user } : null); password: "",
} });
}, [selectedEmail, users]); const [addError, setAddError] = useState<string | null>(null);
const [addLoading, setAddLoading] = useState(false);
const [editUser, setEditUser] = useState<User | null>(null);
const [searchField, setSearchField] = useState<"name" | "email">("name");
const [searchText, setSearchText] = useState("");
const [roleFilter, setRoleFilter] = useState<Role | "all">("all");
const [sortField, setSortField] = useState<SortField>("name");
const [sortDir, setSortDir] = useState<SortDir>("asc");
const [newPassword, setNewPassword] = useState<string>("");
const [filterDropdownOpen, setFilterDropdownOpen] = useState(false);
const [sortDropdownOpen, setSortDropdownOpen] = useState(false);
const filterDropdownRef = useRef<HTMLDivElement>(null);
const sortDropdownRef = useRef<HTMLDivElement>(null);
const [showEmailTooltip, setShowEmailTooltip] = useState(false);
// Search/filter/sort state useEffect(() => {
const [searchField, setSearchField] = useState<"name" | "email">("name"); async function fetchUsers() {
const [searchText, setSearchText] = useState(""); try {
const [roleFilter, setRoleFilter] = useState<Role | "all">("all"); const res = await fetch("/api/admin");
const [sortField, setSortField] = useState<SortField>("name"); if (!res.ok) throw new Error("Failed to fetch");
const [sortDir, setSortDir] = useState<SortDir>("asc"); const data = await res.json();
// Dropdown states setUsers(data.users);
const [filterDropdownOpen, setFilterDropdownOpen] = useState(false); } catch (err) {
const [sortDropdownOpen, setSortDropdownOpen] = useState(false); console.error("Error fetching users:", err);
const filterDropdownRef = useRef<HTMLDivElement>(null); }
const sortDropdownRef = useRef<HTMLDivElement>(null); }
fetchUsers();
}, []);
useEffect(() => {
const handleClick = (e: MouseEvent) => {
if (
filterDropdownRef.current &&
!filterDropdownRef.current.contains(e.target as Node)
)
setFilterDropdownOpen(false);
if (
sortDropdownRef.current &&
!sortDropdownRef.current.contains(e.target as Node)
)
setSortDropdownOpen(false);
};
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, []);
useEffect(() => {
if (!selectedEmail) setEditUser(null);
else {
const user = users.find((u) => u.email === selectedEmail);
setEditUser(user ? { ...user } : null);
}
}, [selectedEmail, users]);
// Filtering, searching, sorting logic
const filteredUsers = users.filter(
(user) => roleFilter === "all" || user.role === roleFilter
);
const searchedUsers = filteredUsers.filter((user) =>
user[searchField].toLowerCase().includes(searchText.toLowerCase())
);
const sortedUsers = [...searchedUsers].sort((a, b) => {
let cmp = a[sortField].localeCompare(b[sortField]);
return sortDir === "asc" ? cmp : -cmp;
});
async function handleAddUser(e: React.FormEvent) {
e.preventDefault();
setAddError(null);
if (!addForm.name || !addForm.email || !addForm.password) {
setAddError("All fields are required.");
return;
}
try {
setAddLoading(true);
const res = await fetch("/api/admin", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(addForm),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data?.error || "Failed to add user");
}
const data = await res.json();
setUsers((prev) => [...prev, data.user]);
setAddOpen(false);
setAddForm({ name: "", email: "", role: "SCIENTIST", password: "" });
} catch (err: any) {
setAddError(err?.message || "Unknown error");
} finally {
setAddLoading(false);
}
}
const handleEditChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>,
) => {
if (!editUser) return;
const { name, value } = e.target;
setEditUser((prev) => (prev ? { ...prev, [name]: value } : null));
};
const handlePasswordChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>,
) => {
if (!editUser) return;
const { name, value } = e.target;
setEditUser((prev) => (prev ? { ...prev, [name]: value } : null));
};
const selectedUser = users.find((u) => u.email === selectedEmail);
const isEditChanged = React.useMemo(() => {
if (!editUser || !selectedUser) return false;
return (
editUser.name !== selectedUser.name ||
editUser.role !== selectedUser.role ||
newPassword
);
}, [editUser, selectedUser, newPassword]);
async function updateUserOnServer(user: User, password: string) {
const body: any = {
id: user.id,
name: user.name,
role: user.role,
};
if (password.trim() !== "") {
body.password = password;
}
const res = await fetch("/api/admin", {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
if (!res.ok) throw new Error("Failed to update user");
const data = await res.json();
return data.user as User;
}
const handleUpdate = async (e: React.FormEvent) => {
e.preventDefault();
if (!editUser) return;
try {
const updated = await updateUserOnServer(editUser, newPassword);
setUsers((prev) =>
prev.map((u) => (u.id === updated.id ? { ...updated } : u))
);
setNewPassword("");
} catch (err) {
console.error("Failed to update user:", err);
}
};
const handleDelete = async () => {
if (!selectedUser) return;
if (
!window.confirm(
`Are you sure you want to delete "${selectedUser.name}"? This cannot be undone.`,
)
)
return;
try {
const res = await fetch("/api/admin", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id: selectedUser.id }),
});
const data = await res.json();
if (!res.ok) throw new Error(data?.error || "Failed to delete user");
setUsers((prev) =>
prev.filter((u) => u.email !== selectedUser.email)
);
setSelectedEmail(null);
setEditUser(null);
} catch (err: any) {
alert(err?.message || "Delete failed!");
}
};
const allRoles: Role[] = ["ADMIN", "GUEST", "SCIENTIST"];
React.useEffect(() => { // --- ADMIN ONLY:
const handleClick = (e: MouseEvent) => { if (!user || user.role !== "ADMIN") {
if (filterDropdownRef.current && !filterDropdownRef.current.contains(e.target as Node)) setFilterDropdownOpen(false); return (
if (sortDropdownRef.current && !sortDropdownRef.current.contains(e.target as Node)) setSortDropdownOpen(false); <div className="flex items-center justify-center min-h-[70vh] flex-col">
}; <h1 className="text-2xl font-bold text-red-500 mb-4">
document.addEventListener("mousedown", handleClick); Unauthorized Access
return () => document.removeEventListener("mousedown", handleClick); </h1>
}, []); <div className="text-gray-600">You do not have access to this page.</div>
</div>
);
}
// Filtering, searching, sorting logic // --- Render admin UI
const filteredUsers = users.filter((user) => roleFilter === "all" || user.role === roleFilter); return (
const searchedUsers = filteredUsers.filter((user) => user[searchField].toLowerCase().includes(searchText.toLowerCase())); <div className="flex flex-col h-full">
const sortedUsers = [...searchedUsers].sort((a, b) => { <div className="flex h-full overflow-hidden bg-gray-50">
let cmp = a[sortField].localeCompare(b[sortField]); {/* SIDEBAR */}
return sortDir === "asc" ? cmp : -cmp; <div className="w-80 h-full border-r border-neutral-200 bg-neutral-100 flex flex-col rounded-l-xl shadow-sm">
}); <div className="p-4 flex flex-col h-full">
{/* Search, filter, sort controls ... (your code unchanged) */}
// Form input change handler <div className="mb-3 flex gap-2">
const handleEditChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => { <input
if (!editUser) return; className="flex-1 border rounded-lg px-2 py-1 text-sm"
const { name, value } = e.target; placeholder={`Search by ${searchField}`}
setEditUser((prev) => (prev ? { ...prev, [name]: value } : null)); value={searchText}
}; onChange={(e) => setSearchText(e.target.value)}
/>
// Update button logic (compare original selectedUser and editUser) <button
const selectedUser = users.find((u) => u.email === selectedEmail); type="button"
const isEditChanged = React.useMemo(() => { className="border rounded-lg px-2 py-1 text-sm bg-white hover:bg-neutral-100 transition font-semibold"
if (!editUser || !selectedUser) return false; style={{ width: "80px" }}
// Compare primitive fields onClick={() => setSearchField((field) => (field === "name" ? "email" : "name"))}
return ( title={`Switch to searching by ${searchField === "name" ? "Email" : "Name"}`}
editUser.name !== selectedUser.name || editUser.role !== selectedUser.role || editUser.password !== selectedUser.password >
); {searchField === "name" ? "Email" : "Name"}
}, [editUser, selectedUser]); </button>
</div>
// Update/save changes <div className="flex gap-2 items-center mb-2">
const handleUpdate = (e: React.FormEvent) => { {/* Filter */}
e.preventDefault(); <div className="relative" ref={filterDropdownRef}>
if (!editUser) return; <button
setUsers((prev) => prev.map((u) => (u.email === editUser.email ? { ...editUser } : u))); className={`px-3 py-1 rounded-lg border font-semibold flex items-center transition
// todo create receiving api route ${roleFilter !== "all"
// todo send to api route ? "bg-blue-600 text-white border-blue-600 hover:bg-blue-700"
// After successful update, update selectedUser local state : "bg-white text-gray-700 border hover:bg-neutral-200"
// (editUser will auto-sync due to useEffect on users) }
};
// Delete user logic
const handleDelete = () => {
if (!selectedUser) return;
if (!window.confirm(`Are you sure you want to delete "${selectedUser.name}"? This cannot be undone.`)) return;
setUsers((prev) => prev.filter((u) => u.email !== selectedUser.email));
setSelectedEmail(null);
setEditUser(null);
};
const allRoles: Role[] = ["ADMIN", "GUEST", "SCIENTIST"];
// Tooltip handling for email field
const [showEmailTooltip, setShowEmailTooltip] = useState(false);
return (
<div className="flex flex-col h-full">
<div className="flex h-full overflow-hidden bg-gray-50">
{/* SIDEBAR */}
<div className="w-80 h-full border-r border-neutral-200 bg-neutral-100 flex flex-col rounded-l-xl shadow-sm">
<div className="p-4 flex flex-col h-full">
{/* Search Bar */}
<div className="mb-3 flex gap-2">
<input
className="flex-1 border rounded-lg px-2 py-1 text-sm"
placeholder={`Search by ${searchField}`}
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
/>
<button
type="button"
className="border rounded-lg px-2 py-1 text-sm bg-white hover:bg-neutral-100 transition font-semibold"
style={{ width: "80px" }} // fixed width, adjust as needed
onClick={() => setSearchField((field) => (field === "name" ? "email" : "name"))}
title={`Switch to searching by ${searchField === "name" ? "Email" : "Name"}`}
>
{searchField === "name" ? "Email" : "Name"}
</button>
</div>
{/* Filter and Sort Buttons */}
<div className="flex gap-2 items-center mb-2">
{/* Filter */}
<div className="relative" ref={filterDropdownRef}>
<button
className={`px-3 py-1 rounded-lg border font-semibold flex items-center transition
${
roleFilter !== "all"
? "bg-blue-600 text-white border-blue-600 hover:bg-blue-700"
: "bg-white text-gray-700 border hover:bg-neutral-200"
}
`} `}
onClick={() => setFilterDropdownOpen((v) => !v)} onClick={() => setFilterDropdownOpen((v) => !v)}
type="button" type="button"
> >
Filter{" "} Filter{" "}
<svg className="w-4 h-4 ml-1" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24"> <svg className="w-4 h-4 ml-1" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" /> <path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
</svg> </svg>
</button> </button>
{filterDropdownOpen && ( {filterDropdownOpen && (
<div className="absolute z-10 mt-1 left-0 bg-white border rounded-lg shadow-sm w-28 py-1"> <div className="absolute z-10 mt-1 left-0 bg-white border rounded-lg shadow-sm w-28 py-1">
<button <button
onClick={() => { onClick={() => {
setRoleFilter("all"); setRoleFilter("all");
setFilterDropdownOpen(false); setFilterDropdownOpen(false);
}} }}
className={`w-full text-left px-3 py-1 hover:bg-blue-50 border-b border-gray-100 last:border-0 className={`w-full text-left px-3 py-1 hover:bg-blue-50 border-b border-gray-100 last:border-0
${roleFilter === "all" ? "font-bold text-blue-600" : ""}`} ${roleFilter === "all" ? "font-bold text-blue-600" : ""}`}
> >
All All
</button> </button>
{allRoles.map((role) => ( {allRoles.map((role) => (
<button <button
key={role} key={role}
onClick={() => { onClick={() => {
setRoleFilter(role); setRoleFilter(role);
setFilterDropdownOpen(false); setFilterDropdownOpen(false);
}} }}
className={`w-full text-left px-3 py-1 hover:bg-blue-50 border-b border-gray-100 last:border-0 className={`w-full text-left px-3 py-1 hover:bg-blue-50 border-b border-gray-100 last:border-0
${roleFilter === role ? "font-bold text-blue-600" : ""}`} ${roleFilter === role ? "font-bold text-blue-600" : ""}`}
> >
{roleLabels[role]} {roleLabels[role]}
</button> </button>
))} ))}
</div> </div>
)} )}
</div> </div>
{/* Sort */} {/* Sort */}
<div className="relative" ref={sortDropdownRef}> <div className="relative" ref={sortDropdownRef}>
<button <button
className="px-3 py-1 rounded-lg bg-white border text-gray-700 font-semibold flex items-center hover:bg-neutral-200" className="px-3 py-1 rounded-lg bg-white border text-gray-700 font-semibold flex items-center hover:bg-neutral-200"
onClick={() => setSortDropdownOpen((v) => !v)} onClick={() => setSortDropdownOpen((v) => !v)}
type="button" type="button"
> >
Sort{" "} Sort{" "}
<svg className="w-4 h-4 ml-1" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24"> <svg className="w-4 h-4 ml-1" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" /> <path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
</svg> </svg>
</button> </button>
{sortDropdownOpen && ( {sortDropdownOpen && (
<div className="absolute z-10 mt-1 left-0 bg-white border rounded-lg shadow-sm w-28 "> <div className="absolute z-10 mt-1 left-0 bg-white border rounded-lg shadow-sm w-28 ">
{sortFields.map((opt) => ( {sortFields.map((opt) => (
<button <button
key={opt.value} key={opt.value}
onClick={() => { onClick={() => {
setSortField(opt.value); setSortField(opt.value);
setSortDropdownOpen(false); setSortDropdownOpen(false);
}} }}
className={`w-full text-left px-3 py-2 hover:bg-blue-50 border-b border-gray-100 last:border-0 className={`w-full text-left px-3 py-2 hover:bg-blue-50 border-b border-gray-100 last:border-0
${sortField === opt.value ? "font-bold text-blue-600" : ""}`} ${sortField === opt.value ? "font-bold text-blue-600" : ""}`}
> >
{opt.label} {opt.label}
</button> </button>
))} ))}
</div> </div>
)} )}
</div> </div>
{/* Asc/Desc Toggle */} {/* Asc/Desc Toggle */}
<button <button
className="ml-2 px-2 py-1 rounded-lg bg-white border text-gray-700 font-semibold flex items-center hover:bg-neutral-200" className="ml-2 px-2 py-1 rounded-lg bg-white border text-gray-700 font-semibold flex items-center hover:bg-neutral-200"
onClick={() => setSortDir((d) => (d === "asc" ? "desc" : "asc"))} onClick={() => setSortDir((d) => (d === "asc" ? "desc" : "asc"))}
title={sortDir === "asc" ? "Ascending" : "Descending"} title={sortDir === "asc" ? "Ascending" : "Descending"}
type="button" type="button"
> >
{sortDir === "asc" ? "↑" : "↓"} {sortDir === "asc" ? "↑" : "↓"}
</button> </button>
</div> {/* ADD BUTTON */}
{/* Sort status text */} <button
<small className="text-xs text-gray-500 mb-2 px-1"> className="ml-2 px-2 py-1 rounded-lg bg-green-600 hover:bg-green-700 text-white font-bold flex items-center shadow transition duration-150"
Users sorted by {fieldLabels[sortField]} {dirLabels[sortDir]} type="button"
</small> style={{ minWidth: 36, minHeight: 36 }}
{/* USERS LIST: full height, scrollable */} onClick={() => setAddOpen(true)}
<ul className="overflow-y-auto flex-1 pr-1"> disabled={addOpen}
{sortedUsers.map((user) => ( title="Add user"
<li >
key={user.email} <svg width="18" height="18" fill="none" stroke="currentColor" strokeWidth={2.2} viewBox="0 0 24 24">
onClick={() => setSelectedEmail(user.email)} <path strokeLinecap="round" strokeLinejoin="round" d="M12 5v14m7-7H5" />
className={`rounded-lg cursor-pointer border </svg>
${selectedEmail === user.email ? "bg-blue-100 border-blue-400" : "hover:bg-gray-200 border-transparent"} </button>
transition px-2 py-1 mb-1`} </div>
> <small className="text-xs text-gray-500 mb-2 px-1">
<div className="flex items-center justify-between"> Users sorted by {fieldLabels[sortField]} {dirLabels[sortDir]}
<span className="text-sm font-medium truncate">{user.name}</span> </small>
<span className="ml-1 text-xs px-2 py-0.5 rounded-lg bg-gray-200 text-gray-700">{roleLabels[user.role]}</span> {/* USERS LIST */}
</div> <ul className="overflow-y-auto flex-1 pr-1">
<div className="flex items-center justify-between mt-0.5"> {sortedUsers.map((user) => (
<span className="text-xs text-gray-600 truncate">{user.email}</span> <li
</div> key={user.email}
</li> onClick={() => setSelectedEmail(user.email)}
))} className={`rounded-lg cursor-pointer border
{sortedUsers.length === 0 && <li className="text-gray-400 text-center py-6">No users found.</li>} ${selectedEmail === user.email ? "bg-blue-100 border-blue-400" : "hover:bg-gray-200 border-transparent"}
</ul> transition px-2 py-1 mb-1`}
</div> >
</div> <div className="flex items-center justify-between">
{/* MAIN PANEL */} <span className="text-sm font-medium truncate">{user.name}</span>
<div className="flex-1 p-24 bg-white overflow-y-auto"> <span className="ml-1 text-xs px-2 py-0.5 rounded-lg bg-gray-200 text-gray-700">{roleLabels[user.role]}</span>
{editUser ? ( </div>
<div className="max-w-lg mx-auto bg-white p-6 rounded-lg shadow"> <div className="flex items-center justify-between mt-0.5">
<h2 className="text-lg font-bold mb-6">Edit User</h2> <span className="text-xs text-gray-600 truncate">{user.email}</span>
<form className="space-y-4" onSubmit={handleUpdate}> </div>
<div className="flex items-center gap-2 mb-2"> </li>
<label className="text-sm font-medium text-gray-700">Account Creation Time:</label> ))}
<span className="text-sm text-gray-500">{editUser.createdAt}</span> {sortedUsers.length === 0 && <li className="text-gray-400 text-center py-6">No users found.</li>}
</div> </ul>
<div className="flex items-center gap-2 mb-2"> </div>
<label className="text-sm font-medium text-gray-700">Account ID Number:</label> </div>
<span className="text-sm text-gray-500">{editUser.id}</span> {/* MAIN PANEL */}
</div> <div className="flex-1 p-24 bg-white overflow-y-auto">
<div className="relative"> {/* Add User Modal */}
<label className="block text-sm font-medium text-gray-700 mb-1">Email (unique):</label> {addOpen && (
<input <div className="fixed inset-0 z-40 flex items-center justify-center bg-black bg-opacity-30">
className="w-full border px-3 py-2 rounded-lg outline-none bg-gray-100 cursor-not-allowed" <div className="bg-white rounded-lg shadow-lg p-8 max-w-sm w-full relative">
type="email" <h3 className="text-lg font-bold mb-4">Add New User</h3>
name="email" <form onSubmit={handleAddUser} className="space-y-3">
value={editUser.email} <div>
readOnly <label className="block text-sm font-medium mb-1">Email</label>
onMouseEnter={() => setShowEmailTooltip(true)} <input
onMouseLeave={() => setShowEmailTooltip(false)} className="w-full border px-2 py-1 rounded-lg"
/> type="email"
{/* Custom tooltip */} required
{showEmailTooltip && ( value={addForm.email}
<div className="absolute left-0 top-full mt-1 z-10 w-max max-w-xs bg-gray-800 text-gray-100 text-xs px-3 py-2 rounded-md shadow-lg border border-gray-700"> onChange={e => setAddForm(f => ({ ...f, email: e.target.value }))}
This field cannot be changed. <br /> />
To change the email, delete and re-add the user. </div>
</div> <div>
)} <label className="block text-sm font-medium mb-1">Name</label>
</div> <input
<div> className="w-full border px-2 py-1 rounded-lg"
<label className="block text-sm font-medium text-gray-700 mb-1">Name:</label> type="text"
<input required
className="w-full border px-3 py-2 rounded-lg outline-none focus:ring-2 focus:ring-blue-300" value={addForm.name}
type="text" onChange={e => setAddForm(f => ({ ...f, name: e.target.value }))}
name="name" />
value={editUser.name} </div>
onChange={handleEditChange} <div>
/> <label className="block text-sm font-medium mb-1">Role</label>
</div> <select
<div> className="w-full border px-2 py-1 rounded-lg"
<label className="block text-sm font-medium text-gray-700 mb-1">Role:</label> value={addForm.role}
<select onChange={e => setAddForm(f => ({ ...f, role: e.target.value as Role }))}
className="w-full border px-3 py-2 rounded-lg outline-none focus:ring-2 focus:ring-blue-300" >
name="role" {allRoles.map(role => (
value={editUser.role} <option value={role} key={role}>{roleLabels[role]}</option>
onChange={handleEditChange} ))}
> </select>
{allRoles.map((role) => ( </div>
<option key={role} value={role}> <div>
{roleLabels[role]} <label className="block text-sm font-medium mb-1">Password</label>
</option> <input
))} className="w-full border px-2 py-1 rounded-lg"
</select> type="text"
</div> required
<div> value={addForm.password}
<label className="block text-sm font-medium text-gray-700 mb-1">Password:</label> onChange={e => setAddForm(f => ({ ...f, password: e.target.value }))}
<input />
className="w-full border px-3 py-2 rounded-lg outline-none focus:ring-2 focus:ring-blue-300" </div>
type="text" {addError && <div className="text-red-600 text-xs">{addError}</div>}
name="password" <div className="flex gap-2 justify-end pt-2">
value={editUser.password} <button
onChange={handleEditChange} type="button"
/> className="px-3 py-1 rounded-lg bg-gray-200 text-gray-700 hover:bg-gray-300 transition"
</div> onClick={() => setAddOpen(false)}
<div className="flex gap-2 justify-end pt-6"> disabled={addLoading}
<button >
type="button" Cancel
className="px-4 py-2 bg-red-500 hover:bg-red-600 text-white font-semibold rounded-lg shadow transition" </button>
onClick={handleDelete} <button
> type="submit"
Delete className={`px-3 py-1 rounded-lg text-white font-semibold ${addLoading ? "bg-green-400" : "bg-green-600 hover:bg-green-700"}`}
</button> disabled={addLoading}
<button >
type="submit" {addLoading ? "Adding..." : "Add"}
className={`px-4 py-2 rounded-lg font-semibold transition </button>
${ </div>
isEditChanged </form>
? "bg-blue-600 hover:bg-blue-700 text-white shadow" </div>
: "bg-gray-300 text-gray-500 cursor-not-allowed" </div>
}`} )}
disabled={!isEditChanged}
> {/* Edit User Panel */}
Update {editUser ? (
</button> <div className="max-w-lg mx-auto bg-white p-6 rounded-lg shadow">
</div> <h2 className="text-lg font-bold mb-6">Edit User</h2>
</form> <form className="space-y-4" onSubmit={handleUpdate}>
</div> <div className="flex items-center gap-2 mb-2">
) : ( <label className="text-sm font-medium text-gray-700">
<div className="text-center text-gray-400 mt-16 text-lg">Select a user...</div> Account Creation Time:
)} </label>
</div> <span className="text-sm text-gray-500">{editUser.createdAt}</span>
</div> </div>
</div> <div className="flex items-center gap-2 mb-2">
); <label className="text-sm font-medium text-gray-700">
Account ID Number:
</label>
<span className="text-sm text-gray-500">{editUser.id}</span>
</div>
<div className="relative">
<label className="block text-sm font-medium text-gray-700 mb-1">
Email (unique):
</label>
<input
className="w-full border px-3 py-2 rounded-lg outline-none bg-gray-100 cursor-not-allowed"
type="email"
name="email"
value={editUser.email}
readOnly
onMouseEnter={() => setShowEmailTooltip(true)}
onMouseLeave={() => setShowEmailTooltip(false)}
/>
{showEmailTooltip && (
<div className="absolute left-0 top-full mt-1 z-10 w-max max-w-xs bg-gray-800 text-gray-100 text-xs px-3 py-2 rounded-md shadow-lg border border-gray-700">
This field cannot be changed. <br />
To change the email, delete and re-add the user.
</div>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Name:
</label>
<input
className="w-full border px-3 py-2 rounded-lg outline-none focus:ring-2 focus:ring-blue-300"
type="text"
name="name"
value={editUser.name}
onChange={handleEditChange}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Role:
</label>
<select
className="w-full border px-3 py-2 rounded-lg outline-none focus:ring-2 focus:ring-blue-300"
name="role"
value={editUser.role}
onChange={handleEditChange}
>
{allRoles.map((role) => (
<option key={role} value={role}>
{roleLabels[role]}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Password:
</label>
<input
className="w-full border px-3 py-2 rounded-lg outline-none focus:ring-2 focus:ring-blue-300"
type="text"
name="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
/>
</div>
<div className="flex gap-2 justify-end pt-6">
<button
type="button"
className="px-4 py-2 bg-red-500 hover:bg-red-600 text-white font-semibold rounded-lg shadow transition"
onClick={handleDelete}
>
Delete
</button>
<button
type="submit"
className={`px-4 py-2 rounded-lg font-semibold transition
${
isEditChanged
? "bg-blue-600 hover:bg-blue-700 text-white shadow"
: "bg-gray-300 text-gray-500 cursor-not-allowed"
}`}
disabled={!isEditChanged}
>
Update
</button>
</div>
</form>
</div>
) : (
<div className="text-center text-gray-400 mt-16 text-lg">
Select a user...
</div>
)}
</div>
</div>
</div>
);
} }

126
src/app/api/admin/route.ts Normal file
View File

@ -0,0 +1,126 @@
import { NextResponse } from "next/server";
import { cookies } from "next/headers";
import { prisma } from "@utils/prisma";
import { env } from "@utils/env";
import { verifyJwt } from "@utils/verifyJwt";
import bcryptjs from "bcryptjs";
import { z } from "zod";
// Helper
async function getUserFromRequest() {
const cookieStore = cookies();
const token = (await cookieStore).get("jwt")?.value;
if (!token) return null;
const payload = await verifyJwt({ token, secret: env.JWT_SECRET_KEY });
if (!payload?.userId) return null;
const user = await prisma.user.findUnique({
where: { id: payload.userId as number },
select: { id: true, role: true },
});
return user;
}
export async function GET() {
try {
const user = await getUserFromRequest();
if (!user || user.role !== "ADMIN") {
return NextResponse.json({ error: "Not authorized" }, { status: 403 });
}
const users = await prisma.user.findMany({
select: { id: true, email: true, name: true, role: true, createdAt: true },
});
const cleanedUsers = users.map(u => ({
...u,
createdAt: u.createdAt instanceof Date ? u.createdAt.toISOString() : u.createdAt,
}));
return NextResponse.json({ users: cleanedUsers }, { status: 200 });
} catch (error) {
console.error("Error fetching users:", error);
return NextResponse.json({ error: "Internal Server Error" }, { status: 500 });
}
}
export async function PUT(request: Request) {
try {
const user = await getUserFromRequest();
if (!user || user.role !== "ADMIN") {
return NextResponse.json({ error: "Not authorized" }, { status: 403 });
}
const body = await request.json();
const { id, name, role, password } = body;
const updateData: any = { name, role };
if (typeof password === "string" && password.trim() !== "") {
updateData.passwordHash = await bcryptjs.hash(password, 10);
}
const updated = await prisma.user.update({
where: { id },
data: updateData,
});
return NextResponse.json({ user: updated }, { status: 200 });
} catch (error) {
console.error("Update error:", error);
return NextResponse.json({ error: "Update failed" }, { status: 500 });
}
}
export async function POST(request: Request) {
try {
const user = await getUserFromRequest();
if (!user || user.role !== "ADMIN") {
return NextResponse.json({ error: "Not authorized" }, { status: 403 });
}
const body = await request.json();
// Validate input (simple for demo, use zod or similar in prod)
const schema = z.object({
email: z.string().email(),
name: z.string().min(1),
role: z.enum(["ADMIN", "SCIENTIST", "GUEST"]),
password: z.string().min(6)
});
const { email, name, role, password } = schema.parse(body);
// Check uniqueness
const exists = await prisma.user.findUnique({ where: { email } });
if (exists) {
return NextResponse.json({ error: "Email already exists" }, { status: 409 });
}
const passwordHash = await bcryptjs.hash(password, 10);
const created = await prisma.user.create({
data: {
email,
name,
role,
passwordHash,
},
select: { id: true, email: true, name: true, role: true, createdAt: true },
});
return NextResponse.json({ user: { ...created, createdAt: created.createdAt instanceof Date ? created.createdAt.toISOString() : created.createdAt } }, { status: 201 });
} catch (error: any) {
console.error("Create user error:", error);
return NextResponse.json({ error: error?.message ?? "Failed to create user" }, { status: 400 });
}
}
export async function DELETE(request: Request) {
try {
const user = await getUserFromRequest();
if (!user || user.role !== "ADMIN") {
return NextResponse.json({ error: "Not authorized" }, { status: 403 });
}
const body = await request.json();
const { id } = body;
if (typeof id !== "number" || isNaN(id)) {
return NextResponse.json({ error: "Invalid id" }, { status: 400 });
}
await prisma.user.delete({
where: { id }
});
return NextResponse.json({ success: true }, { status: 200 });
} catch (error: any) {
console.error("Delete error:", error);
return NextResponse.json({ error: error.message || "Delete failed" }, { status: 500 });
}
}

View File

@ -0,0 +1,21 @@
import { NextResponse } from "next/server";
import { prisma } from "@utils/prisma";
export async function POST(req: Request) {
try {
const { requestType, requestingUserId, scientistId, comment } = await req.json();
const request = await prisma.request.create({
data: {
requestType,
requestingUser: { connect: { id: requestingUserId } },
outcome: "IN_PROGRESS",
// Optionally you can connect to Scientist via an inline relation if you have a foreign key
// If the model has comment or details fields, add it!
},
});
return NextResponse.json({ request }, { status: 201 });
} catch (error) {
console.error("Request create error:", error);
return NextResponse.json({ error: "Failed to create request" }, { status: 500 });
}
}

View File

@ -0,0 +1,80 @@
import { NextResponse } from "next/server";
import { prisma } from "@utils/prisma";
// GET all scientists (with user, superior.user, subordinates)
export async function GET() {
try {
const scientists = await prisma.scientist.findMany({
include: {
user: true,
superior: { include: { user: true } },
subordinates: true,
},
});
return NextResponse.json({ scientists }, { status: 200 });
} catch (error) {
console.error("Error fetching scientists:", error);
return NextResponse.json({ error: "Internal Server Error" }, { status: 500 });
}
}
// CREATE scientist
export async function POST(req: Request) {
try {
const { name, level, userId, superiorId } = await req.json();
const scientist = await prisma.scientist.create({
data: {
name,
level,
user: { connect: { id: userId } },
superior: superiorId ? { connect: { id: superiorId } } : undefined,
},
include: {
user: true,
superior: { include: { user: true } },
subordinates: true,
},
});
return NextResponse.json({ scientist }, { status: 201 });
} catch (error) {
console.error("Scientist create error:", error);
return NextResponse.json({ error: "Failed to create scientist" }, { status: 500 });
}
}
// UPDATE scientist
export async function PUT(req: Request) {
try {
const { id, name, level, userId, superiorId } = await req.json();
const updatedScientist = await prisma.scientist.update({
where: { id },
data: {
name,
level,
user: { connect: { id: userId } },
superior: superiorId ? { connect: { id: superiorId } } : { disconnect: true },
},
include: {
user: true,
superior: { include: { user: true } },
subordinates: true,
},
});
return NextResponse.json({ scientist: updatedScientist }, { status: 200 });
} catch (error) {
console.error("Update error:", error);
return NextResponse.json({ error: "Update failed" }, { status: 500 });
}
}
// DELETE scientist
export async function DELETE(req: Request) {
try {
const { id } = await req.json();
await prisma.scientist.delete({ where: { id } });
return NextResponse.json({ success: true }, { status: 200 });
} catch (error) {
console.error("Delete error:", error);
return NextResponse.json({ error: "Delete failed" }, { status: 500 });
}
}

View File

@ -0,0 +1,16 @@
import { NextResponse } from "next/server";
import { prisma } from "@utils/prisma";
export async function GET() {
try {
const users = await prisma.user.findMany({
include: {
scientist: true, // So you know if the user already has a scientist
}
});
return NextResponse.json({ users }, { status: 200 });
} catch (error) {
console.error("Error fetching all users:", error);
return NextResponse.json({ error: "Internal Server Error" }, { status: 500 });
}
}

View File

@ -9,6 +9,29 @@ import { getRelativeDate } from "@utils/formatters";
import GeologicalEvent from "@appTypes/Event"; import GeologicalEvent from "@appTypes/Event";
import axios from "axios"; import axios from "axios";
import EarthquakeLogModal from "@components/EarthquakeLogModal"; import EarthquakeLogModal from "@components/EarthquakeLogModal";
import { useStoreState } from "@hooks/store";
// --- NO ACCESS MODAL ---
function NoAccessModal({ open, onClose }) {
if (!open) return null;
return (
<div className="fixed z-50 inset-0 bg-black/40 flex items-center justify-center">
<div className="bg-white rounded-lg shadow-lg p-8 max-w-xs w-full text-center relative">
<button
onClick={onClose}
className="absolute right-4 top-4 text-gray-500 hover:text-black text-lg"
aria-label="Close"
>&times;</button>
<h2 className="font-bold text-xl mb-4">Access Denied</h2>
<p className="text-gray-600 mb-3">Sorry, you do not have access rights to Log an Earthquake. Please Log in here, or contact an Admin if you believe this is a mistake</p>
<button
onClick={onClose}
className="bg-blue-600 text-white px-6 py-2 rounded hover:bg-blue-700 mt-2"
>OK</button>
</div>
</div>
);
}
// --- SEARCH MODAL COMPONENT --- // --- SEARCH MODAL COMPONENT ---
function EarthquakeSearchModal({ open, onClose, onSelect }) { function EarthquakeSearchModal({ open, onClose, onSelect }) {
@ -86,10 +109,17 @@ export default function Earthquakes() {
const [selectedEventId, setSelectedEventId] = useState(""); const [selectedEventId, setSelectedEventId] = useState("");
const [hoveredEventId, setHoveredEventId] = useState(""); const [hoveredEventId, setHoveredEventId] = useState("");
const [searchModalOpen, setSearchModalOpen] = useState(false); const [searchModalOpen, setSearchModalOpen] = useState(false);
const [logModalOpen, setLogModalOpen] = useState(false); // <-- Move here! const [logModalOpen, setLogModalOpen] = useState(false);
// Fetch recent earthquakes as before const [noAccessModalOpen, setNoAccessModalOpen] = useState(false);
const user = useStoreState((state) => state.user);
const role: "GUEST" | "SCIENTIST" | "ADMIN" = user?.role ?? "GUEST";
const canLogEarthquake = role === "SCIENTIST" || role === "ADMIN";
// Fetch recent earthquakes
const { data, error, isLoading, mutate } = useSWR("/api/earthquakes", createPoster({ rangeDaysPrev: 10 })); const { data, error, isLoading, mutate } = useSWR("/api/earthquakes", createPoster({ rangeDaysPrev: 10 }));
// Prepare events for maps/sidebar
// Prepare events
const earthquakeEvents = useMemo( const earthquakeEvents = useMemo(
() => () =>
data && data.earthquakes data && data.earthquakes
@ -110,6 +140,16 @@ export default function Earthquakes() {
: [], : [],
[data] [data]
); );
// This handler is always called, regardless of button state!
const handleLogClick = () => {
if (canLogEarthquake) {
setLogModalOpen(true);
} else {
setNoAccessModalOpen(true);
}
};
return ( return (
<div className="flex h-[calc(100vh-3.5rem)] w-full overflow-hidden"> <div className="flex h-[calc(100vh-3.5rem)] w-full overflow-hidden">
<div className="flex-grow h-full"> <div className="flex-grow h-full">
@ -133,22 +173,24 @@ export default function Earthquakes() {
setHoveredEventId={setHoveredEventId} setHoveredEventId={setHoveredEventId}
button1Name="Log an Earthquake" button1Name="Log an Earthquake"
button2Name="Search Earthquakes" button2Name="Search Earthquakes"
onButton1Click={() => setLogModalOpen(true)} // Correct! onButton1Click={handleLogClick} // <--- Important!
onButton2Click={() => setSearchModalOpen(true)} onButton2Click={() => setSearchModalOpen(true)}
button1Disabled={!canLogEarthquake} // <--- For style only!
/> />
<EarthquakeSearchModal <EarthquakeSearchModal
open={searchModalOpen} open={searchModalOpen}
onClose={() => setSearchModalOpen(false)} onClose={() => setSearchModalOpen(false)}
onSelect={(eq) => { onSelect={(eq) => setSelectedEventId(eq.code)}
setSelectedEventId(eq.code);
// setSelectedSearchResult(eq); // optional
}}
/> />
<EarthquakeLogModal <EarthquakeLogModal
open={logModalOpen} open={logModalOpen}
onClose={() => setLogModalOpen(false)} onClose={() => setLogModalOpen(false)}
onSuccess={() => mutate()} // To refresh onSuccess={() => mutate()} // To refresh
/> />
<NoAccessModal
open={noAccessModalOpen}
onClose={() => setNoAccessModalOpen(false)}
/>
</div> </div>
); );
} }

File diff suppressed because it is too large Load Diff

View File

@ -2,15 +2,15 @@ import React, { useCallback, useRef, useState } from "react";
import Link from "next/link"; import Link from "next/link";
export default function BottomFooter() { export default function BottomFooter() {
// Lava flood state & timer // ig easter egg
const [lavaActive, setLavaActive] = useState(false); const [lavaActive, setLavaActive] = useState(false);
const lavaTimeout = useRef<any>(null); const lavaTimeout = useRef<any>(null);
// LinkedIn shake state & timer // LinkedIn easter egg
const [shaking, setShaking] = useState(false); const [shaking, setShaking] = useState(false);
const shakeTimeout = useRef<any>(null); const shakeTimeout = useRef<any>(null);
// Crack+collapse states for the X logo // x easter egg
const [showCracks, setShowCracks] = useState(false); const [showCracks, setShowCracks] = useState(false);
const [collapse, setCollapse] = useState(false); const [collapse, setCollapse] = useState(false);
const crackTimeout = useRef<any>(null); const crackTimeout = useRef<any>(null);
@ -131,6 +131,14 @@ export default function BottomFooter() {
{/* Bottom bar */} {/* Bottom bar */}
<div className="max-w-6xl mx-auto mt-8 pt-6 flex flex-col md:flex-row items-center justify-between border-t border-gray-200/30"> <div className="max-w-6xl mx-auto mt-8 pt-6 flex flex-col md:flex-row items-center justify-between border-t border-gray-200/30">
<div className="flex flex-row items-center w-full md:w-auto">
<img
src="/logo.png"
alt="TremorTracker logo"
className="h-16 w-auto mr-4 object-contain"
style={{ maxHeight: 75 }}
/>
</div>
<span className="text-sm flex items-center"> <span className="text-sm flex items-center">
<span className="mr-2">&#169;</span> TremorTracker 2025 <span className="mr-2">&#169;</span> TremorTracker 2025
</span> </span>

View File

@ -134,11 +134,15 @@ export default function Navbar({}: // currencySelector,
<ManagementNavbarButton name="Warehouse" href="/warehouse"></ManagementNavbarButton> <ManagementNavbarButton name="Warehouse" href="/warehouse"></ManagementNavbarButton>
</div> </div>
)} )}
{user && (user.role === "SCIENTIST" || user.role === "ADMIN") && ( {user && (
<div className="flex h-full mr-5"> (user.role === "ADMIN" ||
<ManagementNavbarButton name="Scientist Management" href="/management"></ManagementNavbarButton> (user.role === "SCIENTIST" && user.scientist?.level === "SENIOR")
</div> ) && (
)} <div className="flex h-full mr-5">
<ManagementNavbarButton name="Scientist Management" href="/management" />
</div>
)
)}
{user && user.role === "ADMIN" && ( {user && user.role === "ADMIN" && (
<div className="flex h-full mr-5"> <div className="flex h-full mr-5">
<ManagementNavbarButton name="Admin" href="/administrator"></ManagementNavbarButton> <ManagementNavbarButton name="Admin" href="/administrator"></ManagementNavbarButton>

View File

@ -1,135 +1,125 @@
import Link from "next/link";
import React, { Dispatch, SetStateAction, useEffect, useRef } from "react"; import React, { Dispatch, SetStateAction, useEffect, useRef } from "react";
import { TbHexagon } from "react-icons/tb"; import { TbHexagon } from "react-icons/tb";
import GeologicalEvent from "@appTypes/Event"; import GeologicalEvent from "@appTypes/Event";
import getMagnitudeColor from "@utils/getMagnitudeColour"; import getMagnitudeColor from "@utils/getMagnitudeColour";
interface SidebarProps { interface SidebarProps {
logTitle: string; logTitle: string;
logSubtitle: string; logSubtitle: string;
recentsTitle: string; recentsTitle: string;
events: GeologicalEvent[]; events: GeologicalEvent[];
selectedEventId: GeologicalEvent["id"]; selectedEventId: GeologicalEvent["id"];
setSelectedEventId: Dispatch<SetStateAction<string>>; setSelectedEventId: Dispatch<SetStateAction<string>>;
hoveredEventId: GeologicalEvent["id"]; hoveredEventId: GeologicalEvent["id"];
setHoveredEventId: Dispatch<SetStateAction<string>>; setHoveredEventId: Dispatch<SetStateAction<string>>;
button1Name: string; button1Name: string;
button2Name: string; button2Name: string;
onButton2Click?: () => void; onButton2Click?: () => void;
onButton1Click?: () => void; onButton1Click?: () => void;
button1Disabled?: boolean;
} }
function MagnitudeNumber({ magnitude }: { magnitude: number }) { function MagnitudeNumber({ magnitude }: { magnitude: number }) {
const magnitudeStr = magnitude.toFixed(1); const magnitudeStr = magnitude.toFixed(1);
const [whole, decimal] = magnitudeStr.split("."); const [whole, decimal] = magnitudeStr.split(".");
return (
return ( <div className="relative" style={{ color: getMagnitudeColor(magnitude) }}>
<div className="relative" style={{ color: getMagnitudeColor(magnitude) }}> <TbHexagon size={40} className="drop-shadow-sm" />
<TbHexagon size={40} className="drop-shadow-sm" /> <div className="absolute inset-0 flex items-center justify-center">
<div className="absolute inset-0 flex items-center justify-center"> <div className="flex items-baseline font-mono font-bold tracking-tight">
<div className="flex items-baseline font-mono font-bold tracking-tight"> <span className="text-xl -mr-1">{whole}</span>
<span className="text-xl -mr-1">{whole}</span> <span className="text-xs ml-[1.5px] -mr-[2.5px]">.</span>
<span className="text-xs ml-[1.5px] -mr-[2.5px]">.</span> <span className="text-xs -mr-[1px]">{decimal}</span>
<span className="text-xs -mr-[1px]">{decimal}</span> </div>
</div> </div>
</div> </div>
</div> );
);
} }
// todo change sidebar event highlighting on selection
export default function Sidebar({ export default function Sidebar({
logTitle, logTitle,
logSubtitle, logSubtitle,
recentsTitle, recentsTitle,
events, events,
selectedEventId, selectedEventId,
setSelectedEventId, setSelectedEventId,
hoveredEventId, hoveredEventId,
setHoveredEventId, setHoveredEventId,
button1Name, button1Name,
button2Name, button2Name,
onButton2Click, onButton2Click,
onButton1Click, onButton1Click,
button1Disabled = false,
}: SidebarProps) { }: SidebarProps) {
const eventsContainerRef = useRef<HTMLDivElement>(null); const eventsContainerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (selectedEventId && eventsContainerRef.current) {
const selectedEventElement = eventsContainerRef.current.querySelector(`[data-event-id="${selectedEventId}"]`);
if (selectedEventElement) {
selectedEventElement.scrollIntoView({
block: "center",
behavior: "smooth",
});
}
}
}, [selectedEventId]);
useEffect(() => { return (
if (selectedEventId && eventsContainerRef.current) { <div className="flex flex-col h-full w-80 bg-gradient-to-b from-neutral-100 to-neutral-50 shadow-lg">
const selectedEventElement = eventsContainerRef.current.querySelector(`[data-event-id="${selectedEventId}"]`); <div className="py-6 flex flex-col h-full">
if (selectedEventElement) { <div className="px-6 pb-8 border-b border-neutral-200">
selectedEventElement.scrollIntoView({ <h2 className="text-2xl font-bold text-neutral-800 mb-2">{logTitle}</h2>
block: "center", <p className="text-sm text-neutral-600 leading-relaxed">{logSubtitle}</p>
behavior: "smooth", <button
}); className={`mt-4 w-full py-2 px-4 rounded-lg transition-colors duration-200 font-medium
} ${button1Disabled
} ? "bg-gray-300 text-gray-500 cursor-not-allowed"
}, [selectedEventId]); : "bg-blue-600 hover:bg-blue-700 text-white"
}`}
onClick={onButton1Click}
type="button"
return ( tabIndex={button1Disabled ? -1 : 0}
<div className="flex flex-col h-full w-80 bg-gradient-to-b from-neutral-100 to-neutral-50 shadow-lg"> aria-disabled={button1Disabled ? "true" : "false"}
<div className="py-6 flex flex-col h-full"> >
<div className="px-6 pb-8 border-b border-neutral-200"> {button1Name}
<h2 className="text-2xl font-bold text-neutral-800 mb-2">{logTitle}</h2> </button>
<p className="text-sm text-neutral-600 leading-relaxed">{logSubtitle}</p> <button
className="mt-4 w-full bg-blue-600 hover:bg-blue-700 text-white py-2 px-4 rounded-lg transition-colors duration-200 font-medium"
{onButton1Click ? ( onClick={onButton2Click}
<button type="button"
className="mt-4 w-full bg-blue-600 hover:bg-blue-700 text-white py-2 px-4 rounded-lg transition-colors duration-200 font-medium" >
onClick={onButton1Click} {button2Name}
type="button" </button>
> </div>
{button1Name} <div className="px-6 pt-6">
</button> <h2 className="text-xl font-bold text-neutral-800 mb-4">{recentsTitle}</h2>
) : ( </div>
<Link href="/"> <div className="flex-1 px-6 overflow-y-auto" ref={eventsContainerRef}>
<div className="space-y-3">
{events.map((event) => (
<button <button
className="mt-4 w-full bg-blue-600 hover:bg-blue-700 text-white py-2 px-4 rounded-lg transition-colors duration-200 font-medium" key={event.id}
type="button" data-event-id={event.id}
className={`w-full border ${hoveredEventId === event.id ? "bg-neutral-100" : "bg-white"} ${
selectedEventId === event.id ? "border-neutral-800" : "border-neutral-200"
} rounded-lg p-4 flex items-center gap-4 hover:bg-neutral-100 transition-colors duration-150 shadow-sm text-left`}
onClick={() => {
setSelectedEventId((prevEventId) => (prevEventId !== event.id ? event.id : ""));
}}
onMouseEnter={() => setHoveredEventId(event.id)}
onMouseLeave={() => setHoveredEventId("")}
> >
{button1Name} <div className="flex-1">
<p className="text-sm font-medium text-neutral-800 line-clamp-1">{event.title}</p>
<p className="text-xs text-neutral-500 mt-1 line-clamp-1">{event.text2}</p>
</div>
{event.magnitude ? <MagnitudeNumber magnitude={event.magnitude} /> : <></>}
</button> </button>
</Link> ))}
)} </div>
</div>
<button </div>
className="mt-4 w-full bg-blue-600 hover:bg-blue-700 text-white py-2 px-4 rounded-lg transition-colors duration-200 font-medium" </div>
onClick={onButton2Click} );
type="button"
>
{button2Name}
</button>
</div>
<div className="px-6 pt-6">
<h2 className="text-xl font-bold text-neutral-800 mb-4">{recentsTitle}</h2>
</div>
<div className="flex-1 px-6 overflow-y-auto" ref={eventsContainerRef}>
<div className="space-y-3">
{events.map((event) => (
<button
key={event.id}
data-event-id={event.id}
className={`w-full border ${hoveredEventId === event.id ? "bg-neutral-100" : "bg-white"} ${
selectedEventId === event.id ? "border-neutral-800" : "border-neutral-200"
} rounded-lg p-4 flex items-center gap-4 hover:bg-neutral-100 transition-colors duration-150 shadow-sm text-left`}
onClick={() => {
setSelectedEventId((prevEventId) => (prevEventId !== event.id ? event.id : ""));
}}
onMouseEnter={() => setHoveredEventId(event.id)}
onMouseLeave={() => setHoveredEventId("")}
>
<div className="flex-1">
<p className="text-sm font-medium text-neutral-800 line-clamp-1">{event.title}</p>
<p className="text-xs text-neutral-500 mt-1 line-clamp-1">{event.text2}</p>
</div>
{event.magnitude ? <MagnitudeNumber magnitude={event.magnitude} /> : <></>}
</button>
))}
</div>
</div>
</div>
</div>
);
} }