From e4c93eb02422d75898cd3027f3532a5bc9964c5e Mon Sep 17 00:00:00 2001 From: kennyboy55 Date: Sat, 1 Aug 2026 11:47:21 +0200 Subject: [PATCH] AI Changes: add user management and hashed passwords, admin dashboard, report fixes --- AGENTS.md | 5 + app/auth/validate.ts | 62 ++++++- app/components/header.tsx | 1 + app/routes/admin/new/report.tsx | 18 +- app/routes/admin/route.tsx | 242 ++++++++++++++++++++++++++- app/routes/inventory/layout.tsx | 57 ++++--- app/routes/users/route.tsx | 118 +++++++++++++ app/utils/pdf/pdf.generate.server.ts | 8 +- prisma/schema.prisma | 2 +- prisma/seed.ts | 9 +- schema.zmodel | 4 +- 11 files changed, 481 insertions(+), 45 deletions(-) create mode 100644 app/routes/users/route.tsx diff --git a/AGENTS.md b/AGENTS.md index aadc1c2..ff334a9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,3 +18,8 @@ Architecture: - user (can view the website on their phone) - scanner (the physical raspberry pi with barcode scanner at the fridge) - admin (me, the developer that can access and update everything) + +Building and validation: +`npx remix vite:build` to build the project and check for errors +`npx remix vite:dev` to run the project in live dev mode +`npx remix routes` to validate all routes are present and correct \ No newline at end of file diff --git a/app/auth/validate.ts b/app/auth/validate.ts index 3def7ee..1318ac4 100644 --- a/app/auth/validate.ts +++ b/app/auth/validate.ts @@ -1,9 +1,67 @@ +import { createHash, timingSafeEqual } from "node:crypto"; + import { db } from "~/utils/db.server"; +function normalizePassword(password: string) { + return password.trim(); +} + +export function hashPassword(password: string) { + const normalizedPassword = normalizePassword(password); + return createHash("sha256").update(normalizedPassword).digest("hex"); +} + +function isHashedPassword(value: string) { + return /^[a-f0-9]{64}$/i.test(value); +} + +export function verifyPassword(password: string, storedPassword: string | null | undefined) { + if (!storedPassword) { + return false; + } + + const normalizedPassword = normalizePassword(password); + const hashedPassword = hashPassword(normalizedPassword); + + if (isHashedPassword(storedPassword)) { + const storedBuffer = Buffer.from(storedPassword); + const hashedBuffer = Buffer.from(hashedPassword); + + if (hashedBuffer.length !== storedBuffer.length) { + return false; + } + + return timingSafeEqual(hashedBuffer, storedBuffer); + } + + return normalizePassword(storedPassword) === normalizedPassword; +} + export async function validateCredentials( username: string, password: string ) { - var user = await db.user.findUnique({where:{username: username, password: password}, select:{id: true}}); - return user?.id; + const user = await db.user.findUnique({ + where: { username }, + select: { id: true, password: true }, + }); + + if (!user) { + return null; + } + + const passwordMatches = verifyPassword(password, user.password); + + if (!passwordMatches) { + return null; + } + + if (!isHashedPassword(user.password ?? "")) { + await db.user.update({ + where: { id: user.id }, + data: { password: hashPassword(password) }, + }); + } + + return user.id; } diff --git a/app/components/header.tsx b/app/components/header.tsx index 0395690..135a7d0 100644 --- a/app/components/header.tsx +++ b/app/components/header.tsx @@ -41,6 +41,7 @@ function Header(data: Arguments) { { loggedin ? ( ) : ( diff --git a/app/routes/admin/new/report.tsx b/app/routes/admin/new/report.tsx index 14166a2..7800b8b 100644 --- a/app/routes/admin/new/report.tsx +++ b/app/routes/admin/new/report.tsx @@ -17,23 +17,27 @@ export const action = async ({ const dateEnd = String(form.get("dateEnd")) + ":00.000z" || Date.now().toString(); try{ - const lastIdEntries = await dbe.report.findMany({select: {id: true}, take: 1, orderBy:{createdAt: "desc"}}) const containersWithHistoryAndDrink = await dbe.container.findMany({ where: {checkouts: {some:{AND: [{checkoutAt: {gte: dateStart}}, {checkoutAt: {lte: dateEnd}}]}}}, include: {drink: true, checkouts: {where:{AND: [{checkoutAt: {gte: dateStart}}, {checkoutAt: {lte: dateEnd}}]}}} }); - const reportId = String(lastIdEntries[0] ? lastIdEntries[0].id + 1 : 0).padStart(8, "0"); + const report = await dbe.report.create({ data: { + name: name, + dateStart: dateStart, + dateEnd: dateEnd + } + }); + + const reportId = String(report.id).padStart(8, "0"); const filepath = await createPdf(slugify(name), reportId, dateStart, dateEnd, containersWithHistoryAndDrink); - await dbe.report.create({ data: { - name: name, - dateStart: dateStart, - dateEnd: dateEnd, + await dbe.report.update({ data: { file: filepath - } + }, + where: {id: report.id} }); } diff --git a/app/routes/admin/route.tsx b/app/routes/admin/route.tsx index dab1978..c7becaa 100644 --- a/app/routes/admin/route.tsx +++ b/app/routes/admin/route.tsx @@ -1,10 +1,174 @@ -import { Link } from "@remix-run/react"; -import { ListGroup } from "react-bootstrap"; +import { UserType } from "@prisma/client"; +import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node"; +import { json, redirect } from "@remix-run/node"; +import { Form, Link, useActionData, useLoaderData } from "@remix-run/react"; +import { Alert, Button, Card, Col, Form as BootstrapForm, ListGroup, Row } from "react-bootstrap"; + +import { hashPassword } from "~/auth/validate"; +import { enhance } from "~/utils/db.server"; + +export async function loader({ request }: LoaderFunctionArgs) { + const { dbe } = await enhance(request); + + const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000); + + const [containersWithoutBarcode, checkoutsLastHour, latestCheckout, users] = await Promise.all([ + dbe.container.count({ where: { barcode: null } }), + dbe.history.count({ where: { checkoutAt: { gte: oneHourAgo } } }), + dbe.history.findFirst({ + orderBy: { checkoutAt: "desc" }, + select: { + id: true, + checkoutAt: true, + container: { + select: { + drink: { + select: { + name: true, + }, + }, + }, + }, + }, + }), + dbe.user.findMany({ + select: { id: true, username: true, type: true }, + orderBy: { username: "asc" }, + }), + ]); + + return json({ + containersWithoutBarcode, + checkoutsLastHour, + latestCheckout, + users, + }); +} + +export async function action({ request }: ActionFunctionArgs) { + const { dbe } = await enhance(request); + const form = await request.formData(); + const intent = form.get("intent")?.toString(); + + if (intent === "undo-last-checkout") { + const checkoutId = Number(form.get("checkoutId")); + + if (!checkoutId) { + return json({ error: "No checkout selected." }); + } + + const checkout = await dbe.history.findUnique({ + where: { id: checkoutId }, + select: { container_id: true }, + }); + + if (checkout) { + await dbe.container.update({ + where: { id: checkout.container_id }, + data: { inventory: { increment: 1 } }, + }); + await dbe.history.delete({ where: { id: checkoutId } }); + } + + return redirect("/admin"); + } + + if (intent === "add-user") { + const username = form.get("username")?.toString().trim(); + const password = form.get("password")?.toString() ?? ""; + const type = form.get("type")?.toString(); + + if (!username || !password) { + return json({ error: "Username and password are required." }); + } + + const normalizedType = type === UserType.Admin || type === UserType.Scanner ? type : UserType.User; + + try { + await dbe.user.create({ + data: { + username, + password: hashPassword(password), + type: normalizedType, + }, + }); + + return json({ success: "User added" }); + } catch { + return json({ error: "That username is already in use." }); + } + } + + if (intent === "set-password") { + const userId = Number(form.get("userId")); + const password = form.get("password")?.toString() ?? ""; + + if (!userId || !password) { + return json({ error: "A user and a password are required." }); + } + + const existingUser = await dbe.user.findUnique({ where: { id: userId }, select: { id: true } }); + if (!existingUser) { + return json({ error: "User not found." }); + } + + await dbe.user.update({ + where: { id: userId }, + data: { password: hashPassword(password) }, + }); + + return json({ success: "Password set" }); + } + + return json({ error: "Unknown action." }); +} export default function AdminRoute() { + const loaderData = useLoaderData(); + const actionData = useActionData(); + return (

Admin

+ +

Dashboard

+ + + + + Containers without barcode + {loaderData.containersWithoutBarcode} + + + + + + + Checkouts in the last hour + {loaderData.checkoutsLastHour} + + + + + + + Last checked out + + {loaderData.latestCheckout?.container.drink.name ?? "No recent checkouts"} + + {loaderData.latestCheckout ? ( +
+ + + +
+ ) : null} +
+
+ +
+ +

Quick actions

Add new items Edit or remove items @@ -12,10 +176,80 @@ export default function AdminRoute() { Undo checkout -

Reports

+

Reports

Reports + +

User management

+ {actionData?.error ? {actionData.error} : null} + {actionData?.success ? {actionData.success} : null} + + + + + Add user +
+ + + Username + + + + Password + + + + Role + + + + + + + +
+
+
+ + + + + Set password +
+ + + User + + {loaderData.users.map((user) => ( + + ))} + + + + New password + + + +
+
+
+ +
+ + + + Existing users + + {loaderData.users.map((user) => ( + + {user.username} + {user.type} + + ))} + + +
); - } \ No newline at end of file +} \ No newline at end of file diff --git a/app/routes/inventory/layout.tsx b/app/routes/inventory/layout.tsx index 5f64194..1bf699b 100644 --- a/app/routes/inventory/layout.tsx +++ b/app/routes/inventory/layout.tsx @@ -1,7 +1,6 @@ import { LoaderFunctionArgs, MetaFunction } from "@remix-run/node"; -import { Outlet, json, useFetcher, useLoaderData, useRevalidator } from "@remix-run/react"; -import { useEffect, useState } from "react"; -import { getSession } from "~/auth/session"; +import { Outlet, json, useLoaderData, useRevalidator } from "@remix-run/react"; +import { useEffect, useRef, useState } from "react"; import Header, { HeaderData } from "~/components/header"; import { enhance } from "~/utils/db.server"; @@ -32,18 +31,16 @@ export default function InventoryLayout() { let revalidator = useRevalidator(); const [checkouts, setCheckouts] = useState(data.checkouts); - let fetcher = useFetcher(); - - let shouldFetch = true; + const shouldFetchRef = useRef(true); // User has switched back to the tab const onFocus = () => { - shouldFetch = true; + shouldFetchRef.current = true; }; // User has switched away from the tab (AKA tab is hidden) const onBlur = () => { - shouldFetch = false; + shouldFetchRef.current = false; }; useEffect(() => { @@ -59,30 +56,44 @@ export default function InventoryLayout() { }, []); useEffect(() => { - const timer = setTimeout(() => { + const pollCheckouts = async () => { + if (!shouldFetchRef.current) { + return; + } - if(shouldFetch){ - fetcher.load("/resource/checkouts"); + try { + const response = await fetch("/resource/checkouts", { credentials: "same-origin" }); + if (!response.ok) { + return; + } - if(fetcher.state === "idle"){ - if(!fetcher.data) return; + const payload = await response.json() as { checkouts?: number | string | null }; + const newCheckouts = Number(payload.checkouts); - let newCheckouts = Number(fetcher.data.checkouts); - if(newCheckouts != checkouts){ + if (!Number.isFinite(newCheckouts)) { + return; + } - setCheckouts(newCheckouts); + if (newCheckouts !== checkouts) { + setCheckouts(newCheckouts); - if (revalidator.state === "idle") { - revalidator.revalidate(); - } + if (revalidator.state === "idle") { + revalidator.revalidate(); } } + } catch { + // Ignore transient network errors while the connection is down. } - + }; + + const timer = window.setInterval(() => { + void pollCheckouts(); }, 5000); - - return () => clearTimeout(timer); - }); + + void pollCheckouts(); + + return () => window.clearInterval(timer); + }, [checkouts, revalidator]); return ( <> diff --git a/app/routes/users/route.tsx b/app/routes/users/route.tsx new file mode 100644 index 0000000..9a77d4e --- /dev/null +++ b/app/routes/users/route.tsx @@ -0,0 +1,118 @@ +import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node"; +import { json, redirect } from "@remix-run/node"; +import { Form, useActionData, useLoaderData } from "@remix-run/react"; +import { Alert, Button, Card, Col, Container, Form as BootstrapForm, Row } from "react-bootstrap"; + +import { hashPassword, verifyPassword } from "~/auth/validate"; +import { enhance } from "~/utils/db.server"; + +export async function loader({ request }: LoaderFunctionArgs) { + const { session, dbe } = await enhance(request); + + if (!session.has("user_id")) { + return redirect("/login"); + } + + const user = await dbe.user.findUnique({ + where: { id: Number(session.get("user_id")) }, + select: { id: true, username: true, type: true }, + }); + + if (!user) { + return redirect("/logout"); + } + + return json({ user }); +} + +export async function action({ request }: ActionFunctionArgs) { + const { session, dbe } = await enhance(request); + + if (!session.has("user_id")) { + return redirect("/login"); + } + + const userId = Number(session.get("user_id")); + const form = await request.formData(); + const currentPassword = form.get("currentPassword")?.toString() ?? ""; + const newPassword = form.get("newPassword")?.toString() ?? ""; + const confirmPassword = form.get("confirmPassword")?.toString() ?? ""; + + const user = await dbe.user.findUnique({ + where: { id: userId }, + select: { id: true, password: true }, + }); + + if (!user) { + return json({ error: "User not found." }, { status: 404 }); + } + + const passwordMatches = verifyPassword(currentPassword, user.password); + if (!passwordMatches) { + return json({ error: "Current password is incorrect." }); + } + + if (newPassword.length < 4) { + return json({ error: "New password must be at least 4 characters long." }); + } + + if (newPassword !== confirmPassword) { + return json({ error: "New passwords do not match." }); + } + + await dbe.user.update({ + where: { id: user.id }, + data: { password: hashPassword(newPassword) }, + }); + + return json({ success: "Password updated successfully." }); +} + +export default function UsersRoute() { + const loaderData = useLoaderData(); + const actionData = useActionData(); + + return ( + + + + + + Account settings + + Manage the password for {loaderData.user.username} + + + {actionData?.error ? ( + {actionData.error} + ) : null} + + {actionData?.success ? ( + {actionData.success} + ) : null} + +
+ + Current password + + + + + New password + + + + + Confirm new password + + + + +
+
+
+ +
+
+ ); +} diff --git a/app/utils/pdf/pdf.generate.server.ts b/app/utils/pdf/pdf.generate.server.ts index 1e80ca8..4914dc0 100644 --- a/app/utils/pdf/pdf.generate.server.ts +++ b/app/utils/pdf/pdf.generate.server.ts @@ -34,15 +34,15 @@ export async function createPdf(filename: string, reportId: string, dateStart: s doc.setFontSize(10); doc.text("Selected date range: " + dateStart.slice(0, -8).replace("T", "") + " - " + dateEnd.slice(0,-8).replace("T", ""), 15, 35); - doc.autoTable({ + const table = doc.autoTable({ startY: 40, head: [['Drink', 'Price', 'Amount', 'Total']], body: body, foot: [["", "", "Total", "€" + (Math.round(total*100)/100)]] }); - - let finalY = doc.autoTable.previous.finalY; - + + const finalY = table?.lastAutoTable?.finalY ?? 40; + doc.text("Generated on " + new Date(Date.now()).toString(), 15, finalY + 10); const filepath = "/reports/" + filename + ".pdf"; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 1803be8..5f2b3a2 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -181,5 +181,5 @@ model Report { name String @unique() dateStart DateTime dateEnd DateTime - file String + file String? } diff --git a/prisma/seed.ts b/prisma/seed.ts index 6957cd7..27915b3 100755 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -1,13 +1,18 @@ import { ContainerType, PrismaClient, UserType } from "@prisma/client"; import { enhance } from "@zenstackhq/runtime"; import { BeerStyle, Country, Manufacturer, Section, Soda, User, Wine, WineStyle } from "@zenstackhq/runtime/models"; + +import { hashPassword } from "../app/auth/validate"; + const pr = new PrismaClient(); async function seed() { - await pr.user.upsert({ create: {username: "kenneth", password: "asdf1239", type: UserType.Admin}, update: {password: "asdf1239"}, where: {username: "kenneth"} }); - await pr.user.upsert({ create: {username: "scanner", password: "asdf1239", type: UserType.Scanner}, update: {password: "asdf1239"}, where: {username: "scanner"} }); + const initialPassword = hashPassword("asdf1239"); + + await pr.user.upsert({ create: {username: "kenneth", password: initialPassword, type: UserType.Admin}, update: {password: initialPassword}, where: {username: "kenneth"} }); + await pr.user.upsert({ create: {username: "scanner", password: initialPassword, type: UserType.Scanner}, update: {password: initialPassword}, where: {username: "scanner"} }); const user = await pr.user.findUnique({where: {username: "kenneth"}}) || undefined; // Delete all sessions diff --git a/schema.zmodel b/schema.zmodel index 8c2cee7..5dff8a2 100644 --- a/schema.zmodel +++ b/schema.zmodel @@ -205,7 +205,7 @@ model History { model User { id Int @id @default(autoincrement()) username String @unique - password String @password @omit + password String type UserType sessions Session[] @@ -243,7 +243,7 @@ model Report { dateStart DateTime dateEnd DateTime - file String + file String? @@allow('all', auth().type == Admin) } \ No newline at end of file