Compare commits

..

No commits in common. "5024ad4324d6a6401bfb6d4b563b013a9e6a6d3a" and "1c08f0364c63755de46c0ab29e8096c5d30ceb9c" have entirely different histories.

12 changed files with 275 additions and 558 deletions

View File

@ -1,11 +1,14 @@
import bcryptjs from "bcryptjs"; import bcryptjs from "bcryptjs";
import { validatePassword } from "@utils/validation";
import { SignJWT } from "jose"; import { SignJWT } from "jose";
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { env } from "@utils/env"; import { env } from "@utils/env";
import { prisma } from "@utils/prisma"; import { prisma } from "@utils/prisma";
import { passwordStrengthCheck } from "@utils/validation";
// todo check email doesn't already exist
export async function POST(req: Request) { export async function POST(req: Request) {
try { try {
const { email, password, name } = await req.json(); const { email, password, name } = await req.json();
@ -20,67 +23,80 @@ export async function POST(req: Request) {
return NextResponse.json({ message: "Sorry, this email is already in use" }, { status: 409 }); return NextResponse.json({ message: "Sorry, this email is already in use" }, { status: 409 });
} }
const passwordCheckResult = validatePassword(password); const passwordCheckResult = await passwordStrengthCheck(password);
if ("message" in passwordCheckResult) {
return NextResponse.json({ message: passwordCheckResult.message }, { status: passwordCheckResult.status });
}
try { if (passwordCheckResult === "short") {
const newUser = await prisma.user.create({ return NextResponse.json({ message: "Your password is shorter than 8 characters" }, { status: 400 });
data: { } else if (passwordCheckResult === "long") {
name, return NextResponse.json({ message: "Your password is longer than 16 characters" }, { status: 400 });
email, } else if (passwordCheckResult === "no lower") {
passwordHash: await bcryptjs.hash(password, 10), return NextResponse.json({ message: "Your password must contain a lowercase letters" }, { status: 400 });
}, } else if (passwordCheckResult === "no upper") {
}); return NextResponse.json({ message: "Your password must contain a uppercase letters" }, { status: 400 });
} else if (passwordCheckResult === "no digit") {
return NextResponse.json({ message: "Your password must contain a number" }, { status: 400 });
} else if (passwordCheckResult === "no special") {
return NextResponse.json({ message: "Your password must contain a special character (!@#$%^&*)" }, { status: 400 });
} else if (passwordCheckResult === "end of function") {
return NextResponse.json({ message: "Password check script failure" }, { status: 500 });
} else {
try {
const newUser = await prisma.user.create({
data: {
name,
email,
passwordHash: await bcryptjs.hash(password, 10),
},
});
// Link orders with matching email to the new user // Link orders with matching email to the new user
await prisma.order.updateMany({ await prisma.order.updateMany({
where: { where: {
email: email, email: email,
userId: null, // Only update orders not already linked to a user userId: null, // Only update orders not already linked to a user
}, },
data: { data: {
userId: newUser.id, userId: newUser.id,
}, },
}); });
const user = await prisma.user.findUnique({ const user = await prisma.user.findUnique({
where: { id: newUser.id }, where: { id: newUser.id },
include: { include: {
earthquakes: true, earthquakes: true,
observatories: true, observatories: true,
artefacts: true, artefacts: true,
purchasedOrders: true, purchasedOrders: true,
requests: true, requests: true,
scientist: { scientist: {
include: { include: {
superior: true, superior: true,
subordinates: true, subordinates: true,
},
}, },
}, },
}, });
}); const { passwordHash, ...userSansHash } = user!;
const { passwordHash, ...userSansHash } = user!;
const secret = new TextEncoder().encode(env.JWT_SECRET_KEY); const secret = new TextEncoder().encode(env.JWT_SECRET_KEY);
const token = await new SignJWT({ userId: user!.id }) const token = await new SignJWT({ userId: user!.id })
.setProtectedHeader({ alg: "HS256" }) .setProtectedHeader({ alg: "HS256" })
.setExpirationTime("2w") .setExpirationTime("2w")
.sign(secret); .sign(secret);
const response = NextResponse.json({ message: "Account Created", user: userSansHash }, { status: 201 }); const response = NextResponse.json({ message: "Account Created", user: userSansHash }, { status: 201 });
response.cookies.set("jwt", token, { response.cookies.set("jwt", token, {
httpOnly: true, httpOnly: true,
secure: process.env.NODE_ENV === "production", secure: process.env.NODE_ENV === "production",
sameSite: "strict", sameSite: "strict",
maxAge: 3600 * 168 * 2, // 2 weeks maxAge: 3600 * 168 * 2, // 2 weeks
path: "/", path: "/",
}); });
return response; return response;
} catch (error) { } catch (error) {
console.error("Error creating user:", error); console.error("Error creating user:", error);
return NextResponse.json({ message: "Internal Server Error" }, { status: 500 }); return NextResponse.json({ message: "Internal Server Error" }, { status: 500 });
}
} }
} catch (error) { } catch (error) {
console.error("Error in signup endpoint:", error); console.error("Error in signup endpoint:", error);

View File

@ -5,7 +5,7 @@ import { env } from "@utils/env";
import { prisma } from "@utils/prisma"; import { prisma } from "@utils/prisma";
import { apiAuthMiddleware } from "@utils/apiAuthMiddleware"; import { apiAuthMiddleware } from "@utils/apiAuthMiddleware";
import { validatePassword } from "@utils/validation"; import { passwordStrengthCheck } from "@utils/validation";
export async function POST(req: Request) { export async function POST(req: Request) {
try { try {
@ -13,22 +13,11 @@ export async function POST(req: Request) {
if ("user" in authResult === false) return authResult; if ("user" in authResult === false) return authResult;
const { user } = authResult; const { user } = authResult;
const { const { userId, email, name, password, requestedRole } = await req.json();
userId,
email,
name,
password,
requestedRole,
}: {
userId?: number;
email?: string;
name?: string;
password?: string;
requestedRole?: string;
} = await req.json();
// Trying to update a different user than themselves // Trying to update a different user than themselves
// Only available to admins // Only available to admins
// todo add senior scientists being able to update their juniors
if (userId && userId !== user.id) { if (userId && userId !== user.id) {
if (user.role !== "ADMIN") { if (user.role !== "ADMIN") {
return NextResponse.json({ message: "Not authorised" }, { status: 401 }); return NextResponse.json({ message: "Not authorised" }, { status: 401 });
@ -45,12 +34,25 @@ export async function POST(req: Request) {
} }
} }
// todo move to dedicated function
// Validate password strength if provided // Validate password strength if provided
let passwordHash = user.passwordHash; let passwordHash = user.passwordHash;
if (password) { if (password) {
const passwordCheckResult = validatePassword(password); const passwordCheckResult = await passwordStrengthCheck(password);
if ("message" in passwordCheckResult) { if (passwordCheckResult === "short") {
return NextResponse.json({ message: passwordCheckResult.message }, { status: passwordCheckResult.status }); return NextResponse.json({ message: "Password is shorter than 8 characters" }, { status: 400 });
} else if (passwordCheckResult === "long") {
return NextResponse.json({ message: "Password is longer than 16 characters" }, { status: 400 });
} else if (passwordCheckResult === "no lower") {
return NextResponse.json({ message: "Password must contain lowercase letters" }, { status: 400 });
} else if (passwordCheckResult === "no upper") {
return NextResponse.json({ message: "Password must contain uppercase letters" }, { status: 400 });
} else if (passwordCheckResult === "no digit") {
return NextResponse.json({ message: "Password must contain a number" }, { status: 400 });
} else if (passwordCheckResult === "no special") {
return NextResponse.json({ message: "Password must contain a special character (!@#$%^&*)" }, { status: 400 });
} else if (passwordCheckResult === "end of function") {
return NextResponse.json({ message: "Password check script failure" }, { status: 500 });
} }
passwordHash = await bcryptjs.hash(password, 10); passwordHash = await bcryptjs.hash(password, 10);
} }

View File

@ -1,59 +0,0 @@
import { NextResponse } from "next/server";
import { env } from "@utils/env";
import { prisma } from "@utils/prisma";
import { apiAuthMiddleware } from "@utils/apiAuthMiddleware";
export async function POST(req: Request) {
try {
const authResult = await apiAuthMiddleware();
if ("user" in authResult === false) return authResult;
const { user } = authResult;
const { userId }: { userId: number } = await req.json();
if (!userId) {
return NextResponse.json({ message: "User id required to delete" }, { status: 401 });
}
if (userId !== user.id && user.role !== "ADMIN") {
return NextResponse.json({ message: "Not authorised" }, { status: 401 });
}
await prisma.$transaction(async (tx) => {
// Handle Scientist and its subordinates
const scientist = await tx.scientist.findUnique({ where: { userId: userId } });
if (scientist) {
// Unlink subordinates
await tx.scientist.updateMany({
where: { superiorId: scientist.id },
data: { superiorId: null },
});
// Delete Scientist
await tx.scientist.delete({ where: { userId: userId } });
}
// Delete Requests
await tx.request.deleteMany({ where: { requestingUserId: userId } });
// Unlink Observatories (set creatorId to null)
await tx.observatory.updateMany({
where: { creatorId: userId },
data: { creatorId: null },
});
// Unlink Artefacts (set creatorId to null)
await tx.artefact.updateMany({
where: { creatorId: userId },
data: { creatorId: null },
});
// Delete User (Orders and Earthquakes are handled automatically)
await tx.user.delete({ where: { id: userId } });
});
return NextResponse.json({ message: "User deleted successfully" }, { status: 200 });
} catch (error) {
console.error("Error in delete-user endpoint:", error);
return NextResponse.json({ message: "Internal Server Error" }, { status: 500 });
}
}

View File

@ -1,86 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import { apiAuthMiddleware } from "@utils/apiAuthMiddleware";
import { prisma } from "@utils/prisma";
import { writeFile } from "fs/promises";
import { join } from "path";
export async function POST(request: NextRequest) {
try {
const formData = await request.formData();
const id = formData.get("id") as string;
const name = formData.get("name") as string | null;
const description = formData.get("description") as string | null;
const warehouseArea = formData.get("warehouseArea") as string | null;
const earthquakeCode = formData.get("earthquakeCode") as string | null;
const image = formData.get("image") as File | null;
const authResult = await apiAuthMiddleware();
if ("user" in authResult === false) return authResult;
const { user } = authResult;
if (!id) {
return NextResponse.json({ error: "Artefact ID required" }, { status: 400 });
}
const artefact = await prisma.artefact.findUnique({
where: { id: parseInt(id) },
});
if (!artefact) {
return NextResponse.json({ error: "Artefact not found" }, { status: 404 });
}
if (user.role !== "ADMIN" && user.role !== "SCIENTIST") {
return NextResponse.json({ error: "Not authorized" }, { status: 401 });
}
if (user.role === "SCIENTIST") {
const scientist = await prisma.scientist.findUnique({
where: {
userId: user.id,
},
include: {
subordinates: true,
},
});
if (!scientist || scientist.level !== "SENIOR") {
return NextResponse.json({ message: "Not authorised" }, { status: 401 });
}
}
let earthquakeId = artefact.earthquakeId;
if (earthquakeCode) {
const linkedEarthquake = await prisma.earthquake.findUnique({ where: { code: earthquakeCode } });
if (!linkedEarthquake) {
return NextResponse.json({ error: "Earthquake code not found" }, { status: 400 });
}
earthquakeId = linkedEarthquake.id;
}
let imageName = artefact.imageName;
if (image) {
const buffer = Buffer.from(await image.arrayBuffer());
const extension = image.type === "image/jpeg" ? "jpg" : "png";
imageName = `${name || artefact.name}-${new Date().toLocaleDateString("en-GB")}.${extension}`;
const imagePath = join(process.cwd(), "public", imageName);
await writeFile(imagePath, buffer);
}
const updatedArtefact = await prisma.artefact.update({
where: { id: parseInt(id) },
data: {
name: name || artefact.name,
description: description || artefact.description,
warehouseArea: warehouseArea || artefact.warehouseArea,
earthquakeId,
imageName,
},
});
return NextResponse.json({ message: "Artefact updated successfully", artefact: updatedArtefact }, { status: 200 });
} catch (e: any) {
return NextResponse.json({ error: e.message }, { status: 500 });
}
}

View File

@ -5,40 +5,21 @@ import { prisma } from "@utils/prisma";
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
const body = await request.json(); const body = await request.json();
const { palletNote, warehouseArea } = body; const { palletNote, warehouseLocation } = body;
const authResult = await apiAuthMiddleware(); const authResult = await apiAuthMiddleware();
if ("user" in authResult === false) return authResult; // Handle error response if ("user" in authResult === false) return authResult; // Handle error response
const { user } = authResult; const { user } = authResult;
if (user.role !== "ADMIN" && user.role !== "SCIENTIST") { if (!palletNote || !warehouseLocation) {
return NextResponse.json({ error: "Not authorized" }, { status: 401 });
}
if (user.role === "SCIENTIST") {
const scientist = await prisma.scientist.findUnique({
where: {
userId: user.id,
},
include: {
subordinates: true,
},
});
if (!scientist || scientist.level !== "SENIOR") {
return NextResponse.json({ message: "Not authorised" }, { status: 401 });
}
}
if (!palletNote || !warehouseArea) {
return NextResponse.json({ error: "Missing fields" }, { status: 400 }); return NextResponse.json({ error: "Missing fields" }, { status: 400 });
} }
await prisma.pallet.create({ await prisma.pallet.create({
data: { data: {
palletNote, palletNote,
warehouseArea, warehouseArea: warehouseLocation,
}, },
}); });

View File

@ -1,70 +1,35 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { apiAuthMiddleware } from "@utils/apiAuthMiddleware"; import { apiAuthMiddleware } from "@utils/apiAuthMiddleware";
import { prisma } from "@utils/prisma"; import { prisma } from "@utils/prisma";
import { writeFile } from "fs/promises";
import { join } from "path";
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
const formData = await request.formData(); const body = await request.json();
const name = formData.get("name") as string; const { name, type, description, location, earthquakeCode, warehouseLocation } = body;
const type = formData.get("type") as string;
const description = formData.get("description") as string;
const earthquakeCode = formData.get("earthquakeCode") as string;
const warehouseArea = formData.get("warehouseArea") as string;
const image = formData.get("image") as File | null;
const authResult = await apiAuthMiddleware(); const authResult = await apiAuthMiddleware();
if ("user" in authResult === false) return authResult; if ("user" in authResult === false) return authResult; // Handle error response
const { user } = authResult; const { user } = authResult;
if (!name || !type || !description || !earthquakeCode || !warehouseArea) { if (!name || !type || !description || !location || !earthquakeCode || !warehouseLocation) {
return NextResponse.json({ error: "Missing fields" }, { status: 400 }); return NextResponse.json({ error: "Missing fields" }, { status: 400 });
} }
if (user.role !== "ADMIN" && user.role !== "SCIENTIST") {
return NextResponse.json({ error: "Not authorized" }, { status: 401 });
}
if (user.role === "SCIENTIST") {
const scientist = await prisma.scientist.findUnique({
where: {
userId: user.id,
},
include: {
subordinates: true,
},
});
if (!scientist || scientist.level !== "SENIOR") {
return NextResponse.json({ message: "Not authorised" }, { status: 401 });
}
}
const linkedEarthquake = await prisma.earthquake.findUnique({ where: { code: earthquakeCode } }); const linkedEarthquake = await prisma.earthquake.findUnique({ where: { code: earthquakeCode } });
if (!linkedEarthquake) { if (!linkedEarthquake) {
return NextResponse.json({ error: "Earthquake code not found" }, { status: 400 }); return NextResponse.json({ error: "Earthquake code not found" }, { status: 400 });
} }
let imageName = "NoImageFound.PNG";
if (image) {
const buffer = Buffer.from(await image.arrayBuffer());
const extension = image.type === "image/jpeg" ? "jpg" : "png";
imageName = `${name}-${new Date().toLocaleDateString("en-GB")}.${extension}`;
const imagePath = join(process.cwd(), "public", imageName);
await writeFile(imagePath, buffer);
}
await prisma.artefact.create({ await prisma.artefact.create({
data: { data: {
name, name,
type, type,
description, description,
earthquakeId: linkedEarthquake.id, earthquakeId: linkedEarthquake.id,
warehouseArea: warehouseArea, warehouseArea: warehouseLocation,
imageName, imageName: "NoImageFound.PNG",
creatorId: user.id, creatorId: user.id,
}, },
}); });

View File

@ -229,8 +229,9 @@ export default function Profile() {
} }
setIsDeleting(true); setIsDeleting(true);
try { try {
// todo add delete user route
const res = await axios.post( const res = await axios.post(
"/api/user/delete", "/api/delete-user",
{ userId: user!.id }, { userId: user!.id },
{ headers: { "Content-Type": "application/json" } } { headers: { "Content-Type": "application/json" } }
); );

View File

@ -295,6 +295,7 @@ export default function Shop() {
"#" + Math.random().toString(24).substring(2, 10).toUpperCase() + new Date().toLocaleDateString("en-GB"); "#" + Math.random().toString(24).substring(2, 10).toUpperCase() + new Date().toLocaleDateString("en-GB");
const orderNum = genOrder(); const orderNum = genOrder();
// todo add display of error
(async () => { (async () => {
try { try {
const response = await axios.post("/api/shop/purchase", { const response = await axios.post("/api/shop/purchase", {
@ -308,8 +309,8 @@ export default function Shop() {
setShowThankYouModal(true); setShowThankYouModal(true);
setCart((c) => c.filter((a) => !artefactsToBuy.map((x) => x.id).includes(a.id))); setCart((c) => c.filter((a) => !artefactsToBuy.map((x) => x.id).includes(a.id)));
} catch (error) { } catch (error) {
setError("Payment Failed");
console.error("Error posting artefacts:", error); console.error("Error posting artefacts:", error);
throw error;
} }
})(); })();
} }

View File

@ -1,5 +1,4 @@
"use client"; "use client";
import Image from "next/image";
import axios from "axios"; import axios from "axios";
import useSWR from "swr"; import useSWR from "swr";
import { Dispatch, SetStateAction, useMemo, useState } from "react"; import { Dispatch, SetStateAction, useMemo, useState } from "react";
@ -11,12 +10,6 @@ import { ExtendedArtefact } from "@appTypes/ApiTypes";
import { fetcher } from "@utils/axiosHelpers"; import { fetcher } from "@utils/axiosHelpers";
// Function to validate earthquake code format
const validateEarthquakeCode = (code: string): boolean => {
const pattern = /^E[A-Z]-[0-9]\.[0-9]-[A-Za-z]+-[0-9]{5}$/;
return pattern.test(code);
};
// Filter Component // Filter Component
function FilterInput({ function FilterInput({
value, value,
@ -40,13 +33,14 @@ function FilterInput({
> >
<IoFilter <IoFilter
className={`cursor-pointer text-neutral-500 font-bold group-hover:text-blue-600 className={`cursor-pointer text-neutral-500 font-bold group-hover:text-blue-600
${!showSelectedFilter && value && "text-blue-600"}`} ${!showSelectedFilter && value && "text-blue-600"}
`}
/> />
</div> </div>
<div <div
className={`absolute z-50 mt-2 w-48 bg-white border border-neutral-300 rounded-md shadow-lg p-2 opacity-0 group-hover:opacity-100 group-hover:visible transition-opacity duration-200 pointer-events-none group-hover:pointer-events-auto className={`absolute z-50 mt-2 w-48 bg-white border border-neutral-300 rounded-md shadow-lg p-2 opacity-0 group-hover:opacity-100 group-hover:visible transition-opacity duration-200 pointer-events-none group-hover:pointer-events-auto
${type === "date" ? "-right-1/2" : "-left-1/2"} ${type === "date" ? "-right-1/2" : "-left-1/2"}
`} `}
> >
{options ? ( {options ? (
<div className="max-h-32 overflow-y-auto"> <div className="max-h-32 overflow-y-auto">
@ -78,119 +72,17 @@ function FilterInput({
); );
} }
// Modal Component for Bulk Logging // Modal Component for Logging Artefact
function BulkLogModal({ onClose }: { onClose: () => void }) {
const [palletNote, setPalletNote] = useState("");
const [warehouseArea, setWarehouseArea] = useState("");
const [error, setError] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const handleOverlayClick = (e: { target: any; currentTarget: any }) => {
if (e.target === e.currentTarget) {
onClose();
}
};
async function handleLog() {
if (!palletNote || !warehouseArea) {
setError("All fields are required.");
return;
}
setIsSubmitting(true);
try {
await axios.post("/api/warehouse/log-bulk", {
palletNote,
warehouseArea,
});
alert(`Logged bulk pallet to storage: ${warehouseArea}`);
onClose();
} catch {
setError("Failed to log pallet. Please try again.");
} finally {
setIsSubmitting(false);
}
}
return (
<div
className="fixed inset-0 bg-neutral-900 bg-opacity-50 flex justify-center items-center z-50"
onClick={handleOverlayClick}
>
<div className="bg-white rounded-lg shadow-xl max-w-md w-full p-6 border border-neutral-300">
<h3 className="text-lg font-semibold mb-4 text-neutral-800">Log Bulk Pallet</h3>
{error && <p className="text-red-600 text-sm mb-2">{error}</p>}
<div className="space-y-2">
<textarea
placeholder="Pallet Delivery Note (e.g., 10 lava chunks, 5 ash samples)"
value={palletNote}
onChange={(e) => setPalletNote(e.target.value)}
className="w-full p-2 border border-neutral-300 rounded-md placeholder-neutral-400 focus:ring-2 focus:ring-blue-500 h-24"
aria-label="Pallet Delivery Note"
disabled={isSubmitting}
/>
<input
type="text"
placeholder="Warehouse Area (e.g., B-05)"
value={warehouseArea}
onChange={(e) => setWarehouseArea(e.target.value)}
className="w-full p-2 border border-neutral-300 rounded-md placeholder-neutral-400 focus:ring-2 focus:ring-blue-500"
aria-label="Warehouse Area"
disabled={isSubmitting}
/>
</div>
<div className="flex justify-end gap-3 mt-4">
<button
onClick={onClose}
className="px-4 py-2 bg-neutral-200 text-neutral-800 rounded-md hover:bg-neutral-300 font-medium"
disabled={isSubmitting}
>
Cancel
</button>
<button
onClick={handleLog}
className={`px-4 py-2 bg-blue-600 text-white rounded-md font-medium flex items-center gap-2 ${
isSubmitting ? "opacity-50 cursor-not-allowed" : "hover:bg-blue-700"
}`}
disabled={isSubmitting}
>
{isSubmitting ? (
<>
<svg
className="animate-spin h-5 w-5 text-white"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
Logging...
</>
) : (
"Log Pallet"
)}
</button>
</div>
</div>
</div>
);
}
function LogModal({ onClose }: { onClose: () => void }) { function LogModal({ onClose }: { onClose: () => void }) {
const [name, setName] = useState(""); const [name, setName] = useState("");
const [type, setType] = useState(""); const [type, setType] = useState("");
const [description, setDescription] = useState(""); const [description, setDescription] = useState("");
const [location, setLocation] = useState("");
const [earthquakeCode, setEarthquakeCode] = useState(""); const [earthquakeCode, setEarthquakeCode] = useState("");
const [warehouseArea, setWarehouseArea] = useState(""); const [warehouseLocation, setWarehouseLocation] = useState("");
const [isRequired, setIsRequired] = useState(true); const [isRequired, setIsRequired] = useState(true);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const [image, setImage] = useState<File | null>(null);
const handleOverlayClick = (e: { target: any; currentTarget: any }) => { const handleOverlayClick = (e: { target: any; currentTarget: any }) => {
if (e.target === e.currentTarget) { if (e.target === e.currentTarget) {
@ -198,47 +90,25 @@ function LogModal({ onClose }: { onClose: () => void }) {
} }
}; };
const handleImageChange = (e: React.ChangeEvent<HTMLInputElement>) => { // todo add uploading image
if (e.target.files && e.target.files[0]) {
const file = e.target.files[0];
if (file.size > 5 * 1024 * 1024) {
setError("Image size must be less than 5MB");
return;
}
if (!["image/jpeg", "image/png"].includes(file.type)) {
setError("Only JPEG or PNG images are allowed");
return;
}
setImage(file);
}
};
async function handleLog() { async function handleLog() {
if (!name || !type || !description || !earthquakeCode || !warehouseArea) { if (!name || !type || !description || !location || !earthquakeCode || !warehouseLocation) {
setError("All fields are required."); setError("All fields are required.");
return; return;
} }
if (!validateEarthquakeCode(earthquakeCode)) {
setError("Earthquake Code must be in format: EX-M.M-Country-##### (e.g., EC-3.9-Belgium-05467)");
return;
}
setIsSubmitting(true); setIsSubmitting(true);
try { try {
const formData = new FormData(); await axios.post("/api/warehouse/log", {
formData.append("name", name); name,
formData.append("type", type); type,
formData.append("description", description); description,
formData.append("earthquakeCode", earthquakeCode); location,
formData.append("warehouseArea", warehouseArea); earthquakeCode,
if (image) { warehouseLocation,
formData.append("image", image);
}
await axios.post("/api/warehouse/log", formData, {
headers: { "Content-Type": "multipart/form-data" },
}); });
alert(`Logged ${name} to storage: ${warehouseArea}`); // todo replace with better alert
alert(`Logged ${name} to storage: ${warehouseLocation}`);
onClose(); onClose();
} catch { } catch {
setError("Failed to log artefact. Please try again."); setError("Failed to log artefact. Please try again.");
@ -256,15 +126,6 @@ function LogModal({ onClose }: { onClose: () => void }) {
<h3 className="text-lg font-semibold mb-4 text-neutral-800">Log New Artefact</h3> <h3 className="text-lg font-semibold mb-4 text-neutral-800">Log New Artefact</h3>
{error && <p className="text-red-600 text-sm mb-2">{error}</p>} {error && <p className="text-red-600 text-sm mb-2">{error}</p>}
<div className="space-y-2"> <div className="space-y-2">
<input
type="file"
accept="image/jpeg,image/png"
onChange={handleImageChange}
className="w-full p-2 border border-neutral-300 rounded-md placeholder-neutral-400 focus:ring-2 focus:ring-blue-500"
aria-label="Artefact Image"
disabled={isSubmitting}
/>
<input <input
type="text" type="text"
placeholder="Name" placeholder="Name"
@ -276,7 +137,7 @@ function LogModal({ onClose }: { onClose: () => void }) {
/> />
<input <input
type="text" type="text"
placeholder="Type (e.g., Lava, Tephra, Ash)" placeholder="Type (e.g., Lava, Tephra, Ash"
value={type} value={type}
onChange={(e) => setType(e.target.value)} onChange={(e) => setType(e.target.value)}
className="w-full p-2 border border-neutral-300 rounded-md placeholder-neutral-400 focus:ring-2 focus:ring-blue-500" className="w-full p-2 border border-neutral-300 rounded-md placeholder-neutral-400 focus:ring-2 focus:ring-blue-500"
@ -293,8 +154,18 @@ function LogModal({ onClose }: { onClose: () => void }) {
/> />
<input <input
type="text" type="text"
placeholder="Earthquake Code (e.g., EC-3.9-Belgium-05467)" placeholder="Location"
value={location}
onChange={(e) => setLocation(e.target.value)}
className="w-full p-2 border border-neutral-300 rounded-md placeholder-neutral-400 focus:ring-2 focus:ring-blue-500"
aria-label="Artefact Location"
disabled={isSubmitting}
/>
<input
type="text"
placeholder="Earthquake Code"
value={earthquakeCode} value={earthquakeCode}
// todo check code is correct format
onChange={(e) => setEarthquakeCode(e.target.value)} onChange={(e) => setEarthquakeCode(e.target.value)}
className="w-full p-2 border border-neutral-300 rounded-md placeholder-neutral-400 focus:ring-2 focus:ring-blue-500" className="w-full p-2 border border-neutral-300 rounded-md placeholder-neutral-400 focus:ring-2 focus:ring-blue-500"
aria-label="Earthquake ID" aria-label="Earthquake ID"
@ -302,11 +173,11 @@ function LogModal({ onClose }: { onClose: () => void }) {
/> />
<input <input
type="text" type="text"
placeholder="Warehouse Area (e.g., A-12)" placeholder="Warehouse Location (e.g., A-12)"
value={warehouseArea} value={warehouseLocation}
onChange={(e) => setWarehouseArea(e.target.value)} onChange={(e) => setWarehouseLocation(e.target.value)}
className="w-full p-2 border border-neutral-300 rounded-md placeholder-neutral-400 focus:ring-2 focus:ring-blue-500" className="w-full p-2 border border-neutral-300 rounded-md placeholder-neutral-400 focus:ring-2 focus:ring-blue-500"
aria-label="Warehouse Area" aria-label="Storage Location"
disabled={isSubmitting} disabled={isSubmitting}
/> />
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@ -363,54 +234,137 @@ function LogModal({ onClose }: { onClose: () => void }) {
); );
} }
function EditModal({ artefact, onClose }: { artefact: ExtendedArtefact; onClose: () => void }) { // Modal Component for Bulk Logging
const [name, setName] = useState(artefact.name); function BulkLogModal({ onClose }: { onClose: () => void }) {
const [type, setType] = useState(artefact.type); const [palletNote, setPalletNote] = useState("");
const [description, setDescription] = useState(artefact.description); const [warehouseLocation, setWarehouseLocation] = useState("");
const [warehouseArea, setWarehouseArea] = useState(artefact.warehouseArea);
const [earthquakeCode, setEarthquakeCode] = useState(artefact.earthquakeCode);
const [isRequired, setIsRequired] = useState(artefact.isRequired);
const [isSold, setIsSold] = useState(artefact.isSold);
const [isCollected, setIsCollected] = useState(artefact.isCollected);
const [image, setImage] = useState<File | null>(null);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
function handleOverlayClick(e: { target: any; currentTarget: any }) { const handleOverlayClick = (e: { target: any; currentTarget: any }) => {
if (e.target === e.currentTarget) { if (e.target === e.currentTarget) {
onClose(); onClose();
} }
} };
async function handleSave() { async function handleLog() {
if (!name || !type || !description || !earthquakeCode || !warehouseArea) { if (!palletNote || !warehouseLocation) {
setError("All fields are required."); setError("All fields are required.");
return; return;
} }
if (!validateEarthquakeCode(earthquakeCode)) {
setError("Earthquake Code must be in format: XX-M.M-Country-##### (e.g., EC-3.9-Belgium-05467)");
return;
}
setIsSubmitting(true); setIsSubmitting(true);
try { try {
const formData = new FormData(); await axios.post("/api/warehouse/log-bulk", {
formData.append("id", artefact.id.toString()); palletNote,
formData.append("name", name); warehouseLocation,
formData.append("type", type);
formData.append("description", description);
formData.append("earthquakeCode", earthquakeCode);
formData.append("warehouseArea", warehouseArea);
formData.append("isRequired", isRequired.toString());
formData.append("isSold", isSold.toString());
formData.append("isCollected", isCollected.toString());
if (image) {
formData.append("image", image);
}
await axios.post("/api/warehouse/edit-artefact", formData, {
headers: { "Content-Type": "multipart/form-data" },
}); });
// todo replace with better alert
alert(`Logged bulk pallet to storage: ${warehouseLocation}`);
onClose();
} catch {
setError("Failed to log pallet. Please try again.");
} finally {
setIsSubmitting(false);
}
}
return (
<div
className="fixed inset-0 bg-neutral-900 bg-opacity-50 flex justify-center items-center z-50"
onClick={handleOverlayClick}
>
<div className="bg-white rounded-lg shadow-xl max-w-md w-full p-6 border border-neutral-300">
<h3 className="text-lg font-semibold mb-4 text-neutral-800">Log Bulk Pallet</h3>
{error && <p className="text-red-600 text-sm mb-2">{error}</p>}
<div className="space-y-2">
<textarea
placeholder="Pallet Delivery Note (e.g., 10 lava chunks, 5 ash samples)"
value={palletNote}
onChange={(e) => setPalletNote(e.target.value)}
className="w-full p-2 border border-neutral-300 rounded-md placeholder-neutral-400 focus:ring-2 focus:ring-blue-500 h-24"
aria-label="Pallet Delivery Note"
disabled={isSubmitting}
/>
<input
type="text"
placeholder="Storage Location (e.g., B-05)"
value={warehouseLocation}
onChange={(e) => setWarehouseLocation(e.target.value)}
className="w-full p-2 border border-neutral-300 rounded-md placeholder-neutral-400 focus:ring-2 focus:ring-blue-500"
aria-label="Storage Location"
disabled={isSubmitting}
/>
</div>
<div className="flex justify-end gap-3 mt-4">
<button
onClick={onClose}
className="px-4 py-2 bg-neutral-200 text-neutral-800 rounded-md hover:bg-neutral-300 font-medium"
disabled={isSubmitting}
>
Cancel
</button>
<button
onClick={handleLog}
className={`px-4 py-2 bg-blue-600 text-white rounded-md font-medium flex items-center gap-2 ${
isSubmitting ? "opacity-50 cursor-not-allowed" : "hover:bg-blue-700"
}`}
disabled={isSubmitting}
>
{isSubmitting ? (
<>
<svg
className="animate-spin h-5 w-5 text-white"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
Logging...
</>
) : (
"Log Pallet"
)}
</button>
</div>
</div>
</div>
);
}
// Modal Component for Editing Artefact
function EditModal({ artefact, onClose }: { artefact: ExtendedArtefact; onClose: () => void }) {
const [name, setName] = useState(artefact.name);
const [description, setDescription] = useState(artefact.description);
const [location, setLocation] = useState(artefact.location);
const [earthquakeCode, setEarthquakeCode] = useState(artefact.earthquakeCode);
const [isRequired, setIsRequired] = useState(artefact.isRequired);
const [isSold, setIsSold] = useState(artefact.isSold);
const [isCollected, setIsCollected] = useState(artefact.isCollected);
const [createdAt, setDateAdded] = useState(new Date(artefact.createdAt).toLocaleDateString("en-GB"));
const [error, setError] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const handleOverlayClick = (e: { target: any; currentTarget: any }) => {
if (e.target === e.currentTarget) {
onClose();
}
};
const handleSave = async () => {
if (!name || !description || !location || !earthquakeCode || !createdAt) {
setError("All fields are required.");
return;
}
setIsSubmitting(true);
try {
await new Promise((resolve) => setTimeout(resolve, 500)); // Simulated API call
alert(`Updated artefact ${name}`); alert(`Updated artefact ${name}`);
onClose(); onClose();
} catch { } catch {
@ -418,22 +372,7 @@ function EditModal({ artefact, onClose }: { artefact: ExtendedArtefact; onClose:
} finally { } finally {
setIsSubmitting(false); setIsSubmitting(false);
} }
} };
function handleImageChange(e: React.ChangeEvent<HTMLInputElement>) {
if (e.target.files && e.target.files[0]) {
const file = e.target.files[0];
if (file.size > 5 * 1024 * 1024) {
setError("Image size must be less than 5MB");
return;
}
if (!["image/jpeg", "image/png"].includes(file.type)) {
setError("Only JPEG or PNG images are allowed");
return;
}
setImage(file);
}
}
return ( return (
<div <div
@ -444,46 +383,15 @@ function EditModal({ artefact, onClose }: { artefact: ExtendedArtefact; onClose:
<h3 className="text-lg font-semibold mb-4 text-neutral-800">Edit Artefact</h3> <h3 className="text-lg font-semibold mb-4 text-neutral-800">Edit Artefact</h3>
{error && <p className="text-red-600 text-sm mb-2">{error}</p>} {error && <p className="text-red-600 text-sm mb-2">{error}</p>}
<div className="space-y-2"> <div className="space-y-2">
{artefact.imageName && (
<div className="mb-2">
<Image
src={`/uploads/${artefact.imageName}`}
alt="Artefact"
width={200}
height={200}
className="object-cover rounded-md"
/>
</div>
)}
<input
type="file"
accept="image/jpeg,image/png"
onChange={handleImageChange}
className="w-full p-2 border border-neutral-300 rounded-md placeholder-neutral-400 focus:ring-2 focus:ring-blue-500"
aria-label="Artefact Image"
disabled={isSubmitting}
/>
<p className="text-sm text-neutral-600">Created At: {new Date(artefact.createdAt).toLocaleDateString("en-GB")}</p>
<input <input
type="text" type="text"
placeholder="Name"
value={name} value={name}
onChange={(e) => setName(e.target.value)} onChange={(e) => setName(e.target.value)}
className="w-full p-2 border border-neutral-300 rounded-md placeholder-neutral-400 focus:ring-2 focus:ring-blue-500" className="w-full p-2 border border-neutral-300 rounded-md placeholder-neutral-400 focus:ring-2 focus:ring-blue-500"
aria-label="Artefact Name" aria-label="Artefact Name"
disabled={isSubmitting} disabled={isSubmitting}
/> />
<input
type="text"
placeholder="Type (e.g., Lava, Tephra, Ash)"
value={type}
onChange={(e) => setType(e.target.value)}
className="w-full p-2 border border-neutral-300 rounded-md placeholder-neutral-400 focus:ring-2 focus:ring-blue-500"
aria-label="Artefact Type"
disabled={isSubmitting}
/>
<textarea <textarea
placeholder="Description"
value={description} value={description}
onChange={(e) => setDescription(e.target.value)} onChange={(e) => setDescription(e.target.value)}
className="w-full p-2 border border-neutral-300 rounded-md placeholder-neutral-400 focus:ring-2 focus:ring-blue-500 h-16" className="w-full p-2 border border-neutral-300 rounded-md placeholder-neutral-400 focus:ring-2 focus:ring-blue-500 h-16"
@ -492,20 +400,18 @@ function EditModal({ artefact, onClose }: { artefact: ExtendedArtefact; onClose:
/> />
<input <input
type="text" type="text"
placeholder="Earthquake Code (e.g., EC-3.9-Belgium-05467)" value={location}
value={earthquakeCode} onChange={(e) => setLocation(e.target.value)}
onChange={(e) => setEarthquakeCode(e.target.value)}
className="w-full p-2 border border-neutral-300 rounded-md placeholder-neutral-400 focus:ring-2 focus:ring-blue-500" className="w-full p-2 border border-neutral-300 rounded-md placeholder-neutral-400 focus:ring-2 focus:ring-blue-500"
aria-label="Earthquake ID" aria-label="Artefact Location"
disabled={isSubmitting} disabled={isSubmitting}
/> />
<input <input
type="text" type="text"
placeholder="Warehouse Area (e.g., A-12)" value={earthquakeCode}
value={warehouseArea} onChange={(e) => setEarthquakeCode(e.target.value)}
onChange={(e) => setWarehouseArea(e.target.value)}
className="w-full p-2 border border-neutral-300 rounded-md placeholder-neutral-400 focus:ring-2 focus:ring-blue-500" className="w-full p-2 border border-neutral-300 rounded-md placeholder-neutral-400 focus:ring-2 focus:ring-blue-500"
aria-label="Storage Location" aria-label="Earthquake ID"
disabled={isSubmitting} disabled={isSubmitting}
/> />
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@ -524,7 +430,7 @@ function EditModal({ artefact, onClose }: { artefact: ExtendedArtefact; onClose:
type="checkbox" type="checkbox"
checked={isSold} checked={isSold}
onChange={(e) => setIsSold(e.target.checked)} onChange={(e) => setIsSold(e.target.checked)}
className="w-4 h-4 text-blue-600 border-neutral-300 rounded focus:ring-blue-500" className="h-4 w-4 text-blue-600 border-neutral-300 rounded focus:ring-blue-500"
aria-label="Sold Artefact" aria-label="Sold Artefact"
disabled={isSubmitting} disabled={isSubmitting}
/> />
@ -541,6 +447,14 @@ function EditModal({ artefact, onClose }: { artefact: ExtendedArtefact; onClose:
/> />
<label className="text-sm text-neutral-600">Collected</label> <label className="text-sm text-neutral-600">Collected</label>
</div> </div>
<input
type="date"
value={createdAt}
onChange={(e) => setDateAdded(e.target.value)}
className="w-full p-2 border border-neutral-300 rounded-md focus:ring-2 focus:ring-blue-500"
aria-label="Date Added"
disabled={isSubmitting}
/>
</div> </div>
<div className="flex justify-end gap-3 mt-4"> <div className="flex justify-end gap-3 mt-4">
<button <button
@ -569,7 +483,7 @@ function EditModal({ artefact, onClose }: { artefact: ExtendedArtefact; onClose:
<path <path
className="opacity-75" className="opacity-75"
fill="currentColor" fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" d="M4 12a8 8 0 018-8V723C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path> ></path>
</svg> </svg>
Saving... Saving...

View File

@ -10,6 +10,7 @@ interface AuthModalProps {
} }
export default function AuthModal({ isOpen, onClose }: AuthModalProps) { export default function AuthModal({ isOpen, onClose }: AuthModalProps) {
// todo add login successful message
const [isLogin, setIsLogin] = useState<boolean>(true); const [isLogin, setIsLogin] = useState<boolean>(true);
const modalRef = useRef<HTMLDivElement>(null); const modalRef = useRef<HTMLDivElement>(null);
const [isFailed, setIsFailed] = useState<boolean>(false); const [isFailed, setIsFailed] = useState<boolean>(false);

View File

@ -23,6 +23,8 @@ const COLUMNS = [
{ label: "Date", key: "date" }, { label: "Date", key: "date" },
]; ];
// todo modify slightly
export default function EarthquakeSearchModal({ export default function EarthquakeSearchModal({
open, open,
onClose, onClose,

View File

@ -1,4 +1,4 @@
export function passwordStrengthCheck(password: string): string { export async function passwordStrengthCheck(password: string): Promise<string> {
if (password.length < 8) { if (password.length < 8) {
return "short"; return "short";
} else if (password.length > 16) { } else if (password.length > 16) {
@ -21,24 +21,3 @@ export function passwordStrengthCheck(password: string): string {
} }
return "end of function"; return "end of function";
} }
export function validatePassword(password: string) {
const result = passwordStrengthCheck(password);
switch (result) {
case "short":
return { message: "Password is shorter than 8 characters", status: 400 };
case "long":
return { message: "Password is longer than 16 characters", status: 400 };
case "no lower":
return { message: "Password must contain lowercase letters", status: 400 };
case "no upper":
return { message: "Password must contain uppercase letters", status: 400 };
case "no digit":
return { message: "Password must contain a number", status: 400 };
case "no special":
return { message: "Password must contain a special character (!@#$%^&*)", status: 400 };
default:
return {};
}
}