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
+
+
+
+
+
+
+
+ Set 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