AI Changes: add user management and hashed passwords, admin dashboard, report fixes
This commit is contained in:
@@ -18,3 +18,8 @@ Architecture:
|
|||||||
- user (can view the website on their phone)
|
- user (can view the website on their phone)
|
||||||
- scanner (the physical raspberry pi with barcode scanner at the fridge)
|
- scanner (the physical raspberry pi with barcode scanner at the fridge)
|
||||||
- admin (me, the developer that can access and update everything)
|
- 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
@@ -1,9 +1,67 @@
|
|||||||
|
import { createHash, timingSafeEqual } from "node:crypto";
|
||||||
|
|
||||||
import { db } from "~/utils/db.server";
|
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(
|
export async function validateCredentials(
|
||||||
username: string,
|
username: string,
|
||||||
password: string
|
password: string
|
||||||
) {
|
) {
|
||||||
var user = await db.user.findUnique({where:{username: username, password: password}, select:{id: true}});
|
const user = await db.user.findUnique({
|
||||||
return user?.id;
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ function Header(data: Arguments) {
|
|||||||
</Nav>
|
</Nav>
|
||||||
{ loggedin ? (
|
{ loggedin ? (
|
||||||
<Nav>
|
<Nav>
|
||||||
|
<Nav.Link as={Link} to="/users">Users</Nav.Link>
|
||||||
<Nav.Link as={Link} to="/logout">Logout ({username})</Nav.Link>
|
<Nav.Link as={Link} to="/logout">Logout ({username})</Nav.Link>
|
||||||
</Nav>
|
</Nav>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -17,23 +17,27 @@ export const action = async ({
|
|||||||
const dateEnd = String(form.get("dateEnd")) + ":00.000z" || Date.now().toString();
|
const dateEnd = String(form.get("dateEnd")) + ":00.000z" || Date.now().toString();
|
||||||
|
|
||||||
try{
|
try{
|
||||||
const lastIdEntries = await dbe.report.findMany({select: {id: true}, take: 1, orderBy:{createdAt: "desc"}})
|
|
||||||
|
|
||||||
const containersWithHistoryAndDrink = await dbe.container.findMany({
|
const containersWithHistoryAndDrink = await dbe.container.findMany({
|
||||||
where: {checkouts: {some:{AND: [{checkoutAt: {gte: dateStart}}, {checkoutAt: {lte: dateEnd}}]}}},
|
where: {checkouts: {some:{AND: [{checkoutAt: {gte: dateStart}}, {checkoutAt: {lte: dateEnd}}]}}},
|
||||||
include: {drink: true, checkouts: {where:{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);
|
const filepath = await createPdf(slugify(name), reportId, dateStart, dateEnd, containersWithHistoryAndDrink);
|
||||||
|
|
||||||
await dbe.report.create({ data: {
|
await dbe.report.update({ data: {
|
||||||
name: name,
|
|
||||||
dateStart: dateStart,
|
|
||||||
dateEnd: dateEnd,
|
|
||||||
file: filepath
|
file: filepath
|
||||||
}
|
},
|
||||||
|
where: {id: report.id}
|
||||||
});
|
});
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
+237
-3
@@ -1,10 +1,174 @@
|
|||||||
import { Link } from "@remix-run/react";
|
import { UserType } from "@prisma/client";
|
||||||
import { ListGroup } from "react-bootstrap";
|
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() {
|
export default function AdminRoute() {
|
||||||
|
const loaderData = useLoaderData<typeof loader>();
|
||||||
|
const actionData = useActionData<typeof action>();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h1>Admin</h1>
|
<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>
|
||||||
<ListGroup.Item><Link to="/admin/new">Add new items</Link></ListGroup.Item>
|
<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>
|
<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.Item><Link to="/admin/manage/undo-checkout">Undo checkout</Link></ListGroup.Item>
|
||||||
</ListGroup>
|
</ListGroup>
|
||||||
|
|
||||||
<h1>Reports</h1>
|
<h2 className="mt-4">Reports</h2>
|
||||||
<ListGroup>
|
<ListGroup>
|
||||||
<ListGroup.Item><Link to="/admin/reports">Reports</Link></ListGroup.Item>
|
<ListGroup.Item><Link to="/admin/reports">Reports</Link></ListGroup.Item>
|
||||||
</ListGroup>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import { LoaderFunctionArgs, MetaFunction } from "@remix-run/node";
|
import { LoaderFunctionArgs, MetaFunction } from "@remix-run/node";
|
||||||
import { Outlet, json, useFetcher, useLoaderData, useRevalidator } from "@remix-run/react";
|
import { Outlet, json, useLoaderData, useRevalidator } from "@remix-run/react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { getSession } from "~/auth/session";
|
|
||||||
import Header, { HeaderData } from "~/components/header";
|
import Header, { HeaderData } from "~/components/header";
|
||||||
import { enhance } from "~/utils/db.server";
|
import { enhance } from "~/utils/db.server";
|
||||||
|
|
||||||
@@ -32,18 +31,16 @@ export default function InventoryLayout() {
|
|||||||
let revalidator = useRevalidator();
|
let revalidator = useRevalidator();
|
||||||
|
|
||||||
const [checkouts, setCheckouts] = useState(data.checkouts);
|
const [checkouts, setCheckouts] = useState(data.checkouts);
|
||||||
let fetcher = useFetcher();
|
const shouldFetchRef = useRef(true);
|
||||||
|
|
||||||
let shouldFetch = true;
|
|
||||||
|
|
||||||
// User has switched back to the tab
|
// User has switched back to the tab
|
||||||
const onFocus = () => {
|
const onFocus = () => {
|
||||||
shouldFetch = true;
|
shouldFetchRef.current = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
// User has switched away from the tab (AKA tab is hidden)
|
// User has switched away from the tab (AKA tab is hidden)
|
||||||
const onBlur = () => {
|
const onBlur = () => {
|
||||||
shouldFetch = false;
|
shouldFetchRef.current = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -59,30 +56,44 @@ export default function InventoryLayout() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = setTimeout(() => {
|
const pollCheckouts = async () => {
|
||||||
|
if (!shouldFetchRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if(shouldFetch){
|
try {
|
||||||
fetcher.load("/resource/checkouts");
|
const response = await fetch("/resource/checkouts", { credentials: "same-origin" });
|
||||||
|
if (!response.ok) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if(fetcher.state === "idle"){
|
const payload = await response.json() as { checkouts?: number | string | null };
|
||||||
if(!fetcher.data) return;
|
const newCheckouts = Number(payload.checkouts);
|
||||||
|
|
||||||
let newCheckouts = Number(fetcher.data.checkouts);
|
if (!Number.isFinite(newCheckouts)) {
|
||||||
if(newCheckouts != checkouts){
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newCheckouts !== checkouts) {
|
||||||
setCheckouts(newCheckouts);
|
setCheckouts(newCheckouts);
|
||||||
|
|
||||||
if (revalidator.state === "idle") {
|
if (revalidator.state === "idle") {
|
||||||
revalidator.revalidate();
|
revalidator.revalidate();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore transient network errors while the connection is down.
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
|
const timer = window.setInterval(() => {
|
||||||
|
void pollCheckouts();
|
||||||
}, 5000);
|
}, 5000);
|
||||||
|
|
||||||
return () => clearTimeout(timer);
|
void pollCheckouts();
|
||||||
});
|
|
||||||
|
return () => window.clearInterval(timer);
|
||||||
|
}, [checkouts, revalidator]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -34,14 +34,14 @@ export async function createPdf(filename: string, reportId: string, dateStart: s
|
|||||||
doc.setFontSize(10);
|
doc.setFontSize(10);
|
||||||
doc.text("Selected date range: " + dateStart.slice(0, -8).replace("T", "") + " - " + dateEnd.slice(0,-8).replace("T", ""), 15, 35);
|
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,
|
startY: 40,
|
||||||
head: [['Drink', 'Price', 'Amount', 'Total']],
|
head: [['Drink', 'Price', 'Amount', 'Total']],
|
||||||
body: body,
|
body: body,
|
||||||
foot: [["", "", "Total", "€" + (Math.round(total*100)/100)]]
|
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);
|
doc.text("Generated on " + new Date(Date.now()).toString(), 15, finalY + 10);
|
||||||
|
|
||||||
|
|||||||
@@ -181,5 +181,5 @@ model Report {
|
|||||||
name String @unique()
|
name String @unique()
|
||||||
dateStart DateTime
|
dateStart DateTime
|
||||||
dateEnd DateTime
|
dateEnd DateTime
|
||||||
file String
|
file String?
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-2
@@ -1,13 +1,18 @@
|
|||||||
import { ContainerType, PrismaClient, UserType } from "@prisma/client";
|
import { ContainerType, PrismaClient, UserType } from "@prisma/client";
|
||||||
import { enhance } from "@zenstackhq/runtime";
|
import { enhance } from "@zenstackhq/runtime";
|
||||||
import { BeerStyle, Country, Manufacturer, Section, Soda, User, Wine, WineStyle } from "@zenstackhq/runtime/models";
|
import { BeerStyle, Country, Manufacturer, Section, Soda, User, Wine, WineStyle } from "@zenstackhq/runtime/models";
|
||||||
|
|
||||||
|
import { hashPassword } from "../app/auth/validate";
|
||||||
|
|
||||||
const pr = new PrismaClient();
|
const pr = new PrismaClient();
|
||||||
|
|
||||||
|
|
||||||
async function seed() {
|
async function seed() {
|
||||||
|
|
||||||
await pr.user.upsert({ create: {username: "kenneth", password: "asdf1239", type: UserType.Admin}, update: {password: "asdf1239"}, where: {username: "kenneth"} });
|
const initialPassword = hashPassword("asdf1239");
|
||||||
await pr.user.upsert({ create: {username: "scanner", password: "asdf1239", type: UserType.Scanner}, update: {password: "asdf1239"}, where: {username: "scanner"} });
|
|
||||||
|
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;
|
const user = await pr.user.findUnique({where: {username: "kenneth"}}) || undefined;
|
||||||
|
|
||||||
// Delete all sessions
|
// Delete all sessions
|
||||||
|
|||||||
+2
-2
@@ -205,7 +205,7 @@ model History {
|
|||||||
model User {
|
model User {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
username String @unique
|
username String @unique
|
||||||
password String @password @omit
|
password String
|
||||||
type UserType
|
type UserType
|
||||||
|
|
||||||
sessions Session[]
|
sessions Session[]
|
||||||
@@ -243,7 +243,7 @@ model Report {
|
|||||||
dateStart DateTime
|
dateStart DateTime
|
||||||
dateEnd DateTime
|
dateEnd DateTime
|
||||||
|
|
||||||
file String
|
file String?
|
||||||
|
|
||||||
@@allow('all', auth().type == Admin)
|
@@allow('all', auth().type == Admin)
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user