feat(admin): users and roles added to the frontend to manage easier

This commit is contained in:
2025-10-15 21:16:14 -05:00
parent 817a5c6876
commit 2142c06ac3
14 changed files with 856 additions and 230 deletions

View File

@@ -1,81 +1,81 @@
import {
createRootRouteWithContext,
Outlet,
useRouter,
} from "@tanstack/react-router";
import type { QueryClient } from "@tanstack/react-query";
import { Toaster } from "sonner";
import Cookies from "js-cookie";
import { SessionGuard } from "../lib/providers/SessionProvider";
import Nav from "../components/navBar/Nav";
import { ThemeProvider } from "../lib/providers/theme-provider";
import { SidebarProvider } from "../components/ui/sidebar";
import SideBarNav from "../components/navBar/SideBarNav";
import {
createRootRouteWithContext,
Outlet,
useRouter,
} from "@tanstack/react-router";
import { TanStackRouterDevtools } from "@tanstack/react-router-devtools";
import { userAccess } from "../lib/authClient";
import mobile from "is-mobile";
import Cookies from "js-cookie";
import { useEffect } from "react";
import { Toaster } from "sonner";
import Nav from "../components/navBar/Nav";
import SideBarNav from "../components/navBar/SideBarNav";
import { SidebarProvider, SidebarTrigger } from "../components/ui/sidebar";
import { userAccess } from "../lib/authClient";
import { SessionGuard } from "../lib/providers/SessionProvider";
import { ThemeProvider } from "../lib/providers/theme-provider";
import { coreSocket } from "../lib/socket.io/socket";
interface RootRouteContext {
queryClient: QueryClient;
//user: User | null;
//login: (user: User) => void;
//logout: () => void;
queryClient: QueryClient;
//user: User | null;
//login: (user: User) => void;
//logout: () => void;
}
const RootLayout = () => {
//const { logout, login } = Route.useRouteContext();
const defaultOpen = Cookies.get("sidebar_state") === "true";
const router = useRouter();
// console.log(mobile({ featureDetect: true, tablet: true }));
//const { logout, login } = Route.useRouteContext();
const defaultOpen = Cookies.get("sidebar_state") === "true";
const router = useRouter();
// console.log(mobile({ featureDetect: true, tablet: true }));
// if mobile lets move to the mobile section.
useEffect(() => {
if (mobile({ featureDetect: true, tablet: true })) {
router.navigate({ to: "/m" });
}
// if mobile lets move to the mobile section.
useEffect(() => {
if (mobile({ featureDetect: true, tablet: true })) {
router.navigate({ to: "/m" });
}
coreSocket.on("connect", () => {
console.log("✅ Connected:", coreSocket.id);
});
coreSocket.on("connect", () => {
console.log("✅ Connected:", coreSocket.id);
});
coreSocket.on("disconnect", () => {
console.log("🔴 Disconnected");
});
coreSocket.on("disconnect", () => {
console.log("🔴 Disconnected");
});
return () => {
coreSocket.off("connect");
coreSocket.off("disconnect");
};
}, []);
return () => {
coreSocket.off("connect");
coreSocket.off("disconnect");
};
}, []);
return (
<div>
<SessionGuard>
<ThemeProvider>
<div className="flex flex-col h-screen overflow-hidden">
<Nav />
<div className="flex flex-1 overflow-hidden">
<SidebarProvider defaultOpen={defaultOpen}>
<SideBarNav />
<div className="flex-2 overflow-y-auto">
<Outlet />
</div>
</SidebarProvider>
</div>
<Toaster expand richColors closeButton />
{userAccess(null, ["systemAdmin"]) && (
<TanStackRouterDevtools position="bottom-right" />
)}
</div>
</ThemeProvider>
</SessionGuard>
</div>
);
return (
<div>
<SessionGuard>
<ThemeProvider>
<div className="flex flex-col h-screen overflow-hidden">
<Nav />
<div className="flex flex-1 overflow-hidden">
<SidebarProvider defaultOpen={defaultOpen}>
<SideBarNav />
<div className="flex-2 overflow-y-auto">
<Outlet />
</div>
</SidebarProvider>
</div>
<Toaster expand richColors closeButton />
{userAccess(null, ["systemAdmin"]) && (
<TanStackRouterDevtools position="bottom-right" />
)}
</div>
</ThemeProvider>
</SessionGuard>
</div>
);
};
export const Route = createRootRouteWithContext<RootRouteContext>()({
component: RootLayout,
component: RootLayout,
});

View File

