AI Changes: add user management and hashed passwords, admin dashboard, report fixes
Build dev docker image / build (push) Successful in 34s
Build dev docker image / release (push) Successful in 1s

This commit is contained in:
2026-08-01 11:47:21 +02:00
parent b8a20204c7
commit e4c93eb024
11 changed files with 481 additions and 45 deletions
+5
View File
@@ -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
+60 -2
View File
@@ -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;
}
+1
View File
@@ -41,6 +41,7 @@ function Header(data: Arguments) {
</Nav>
{ loggedin ? (
<Nav>
<Nav.Link as={Link} to="/users">Users</Nav.Link>
<Nav.Link as={Link} to="/logout">Logout ({username})</Nav.Link>
</Nav>
) : (
+11 -7
View File
@@ -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}
});
}
+238 -4
View File
@@ -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<typeof loader>();
const actionData = useActionData<typeof action>();
return (
<div>
<h1>Admin</h1>
<h2 className="mt-4">Dashboard</h2>
<Row className="g-3">
<Col md={4}>
<Card className="h-100">
<Card.Body>
<Card.Title>Containers without barcode</Card.Title>
<Card.Text className="display-6">{loaderData.containersWithoutBarcode}</Card.Text>
</Card.Body>
</Card>
</Col>
<Col md={4}>
<Card className="h-100">
<Card.Body>
<Card.Title>Checkouts in the last hour</Card.Title>
<Card.Text className="display-6">{loaderData.checkoutsLastHour}</Card.Text>
</Card.Body>
</Card>
</Col>
<Col md={4}>
<Card className="h-100">
<Card.Body>
<Card.Title>Last checked out</Card.Title>
<Card.Text>
{loaderData.latestCheckout?.container.drink.name ?? "No recent checkouts"}
</Card.Text>
{loaderData.latestCheckout ? (
<Form method="post">
<input type="hidden" name="intent" value="undo-last-checkout" />
<input type="hidden" name="checkoutId" value={loaderData.latestCheckout.id} />
<Button type="submit" variant="outline-danger">Undo</Button>
</Form>
) : null}
</Card.Body>
</Card>
</Col>
</Row>
<h2 className="mt-4">Quick actions</h2>
<ListGroup>
<ListGroup.Item><Link to="/admin/new">Add new items</Link></ListGroup.Item>
<ListGroup.Item><Link to="/admin/edit">Edit or remove items</Link></ListGroup.Item>
@@ -12,10 +176,80 @@ export default function AdminRoute() {
<ListGroup.Item><Link to="/admin/manage/undo-checkout">Undo checkout</Link></ListGroup.Item>
</ListGroup>
<h1>Reports</h1>
<h2 className="mt-4">Reports</h2>
<ListGroup>
<ListGroup.Item><Link to="/admin/reports">Reports</Link></ListGroup.Item>
</ListGroup>
<h2 className="mt-4">User management</h2>
{actionData?.error ? <Alert variant="danger" className="mb-3">{actionData.error}</Alert> : null}
{actionData?.success ? <Alert variant="success" className="mb-3">{actionData.success}</Alert> : null}
<Row className="g-3 mb-4">
<Col lg={6}>
<Card>
<Card.Body>
<Card.Title>Add user</Card.Title>
<Form method="post">
<input type="hidden" name="intent" value="add-user" />
<BootstrapForm.Group className="mb-3">
<BootstrapForm.Label>Username</BootstrapForm.Label>
<BootstrapForm.Control name="username" required />
</BootstrapForm.Group>
<BootstrapForm.Group className="mb-3">
<BootstrapForm.Label>Password</BootstrapForm.Label>
<BootstrapForm.Control type="password" name="password" required />
</BootstrapForm.Group>
<BootstrapForm.Group className="mb-3">
<BootstrapForm.Label>Role</BootstrapForm.Label>
<BootstrapForm.Select name="type" defaultValue={UserType.User}>
<option value={UserType.User}>User</option>
<option value={UserType.Scanner}>Scanner</option>
<option value={UserType.Admin}>Admin</option>
</BootstrapForm.Select>
</BootstrapForm.Group>
<Button type="submit">Create user</Button>
</Form>
</Card.Body>
</Card>
</Col>
<Col lg={6}>
<Card>
<Card.Body>
<Card.Title>Set password</Card.Title>
<Form method="post">
<input type="hidden" name="intent" value="set-password" />
<BootstrapForm.Group className="mb-3">
<BootstrapForm.Label>User</BootstrapForm.Label>
<BootstrapForm.Select name="userId" defaultValue={loaderData.users[0]?.id ?? ""}>
{loaderData.users.map((user) => (
<option key={user.id} value={user.id}>{user.username} ({user.type})</option>
))}
</BootstrapForm.Select>
</BootstrapForm.Group>
<BootstrapForm.Group className="mb-3">
<BootstrapForm.Label>New password</BootstrapForm.Label>
<BootstrapForm.Control type="password" name="password" required />
</BootstrapForm.Group>
<Button type="submit" variant="outline-primary">Update password</Button>
</Form>
</Card.Body>
</Card>
</Col>
</Row>
<Card className="mb-4">
<Card.Body>
<Card.Title>Existing users</Card.Title>
<ListGroup variant="flush">
{loaderData.users.map((user) => (
<ListGroup.Item key={user.id} className="d-flex justify-content-between align-items-center">
<span>{user.username}</span>
<span className="text-muted">{user.type}</span>
</ListGroup.Item>
))}
</ListGroup>
</Card.Body>
</Card>
</div>
);
}
}
+29 -18
View File
@@ -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;
}
if (newCheckouts !== checkouts) {
setCheckouts(newCheckouts);
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 (
<>
+118
View File
@@ -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<typeof loader>();
const actionData = useActionData<typeof action>();
return (
<Container className="py-3">
<Row className="justify-content-center">
<Col md={8} lg={6}>
<Card>
<Card.Body>
<Card.Title>Account settings</Card.Title>
<Card.Subtitle className="mb-3 text-muted">
Manage the password for {loaderData.user.username}
</Card.Subtitle>
{actionData?.error ? (
<Alert variant="danger">{actionData.error}</Alert>
) : null}
{actionData?.success ? (
<Alert variant="success">{actionData.success}</Alert>
) : null}
<Form method="post">
<BootstrapForm.Group className="mb-3">
<BootstrapForm.Label>Current password</BootstrapForm.Label>
<BootstrapForm.Control type="password" name="currentPassword" required />
</BootstrapForm.Group>
<BootstrapForm.Group className="mb-3">
<BootstrapForm.Label>New password</BootstrapForm.Label>
<BootstrapForm.Control type="password" name="newPassword" required />
</BootstrapForm.Group>
<BootstrapForm.Group className="mb-3">
<BootstrapForm.Label>Confirm new password</BootstrapForm.Label>
<BootstrapForm.Control type="password" name="confirmPassword" required />
</BootstrapForm.Group>
<Button type="submit">Save password</Button>
</Form>
</Card.Body>
</Card>
</Col>
</Row>
</Container>
);
}
+2 -2
View File
@@ -34,14 +34,14 @@ 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);
+1 -1
View File
@@ -181,5 +181,5 @@ model Report {
name String @unique()
dateStart DateTime
dateEnd DateTime
file String
file String?
}
+7 -2
View File
@@ -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
+2 -2
View File
@@ -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)
}