diff --git a/backend/src/user/user.service.ts b/backend/src/user/user.service.ts index db04a5f..5ca889a 100644 --- a/backend/src/user/user.service.ts +++ b/backend/src/user/user.service.ts @@ -732,12 +732,7 @@ async addUserToGroup( this.logger.log('Executing DynamoDB scan with filter for Inactive users...'); const result = await this.dynamoDb.scan(params).promise(); - const users: User[] = (result.Items || []).map((item) => ({ - position: item.position as UserStatus, - email: item.email, - firstName: item.firstName, - lastName: item.lastName - })); + const users: User[] = (result.Items || []).map((item) => item as User); this.logger.log(`✅ Successfully retrieved ${users.length} inactive users`); return users; @@ -787,12 +782,7 @@ async getAllActiveUsers(): Promise { this.logger.error("DynamoDB scan result:", result); throw new NotFoundException("No active users found."); } - const users: User[] = (result.Items || []).map((item) => ({ - position: item.position as UserStatus, - email: item.email, - firstName: item.firstName, - lastName: item.lastName - })); + const users: User[] = (result.Items || []).map((item) => item as User); this.logger.debug(`Fetched ${users.length} active users.`); diff --git a/frontend/src/components/Button.tsx b/frontend/src/components/Button.tsx index a09c4ce..f6c3e0f 100644 --- a/frontend/src/components/Button.tsx +++ b/frontend/src/components/Button.tsx @@ -19,7 +19,7 @@ export default function Button({ text, onClick, className, logo, logoPosition, d return ( - + }} className="border-grey-500" /> diff --git a/frontend/src/external/bcanSatchel/actions.ts b/frontend/src/external/bcanSatchel/actions.ts index 721f718..161e02a 100644 --- a/frontend/src/external/bcanSatchel/actions.ts +++ b/frontend/src/external/bcanSatchel/actions.ts @@ -65,6 +65,11 @@ export const updateSort = action( (sort: {header: keyof Grant, asc: boolean}) => ({ sort, }) ); +export const updateUserSort = action( + "updateUserSort", + (sort: {header: keyof User, sort: "asc" | "desc" | "none"}) => ({ sort, }) +); + /** * Append a new grant to the current list of grants. */ @@ -75,6 +80,11 @@ export const updateSearchQuery = action( (searchQuery: string) => ({ searchQuery }) ); +export const updateUserQuery = action( + "updateUserQuery", + (userQuery: string) => ({ userQuery }) +); + export const setNotifications = action( 'setNotifications', (notifications: Notification[]) => ({notifications}) diff --git a/frontend/src/external/bcanSatchel/mutators.ts b/frontend/src/external/bcanSatchel/mutators.ts index c65065d..291469a 100644 --- a/frontend/src/external/bcanSatchel/mutators.ts +++ b/frontend/src/external/bcanSatchel/mutators.ts @@ -9,7 +9,9 @@ import { updateSearchQuery, updateYearFilter, setNotifications, - updateSort + updateSort, + updateUserQuery, + updateUserSort, } from './actions'; import { getAppStore, persistToSessionStorage } from './store'; import { setActiveUsers, setInactiveUsers } from './actions'; @@ -117,3 +119,14 @@ mutator(updateSort, (actionMessage) => { const store = getAppStore(); store.sort = actionMessage.sort; }) + +mutator(updateUserQuery, (actionMessage) => { + const store = getAppStore(); + store.userQuery = actionMessage.userQuery; + console.log('Updated userQuery:', store.userQuery); +}) + +mutator(updateUserSort, (actionMessage) => { + const store = getAppStore(); + store.userSort = actionMessage.sort; +}) \ No newline at end of file diff --git a/frontend/src/external/bcanSatchel/store.ts b/frontend/src/external/bcanSatchel/store.ts index 4f47952..eea44b2 100644 --- a/frontend/src/external/bcanSatchel/store.ts +++ b/frontend/src/external/bcanSatchel/store.ts @@ -18,7 +18,9 @@ export interface AppState { activeUsers: User[] | []; inactiveUsers: User[] | []; sort: {header: keyof Grant, asc: boolean} | null; + userSort: {header: keyof User, sort: "asc" | "desc" | "none"} | null; notifications: Notification[]; + userQuery: string; } // Define initial state @@ -36,6 +38,8 @@ const initialState: AppState = { inactiveUsers: [], notifications: [], sort: null, + userSort: null, + userQuery: '', }; /** diff --git a/frontend/src/main-page/MainPage.tsx b/frontend/src/main-page/MainPage.tsx index 642d535..750573c 100644 --- a/frontend/src/main-page/MainPage.tsx +++ b/frontend/src/main-page/MainPage.tsx @@ -2,35 +2,120 @@ import { Routes, Route } from "react-router-dom"; import Dashboard from "./dashboard/Dashboard"; import GrantPage from "./grants/GrantPage"; import NavBar from "./navbar/NavBar"; -import Users from "./users/Users"; import RestrictedPage from "./restricted/RestrictedPage"; import CashFlowPage from "./cash-flow/CashFlowPage"; import Settings from "./settings/Settings"; import Footer from "../Footer"; +import UsersPage from "./users/UsersPage"; + +import { UserStatus } from "../../../middle-layer/types/UserStatus"; +import { observer } from "mobx-react-lite"; +import { Navigate } from "react-router-dom"; +import { getAppStore } from "../external/bcanSatchel/store"; +import BellButton from "./navbar/Bell"; +import { useState } from "react"; + +interface PositionGuardProps { + children: React.ReactNode; + adminOnly?: boolean; +} + +const PositionGuard = observer( + ({ children, adminOnly = false }: PositionGuardProps) => { + const { user } = getAppStore(); + + // If user hasn't been resolved yet, avoid redirect + if (user === undefined) { + return null; // or a loading spinner + } + + if (!user) { + return ; + } + + if ( + user.position === undefined || + user.position === UserStatus.Inactive || + (user.position === UserStatus.Employee && adminOnly) + ) { + return ; + } + + return <>{children}; + }, +); function MainPage() { + const [openModal, setOpenModal] = useState(false); + return (
-
+
+
+ +
- } /> + + + + } + /> } + element={ + + + + } /> } + element={ + + + + } + /> + + + + } /> - } /> } /> - } /> - } /> - } /> + + + + } + /> + + + + } + /> + + + + } + />
diff --git a/frontend/src/main-page/dashboard/Charts/DonutMoneyApplied.tsx b/frontend/src/main-page/dashboard/Charts/DonutMoneyApplied.tsx index 936f8a8..c12d82c 100644 --- a/frontend/src/main-page/dashboard/Charts/DonutMoneyApplied.tsx +++ b/frontend/src/main-page/dashboard/Charts/DonutMoneyApplied.tsx @@ -77,7 +77,7 @@ const DonutMoneyApplied = observer(({ grants }: { grants: Grant[] }) => { }, )}M`}
-
250 ? "mt-12" : "mt-2"}`}> +
250 ? "mt-12" : "mt-2"}`}> { className="bg-white border-grey-500 inline-flex items-center justify-between text-sm lg:text-base" />
diff --git a/frontend/src/main-page/grants/GrantPage.tsx b/frontend/src/main-page/grants/GrantPage.tsx index 4ba371d..1210676 100644 --- a/frontend/src/main-page/grants/GrantPage.tsx +++ b/frontend/src/main-page/grants/GrantPage.tsx @@ -16,9 +16,6 @@ import { import { toJS } from "mobx"; import { observer } from "mobx-react-lite"; import { ProcessGrantData } from "./filter-bar/processGrantData.ts"; -import { UserStatus } from "../../../../middle-layer/types/UserStatus.ts"; -import { Navigate } from "react-router-dom"; -import BellButton from "../navbar/Bell.tsx"; import GrantCard from "./grant-list/GrantCard.tsx"; import { api } from "../../api.ts"; import Button from "../../components/Button.tsx"; @@ -31,7 +28,7 @@ interface GrantPageProps { function GrantPage({}: GrantPageProps) { const [showNewGrantModal, setShowNewGrantModal] = useState(false); const [wasGrantSubmitted, setWasGrantSubmitted] = useState(false); - + // Use ProcessGrantData reactively to get filtered grants const { grants } = ProcessGrantData(); const [curGrant, setCurGrant] = useState(null); @@ -43,53 +40,51 @@ function GrantPage({}: GrantPageProps) { } }, [grants]); - // If the NewGrantModal has been closed and a new grant submitted (or existing grant edited), - // refetch the grants list and update the current grant to reflect any changes - // SHOULD BE CHANGED TO ALSO ACCOMODATE DELETIONS (CURRENTLY ONLY UPDATES IF GRANT WAS CREATED/EDITED, NOT DELETED) - useEffect(() => { - if (!wasGrantSubmitted || !curGrant) return; - - const updateGrant = async () => { - try { - const response = await api(`/grant/${curGrant.grantId}`, { - method: "GET", - headers: { - "Content-Type": "application/json", - }, - }); - - if (response.ok) { - const updatedGrant = await response.json(); - setCurGrant(updatedGrant); - console.log("✅ Grant refreshed:", updatedGrant); - } else { - console.error("❌ Failed to fetch updated grant"); - } - } catch (err) { - console.error("Error fetching updated grant:", err); + // If the NewGrantModal has been closed and a new grant submitted (or existing grant edited), + // refetch the grants list and update the current grant to reflect any changes + // SHOULD BE CHANGED TO ALSO ACCOMODATE DELETIONS (CURRENTLY ONLY UPDATES IF GRANT WAS CREATED/EDITED, NOT DELETED) + useEffect(() => { + if (!wasGrantSubmitted || !curGrant) return; + + const updateGrant = async () => { + try { + const response = await api(`/grant/${curGrant.grantId}`, { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + }); + + if (response.ok) { + const updatedGrant = await response.json(); + setCurGrant(updatedGrant); + console.log("✅ Grant refreshed:", updatedGrant); + } else { + console.error("❌ Failed to fetch updated grant"); } - }; - - const updateGrants = async () => { - try { - const response = await api("/grant"); - if (!response.ok) { - throw new Error(`HTTP Error, Status: ${response.status}`); - } - const updatedGrants: Grant[] = await response.json(); - fetchAllGrants(updatedGrants); - console.log("✅ Grants list refreshed"); - } catch (error) { - console.error("Error fetching grants:", error); + } catch (err) { + console.error("Error fetching updated grant:", err); + } + }; + + const updateGrants = async () => { + try { + const response = await api("/grant"); + if (!response.ok) { + throw new Error(`HTTP Error, Status: ${response.status}`); } - }; + const updatedGrants: Grant[] = await response.json(); + fetchAllGrants(updatedGrants); + console.log("✅ Grants list refreshed"); + } catch (error) { + console.error("Error fetching grants:", error); + } + }; - updateGrants(); - updateGrant(); - setWasGrantSubmitted(false); - }, [wasGrantSubmitted]); - - const [openModal, setOpenModal] = useState(false); + updateGrants(); + updateGrant(); + setWasGrantSubmitted(false); + }, [wasGrantSubmitted]); const { user } = useAuthContext(); //gets current logged in user const userObj = toJS(user); @@ -106,41 +101,41 @@ function GrantPage({}: GrantPageProps) { updateSearchQuery(""); }, []); - return user ? ( - user?.position !== UserStatus.Inactive ? ( -
-
- -
- -
-
+ return ( +
+ +
+
-
-
- {grants.map((grant) => ( - setCurGrant(grant)} - /> - ))} -
-
- {curGrant ? ( - - ) : ( -
- No grants found. -
- )} -
+
+
+ {grants.map((grant) => ( + setCurGrant(grant)} + /> + ))} +
+
+ {curGrant ? ( + + ) : ( +
+ No grants found. +
+ )}
+
- {/*
+ {/*
@@ -156,24 +151,19 @@ function GrantPage({}: GrantPageProps) {
*/} -
- {showNewGrantModal && ( - { - setShowNewGrantModal(false); - setWasGrantSubmitted(true); - }} - isOpen={showNewGrantModal} - /> - )} -
+
+ {showNewGrantModal && ( + { + setShowNewGrantModal(false); + setWasGrantSubmitted(true); + }} + isOpen={showNewGrantModal} + /> + )}
- ) : ( - - ) - ) : ( - +
); } diff --git a/frontend/src/main-page/grants/filter-bar/GrantSearch.tsx b/frontend/src/main-page/grants/filter-bar/GrantSearch.tsx index 4f2c4fa..064546f 100644 --- a/frontend/src/main-page/grants/filter-bar/GrantSearch.tsx +++ b/frontend/src/main-page/grants/filter-bar/GrantSearch.tsx @@ -1,10 +1,9 @@ -import { IoMdSearch } from "react-icons/io"; import { useState } from "react"; import Fuse from "fuse.js"; import { updateSearchQuery } from "../../../external/bcanSatchel/actions"; import { getAppStore } from "../../../external/bcanSatchel/store"; import { Grant } from "../../../../../middle-layer/types/Grant"; -import { Input } from "@chakra-ui/react"; +import SearchBar from "../../../components/SearchBar"; function GrantSearch() { const [userInput, setUserInput] = useState(getAppStore().searchQuery || ""); @@ -31,33 +30,7 @@ function GrantSearch() { }; return ( -
- {/* Absolutely-positioned icon */} - - { - if (e.key === "Enter") { - e.preventDefault(); - } - }} - /> -
+ ); } diff --git a/frontend/src/main-page/grants/grant-view/ContactCard.tsx b/frontend/src/main-page/grants/grant-view/ContactCard.tsx index 5d2005a..c65505f 100644 --- a/frontend/src/main-page/grants/grant-view/ContactCard.tsx +++ b/frontend/src/main-page/grants/grant-view/ContactCard.tsx @@ -1,3 +1,4 @@ +import { getAppStore } from "../../../external/bcanSatchel/store"; import POC from "../../../../../middle-layer/types/POC"; import logo from "../../../images/logo.svg"; @@ -6,11 +7,21 @@ type ContactCardProps = { type?: "BCAN" | "Granter"; }; +const store = getAppStore() +const activeUsers = store.activeUsers || []; + export default function ContactCard({ contact, type }: ContactCardProps) { + +const contactPhoto = + type === "BCAN" + ? activeUsers.find((user) => user.email === contact?.POC_email) + ?.profilePicUrl + : logo; + return (
Profile diff --git a/frontend/src/main-page/grants/new-grant/NewGrantModal.tsx b/frontend/src/main-page/grants/new-grant/NewGrantModal.tsx index f4942cd..7ffdbdf 100644 --- a/frontend/src/main-page/grants/new-grant/NewGrantModal.tsx +++ b/frontend/src/main-page/grants/new-grant/NewGrantModal.tsx @@ -950,7 +950,7 @@ const NewGrantModal: React.FC<{ {showErrorPopup && (

Error diff --git a/frontend/src/main-page/navbar/NavBar.tsx b/frontend/src/main-page/navbar/NavBar.tsx index f2729ab..69f5e6c 100644 --- a/frontend/src/main-page/navbar/NavBar.tsx +++ b/frontend/src/main-page/navbar/NavBar.tsx @@ -31,9 +31,9 @@ const NavBar: React.FC = observer(() => { }; return ( -

diff --git a/frontend/src/main-page/navbar/NavTab.tsx b/frontend/src/main-page/navbar/NavTab.tsx index 9c698d0..3fc0a81 100644 --- a/frontend/src/main-page/navbar/NavTab.tsx +++ b/frontend/src/main-page/navbar/NavTab.tsx @@ -23,7 +23,7 @@ const NavTab: React.FC = ({ name, linkTo, icon }) => { }`} > - {name} + {name} ); }; diff --git a/frontend/src/main-page/notifications/NotificationPopup.tsx b/frontend/src/main-page/notifications/NotificationPopup.tsx index b38721b..9c8eabf 100644 --- a/frontend/src/main-page/notifications/NotificationPopup.tsx +++ b/frontend/src/main-page/notifications/NotificationPopup.tsx @@ -51,7 +51,7 @@ const NotificationPopup: React.FC = observer(({ return createPortal( -
+

Alerts

- +
+
+
+

So Sorry!

+

+ {user?.position === UserStatus.Inactive ? "Your account is currently inactive or pending approval." : "You don't have access to this page."} Contact the admin if you think + there's a mistake. +

+ +
+
+
+
- BCAN logo
); } diff --git a/frontend/src/main-page/settings/components/InfoCard.tsx b/frontend/src/main-page/settings/components/InfoCard.tsx index a8ffa24..cbe9d8f 100644 --- a/frontend/src/main-page/settings/components/InfoCard.tsx +++ b/frontend/src/main-page/settings/components/InfoCard.tsx @@ -13,7 +13,7 @@ type InfoCardProps = { export default function InfoCard({ title, fields, action }: InfoCardProps) { return ( -
+
{(title || action) && (
{title && ( diff --git a/frontend/src/main-page/users/ApprovedUserCard.tsx b/frontend/src/main-page/users/ApprovedUserCard.tsx deleted file mode 100644 index 454be43..0000000 --- a/frontend/src/main-page/users/ApprovedUserCard.tsx +++ /dev/null @@ -1,179 +0,0 @@ -import UserPositionCard from "./UserPositionCard"; -import { Button, Menu } from "@chakra-ui/react"; -import { FaEllipsisVertical } from "react-icons/fa6"; -import { UserStatus } from "../../../../middle-layer/types/UserStatus"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { faPencil, faTrash } from "@fortawesome/free-solid-svg-icons"; -import ActionConfirmation from "../../custom/ActionConfirmation"; -import { useState } from "react"; -import { api } from "../../api"; -import { User } from "../../../../middle-layer/types/User"; -import { toJS } from "mobx"; -import { getAppStore } from "../../external/bcanSatchel/store"; -import { setActiveUsers } from "../../external/bcanSatchel/actions"; -// Did not change this to using the email/first name last name due to user page redesign so someone will be changing all of this anyway -interface ApprovedUserCardProps { - userId: string; - email: string; - position: UserStatus; -} - -const ApprovedUserCard = ({ - userId, - email, - position, -}: ApprovedUserCardProps) => { - const store = getAppStore(); - const [isChangeGroupModalOpen, setIsChangeGroupModalOpen] = useState(false); - const [isDeleteUserModalOpen, setIsDeleteUserModalOpen] = useState(false); - - const changeUserGroup = async () => { - console.log( - `Changing user ${email} to ${ - position === UserStatus.Admin ? "employee" : "admin" - }...` - ); - - try { - const response = await api("/user/change-role", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - user: { - email, - position, - } as User, - groupName: - position === UserStatus.Admin - ? UserStatus.Employee - : UserStatus.Admin, - requestedBy: toJS(store.user) as User, - }), - }); - - if (response.ok) { - console.log( - `User ${userId} successfully changed to ${ - position === UserStatus.Admin ? "employee" : "admin" - }` - ); - alert( - `User ${userId} successfully changed to ${ - position === UserStatus.Admin ? "employee" : "admin" - }` - ); - const updatedUser = await response.json(); - setActiveUsers([...store.activeUsers.filter(u => u.email !== email), updatedUser as User]); - - setIsChangeGroupModalOpen(false); - } else { - const errorBody = await response.json(); - console.error("Error: ", errorBody) - } - } catch (error) { - console.error("Error changing user group: ", error); - } - }; - - const deleteUser = async () => { - try { - const response = await api("user/delete-user", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - user: { - email, - position, - } as User, - requestedBy: toJS(store.user) as User, - }), - }); - - if (response.ok) { - console.log(`User ${email} has been deleted successfully`); - alert(`User ${email} has been deleted successfully`); - setActiveUsers(store.activeUsers.filter(u => u.email !== email)); - - } else { - const errorBody = await response.json(); - console.error("Error: ", errorBody) - alert("Failed to delete user"); - } - setIsDeleteUserModalOpen(false); - } catch (error) { - console.error("Error deleting user:", error); - alert("Error deleting user"); - } - }; - - return ( -
- setIsChangeGroupModalOpen(false)} - onConfirmDelete={changeUserGroup} - title={`Change User to ${ - position === UserStatus.Admin ? "Employee" : "Admin" - }`} - subtitle="Are you sure you want to change to" - boldSubtitle={position === UserStatus.Admin ? "employee" : "admin"} - warningMessage={`By changing to ${ - position === UserStatus.Admin ? "employee" : "admin" - }, they will ${ - position === UserStatus.Admin - ? "gain access to sensitive data." - : "lose access to admin pages." - }`} - /> - setIsDeleteUserModalOpen(false)} - onConfirmDelete={deleteUser} - title="Delete User" - subtitle="Are you sure you want to delete" - boldSubtitle={userId} - warningMessage="By deleting this user, they won't be available in the system anymore." - /> -

{userId}

-

{email}

-
- -
-
- - - - - - - - - - - -
-
- ); -}; - -export default ApprovedUserCard; diff --git a/frontend/src/main-page/users/PendingUserCard.tsx b/frontend/src/main-page/users/PendingUserCard.tsx deleted file mode 100644 index fa740e4..0000000 --- a/frontend/src/main-page/users/PendingUserCard.tsx +++ /dev/null @@ -1,115 +0,0 @@ -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import UserPositionCard from "./UserPositionCard"; -import { faCheck, faX } from "@fortawesome/free-solid-svg-icons"; -import { api } from "../../api" -import { UserStatus } from "../../../../middle-layer/types/UserStatus"; -import { getAppStore } from "../../external/bcanSatchel/store"; -import { User } from "../../../../middle-layer/types/User"; -import { toJS } from "mobx"; -import { moveUserToActive, removeUser } from "./UserActions"; -import { useState } from "react"; - -// Did not change this to using the email/first name last name due to user page redesign so someone will be changing all of this anyway - -const store = getAppStore(); - -interface PendingUserCardProps { - userId: string; - email: string; - position: UserStatus; -} - - -const PendingUserCard = ({ - userId, - email, - position, -}: PendingUserCardProps) => { - - const [isLoading, setIsLoading] = useState(false); - - const approveUser = async () => { - setIsLoading(true); - try { - const response = await api("/user/change-role", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - user: { - email, - position - } as User, - groupName: "Employee", - requestedBy: toJS(store.user) as User, - }), - }); - if (response.ok) { - alert(`User ${userId} has been approved successfully`); - const body = await response.json(); - moveUserToActive(body as User) - } else { - alert("Failed to approve user"); - } - } catch (error) { - console.error("Error approving user:", error); - alert("Error approving user"); - } finally { - setIsLoading(false); - } - }; - - const rejectUser = async () => { - setIsLoading(true); - try { - const response = await api("user/delete-user", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - user: { - email: email, - position, - } as User, - requestedBy: toJS(store.user) as User, - }), - }); - if (response.ok) { - alert(`User ${name} has been deleted successfully`); - const body = await response.json(); - removeUser(body) - } else { - alert("Failed to reject user"); - } - } catch (error) { - console.error("Error rejecting user:", error); - alert("Error rejecting user"); - } finally { - setIsLoading(false); - } - }; - - return ( -
-

{userId}

-

{email}

-
- -
-
- - -
-
- ); -}; - -export default PendingUserCard; diff --git a/frontend/src/main-page/users/UserActions.ts b/frontend/src/main-page/users/UserActions.ts index f726fbd..ddffc50 100644 --- a/frontend/src/main-page/users/UserActions.ts +++ b/frontend/src/main-page/users/UserActions.ts @@ -1,12 +1,19 @@ -import { api } from "../../api" +import { api } from "../../api"; import { User } from "../../../../middle-layer/types/User"; -import { setActiveUsers, setInactiveUsers } from "../../external/bcanSatchel/actions"; +import { + setActiveUsers, + setInactiveUsers, +} from "../../external/bcanSatchel/actions"; import { getAppStore } from "../../external/bcanSatchel/store"; import { toJS } from "mobx"; +import { UserStatus } from "../../../../middle-layer/types/UserStatus"; + +const store = getAppStore(); + export const fetchActiveUsers = async (): Promise => { try { const response = await api("/user/active", { - method: 'GET' + method: "GET", }); if (!response.ok && response.status !== 200) { @@ -19,43 +26,163 @@ export const fetchActiveUsers = async (): Promise => { console.error("Error fetching active users:", error); return []; // Return empty array on error } -} +}; export const fetchInactiveUsers = async (): Promise => { try { - const response = await api("/user/inactive", { method: 'GET' }); + const response = await api("/user/inactive", { method: "GET" }); if (!response.ok && response.status !== 200) { throw new Error(`HTTP Error, Status: ${response.status}`); } const inactiveUsers = await response.json(); return inactiveUsers as User[]; + } catch (error) { + console.error("Error fetching inactive users:", error); + return []; // Return empty array on error } - catch (error) { - console.error("Error fetching active users:", error); - return []; // Return empty array on error - - } -} +}; export const fetchUsers = async () => { console.log("Fetching users..."); - const active = await fetchActiveUsers(); - const inactive = await fetchInactiveUsers(); - if (active) { - setActiveUsers(active); - console.log("Active users fetched:", toJS(getAppStore().activeUsers)); - } - if (inactive) { - setInactiveUsers(inactive); - console.log("Inactive users fetched:", toJS(getAppStore().inactiveUsers)); - } - }; + const active = await fetchActiveUsers(); + const inactive = await fetchInactiveUsers(); + if (active) { + setActiveUsers(active); + console.log("Active users fetched:", toJS(store.activeUsers)); + } + if (inactive) { + setInactiveUsers(inactive); + console.log("Inactive users fetched:", toJS(store.inactiveUsers)); + } +}; export const moveUserToActive = (user: User) => { - setActiveUsers([...getAppStore().activeUsers, user]); - setInactiveUsers(getAppStore().inactiveUsers.filter(u => u.email !== user.email)); -} + setActiveUsers([...store.activeUsers, user]); + setInactiveUsers(store.inactiveUsers.filter((u) => u.email !== user.email)); +}; export const removeUser = (user: User) => { - setInactiveUsers(getAppStore().inactiveUsers.filter(u => u.email !== user.email)); -} \ No newline at end of file + setInactiveUsers(store.inactiveUsers.filter((u) => u.email !== user.email)); + setActiveUsers(store.activeUsers.filter((u) => u.email !== user.email)); +}; + +export const approveUser = async ( + user: User, + setIsLoading: (loading: boolean) => void, +) => { + setIsLoading(true); + try { + const response = await api("/user/change-role", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + user: { + email: user.email, + position: user.position, + } as User, + groupName: "Employee", + requestedBy: toJS(store.user) as User, + }), + }); + if (response.ok) { + alert(`User ${user.email} has been approved successfully`); + const body = await response.json(); + moveUserToActive(body as User); + } else { + alert("Failed to approve user"); + } + } catch (error) { + console.error("Error approving user:", error); + alert("Error approving user"); + } finally { + setIsLoading(false); + } +}; + +export const deleteUser = async ( + user: User, + setIsLoading: (loading: boolean) => void, +) => { + setIsLoading(true); + try { + const response = await api( + `user/delete-user/${encodeURIComponent(user.email)}`, + { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + user: { + email: user.email, + position: user.position, + } as User, + requestedBy: toJS(store.user) as User, + }), + }, + ); + + if (response.ok) { + console.log(`User ${user.email} has been deleted successfully`); + alert(`User ${user.email} has been deleted successfully`); + const body = await response.json(); + removeUser(body); + } else { + const errorBody = await response.json(); + console.error("Error: ", errorBody); + alert("Failed to delete user"); + } + } catch (error) { + console.error("Error deleting user:", error); + alert("Error deleting user"); + } finally { + setIsLoading(false); + } +}; + +export const changeUserGroup = async (user: User) => { + console.log( + `Changing user ${user.email} to ${ + user.position === UserStatus.Admin ? "employee" : "admin" + }...`, + ); + + try { + const response = await api("/user/change-role", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + user: { + email: user.email, + position: user.position, + } as User, + groupName: + user.position === UserStatus.Admin + ? UserStatus.Employee + : UserStatus.Admin, + requestedBy: toJS(store.user) as User, + }), + }); + + if (response.ok) { + console.log( + `User ${user.email} successfully changed to ${ + user.position === UserStatus.Admin ? "employee" : "admin" + }`, + ); + alert( + `User ${user.email} successfully changed to ${ + user.position === UserStatus.Admin ? "employee" : "admin" + }`, + ); + const updatedUser = await response.json(); + setActiveUsers([ + ...store.activeUsers.filter((u) => u.email !== user.email), + updatedUser as User, + ]); + } else { + const errorBody = await response.json(); + console.error("Error: ", errorBody); + } + } catch (error) { + console.error("Error changing user group: ", error); + } +}; diff --git a/frontend/src/main-page/users/UserSearch.tsx b/frontend/src/main-page/users/UserSearch.tsx new file mode 100644 index 0000000..2f1c328 --- /dev/null +++ b/frontend/src/main-page/users/UserSearch.tsx @@ -0,0 +1,37 @@ +import { useState } from "react"; +import Fuse from "fuse.js"; +import { getAppStore } from "../../external/bcanSatchel/store"; +import { updateUserQuery } from "../../external/bcanSatchel/actions"; +import { User } from "../../../../middle-layer/types/User"; +import SearchBar from "../../components/SearchBar"; + +function UserSearch() { + const [userInput, setUserInput] = useState(getAppStore().userQuery || ""); + // @ts-ignore + const [users, _setUsers] = useState([]); + + const handleInputChange = (e: React.ChangeEvent) => { + setUserInput(e.target.value); + performSearch(e.target.value); + }; + + const performSearch = (query: string) => { + if (!query) { + updateUserQuery(""); + return; + } + const fuse = new Fuse(users, { + keys: ["firstName", "lastName", "email"], + threshold: 0.3, + }); + // const results = + fuse.search(query).map((res) => res.item); + updateUserQuery(query); + }; + + return ( + + ); +} + +export default UserSearch; diff --git a/frontend/src/main-page/users/Users.tsx b/frontend/src/main-page/users/Users.tsx deleted file mode 100644 index 467252f..0000000 --- a/frontend/src/main-page/users/Users.tsx +++ /dev/null @@ -1,221 +0,0 @@ -import { useEffect, useState } from "react"; -import ApprovedUserCard from "./ApprovedUserCard"; -import PendingUserCard from "./PendingUserCard"; -import { User } from "../../../../middle-layer/types/User"; -import { Pagination, ButtonGroup, IconButton } from "@chakra-ui/react"; -import { HiChevronLeft, HiChevronRight } from "react-icons/hi"; -import { observer } from "mobx-react-lite"; -import { getAppStore } from "../../external/bcanSatchel/store"; -import { api } from "../../api"; -import { Navigate } from "react-router-dom"; -import { UserStatus } from "../../../../middle-layer/types/UserStatus"; -import { useAuthContext } from "../../context/auth/authContext"; - -// Did not change this to using the email/first name last name due to user page redesign so someone will be changing all of this anyway -// Represents a specific tab to show on the user page -enum UsersTab { - PendingUsers, - CurrentUsers, -} - -const fetchActiveUsers = async (): Promise => { - try { - const response = await api("/user/active", { - method: "GET", - }); - - if (!response.ok) { - throw new Error(`HTTP Error, Status: ${response.status}`); - } - - const activeUsers = await response.json(); - return activeUsers as User[]; - } catch (error) { - console.error("Error fetching active users:", error); - return []; // Return empty array on error - } -}; - -const fetchInactiveUsers = async () => { - try { - const response = await api("/user/inactive", { method: "GET" }); - if (!response.ok) { - throw new Error(`HTTP Error, Status: ${response.status}`); - } - const inactiveUsers = await response.json(); - return inactiveUsers as User[]; - } catch (error) { - console.error("Error fetching active users:", error); - } -}; - -const ITEMS_PER_PAGE = 8; - -const Users = observer(() => { - const store = getAppStore(); - const { user } = useAuthContext(); - - useEffect(() => { - const fetchUsers = async () => { - const active = await fetchActiveUsers(); - const inactive = await fetchInactiveUsers(); - if (active) { - store.activeUsers = active; - } - if (inactive) { - store.inactiveUsers = inactive; - } - }; - fetchUsers(); - }, []); - - const [usersTabStatus, setUsersTabStatus] = useState( - UsersTab.CurrentUsers - ); - const [currentPage, setCurrentPage] = useState(1); - - const filteredUsers = - usersTabStatus === UsersTab.PendingUsers - ? store.inactiveUsers - : store.activeUsers; - - const numInactiveUsers = store.inactiveUsers.length; - const numUsers = filteredUsers.length; - const pageStartIndex = (currentPage - 1) * ITEMS_PER_PAGE; - const pageEndIndex = - pageStartIndex + ITEMS_PER_PAGE > numUsers - ? numUsers - : pageStartIndex + ITEMS_PER_PAGE; - const currentPageUsers = filteredUsers.slice(pageStartIndex, pageEndIndex); - - return user ? ( - user?.position !== UserStatus.Inactive ? ( -
-
-

- {usersTabStatus === UsersTab.CurrentUsers - ? "All Users" - : "Pending Users"} -

-

{numInactiveUsers} new users

-
-
-
- - -
-
- {usersTabStatus === UsersTab.CurrentUsers ? ( - <> -
-

User ID

-

Email

-

Position

-
- {currentPageUsers.map((user) => ( - - ))} - - ) : ( - <> -
-

User ID

-

Email

-

Position

-
-
- {currentPageUsers.map((user) => ( - - ))} - - )} -
- { - setCurrentPage(e.page); - }} - > - - - - - - - - {({ pages }) => - pages.map((page, index) => - page.type === "page" ? ( - setCurrentPage(page.value)} - aria-label={`Go to page ${page.value}`} - > - {page.value} - - ) : ( - "..." - ) - ) - } - - - - - - - - -
-
- ) : ( - - ) - ) : ( - - ); -}); - -export default Users; diff --git a/frontend/src/main-page/users/UsersPage.tsx b/frontend/src/main-page/users/UsersPage.tsx new file mode 100644 index 0000000..639527a --- /dev/null +++ b/frontend/src/main-page/users/UsersPage.tsx @@ -0,0 +1,126 @@ +import { useState } from "react"; +//import { ButtonGroup, IconButton, Pagination } from "@chakra-ui/react"; + +import { observer } from "mobx-react-lite"; +import Button from "../../components/Button.tsx"; +import UserSearch from "./UserSearch.tsx"; +import { ProcessUserData } from "./processUserData.ts"; +import UserRow from "./user-rows/UserRow.tsx"; +import UserMenu from "./user-rows/UserMenu.tsx"; +import UserRowHeader from "./user-rows/UserRowHeader.tsx"; +import UserApprove from "./user-rows/UserApprove.tsx"; + +const UsersPage = observer(() => { + const [showAll, setShowAll] = useState(true); + + const { activeUsers, inactiveUsers } = ProcessUserData(); + //const ITEMS_PER_PAGE = 8; + + //const [currentPage, setCurrentPage] = useState(1); + + const filteredUsers = showAll ? activeUsers : inactiveUsers; + + // const numUsers = filteredUsers.length; + // const pageStartIndex = (currentPage - 1) * ITEMS_PER_PAGE; + // const pageStartIndex = 1; // Temporarily disable pagination by always starting at index 0 + // const pageEndIndex = + // pageStartIndex + ITEMS_PER_PAGE > numUsers + // ? numUsers + // : pageStartIndex + ITEMS_PER_PAGE; + // const currentPageUsers = filteredUsers.slice(pageStartIndex, pageEndIndex); + const currentPageUsers = filteredUsers; // Temporarily disable pagination by showing all users + return ( +
+ +
+
+
+ +
+
+ + {currentPageUsers.map((user) => ( +
+ + ) : ( + + ) + } + /> +
+ ))} + {currentPageUsers.length === 0 && ( +
+

No users to display

+
+ )} +
+
+ {/* Commenting out pagination for now to check if needed */} + {/* { + setCurrentPage(e.page); + }} + > + + + + + + + + {({ pages }) => + pages.map((page, index) => + page.type === "page" ? ( + setCurrentPage(page.value)} + aria-label={`Go to page ${page.value}`} + > + {page.value} + + ) : ( + "..." + ), + ) + } + + + + + + + + */} +
+ ); +}); + +export default UsersPage; diff --git a/frontend/src/main-page/users/processUserData.ts b/frontend/src/main-page/users/processUserData.ts new file mode 100644 index 0000000..34fc80c --- /dev/null +++ b/frontend/src/main-page/users/processUserData.ts @@ -0,0 +1,73 @@ +import { useEffect } from "react"; +import { User } from "../../../../middle-layer/types/User.ts"; +import { getAppStore } from "../../external/bcanSatchel/store.ts"; +import { fetchActiveUsers, fetchInactiveUsers } from "./UserActions.ts"; + +const searchFilter = (searchQuery: string) => (user: User) => { + if (!searchQuery.trim()) return true; + + const query = searchQuery.toLowerCase(); + const firstName = user.firstName?.toLowerCase() || ""; + const lastName = user.lastName?.toLowerCase() || ""; + const email = user.email?.toLowerCase() || ""; + + return ( + firstName.includes(query) || + lastName.includes(query) || + email.includes(query) + ); +}; + +const filterUsers = (users: User[], predicates: ((user: User) => boolean)[]) => + users.filter((user) => predicates.every((fn) => fn(user))); + +const sortUsers = ( + users: User[], + header: keyof User, + sort: "asc" | "desc" | "none", +) => + [...users].sort((a: User, b: User) => { + const direction = sort === "asc" ? -1 : 1; + + const aValue = a[header]; + const bValue = b[header]; + + if (aValue == null) return -1 * direction; + if (bValue == null) return 1 * direction; + + if (aValue > bValue) return 1 * direction; + if (aValue < bValue) return -1 * direction; + + return 0; + }); + +// contains callbacks for sorting and filtering users +// stores state for list of users/filter +export const ProcessUserData = () => { + const { activeUsers, inactiveUsers, userQuery, userSort } = getAppStore(); + + // fetch users on mount if empty + useEffect(() => { + if (activeUsers.length === 0) fetchActiveUsers(); + if (inactiveUsers.length === 0) fetchInactiveUsers(); + }, [activeUsers.length, inactiveUsers.length]); + + // compute filtered users dynamically — no useState needed + const activeFiltered = filterUsers(activeUsers, [searchFilter(userQuery)]); + + const inactiveFiltered = filterUsers(inactiveUsers, [ + searchFilter(userQuery), + ]); + + const sortedActive = + userSort && userSort.sort !== "none" + ? sortUsers(activeFiltered, userSort.header, userSort.sort) + : activeFiltered; + + const sortedInactive = + userSort && userSort.sort !== "none" + ? sortUsers(inactiveFiltered, userSort.header, userSort.sort) + : inactiveFiltered; + + return { activeUsers: sortedActive, inactiveUsers: sortedInactive }; +}; diff --git a/frontend/src/main-page/users/user-rows/UserApprove.tsx b/frontend/src/main-page/users/user-rows/UserApprove.tsx new file mode 100644 index 0000000..4cfc7d6 --- /dev/null +++ b/frontend/src/main-page/users/user-rows/UserApprove.tsx @@ -0,0 +1,33 @@ +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faCheck, faX } from "@fortawesome/free-solid-svg-icons"; +import { User } from "../../../../../middle-layer/types/User"; +import { approveUser, deleteUser } from "../UserActions"; +import { useState } from "react"; +interface UserApproveProps { + user: User; +} + +const UserApprove = ({ user }: UserApproveProps) => { + const [isLoading, setIsLoading] = useState(false); + + return ( +
+ + +
+ ); +}; + +export default UserApprove; diff --git a/frontend/src/main-page/users/user-rows/UserMenu.tsx b/frontend/src/main-page/users/user-rows/UserMenu.tsx new file mode 100644 index 0000000..e94e739 --- /dev/null +++ b/frontend/src/main-page/users/user-rows/UserMenu.tsx @@ -0,0 +1,89 @@ +import { Menu } from "@chakra-ui/react"; +import { UserStatus } from "../../../../../middle-layer/types/UserStatus"; +import { faUserPen, faTrash } from "@fortawesome/free-solid-svg-icons"; +import ActionConfirmation from "../../../custom/ActionConfirmation"; +import { useState } from "react"; +import { User } from "../../../../../middle-layer/types/User"; +import Button from "../../../components/Button"; +import { FaEllipsis } from "react-icons/fa6"; +import { changeUserGroup, deleteUser } from "../UserActions"; + +interface UserMenuProps { + user: User; +} + +const UserMenu = ({ user }: UserMenuProps) => { + const [isChangeGroupModalOpen, setIsChangeGroupModalOpen] = useState(false); + const [isDeleteUserModalOpen, setIsDeleteUserModalOpen] = useState(false); + + return ( +
+ setIsChangeGroupModalOpen(false)} + onConfirmDelete={() => changeUserGroup(user)} + title={`Change User to ${ + user.position === UserStatus.Admin ? "Employee" : "Admin" + }`} + subtitle="Are you sure you want to change to" + boldSubtitle={user.position === UserStatus.Admin ? "employee" : "admin"} + warningMessage={`By changing to ${ + user.position === UserStatus.Admin ? "employee" : "admin" + }, they will ${ + user.position === UserStatus.Admin + ? "lose access to sensitive data." + : "gain access to admin pages." + }`} + /> + setIsDeleteUserModalOpen(false)} + onConfirmDelete={() => deleteUser(user, () => {})} + title="Delete User" + subtitle="Are you sure you want to delete" + boldSubtitle={user.email} + warningMessage="By deleting this user, they won't be available in the system anymore." + /> +
+ + +
+ +
+
+ + +
+
+
+
+
+
+
+ ); +}; + +export default UserMenu; diff --git a/frontend/src/main-page/users/UserPositionCard.tsx b/frontend/src/main-page/users/user-rows/UserPositionCard.tsx similarity index 66% rename from frontend/src/main-page/users/UserPositionCard.tsx rename to frontend/src/main-page/users/user-rows/UserPositionCard.tsx index 4cc2988..45c5f20 100644 --- a/frontend/src/main-page/users/UserPositionCard.tsx +++ b/frontend/src/main-page/users/user-rows/UserPositionCard.tsx @@ -1,5 +1,5 @@ import { useMemo } from "react"; -import { UserStatus } from "../../../../middle-layer/types/UserStatus"; +import { UserStatus } from "../../../../../middle-layer/types/UserStatus"; interface UserPositionCardProps { position: UserStatus; @@ -14,13 +14,13 @@ const UserPositionCard = ({ position }: UserPositionCardProps) => { return "bg-yellow-light border-yellow-dark text-yellow-dark"; case UserStatus.Inactive: default: - return "bg-grey-400 border-gray text-gray"; + return "bg-grey-400 border-grey text-grey-700"; } }, [position]); return ( -
-

{position}

+
+

{position}

); }; diff --git a/frontend/src/main-page/users/user-rows/UserRow.tsx b/frontend/src/main-page/users/user-rows/UserRow.tsx new file mode 100644 index 0000000..1cd1d96 --- /dev/null +++ b/frontend/src/main-page/users/user-rows/UserRow.tsx @@ -0,0 +1,32 @@ +import { User } from "../../../../../middle-layer/types/User"; +import UserPositionCard from "./UserPositionCard"; +import logo from "../../../images/logo.svg"; + +interface UserRowProps { + user: User; + action: React.ReactNode; +} +const UserRow = ({ user, action }: UserRowProps) => { + return ( +
+
+ Profile + {user.firstName} {user.lastName} +
+
{user.email}
+
+ +
+
{action}
+
+ ); +}; + +export default UserRow; diff --git a/frontend/src/main-page/users/user-rows/UserRowHeader.tsx b/frontend/src/main-page/users/user-rows/UserRowHeader.tsx new file mode 100644 index 0000000..e7c84b5 --- /dev/null +++ b/frontend/src/main-page/users/user-rows/UserRowHeader.tsx @@ -0,0 +1,49 @@ +import { useState } from "react"; +import { User } from "../../../../../middle-layer/types/User"; +import { updateUserSort } from "../../../external/bcanSatchel/actions"; +import UserRowHeaderButton from "./UserRowHeaderButton"; + +// Did not change this to using the email/first name last name due to user page redesign so someone will be changing all of this anyway + +const UserRowHeader = () => { + const [labels, setLabels] = useState({ + header: "lastName", + sort: "desc", + } as { header: keyof User; sort: "asc" | "desc" | "none" }); + + function buttonHandler(header: keyof User) { + const isAsc = + labels.header == header + ? labels.sort == "asc" + ? "desc" + : labels.sort == "desc" + ? "asc" + : "none" + : "desc"; + updateUserSort({ header, sort: isAsc }); + setLabels({ header: header, sort: isAsc }); + } + + return ( +
+ buttonHandler("firstName")} + /> + buttonHandler("email")} + /> + buttonHandler("position")} + /> +
{"Action"}
+
+ ); +}; + +export default UserRowHeader; diff --git a/frontend/src/main-page/users/user-rows/UserRowHeaderButton.tsx b/frontend/src/main-page/users/user-rows/UserRowHeaderButton.tsx new file mode 100644 index 0000000..0fde678 --- /dev/null +++ b/frontend/src/main-page/users/user-rows/UserRowHeaderButton.tsx @@ -0,0 +1,41 @@ +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { + faSort, + faSortUp, + faSortDown, +} from "@fortawesome/free-solid-svg-icons"; + +// Did not change this to using the email/first name last name due to user page redesign so someone will be changing all of this anyway + +interface UserRowHeaderButtonProps { + header: string; + sort: "asc" | "desc" | "none"; + onClick: () => void; +} + +const UserRowHeaderButton = (props: UserRowHeaderButtonProps) => { + return ( + + ); +}; + +export default UserRowHeaderButton; diff --git a/frontend/src/styles/notification.css b/frontend/src/styles/notification.css index 7f794c1..942690e 100644 --- a/frontend/src/styles/notification.css +++ b/frontend/src/styles/notification.css @@ -1,12 +1,9 @@ .notification-popup { position: absolute; - right: 7rem; - top: 110px; + right: 6rem; + top: 2.5rem; width: min(340px, 70%); background-color: white; - border: 1px solid black; - border-radius: 6px; - box-shadow: 0 4px 10px rgba(0,0,0,0.1); padding: 0.5rem; z-index: 1000; } diff --git a/frontend/tailwind.config.ts b/frontend/tailwind.config.ts index 435c676..e326bed 100644 --- a/frontend/tailwind.config.ts +++ b/frontend/tailwind.config.ts @@ -90,12 +90,15 @@ export default { }, borderWidth: { DEFAULT: "2px", + 2: "2px", + 1: "1px", 0: "0", }, borderRadius: { sm: "0.5rem", md: "0.75rem", DEFAULT: "0.75rem", + lg: "1rem", "4xl": "2rem", }, },