@@ -0,0 +1,144 @@
import { useState } from "react";
import { toast } from "sonner";
import { Button } from "../../../components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "../../../components/ui/select";
import { api } from "../../../lib/axiosAPI";
const modules: string[] = [
"users",
"system",
"ocp",
"siloAdjustments",
"demandManagement",
"logistics",
"production",
"quality",
"eom",
"forklifts",
];
const roles: string[] = [
"systemAdmin",
"admin",
"manager",
"supervisor",
"tester",
"user",
"viewer",
];
export default function ExpandedRow({ row }: { row: any }) {
const user = row.original;
const existingRolesMap = Object.fromEntries(
(user.roles || []).map((r: { module: string; role: string }) => [
r.module,
r.role,
]),
);
// local state for selections
const [selectedRoles, setSelectedRoles] = useState<Record<string, string>>(
() =>
Object.fromEntries(
modules.map((m) => [m, existingRolesMap[m] || "viewer"]),
),
);
const onSubmitRole = async (module: string) => {
const role = selectedRoles[module];
// console.log("Saving module role:", {
// module,
// role,
// user,
// });
try {
const result = await api.patch(`/api/admin/${user.id}/grant`, {
module: module,
role: role,
});
if (result.status === 200) {
toast.success(
`${user.username} was just granted ${role} on module ${module}`,
);
}
} catch (error) {
console.log(error);
toast.error(
"There was an error granting the user a role if this continues please contact your admin.",
);
}
};
const handleSelectChange = (module: string, value: string) => {
setSelectedRoles((prev) => ({ ...prev, [module]: value }));
};
const onDeleteRole = async (module: string) => {
try {
const result = await api.patch(`/api/admin/${user.id}/revoke`, {
module: module,
});
if (result.status === 200) {
toast.success(`${user.username} no longer has access to ${module}`);
setSelectedRoles((prev) => ({ ...prev, [module]: "viewer" }));
}
} catch (error) {
console.log(error);
toast.error(
"There was an error granting the user a role if this continues please contact your admin.",
);
}
};
return (
<div className="p-4 bg-muted w-128">
<div className="">
{modules.map((i) => {
return (
<div
key={i}
className="flex flex-row items-center justify-between gap-4 border-b py-2"
>
<span className="text-sm font-medium capitalize w-40">{i}</span>
<Select
value={selectedRoles[i]}
onValueChange={(v) => handleSelectChange(i, v)}
>
<SelectTrigger className="w-40">
<SelectValue placeholder="Select role" />
</SelectTrigger>
<SelectContent>
{roles.map((r) => (
<SelectItem key={r} value={r}>
{r}
</SelectItem>
))}
</SelectContent>
</Select>
<Button onClick={() => onSubmitRole(i)} size="sm">
Grant
</Button>
<Button
onClick={() => onDeleteRole(i)}
size="sm"
variant="destructive"
>
Revoke
</Button>
</div>
);
})}
</div>
</div>
);
}

View File

@@ -1,9 +1,215 @@
import { useQuery } from "@tanstack/react-query";
import { createFileRoute } from "@tanstack/react-router";
import {
createColumnHelper,
flexRender,
getCoreRowModel,
getPaginationRowModel,
getSortedRowModel,
type SortingState,
useReactTable,
} from "@tanstack/react-table";
import {
ArrowDown,
ArrowUp,
ChevronDown,
ChevronRight,
Mail,
User,
} from "lucide-react";
import React, { useState } from "react";
import { Button } from "../../../../components/ui/button";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "../../../../components/ui/table";
import { getUsers } from "../../../../lib/querys/admin/getUsers";
import ExpandedRow from "../../-components/ExpandedRow";
type User = {
username: string;
email: string;
roles: string | null;
};
export const Route = createFileRoute("/_adminLayout/admin/_users/users")({
component: RouteComponent,
component: RouteComponent,
});
function RouteComponent() {
return <div className="">Hello "/_admin/admin/users "!</div>;
const { data, isLoading } = useQuery(getUsers());
const [sorting, setSorting] = useState<SortingState>([]);
const columnHelper = createColumnHelper<User>();
const columns = [
columnHelper.accessor("username", {
cell: (i) => i.getValue(),
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
<span className="flex flex-row gap-2">
<User />
Username
</span>
{column.getIsSorted() === "asc" ? (
<ArrowUp className="ml-2 h-4 w-4" />
) : (
<ArrowDown className="ml-2 h-4 w-4" />
)}
</Button>
);
},
}),
columnHelper.accessor("email", {
cell: (i) => i.getValue(),
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
<span className="flex flex-row gap-2">
<Mail />
Email
</span>
{column.getIsSorted() === "asc" ? (
<ArrowUp className="ml-2 h-4 w-4" />
) : (
<ArrowDown className="ml-2 h-4 w-4" />
)}
</Button>
);
},
}),
columnHelper.accessor("roles", {
header: () => <span>Roles</span>,
cell: ({ row }) => {
return row.getCanExpand() ? (
<button
{...{
onClick: row.getToggleExpandedHandler(),
style: { cursor: "pointer" },
}}
>
{row.getIsExpanded() ? (
<span className="flex flex-row gap-2">
Roles <ChevronDown />
</span>
) : (
<span className="flex flex-row gap-2">
Roles <ChevronRight />
</span>
)}
</button>
) : (
"No expanding"
);
},
}),
];
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
onSortingChange: setSorting,
getSortedRowModel: getSortedRowModel(),
//renderSubComponent: ({ row }: { row: any }) => <ExpandedRow row={row} />,
getRowCanExpand: () => true,
state: {
sorting,
},
});
if (isLoading) {
return <div className="m-auto">Loading user data</div>;
}
// render the roles card and make ts happy by not including it in the useReactTable hook
const renderSubComponent = ({ row }: { row: any }) => (
<ExpandedRow row={row} />
);
return (
<div className="p-4">
<div>
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</TableHead>
);
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows.map((row) => (
<React.Fragment key={row.id}>
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</TableCell>
))}
</TableRow>
{row.getIsExpanded() && (
<TableRow>
<TableCell colSpan={row.getVisibleCells().length}>
{renderSubComponent({ row })}
</TableCell>
</TableRow>
)}
</React.Fragment>
))}
</TableBody>
</Table>
</div>
<div className="flex items-center justify-end space-x-2 py-4">
<Button
variant="outline"
size="sm"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
Next
</Button>
</div>
</div>
);
}