diff --git a/.gitignore b/.gitignore index 333f236..ef17a3c 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ node-v24.14.0-x64.msi postgresql-17.9-2-windows-x64.exe VSCodeUserSetup-x64-1.112.0.exe nssm.exe +frontend/.tanstack # Logs logs diff --git a/frontend/src/components/Sidebar/AdminBar.tsx b/frontend/src/components/Sidebar/AdminBar.tsx index 45a9eac..0ea5444 100644 --- a/frontend/src/components/Sidebar/AdminBar.tsx +++ b/frontend/src/components/Sidebar/AdminBar.tsx @@ -68,7 +68,7 @@ export default function AdminSidebar({ session }: any) { title: "Scan users", url: "/admin/scanUsers", icon: UsersRound, - role: ["systemAdmin", "admin"], + role: ["systemAdmin", "admin", "manager"], module: "admin", active: true, }, @@ -79,9 +79,9 @@ export default function AdminSidebar({ session }: any) { {items.map((item) => ( - <> + {item.role.includes(session.user.role) && ( - + setOpen(false)}> @@ -90,7 +90,7 @@ export default function AdminSidebar({ session }: any) { )} - > + ))} diff --git a/frontend/src/components/ui/button.tsx b/frontend/src/components/ui/button.tsx index aab525f..6138844 100644 --- a/frontend/src/components/ui/button.tsx +++ b/frontend/src/components/ui/button.tsx @@ -1,64 +1,67 @@ -import { cva, type VariantProps } from "class-variance-authority"; -import { Slot } from "radix-ui"; -import type * as React from "react"; +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" +import { Slot } from "radix-ui" -import { cn } from "@/lib/utils"; +import { cn } from "@/lib/utils" const buttonVariants = cva( - "inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", - { - variants: { - variant: { - default: "bg-primary text-primary-foreground hover:bg-primary/90", - destructive: - "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40", - outline: - "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50", - secondary: - "bg-secondary text-secondary-foreground hover:bg-secondary/80", - ghost: - "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", - link: "text-primary underline-offset-4 hover:underline", - }, - size: { - default: "h-9 px-4 py-2 has-[>svg]:px-3", - xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3", - sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5", - lg: "h-10 rounded-md px-6 has-[>svg]:px-4", - icon: "size-9", - "icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3", - "icon-sm": "size-8", - "icon-lg": "size-10", - }, - }, - defaultVariants: { - variant: "default", - size: "default", - }, - }, -); + "group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80", + outline: + "border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50", + secondary: + "bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground", + ghost: + "hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50", + destructive: + "bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: + "h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", + xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3", + sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5", + lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", + icon: "size-8", + "icon-xs": + "size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3", + "icon-sm": + "size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg", + "icon-lg": "size-9", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +) function Button({ - className, - variant = "default", - size = "default", - asChild = false, - ...props + className, + variant = "default", + size = "default", + asChild = false, + ...props }: React.ComponentProps<"button"> & - VariantProps & { - asChild?: boolean; - }) { - const Comp = asChild ? Slot.Root : "button"; + VariantProps & { + asChild?: boolean + }) { + const Comp = asChild ? Slot.Root : "button" - return ( - - ); + return ( + + ) } -export { Button, buttonVariants }; +export { Button, buttonVariants } diff --git a/frontend/src/components/ui/dialog.tsx b/frontend/src/components/ui/dialog.tsx new file mode 100644 index 0000000..c44b1db --- /dev/null +++ b/frontend/src/components/ui/dialog.tsx @@ -0,0 +1,166 @@ +import * as React from "react" +import { Dialog as DialogPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { XIcon } from "lucide-react" + +function Dialog({ + ...props +}: React.ComponentProps) { + return +} + +function DialogTrigger({ + ...props +}: React.ComponentProps) { + return +} + +function DialogPortal({ + ...props +}: React.ComponentProps) { + return +} + +function DialogClose({ + ...props +}: React.ComponentProps) { + return +} + +function DialogOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DialogContent({ + className, + children, + showCloseButton = true, + ...props +}: React.ComponentProps & { + showCloseButton?: boolean +}) { + return ( + + + + {children} + {showCloseButton && ( + + + + Close + + + )} + + + ) +} + +function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( + + ) +} + +function DialogFooter({ + className, + showCloseButton = false, + children, + ...props +}: React.ComponentProps<"div"> & { + showCloseButton?: boolean +}) { + return ( + + {children} + {showCloseButton && ( + + Close + + )} + + ) +} + +function DialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogOverlay, + DialogPortal, + DialogTitle, + DialogTrigger, +} diff --git a/frontend/src/lib/queries/getScannerIds.ts b/frontend/src/lib/queries/getScannerIds.ts new file mode 100644 index 0000000..6a8eb77 --- /dev/null +++ b/frontend/src/lib/queries/getScannerIds.ts @@ -0,0 +1,25 @@ +import { keepPreviousData, queryOptions } from "@tanstack/react-query"; +import axios from "axios"; + +export function getScannerIds() { + return queryOptions({ + queryKey: ["getScannerIds"], + queryFn: () => fetch(), + staleTime: 5000, + refetchOnWindowFocus: true, + placeholderData: keepPreviousData, + }); +} + +const fetch = async () => { + if (window.location.hostname === "localhost") { + await new Promise((res) => setTimeout(res, 1500)); + } + + const { data } = await axios.get("/lst/api/mobile/available", { + withCredentials: true, + timeout: 5000, + }); + + return data.data; +}; diff --git a/frontend/src/routeTree.gen.ts b/frontend/src/routeTree.gen.ts index acf76fe..77a86da 100644 --- a/frontend/src/routeTree.gen.ts +++ b/frontend/src/routeTree.gen.ts @@ -13,6 +13,7 @@ import { Route as AboutRouteImport } from './routes/about' import { Route as IndexRouteImport } from './routes/index' import { Route as DocsIndexRouteImport } from './routes/docs/index' import { Route as DocsSplatRouteImport } from './routes/docs/$' +import { Route as AdminUsersRouteImport } from './routes/admin/users' import { Route as AdminSettingsRouteImport } from './routes/admin/settings' import { Route as AdminServersRouteImport } from './routes/admin/servers' import { Route as AdminScanUsersRouteImport } from './routes/admin/scanUsers' @@ -43,6 +44,11 @@ const DocsSplatRoute = DocsSplatRouteImport.update({ path: '/docs/$', getParentRoute: () => rootRouteImport, } as any) +const AdminUsersRoute = AdminUsersRouteImport.update({ + id: '/admin/users', + path: '/admin/users', + getParentRoute: () => rootRouteImport, +} as any) const AdminSettingsRoute = AdminSettingsRouteImport.update({ id: '/admin/settings', path: '/admin/settings', @@ -98,6 +104,7 @@ export interface FileRoutesByFullPath { '/admin/scanUsers': typeof AdminScanUsersRoute '/admin/servers': typeof AdminServersRoute '/admin/settings': typeof AdminSettingsRoute + '/admin/users': typeof AdminUsersRoute '/docs/$': typeof DocsSplatRoute '/docs/': typeof DocsIndexRoute '/user/profile': typeof authUserProfileRoute @@ -113,6 +120,7 @@ export interface FileRoutesByTo { '/admin/scanUsers': typeof AdminScanUsersRoute '/admin/servers': typeof AdminServersRoute '/admin/settings': typeof AdminSettingsRoute + '/admin/users': typeof AdminUsersRoute '/docs/$': typeof DocsSplatRoute '/docs': typeof DocsIndexRoute '/user/profile': typeof authUserProfileRoute @@ -129,6 +137,7 @@ export interface FileRoutesById { '/admin/scanUsers': typeof AdminScanUsersRoute '/admin/servers': typeof AdminServersRoute '/admin/settings': typeof AdminSettingsRoute + '/admin/users': typeof AdminUsersRoute '/docs/$': typeof DocsSplatRoute '/docs/': typeof DocsIndexRoute '/(auth)/user/profile': typeof authUserProfileRoute @@ -146,6 +155,7 @@ export interface FileRouteTypes { | '/admin/scanUsers' | '/admin/servers' | '/admin/settings' + | '/admin/users' | '/docs/$' | '/docs/' | '/user/profile' @@ -161,6 +171,7 @@ export interface FileRouteTypes { | '/admin/scanUsers' | '/admin/servers' | '/admin/settings' + | '/admin/users' | '/docs/$' | '/docs' | '/user/profile' @@ -176,6 +187,7 @@ export interface FileRouteTypes { | '/admin/scanUsers' | '/admin/servers' | '/admin/settings' + | '/admin/users' | '/docs/$' | '/docs/' | '/(auth)/user/profile' @@ -192,6 +204,7 @@ export interface RootRouteChildren { AdminScanUsersRoute: typeof AdminScanUsersRoute AdminServersRoute: typeof AdminServersRoute AdminSettingsRoute: typeof AdminSettingsRoute + AdminUsersRoute: typeof AdminUsersRoute DocsSplatRoute: typeof DocsSplatRoute DocsIndexRoute: typeof DocsIndexRoute authUserProfileRoute: typeof authUserProfileRoute @@ -229,6 +242,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof DocsSplatRouteImport parentRoute: typeof rootRouteImport } + '/admin/users': { + id: '/admin/users' + path: '/admin/users' + fullPath: '/admin/users' + preLoaderRoute: typeof AdminUsersRouteImport + parentRoute: typeof rootRouteImport + } '/admin/settings': { id: '/admin/settings' path: '/admin/settings' @@ -304,6 +324,7 @@ const rootRouteChildren: RootRouteChildren = { AdminScanUsersRoute: AdminScanUsersRoute, AdminServersRoute: AdminServersRoute, AdminSettingsRoute: AdminSettingsRoute, + AdminUsersRoute: AdminUsersRoute, DocsSplatRoute: DocsSplatRoute, DocsIndexRoute: DocsIndexRoute, authUserProfileRoute: authUserProfileRoute, diff --git a/frontend/src/routes/admin/-components/NewScanUser.tsx b/frontend/src/routes/admin/-components/NewScanUser.tsx new file mode 100644 index 0000000..ff05f94 --- /dev/null +++ b/frontend/src/routes/admin/-components/NewScanUser.tsx @@ -0,0 +1,161 @@ +import { useSuspenseQuery } from "@tanstack/react-query"; +import axios from "axios"; +import { useState } from "react"; +import { toast } from "sonner"; +import { Button } from "../../../components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "../../../components/ui/dialog"; +import { useAppForm } from "../../../lib/formSutff"; +import { getScannerIds } from "../../../lib/queries/getScannerIds"; + +export default function NewScanUser({ refetch }: { refetch: any }) { + const [open, setOpen] = useState(false); + const { data, refetch: scannerFetch } = useSuspenseQuery(getScannerIds()); + const form = useAppForm({ + defaultValues: { + name: "", + scannerId: "", + pinNumber: "", + }, + onSubmit: async ({ value }) => { + if (value.scannerId === "") { + toast.error( + "Scanner id is required please select a scanner id before submitting ", + ); + return; + } + + try { + const { data } = await axios.post( + "/lst/api/mobile/auth/user", + { + name: value.name, + pinNumber: value.pinNumber, + scannerId: value.scannerId, + }, + { + withCredentials: true, + timeout: 15000, + validateStatus: () => true, + }, + ); + + if (data.success) { + toast.success( + `${value.name}, was just created and can now log into the scanner with PIN: ${value.pinNumber}`, + ); + form.reset(); + setOpen(false); + refetch(); + } + + if (!data.success) { + toast.error(data.message); + return; + } + } catch (error) { + console.error(error); + } + }, + }); + + const closeModel = (e: boolean) => { + setOpen(e); + + if (!e) { + form.reset(); + scannerFetch(); + } + }; + + const openForm = () => { + setOpen(true); + scannerFetch(); + }; + + let n: any = []; + if (data) { + n = data.map((i: any) => ({ + label: i.label, + value: i.value.toString(), + })); + } + + return ( + closeModel(e)} open={open}> + Create new user + + + + Create New Scan user. + + + { + e.preventDefault(); + form.handleSubmit(); + }} + > + + + {(field) => ( + + )} + + + + + + {(field) => ( + + )} + + + + + + {(field) => ( + + )} + + + + { + const { data } = await axios.get("/lst/api/mobile/pin/new"); + + form.setFieldValue("pinNumber", data.data[0].pin); + }} + > + New Pin + + + + + + Submit + + + + + + ); +} diff --git a/frontend/src/routes/admin/scanUsers.tsx b/frontend/src/routes/admin/scanUsers.tsx index 73ea8c5..67c61f6 100644 --- a/frontend/src/routes/admin/scanUsers.tsx +++ b/frontend/src/routes/admin/scanUsers.tsx @@ -1,16 +1,258 @@ -import { useSuspenseQuery } from "@tanstack/react-query"; -import { createFileRoute } from "@tanstack/react-router"; +import { useMutation, useSuspenseQuery } from "@tanstack/react-query"; +import { createFileRoute, redirect } from "@tanstack/react-router"; +import { createColumnHelper } from "@tanstack/react-table"; +import axios from "axios"; +import { format } from "date-fns-tz"; +import { CircleFadingArrowUp, Trash } from "lucide-react"; +import { Suspense, useState } from "react"; +import { toast } from "sonner"; +import { Button } from "../../components/ui/button"; +import { Spinner } from "../../components/ui/spinner"; +import { authClient } from "../../lib/auth-client"; import { getScanUsers } from "../../lib/queries/getScanUsers"; +import EditableCellInput from "../../lib/tableStuff/EditableCellInput"; +import LstTable from "../../lib/tableStuff/LstTable"; +import SearchableHeader from "../../lib/tableStuff/SearchableHeader"; +import SkellyTable from "../../lib/tableStuff/SkellyTable"; +import NewScanUser from "./-components/NewScanUser"; export const Route = createFileRoute("/admin/scanUsers")({ + beforeLoad: async ({ location }) => { + const { data: session } = await authClient.getSession(); + const allowedRole = ["systemAdmin", "admin"]; + + if (!session?.user) { + throw redirect({ + to: "/", + search: { + redirect: location.href, + }, + }); + } + + if (!allowedRole.includes(session.user.role as string)) { + throw redirect({ + to: "/", + }); + } + + return { user: session.user }; + }, component: RouteComponent, }); -const ScanUserTable = () => { - const { data } = useSuspenseQuery(getScanUsers()); - console.log(data); - return Hello "/admin/scanUsers"!; +const updateSettings = async ( + id: string, + data: Record, +) => { + //console.log(id, data); + try { + const res = await axios.patch(`/lst/api/mobile/auth/user/${id}`, data, { + withCredentials: true, + timeout: 15000, + validateStatus: () => true, + }); + toast.success(`User was just updated`); + return res; + } catch (err) { + toast.error("Error in updating the user"); + return err; + } }; + +const ScanUserTable = () => { + const { data, refetch } = useSuspenseQuery(getScanUsers()); + const columnHelper = createColumnHelper(); + + const updateSetting = useMutation({ + mutationFn: ({ + id, + field, + value, + }: { + id: string; + field: string; + value: string | number | boolean | null; + }) => updateSettings(id, { [field]: value }), + + onSuccess: () => { + // refetch or update cache + refetch(); + }, + }); + + const columns = [ + columnHelper.accessor("name", { + header: ({ column }) => ( + + ), + filterFn: "includesString", + cell: (i) => i.getValue(), + }), + columnHelper.accessor("scannerId", { + header: ({ column }) => ( + + ), + filterFn: "includesString", + cell: (i) => i.getValue(), + }), + columnHelper.accessor("pinNumber", { + header: ({ column }) => ( + + ), + + filterFn: "includesString", + cell: ({ row, getValue }) => ( + + + { + updateSetting.mutate({ id, field, value }); + }} + /> + + + + { + const { data } = await axios.get("/lst/api/mobile/pin/new"); + updateSetting.mutate({ + id: row.original.id, + field: "pinNumber", + value: data.data[0].pin, + }); + }} + > + New Pin + + + + ), + }), + columnHelper.accessor("lastScan", { + header: ({ column }) => ( + + ), + cell: (i) => {format(i.getValue(), "M/d/yyyy HH:mm")}, + }), + columnHelper.accessor("excludedCommand", { + header: ({ column }) => ( + + ), + cell: (i) => { + const commands = i.getValue().join(); + return ( + {commands === "" ? "All commands allowed" : commands} + ); + }, + }), + columnHelper.accessor("deleteUser", { + header: ({ column }) => ( + + ), + filterFn: "includesString", + cell: (i) => { + // biome-ignore lint: just removing the lint for now to get this going will maybe fix later + const [activeToggle, setActiveToggle] = useState(false); + + const onTrigger = async () => { + setActiveToggle(true); + + try { + const res = await axios.delete( + `/lst/api/mobile/auth/user/${i.row.original.id}`, + + { + withCredentials: true, + timeout: 5000, + validateStatus: () => true, + }, + ); + + if (res.data.success) { + toast.success(`${i.row.original.name} was deleted.`); + refetch(); + setActiveToggle(false); + } + + if (!res.data.success) { + toast.error( + `${i.row.original.name} encountered an error when trying to delete: ${res.data.message}`, + ); + refetch(); + setActiveToggle(false); + } + } catch (error) { + setActiveToggle(false); + console.error(error); + } + }; + + return ( + + + + {activeToggle ? ( + + + + ) : ( + + + + )} + + + + ); + }, + }), + ]; + + return ( + + + + Loading... + + } + > + + + + + + + + ); +}; + +// const NewUserForm = ()=>{ +// const { data, refetch } = useSuspenseQuery(getScanUsers()); +// } function RouteComponent() { - return ; + //const { data: session } = useSession(); + return ( + }> + + + ); }
Loading...