completed admin & rfid pages

This commit is contained in:
Aaron Lee 2023-12-23 01:54:27 +08:00
parent af65bb7191
commit 54cdb7e83f
16 changed files with 882 additions and 63 deletions

View file

@ -13,3 +13,4 @@
# https://www.prisma.io/docs/reference/database-reference/connection-urls#env # https://www.prisma.io/docs/reference/database-reference/connection-urls#env
DATABASE_URL="file:./db.sqlite" DATABASE_URL="file:./db.sqlite"
JWT_SECRET="well, now the secret is spoiled, isn't it?" JWT_SECRET="well, now the secret is spoiled, isn't it?"
TZ="Asia/Taipei"

View file

@ -17,7 +17,9 @@ model User {
name String name String
username String @unique username String @unique
password String password String
rfid String? @unique
isAdmin Boolean @default(false) isAdmin Boolean @default(false)
rosterOnly Boolean @default(false)
periods Period[] periods Period[]
@ -31,6 +33,10 @@ model User {
model TimePeriod { model TimePeriod {
id Int @id id Int @id
name String name String
start String
end String
periods Period[] periods Period[]
createdAt DateTime @default(now()) createdAt DateTime @default(now())

View file

@ -14,6 +14,11 @@ export default function DashboardHeader({ url }: { url: string }) {
} }
}, [logout.isSuccess]) }, [logout.isSuccess])
useEffect(() => {
if (session.data?.rosterOnly && !url.startsWith("/dash/admin/roster"))
push("/dash/admin/roster");
}, [session.data])
return ( return (
<div className="text-3xl md:text-5xl font-bold bg-indigo-100"> <div className="text-3xl md:text-5xl font-bold bg-indigo-100">
<div className="p-5 pb-0"> <div className="p-5 pb-0">
@ -28,49 +33,57 @@ export default function DashboardHeader({ url }: { url: string }) {
</button> </button>
</div> </div>
<div className="flex items-center w-full gap-3"> <div className="flex items-center w-full gap-3">
<Link href="/dash"> {
<button className={[ !session?.data?.rosterOnly && (<>
"text-xl px-3 py-2 rounded-lg rounded-b-none focus:ring focus:ring-sky-200 focus:ring-opacity-70", <Link href="/dash">
url == "/dash" ? "bg-sky-600 text-white" : "bg-sky-300 text-black" <button className={[
].join(" ")}> "text-sm md:text-xl px-3 py-2 rounded-lg rounded-b-none focus:ring focus:ring-sky-200 focus:ring-opacity-70",
Time Selection url == "/dash" ? "bg-sky-600 text-white" : "bg-sky-300 text-black"
</button> ].join(" ")}>
</Link> Time Selection
<Link href="/dash/chgPassword"> </button>
<button className={[ </Link>
"text-xl px-3 py-2 rounded-lg rounded-b-none focus:ring focus:ring-sky-200 focus:ring-opacity-70", <Link href="/dash/chgPassword">
url == "/dash/chgPassword" ? "bg-sky-600 text-white" : "bg-sky-300 text-black" <button className={[
].join(" ")}> "text-sm md:text-xl px-3 py-2 rounded-lg rounded-b-none focus:ring focus:ring-sky-200 focus:ring-opacity-70",
Change Password url == "/dash/chgPassword" ? "bg-sky-600 text-white" : "bg-sky-300 text-black"
</button> ].join(" ")}>
</Link> Change Password
</button>
</Link>
</>)
}
{ {
session?.data?.isAdmin && ( session?.data?.isAdmin && (
<> <>
<Link href="/dash/admin">
<button className={[
"text-xl px-3 py-2 rounded-lg rounded-b-none focus:ring focus:ring-emerald-200 focus:ring-opacity-70",
url == "/dash/admin" ? "bg-emerald-600 text-white" : "bg-emerald-200 text-black"
].join(" ")}>
Admin
</button>
</Link>
<Link href="/dash/admin/roster"> <Link href="/dash/admin/roster">
<button className={[ <button className={[
"text-xl px-3 py-2 rounded-lg rounded-b-none focus:ring focus:ring-emerald-200 focus:ring-opacity-70", "text-sm md:text-xl px-3 py-2 rounded-lg rounded-b-none focus:ring focus:ring-emerald-200 focus:ring-opacity-70",
url == "/dash/admin/roster" ? "bg-emerald-600 text-white" : "bg-emerald-200 text-black" url == "/dash/admin/roster" ? "bg-emerald-600 text-white" : "bg-emerald-200 text-black"
].join(" ")}> ].join(" ")}>
Print Roster View Roster
</button>
</Link>
<Link href="/dash/admin/rfid">
<button className={[
"text-xl px-3 py-2 rounded-lg rounded-b-none focus:ring focus:ring-emerald-200 focus:ring-opacity-70",
url == "/dash/admin/rfid" ? "bg-emerald-600 text-white" : "bg-emerald-200 text-black"
].join(" ")}>
RFID Log
</button> </button>
</Link> </Link>
{
!session?.data?.rosterOnly && (<>
<Link href="/dash/admin">
<button className={[
"text-sm md:text-xl px-3 py-2 rounded-lg rounded-b-none focus:ring focus:ring-emerald-200 focus:ring-opacity-70",
url == "/dash/admin" ? "bg-emerald-600 text-white" : "bg-emerald-200 text-black"
].join(" ")}>
Admin
</button>
</Link>
<Link href="/dash/admin/rfid">
<button className={[
"text-sm md:text-xl px-3 py-2 rounded-lg rounded-b-none focus:ring focus:ring-emerald-200 focus:ring-opacity-70",
url == "/dash/admin/rfid" ? "bg-emerald-600 text-white" : "bg-emerald-200 text-black"
].join(" ")}>
RFID Setup
</button>
</Link>
</>)
}
</> </>
) )
} }

View file

@ -0,0 +1,79 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { SubmitHandler, useForm } from "react-hook-form";
import { z } from "zod";
import { api } from "~/utils/api";
import { AddTimePeriodSchema } from "~/utils/types";
export default function Config() {
const timePeriods = api.admin.getTimePeriods.useQuery();
const addTimePeriod = api.admin.addTimePeriod.useMutation();
const deleteTimePeriod = api.admin.deleteTimePeriod.useMutation();
type AddTimePeriodSchemaType = z.infer<typeof AddTimePeriodSchema>;
const { register, handleSubmit, formState: { errors } } = useForm<AddTimePeriodSchemaType>({ resolver: zodResolver(AddTimePeriodSchema) });
const onSubmit: SubmitHandler<AddTimePeriodSchemaType> = async (data, event) => addTimePeriod.mutateAsync(data).then(() => {
event?.target.reset();
timePeriods.refetch();
}).catch((err) => console.log(err));
return (<div>
<section>
<form onSubmit={handleSubmit(onSubmit)}>
<div>
<p className="text-xl font-bold mb-1">Current time periods:</p>
<table className="table-auto border-collapse border w-fit">
<thead>
<tr className="*:p-1 *:border">
<th>ID</th>
<th>Name</th>
<th>Start</th>
<th>End</th>
<th>Operation</th>
</tr>
</thead>
<tbody>
{
timePeriods.data?.map((period) => (
<tr className="*:p-1 *:border" key={period.id}>
<td>{period.id}</td>
<td>{period.name}</td>
<td>{period.start}</td>
<td>{period.end}</td>
<td className="text-center">
<button className="p-1 px-2 bg-red-600 text-white rounded-lg" onClick={(evt) => {
evt.preventDefault();
deleteTimePeriod.mutateAsync(period.id).then(() => timePeriods.refetch());
}} type="button">Delete</button>
</td>
</tr>
))
}
<tr className="*:p-1 *:border">
<td>
<input type="number" className="mt-1 block w-16 rounded-md border-gray-300 shadow-sm focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-70" placeholder="ID" {...register("id")} />
</td>
<td>
<input type="text" className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-70" placeholder="Name" {...register("name")} />
</td>
<td>
<input type="text" className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-70" placeholder="Start" {...register("startTime")} />
</td>
<td>
<input type="text" className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-70" placeholder="End" {...register("endTime")} />
</td>
<td className="text-center">
<button className="p-1 px-2 bg-emerald-600 text-white rounded-lg" type="submit">Add</button>
</td>
</tr>
</tbody>
</table>
{errors.id && <p className="text-red-500">{errors.id.message}</p>}
{errors.startTime && <p className="text-red-500">{errors.startTime.message}</p>}
{errors.endTime && <p className="text-red-500">{errors.endTime.message}</p>}
{errors.name && <p className="text-red-500">{errors.name.message}</p>}
</div>
</form>
</section>
{addTimePeriod.isError && <p className="text-red-500">{addTimePeriod.error.message}</p>}
</div>)
}

View file

@ -0,0 +1,93 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useEffect, useState } from "react";
import { SubmitHandler, useForm } from "react-hook-form";
import { z } from "zod";
import { api } from "~/utils/api";
import { AddPeriods, AddTimePeriodSchema } from "~/utils/types";
export default function Periods() {
const timePeriods = api.admin.getTimePeriods.useQuery();
const periods = api.admin.getPeriods.useQuery();
const addPeriods = api.admin.addPeriods.useMutation();
const enablePeriod = api.admin.enablePeriod.useMutation();
const disablePeriod = api.admin.disablePeriod.useMutation();
type AddPeriodsType = z.infer<typeof AddPeriods>;
const { register, handleSubmit, formState: { errors } } = useForm<AddPeriodsType>({ resolver: zodResolver(AddPeriods) });
const onSubmit: SubmitHandler<AddPeriodsType> = async (data, event) => addPeriods.mutateAsync(data).then(() => {
event?.target.reset();
periods.refetch();
}).catch((err) => console.log(err));
let periodCnt = 0;
const [periodsDataId, setPeriodsDataId] = useState<number[]>([]);
useEffect(() => {
const periodsDataId: number[] = [];
timePeriods.data?.forEach((timePeriod) => {
periodsDataId.push(timePeriod.id);
})
setPeriodsDataId(periodsDataId);
}, [timePeriods.data])
return (<div>
<table className="table-auto border-collapse border-2 border-black w-fit mb-5">
<thead>
<tr className="*:p-1 *:border border-b-2 border-b-black">
<th>Date</th>
{
timePeriods.data?.map((timePeriods) => {
return (<th key={timePeriods.id}>{timePeriods.name} ({timePeriods.start} ~ {timePeriods.end})</th>)
})
}
</tr>
</thead>
<tbody>
{
Object.keys(periods.data || {}).map((date) => {
periodCnt = 0;
return (
<tr className="*:p-1 *:border" key={date}>
<td>{date}</td>
{
timePeriods.data?.map((timePeriod) => {
const thisPeriodCnt = periodCnt;
if (periods.data![date]![periodCnt]?.timePeriodId == timePeriod.id) {
periodCnt++;
return (<td key={timePeriod.id * periodCnt} className="bg-emerald-600 text-white hover:cursor-pointer"
onClick={() => disablePeriod.mutateAsync(periods.data![date]![thisPeriodCnt]!.id!).then(() => periods.refetch())}>
Disable
</td>)
} else {
return (<td key={timePeriod.id * periodCnt} className="bg-yellow-600 text-white hover:cursor-pointer" onClick={() => enablePeriod.mutateAsync({
date: date,
timePeriodId: periodsDataId[thisPeriodCnt]!
}).then(() => periods.refetch())}>
Enable
</td>)
}
})
}
</tr>
)
})
}
</tbody>
</table>
<hr />
<h2 className="text-xl font-bold mt-5">Add Date</h2>
<form onSubmit={handleSubmit(onSubmit)}>
<div className="flex gap-3">
<div className="mb-2">
<label className="block text-md font-medium text-gray-700 mt-2">Date</label>
<input type="text" className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-emerald-300 focus:ring focus:ring-emerald-200 focus:ring-opacity-70" {...register("date")} />
{errors.date && <span className="text-red-500">{errors.date.message}</span>}
</div>
<button className="block w-fit h-fit mt-9 bg-emerald-600 px-3 py-2 rounded text-white focus:ring focus:ring-emerald-200 focus:ring-opacity-70 disabled:bg-emerald-400 mb-5" disabled={addPeriods.isLoading}>
Create Periods
</button>
</div>
{addPeriods.error && <p className="text-red-500 mb-2">{addPeriods.error.message}</p>}
{addPeriods.isSuccess && <p className="text-green-500 mb-2">Add periods success!</p>}
</form>
</div>)
}

View file

@ -0,0 +1,107 @@
import { zodResolver } from "@hookform/resolvers/zod";
import Link from "next/link";
import { SubmitHandler, useForm } from "react-hook-form";
import { z } from "zod";
import { api } from "~/utils/api";
import { AddTimePeriodSchema, AddUserSchema } from "~/utils/types";
export default function Users() {
const users = api.admin.getAllUsers.useQuery();
const addUser = api.admin.addUser.useMutation();
const resetPassword = api.admin.resetPassword.useMutation();
const toggleAdmin = api.admin.toggleUserIsAdmin.useMutation();
const toggleRosterOnly = api.admin.toggleUserRosterOnly.useMutation();
type AddUserSchemaType = z.infer<typeof AddUserSchema>;
const { register, handleSubmit, formState: { errors } } = useForm<AddUserSchemaType>({ resolver: zodResolver(AddUserSchema) });
const onSubmit: SubmitHandler<AddUserSchemaType> = async (data, event) => addUser.mutateAsync(data).then(() => {
event?.target.reset();
users.refetch();
}).catch((err) => console.log(err));
return (<div>
<section>
<div>
<table className="table-auto border-collapse border-2 border-black w-fit">
<thead>
<tr className="*:p-1 *:border border-b-2 border-b-black">
<th>Username</th>
<th>Grade</th>
<th>Class</th>
<th>Number</th>
<th>Name</th>
<th>RFID</th>
<th>Admin?</th>
<th>Roster Only?</th>
<th>Operation</th>
</tr>
</thead>
<tbody>
{
users.data?.map((user) => (
<tr className="*:p-1 *:border" key={user.username}>
<td>{user.username}</td>
<td>{user.grade}</td>
<td>{user.class}</td>
<td>{user.number}</td>
<td>{user.name}</td>
<td>{user.rfid}</td>
<td className="text-sky-600 underline text-center hover:cursor-pointer"
onClick={() => toggleAdmin.mutateAsync(user.username).then(() => users.refetch())}
>{user.isAdmin ? "V" : "X"}</td>
<td className="text-sky-600 underline text-center hover:cursor-pointer"
onClick={() => toggleRosterOnly.mutateAsync(user.username).then(() => users.refetch())}
>{user.rosterOnly ? "V" : "X"}</td>
<td className="text-center">
<button className="p-1 px-2 bg-yellow-600 text-white rounded-lg" onClick={(evt) => {
evt.preventDefault();
resetPassword.mutateAsync(user.username)
.then(() => alert(`Password reset for ${user.username} to password.`))
.catch((err) => alert(err))
}} type="button">Reset Password</button>
</td>
</tr>
))
}
</tbody>
</table>
</div>
</section>
<p className="my-2">For editing rows, please use backend SQL.</p>
<p className="my-2">Roster Only users must also be Admin.</p>
<hr />
<h2 className="text-xl mt-5 mb-2 font-bold">Create new user</h2>
<form onSubmit={handleSubmit(onSubmit)}>
<div className="mb-5">
<label className="block text-md font-medium text-gray-700 mt-2">Username</label>
<input type="text" className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-emerald-300 focus:ring focus:ring-emerald-200 focus:ring-opacity-70" {...register("username")} />
{errors.username && <span className="text-red-500">{errors.username.message}</span>}
</div>
<div className="mb-5">
<label className="block text-md font-medium text-gray-700 mt-2">Grade</label>
<input type="text" className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-emerald-300 focus:ring focus:ring-emerald-200 focus:ring-opacity-70" {...register("grade")} />
{errors.grade && <span className="text-red-500">{errors.grade.message}</span>}
</div>
<div className="mb-5">
<label className="block text-md font-medium text-gray-700 mt-2">Class</label>
<input type="text" className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-emerald-300 focus:ring focus:ring-emerald-200 focus:ring-opacity-70" {...register("class")} />
{errors.class && <span className="text-red-500">{errors.class.message}</span>}
</div>
<div className="mb-5">
<label className="block text-md font-medium text-gray-700 mt-2">Number</label>
<input type="text" className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-emerald-300 focus:ring focus:ring-emerald-200 focus:ring-opacity-70" {...register("number")} />
{errors.number && <span className="text-red-500">{errors.number.message}</span>}
</div>
<div className="mb-5">
<label className="block text-md font-medium text-gray-700 mt-2">Name</label>
<input type="text" className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-emerald-300 focus:ring focus:ring-emerald-200 focus:ring-opacity-70" {...register("name")} />
{errors.name && <span className="text-red-500">{errors.name.message}</span>}
</div>
<button className="bg-emerald-600 px-3 py-2 rounded text-white focus:ring focus:ring-emerald-200 focus:ring-opacity-70 disabled:bg-emerald-400 mb-5" disabled={addUser.isLoading}>
Add User
</button>
<p>Default password is <code className="bg-gray-600 text-white p-1 rounded">password</code>.</p>
</form>
{addUser.isError && <p className="text-red-500">{addUser.error.message}</p>}
</div>)
}

26
src/pages/api/rfid.ts Normal file
View file

@ -0,0 +1,26 @@
import type { NextApiRequest, NextApiResponse } from 'next'
import { z } from 'zod'
import { setLastRfid } from '~/utils/rfid'
type ResponseData = {
message: string
}
export default function handler(
req: NextApiRequest,
res: NextApiResponse<ResponseData>
) {
if ("uid" in req.body) {
try {
z.string().regex(/^[0-9a-f]{8}$/i).parse(req.body.uid)
} catch (e) {
res.status(200).json({ message: 'Invalid UID!' })
return
}
setLastRfid(req.body.uid)
res.status(200).json({ message: 'Received!' })
}
else {
res.status(200).json({ message: 'No UID!' })
}
}

View file

@ -0,0 +1,53 @@
import Head from "next/head";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import DashboardHeader from "~/components/DashboardHeader";
import Config from "~/components/admin/Config";
import Periods from "~/components/admin/Periods";
import Users from "~/components/admin/Users";
import { api } from "~/utils/api";
export default function Dash() {
const { push } = useRouter();
const isLoggedIn = api.admin.isLoggedIn.useQuery();
const [currentPage, setCurrentPage] = useState("config");
useEffect(() => {
if (isLoggedIn.failureCount > 0) {
push("/");
}
}, [isLoggedIn.failureCount])
if (isLoggedIn.isLoading) return <></>
return (<>
<Head>
<title>Savage Tracking</title>
<meta name="description" content="Time tracking app for FRC build season personnel management." />
<link rel="icon" href="/favicon.png" />
</Head>
<main className="">
<DashboardHeader url="/dash/admin" />
<div className="p-5 flex flex-col md:flex-row gap-5">
<nav className="flex flex-grow h-fit md:flex-col w-auto md:max-w-48 border border-emerald-700 rounded-lg">
<button className={[
"p-1 px-2 text-lg font-bold border-r md:border-b rounded-l-lg md:rounded-none md:rounded-t-lg transition-colors",
currentPage === "config" ? "bg-emerald-600 border-none text-white" : "bg-white text-emerald-700"
].join(" ")} onClick={() => setCurrentPage("config")}>Configuration</button>
<button className={[
"p-1 px-2 text-lg font-bold border-r md:border-b transition-colors",
currentPage === "periods" ? "bg-emerald-600 border-none text-white" : "bg-white text-emerald-700"
].join(" ")} onClick={() => setCurrentPage("periods")}>All Periods</button>
<button className={[
"p-1 px-2 text-lg font-bold rounded-r-lg md:rounded-none md:rounded-b-lg transition-colors",
currentPage === "users" ? "bg-emerald-600 border-none text-white" : "bg-white text-emerald-700"
].join(" ")} onClick={() => setCurrentPage("users")}>Users</button>
</nav>
{ currentPage === "config" && <Config /> }
{ currentPage === "users" && <Users /> }
{ currentPage === "periods" && <Periods /> }
</div>
</main>
</>)
}

View file

@ -0,0 +1,79 @@
import Head from "next/head";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import DashboardHeader from "~/components/DashboardHeader";
import { api } from "~/utils/api";
export default function Dash() {
const { push } = useRouter();
const isLoggedIn = api.admin.isLoggedIn.useQuery();
const users = api.admin.getAllUsers.useQuery();
const lastRfid = api.admin.getLastRfid.useQuery();
const setUserRfid = api.admin.setUserRfid.useMutation();
useEffect(() => {
if (isLoggedIn.failureCount > 0) {
push("/");
}
}, [isLoggedIn.failureCount])
useEffect(() => {
if (lastRfid.isSuccess) {
const interval = setInterval(() => {
lastRfid.refetch();
}, 2000);
return () => clearInterval(interval);
}
}, [])
if (isLoggedIn.isLoading) return <></>
return (<>
<Head>
<title>Savage Tracking</title>
<meta name="description" content="Time tracking app for FRC build season personnel management." />
<link rel="icon" href="/favicon.png" />
</Head>
<main className="">
<DashboardHeader url="/dash/admin/rfid" />
<div className="m-5">
<table className="table-auto border-collapse border-2 border-black w-fit">
<thead>
<tr className="*:p-1 *:border border-b-2 border-b-black">
<th>Username</th>
<th>Grade</th>
<th>Class</th>
<th>Number</th>
<th>Name</th>
<th>RFID</th>
<th>Operation</th>
</tr>
</thead>
<tbody>
{
users.data?.map((user) => (
<tr className="*:p-1 *:border" key={user.username}>
<td>{user.username}</td>
<td>{user.grade}</td>
<td>{user.class}</td>
<td>{user.number}</td>
<td>{user.name}</td>
<td>{user.rfid}</td>
<td className="text-emerald-600 underline text-center hover:cursor-pointer" onClick={() => {
setUserRfid.mutateAsync({
username: user.username,
rfid: lastRfid.data || ""
}).then(() => users.refetch())
}}>
Select
</td>
</tr>
))
}
</tbody>
</table>
<h2 className="my-5 text-2xl">Last RFID: <code className="rounded bg-slate-700 text-white p-1 ml-1">{lastRfid.data}</code></h2>
</div>
</main>
</>)
}

View file

@ -0,0 +1,30 @@
import Head from "next/head";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
import DashboardHeader from "~/components/DashboardHeader";
import { api } from "~/utils/api";
export default function Dash() {
const { push } = useRouter();
const isLoggedIn = api.admin.isLoggedIn.useQuery();
useEffect(() => {
if (isLoggedIn.failureCount > 0) {
push("/");
}
}, [isLoggedIn.failureCount])
if (isLoggedIn.isLoading) return <></>
return (<>
<Head>
<title>Savage Tracking</title>
<meta name="description" content="Time tracking app for FRC build season personnel management." />
<link rel="icon" href="/favicon.png" />
</Head>
<main className="">
<DashboardHeader url="/dash/admin/roster" />
</main>
</>)
}

View file

@ -0,0 +1,63 @@
import { zodResolver } from "@hookform/resolvers/zod";
import Head from "next/head";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
import { SubmitHandler, useForm } from "react-hook-form";
import { z } from "zod";
import DashboardHeader from "~/components/DashboardHeader";
import { api } from "~/utils/api";
import { ChgPasswordSchema } from "~/utils/types";
export default function Dash() {
const { push } = useRouter();
const isLoggedIn = api.admin.isLoggedIn.useQuery();
const chgPassword = api.admin.chgPassword.useMutation();
const logout = api.admin.logout.useMutation();
type ChgPasswordSchemaType = z.infer<typeof ChgPasswordSchema>;
const { register, handleSubmit, formState: { errors } } = useForm<ChgPasswordSchemaType>({ resolver: zodResolver(ChgPasswordSchema) });
const onSubmit: SubmitHandler<ChgPasswordSchemaType> = async (data) => chgPassword.mutateAsync(data).catch((err) => console.log(err));
useEffect(() => {
if (isLoggedIn.failureCount > 1) {
push("/");
}
}, [isLoggedIn.failureCount])
if (isLoggedIn.isLoading) return <></>
useEffect(() => {
if (chgPassword.isSuccess) {
logout.mutateAsync().then(() => push("/"));
}
}, [chgPassword.isSuccess])
return (<>
<Head>
<title>Savage Tracking</title>
<meta name="description" content="Time tracking app for FRC build season personnel management." />
<link rel="icon" href="/favicon.png" />
</Head>
<main className="">
<DashboardHeader url="/dash/chgPassword" />
<div className="max-w-[40rem] p-5">
<form onSubmit={handleSubmit(onSubmit)}>
<div className="mb-5">
<label className="block text-md font-medium text-gray-700 mt-2">Old Password</label>
<input type="password" className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-sky-300 focus:ring focus:ring-sky-200 focus:ring-opacity-70" {...register("oldPassword")} />
{errors.oldPassword && <span className="text-red-500">{errors.oldPassword.message}</span>}
</div>
<div className="mb-7">
<label className="block text-md font-medium text-gray-700 mt-2">New Password</label>
<input type="password" className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-sky-300 focus:ring focus:ring-sky-200 focus:ring-opacity-70" {...register("newPassword")} />
{errors.newPassword && <span>{errors.newPassword.message}</span>}
</div>
{chgPassword.isError && <div className="text-red-500 mb-5">{chgPassword.error.message}</div>}
{chgPassword.isSuccess && <div className="text-green-500 mb-5">Success! Please sign in again...</div>}
<button className="bg-sky-600 px-3 py-2 rounded text-white focus:ring focus:ring-sky-200 focus:ring-opacity-70 disabled:bg-sky-400 mb-5" disabled={chgPassword.isLoading}>
Login
</button>
</form>
</div>
</main>
</>)
}

View file

@ -13,13 +13,12 @@ export default function Home() {
const { push } = useRouter(); const { push } = useRouter();
type LoginSchemaType = z.infer<typeof LoginSchema>; type LoginSchemaType = z.infer<typeof LoginSchema>;
const { register, handleSubmit, formState: { errors } } = useForm<LoginSchemaType>({ resolver: zodResolver(LoginSchema) }); const { register, handleSubmit, formState: { errors } } = useForm<LoginSchemaType>({ resolver: zodResolver(LoginSchema) });
const onSubmit: SubmitHandler<LoginSchemaType> = async (data) => const onSubmit: SubmitHandler<LoginSchemaType> = async (data) => login.mutateAsync(data).catch((err) => console.log(err));
login.mutateAsync(data).catch((err) => console.log(err))
useEffect(() => { useEffect(() => {
if (login.isSuccess) { if (login.isSuccess) {
push("/dash") push("/dash")
} }
}, [login.isSuccess]) }, [login.isSuccess]);
return ( return (
<> <>

View file

@ -1,38 +1,40 @@
import { compare, compareSync } from "bcrypt"; import { compareSync, hashSync } from "bcrypt";
import { z } from "zod";
import jwt from "jsonwebtoken"; import jwt from "jsonwebtoken";
import { createTRPCRouter, loggedInProcedure, publicProcedure } from "~/server/api/trpc"; import { adminProcedure, createTRPCRouter, loggedInProcedure, publicProcedure } from "~/server/api/trpc";
import { LoginSchema, PublicUserType } from "~/utils/types"; import { AddPeriods, AddTimePeriodSchema, AddUserSchema, ChgPasswordSchema, LoginSchema, PublicUserType } from "~/utils/types";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { Period } from "@prisma/client";
import { lastGotRfid } from "~/utils/rfid";
export const adminRouter = createTRPCRouter({ export const adminRouter = createTRPCRouter({
isLoggedIn: loggedInProcedure.query(() => true), isLoggedIn: loggedInProcedure.query(() => true),
login: publicProcedure login: publicProcedure
.input(LoginSchema) .input(LoginSchema)
.mutation(async ({ input, ctx }) => await ctx.db.user.findUnique({ .mutation(async ({ input, ctx }) => await ctx.db.user.findUnique({
where: { where: {
username: input.username, username: input.username,
} }
}).then((user) => { }).then((user) => {
const result = compareSync(input.password, user?.password || ""); const result = compareSync(input.password, user?.password || "");
if (result) { if (result) {
const session = PublicUserType.parse(user)!; const session = PublicUserType.parse(user)!;
const token = jwt.sign(session, process.env.JWT_SECRET || "", { expiresIn: "1d" }); const token = jwt.sign(session, process.env.JWT_SECRET || "", { expiresIn: "1d" });
ctx.res.setHeader("Set-Cookie", `token=${token}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${60 * 60 * 24};`); ctx.res.setHeader("Set-Cookie", `token=${token}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${60 * 60 * 24};`);
return { return {
status: "success", status: "success",
message: "Login successful.", message: "Login successful.",
}; };
} else { } else {
throw new Error("Please check your username and password."); throw new Error("Please check your username and password.");
} }
}).catch((err) => { }).catch((err) => {
throw new TRPCError({ throw new TRPCError({
code: "FORBIDDEN", code: "FORBIDDEN",
message: "Please check your username and password.", message: "Please check your username and password.",
})
}) })
})
), ),
logout: loggedInProcedure logout: loggedInProcedure
.mutation(async ({ ctx }) => { .mutation(async ({ ctx }) => {
@ -46,4 +48,240 @@ export const adminRouter = createTRPCRouter({
.query(async ({ ctx }) => { .query(async ({ ctx }) => {
return ctx.session; return ctx.session;
}), }),
chgPassword: loggedInProcedure
.input(ChgPasswordSchema)
.mutation(async ({ input, ctx }) => {
await ctx.db.user.findUnique({
where: {
username: ctx.session?.username,
}
}).then(async (user) => {
const result = compareSync(input.oldPassword, user?.password || "");
if (result) {
await ctx.db.user.update({
where: {
username: ctx.session?.username,
},
data: {
password: hashSync(input.newPassword, 10),
},
});
return {
status: "success",
message: "Password changed successfully.",
};
} else {
throw new TRPCError({
code: "FORBIDDEN",
message: "Please check your old password.",
})
}
}).catch((err) => {
throw new TRPCError({
code: "FORBIDDEN",
message: "Please check your old password.",
})
})
}),
getTimePeriods: loggedInProcedure
.query(async ({ ctx }) => {
return await ctx.db.timePeriod.findMany({
orderBy: {
id: "asc",
},
});
}),
addTimePeriod: adminProcedure
.input(AddTimePeriodSchema)
.mutation(async ({ input, ctx }) => {
return await ctx.db.timePeriod.create({
data: {
id: input.id,
name: input.name,
start: input.startTime,
end: input.endTime,
},
});
}),
deleteTimePeriod: adminProcedure
.input(z.number().int())
.mutation(async ({ input, ctx }) => {
return await ctx.db.timePeriod.delete({
where: {
id: input,
},
});
}),
getAllUsers: adminProcedure
.query(async ({ ctx }) => {
return await ctx.db.user.findMany({
orderBy: {
username: "asc",
},
select: {
username: true,
name: true,
grade: true,
class: true,
number: true,
isAdmin: true,
rosterOnly: true,
rfid: true,
}
});
}),
resetPassword: adminProcedure
.input(z.string())
.mutation(async ({ input, ctx }) => {
return await ctx.db.user.update({
where: {
username: input,
},
data: {
password: hashSync("password", 10),
},
});
}),
toggleUserIsAdmin: adminProcedure
.input(z.string())
.mutation(async ({ input, ctx }) => {
return await ctx.db.user.update({
where: {
username: input,
},
data: {
isAdmin: await ctx.db.user.findUnique({
where: {
username: input,
},
}).then((user) => {
return !user?.isAdmin;
}),
},
});
}),
toggleUserRosterOnly: adminProcedure
.input(z.string())
.mutation(async ({ input, ctx }) => {
return await ctx.db.user.update({
where: {
username: input,
},
data: {
rosterOnly: await ctx.db.user.findUnique({
where: {
username: input,
},
}).then((user) => {
return !user?.rosterOnly;
}),
},
});
}),
addUser: adminProcedure
.input(AddUserSchema)
.mutation(async ({ input, ctx }) => {
return await ctx.db.user.create({
data: {
username: input.username,
name: input.name,
grade: input.grade,
class: input.class,
number: input.number,
password: hashSync("password", 10),
},
});
}),
getPeriods: loggedInProcedure
.query(async ({ ctx }) => {
return await ctx.db.period.findMany({
orderBy: [{
date: "asc",
}, {
timePeriodId: "asc",
}],
}).then((periods) => {
const groupedPeriods: { [key: string]: Period[] } = {};
periods.forEach((period) => {
const utcDate = period.date.toLocaleDateString();
if (groupedPeriods[utcDate] === undefined) {
groupedPeriods[utcDate] = [];
}
groupedPeriods[utcDate]?.push(period);
});
return groupedPeriods;
}).catch((err) => {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "There was an error getting the periods.",
})
})
}),
addPeriods: adminProcedure
.input(AddPeriods)
.mutation(async ({ input, ctx }) => {
await ctx.db.timePeriod.findMany().then(
async (timePeriods) => {
timePeriods.forEach(async (timePeriod) => {
await ctx.db.period.create({
data: {
date: new Date(input.date),
timePeriodId: timePeriod.id,
},
});
});
}
).then(() => {
return {
status: "success",
message: "Periods added successfully.",
};
}).catch((err) => {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "There was an error adding the periods.",
})
})
}),
enablePeriod: adminProcedure
.input(z.object({
date: z.string(),
timePeriodId: z.number().int(),
}))
.mutation(async ({ input, ctx }) => {
return await ctx.db.period.create({
data: {
date: new Date(input.date),
timePeriodId: input.timePeriodId,
},
})
}),
disablePeriod: adminProcedure
.input(z.number().int())
.mutation(async ({ input, ctx }) => {
return await ctx.db.period.delete({
where: {
id: input,
},
})
}),
getLastRfid: adminProcedure
.query(() => {
return lastGotRfid;
}),
setUserRfid: adminProcedure
.input(z.object({
username: z.string(),
rfid: z.string(),
}))
.mutation(async ({ input, ctx }) => {
return await ctx.db.user.update({
where: {
username: input.username,
},
data: {
rfid: input.rfid,
},
})
}),
}); });

3
src/utils/config.ts Normal file
View file

@ -0,0 +1,3 @@
export const config = {
latestSignup: "2024-01-01T00:00:00.000Z",
}

4
src/utils/rfid.ts Normal file
View file

@ -0,0 +1,4 @@
export let lastGotRfid = "";
export const setLastRfid = (uid: string) => {
lastGotRfid = uid;
};

View file

@ -6,9 +6,34 @@ export const PublicUserType = z.object({
name: z.string(), name: z.string(),
username: z.string(), username: z.string(),
isAdmin: z.boolean(), isAdmin: z.boolean(),
rosterOnly: z.boolean().default(false),
}).or(z.undefined()); }).or(z.undefined());
export const LoginSchema = z.object({ export const LoginSchema = z.object({
username: z.string().min(1, { message: "Username cannot be empty." }), username: z.string().min(1, { message: "Username cannot be empty." }),
password: z.string(), password: z.string(),
}) })
export const ChgPasswordSchema = z.object({
oldPassword: z.string().min(1, { message: "Old password cannot be empty." }),
newPassword: z.string().min(8, { message: "New password must be at least 8 characters long." }),
})
export const AddTimePeriodSchema = z.object({
id: z.coerce.number().int({ message: "ID must be an integer." }).min(1, { message: "ID must be greater than 0." }).max(999, { message: "ID must be less than 1000." }),
name: z.string().min(1, { message: "Name cannot be empty." }),
startTime: z.string().regex(/^(0[0-9]|1[0-9]|2[0-3]):[0-5][0-9]$/, { message: "Start time must be in the format HH:MM."}),
endTime: z.string().regex(/^(0[0-9]|1[0-9]|2[0-3]):[0-5][0-9]$/, { message: "End time must be in the format HH:MM."}),
})
export const AddUserSchema = z.object({
username: z.string().min(1, { message: "Username cannot be empty." }),
name: z.string().min(1, { message: "Name cannot be empty." }),
grade: z.coerce.number().int().nullable(),
class: z.string().nullable(),
number: z.coerce.number().int().nullable(),
})
export const AddPeriods = z.object({
date: z.string().regex(/^20[2-3][0-9]\/(0?[1-9]|1[0-2]){1}\/(0?[1-9]|1[0-9]|2[0-9]|3[0-1]){1}$/, { message: "Periods must be in the format YYYY/MM/DD."})
})