diff --git a/src/backend/routers/user.ts b/src/backend/routers/user.ts
index 0c0bfdb3..16910d21 100644
--- a/src/backend/routers/user.ts
+++ b/src/backend/routers/user.ts
@@ -1,4 +1,29 @@
-import { hasAuthenticated, router } from "../trpc";
+import { hasAuthenticated, hasAdmin, router } from "../trpc";
+import { z } from "zod";
+import { UserType, ROLE_OPTIONS } from "@/types/auth";
+import { TRPCError } from "@trpc/server";
+
+export const sortOrderSchema = z.enum(["asc", "desc"]).default("asc");
+export const sortBySchema = z
+ .enum(["first_name", "last_name", "email", "role"])
+ .default("first_name");
+
+const paginationInput = z.object({
+ page: z.number().min(1).default(1),
+ pageSize: z.number().min(1).default(10),
+ sortBy: sortBySchema,
+ sortOrder: sortOrderSchema,
+ search: z.string().optional(),
+});
+
+const createUserSchema = z.object({
+ first_name: z.string(),
+ last_name: z.string(),
+ email: z.string().email(),
+ role: z.string(),
+});
+
+const roleValues = ROLE_OPTIONS.map((r) => r.value) as [string, ...string[]];
export const user = router({
getMe: hasAuthenticated.query(async (req) => {
@@ -20,6 +45,64 @@ export const user = router({
return user;
}),
+ getUsers: hasAdmin.input(paginationInput).query(async (req) => {
+ const { page, pageSize, sortBy, sortOrder, search } = req.input;
+ const offset = (page - 1) * pageSize;
+
+ let baseQuery = req.ctx.db
+ .selectFrom("user")
+ .select([
+ "user_id",
+ "first_name",
+ "last_name",
+ "email",
+ "image_url",
+ "role",
+ ]);
+
+ if (search) {
+ baseQuery = baseQuery.where((eb) =>
+ eb.or([
+ eb("first_name", "ilike", `%${search}%`),
+ eb("last_name", "ilike", `%${search}%`),
+ eb("email", "ilike", `%${search}%`),
+ eb("role", "ilike", `%${search}%`),
+ ])
+ );
+ }
+
+ // Separate count query
+ let countQuery = req.ctx.db
+ .selectFrom("user")
+ .select(req.ctx.db.fn.countAll().as("count"));
+
+ // Apply search filter to count query if exists
+ if (search) {
+ countQuery = countQuery.where((eb) =>
+ eb.or([
+ eb("first_name", "ilike", `%${search}%`),
+ eb("last_name", "ilike", `%${search}%`),
+ eb("email", "ilike", `%${search}%`),
+ eb("role", "ilike", `%${search}%`),
+ ])
+ );
+ }
+
+ const [users, totalCount] = await Promise.all([
+ baseQuery
+ .orderBy(sortBy, sortOrder)
+ .limit(pageSize)
+ .offset(offset)
+ .execute(),
+ countQuery.executeTakeFirst(),
+ ]);
+
+ return {
+ users,
+ totalCount: Number(totalCount?.count ?? 0),
+ totalPages: Math.ceil(Number(totalCount?.count ?? 0) / pageSize),
+ };
+ }),
/**
* @returns Whether the current user is a case manager
*/
@@ -34,4 +117,99 @@ export const user = router({
return result.length > 0;
}),
+
+ createUser: hasAdmin.input(createUserSchema).mutation(async (req) => {
+ const { first_name, last_name, email, role } = req.input;
+
+ // Check if user already exists
+ const existingUser = await req.ctx.db
+ .selectFrom("user")
+ .where("email", "=", email)
+ .selectAll()
+ .executeTakeFirst();
+
+ if (existingUser) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "User with this email already exists",
+ });
+ }
+
+ const user = await req.ctx.db
+ .insertInto("user")
+ .values({
+ first_name,
+ last_name,
+ email,
+ role,
+ })
+ .returningAll()
+ .executeTakeFirstOrThrow();
+
+ return user;
+ }),
+
+ getUserById: hasAdmin
+ .input(z.object({ user_id: z.string() }))
+ .query(async (req) => {
+ const { user_id } = req.input;
+
+ return await req.ctx.db
+ .selectFrom("user")
+ .selectAll()
+ .where("user_id", "=", user_id)
+ .executeTakeFirstOrThrow();
+ }),
+
+ editUser: hasAdmin
+ .input(
+ z.object({
+ user_id: z.string(),
+ first_name: z.string(),
+ last_name: z.string(),
+ email: z.string().email(),
+ role: z.enum(roleValues).transform((role) => {
+ switch (role) {
+ case "admin":
+ return UserType.Admin;
+ case "case_manager":
+ return UserType.CaseManager;
+ case "para":
+ return UserType.Para;
+ default:
+ return UserType.User;
+ }
+ }),
+ })
+ )
+ .mutation(async (req) => {
+ const { user_id, first_name, last_name, email, role } = req.input;
+
+ const { userId } = req.ctx.auth;
+
+ const dbUser = await req.ctx.db
+ .selectFrom("user")
+ .where("user_id", "=", user_id)
+ .selectAll()
+ .executeTakeFirstOrThrow();
+
+ if (userId === user_id && dbUser.role !== (role as string)) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "You cannot change your own role",
+ });
+ }
+
+ return await req.ctx.db
+ .updateTable("user")
+ .set({
+ first_name,
+ last_name,
+ email: email.toLowerCase(),
+ role,
+ })
+ .where("user_id", "=", user_id)
+ .returningAll()
+ .executeTakeFirstOrThrow();
+ }),
});
diff --git a/src/components/CustomToast.tsx b/src/components/CustomToast.tsx
index fea3e8e2..31afc256 100644
--- a/src/components/CustomToast.tsx
+++ b/src/components/CustomToast.tsx
@@ -1,43 +1,37 @@
-import React, { useState } from "react";
+import React from "react";
import styles from "./styles/Toast.module.css";
import Image from "next/image";
interface CustomToastProps {
errorMessage: string;
+ onClose: () => void;
}
-const CustomToast = ({ errorMessage }: CustomToastProps) => {
- const [showToast, setShowToast] = useState(true);
-
+const CustomToast = ({ errorMessage, onClose }: CustomToastProps) => {
const handleCloseToast = () => {
- setShowToast(false);
+ onClose();
};
return (
- <>
- {showToast && (
-
-
-
-
{errorMessage ?? null}
-
-
-
-
- )}
- >
+
+
+
+
{errorMessage}
+
+
+
);
};
diff --git a/src/components/design_system/breadcrumbs/Breadcrumbs.tsx b/src/components/design_system/breadcrumbs/Breadcrumbs.tsx
index 7c2cb884..0a447ddc 100644
--- a/src/components/design_system/breadcrumbs/Breadcrumbs.tsx
+++ b/src/components/design_system/breadcrumbs/Breadcrumbs.tsx
@@ -22,8 +22,12 @@ const BreadcrumbsNav = () => {
{ user_id: paths[2] },
{ enabled: Boolean(paths[2] && paths[1] === "staff") }
);
+ const { data: user } = trpc.user.getUserById.useQuery(
+ { user_id: paths[2] },
+ { enabled: Boolean(paths[2] && paths[1] === "admin") }
+ );
- const personData: Student | Para | undefined = student || para;
+ const personData: Student | Para | undefined = student || para || user;
// An array of breadcrumbs fixed to students/staff as the first index. This will be modified depending on how the address bar will be displayed.
const breadcrumbs = paths.map((path, index) => {
diff --git a/src/components/design_system/dropdown/Dropdown.tsx b/src/components/design_system/dropdown/Dropdown.tsx
index c4625605..69940c96 100644
--- a/src/components/design_system/dropdown/Dropdown.tsx
+++ b/src/components/design_system/dropdown/Dropdown.tsx
@@ -24,7 +24,7 @@ interface DropdownProps {
optionDisabled?: string[];
}
-const Dropdown = ({
+export const Dropdown = ({
itemList,
selectedOption,
setSelectedOption,
@@ -38,7 +38,6 @@ const Dropdown = ({
};
return (
- // Minimum styles used. More can be defined in className.
{label}
@@ -48,8 +47,13 @@ const Dropdown = ({
value={selectedOption}
label={label}
onChange={handleChange}
- // Allow disabling of form
disabled={formDisabled}
+ MenuProps={{
+ PaperProps: {
+ elevation: 1,
+ sx: { maxHeight: 300 },
+ },
+ }}
>
{itemList?.map((item) => (