From ba2e9c341fd0cdca2920775b9f0055eb689c9682 Mon Sep 17 00:00:00 2001 From: prathyu99 Date: Mon, 3 Feb 2025 16:22:09 -0500 Subject: [PATCH 1/2] impersonate user --- package.json | 1 + src/App.tsx | 5 + src/layout/Header.tsx | 142 +++++++++++++- src/pages/Impersonate/ImpersonateUser.css | 29 +++ src/pages/Impersonate/ImpersonateUser.tsx | 218 ++++++++++++++++++++++ 5 files changed, 393 insertions(+), 2 deletions(-) create mode 100644 src/pages/Impersonate/ImpersonateUser.css create mode 100644 src/pages/Impersonate/ImpersonateUser.tsx diff --git a/package.json b/package.json index a1ed9d64..d90d5be5 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "@types/react-dom": "^18.2.4", "@types/react-redux": "^7.1.25", "@types/react-router-dom": "^5.3.3", + "@types/lodash.debounce": "^4.0.8", "axios": "^1.4.0", "bootstrap": "^5.3.3", "chart.js": "^4.1.1", diff --git a/src/App.tsx b/src/App.tsx index 27736ba3..82c04fec 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -40,6 +40,7 @@ import ViewSubmissions from "pages/Assignments/ViewSubmissions"; import ViewScores from "pages/Assignments/ViewScores"; import ViewReports from "pages/Assignments/ViewReports"; import ViewDelayedJobs from "pages/Assignments/ViewDelayedJobs"; +import ImpersonateUser from "pages/Impersonate/ImpersonateUser"; function App() { const router = createBrowserRouter([ { @@ -288,6 +289,10 @@ function App() { }, ], }, + { + path: "impersonate", + element: } />, + }, { path: "*", element: }, { path: "questionnaire", element: }, // Added the Questionnaire route ], diff --git a/src/layout/Header.tsx b/src/layout/Header.tsx index 5b278dd8..816013a7 100644 --- a/src/layout/Header.tsx +++ b/src/layout/Header.tsx @@ -1,10 +1,14 @@ import React, { Fragment, useState, useEffect } from "react"; import { Button, Container, Nav, Navbar, NavDropdown } from "react-bootstrap"; -import { useSelector } from "react-redux"; -import { Link, useNavigate } from "react-router-dom"; +import { useSelector, useDispatch } from "react-redux"; +import useAPI from "hooks/useAPI"; +import { Link, useNavigate, useLocation } from "react-router-dom"; import { RootState } from "../store/store"; +import { alertActions } from "store/slices/alertSlice"; import { ROLE } from "../utils/interfaces"; import { hasAllPrivilegesOf } from "../utils/util"; +import { authenticationActions } from "../store/slices/authenticationSlice"; +import { setAuthToken } from "../utils/auth"; import detective from "../assets/detective.png"; /** @@ -12,11 +16,14 @@ import detective from "../assets/detective.png"; */ const Header: React.FC = () => { + const { data: userResponse, sendRequest: fetchUsers } = useAPI(); const auth = useSelector( (state: RootState) => state.authentication, (prev, next) => prev.isAuthenticated === next.isAuthenticated ); const navigate = useNavigate(); + const dispatch = useDispatch(); + const location = useLocation(); const [visible, setVisible] = useState(true); @@ -67,6 +74,136 @@ const Header: React.FC = () => { // console.log(visible, 'Changed'); // }, [visible]); +const impersonateUserPayload = localStorage.getItem("impersonateBannerMessage"); + +useEffect(() => { + fetchUsers({ + method: "get", + url: `/users/${auth.user.id}/managed`, + }); + }, [fetchUsers, auth.user.id]); + +const ImpersonateBanner = () => { + const [impersonateName, setImpersonateName] = useState(""); + const { error, data: impersonateUserResponse, sendRequest: impersonateUser } = useAPI(); + const handleImpersonate = (value: string) => { + if (!userResponse || !userResponse.data) { + console.error("userResponse is undefined or does not have data."); + return; + } + const matchedUser = userResponse.data.find((user: { name: string }) => user.name === value); + if (matchedUser) { + console.log("Match found:", matchedUser.id); + impersonateUser({ + method: "post", + url: `/impersonate`, + data: { + impersonate_id: matchedUser.id, + }, + }); + } else { + console.log("No match found for", value); + dispatch( + alertActions.showAlert({ + variant: "danger", + message: "Invalid Request!", + }) + ); + } + }; + + // Set Impersonation Message after the impersonateUser POST API call is complete + useEffect(() => { + if (impersonateUserResponse?.status === 200) { + const impersonateMessage = + "impersonate..." + localStorage.setItem("impersonateBannerMessage", impersonateMessage); + } + }, [impersonateUserResponse]); + + // Handle any uncaught user impersonation errors + useEffect(() => { + if (error) { + dispatch(alertActions.showAlert({ variant: "danger", message: error })); + } + }, [error, dispatch]); + + // Impersonate user authentication + useEffect(() => { + if (impersonateUserResponse?.data) { + dispatch( + authenticationActions.setAuthentication({ + authToken: impersonateUserResponse.data.token, + user: setAuthToken(impersonateUserResponse.data.token), + }) + ); + + navigate(location.state?.from ? location.state.from : "/"); + navigate(0); + } + }, [impersonateUserResponse]); + + const handleCancelImpersonate = () => { + dispatch( + authenticationActions.setAuthentication({ + authToken: localStorage.getItem("originalUserToken"), + user: setAuthToken(localStorage.getItem("originalUserToken") || ""), + }) + ); + + localStorage.removeItem("originalUserToken"); + localStorage.removeItem("impersonateBannerMessage"); + + navigate(location.state?.from ? location.state.from : "/"); + navigate(0); + }; + + return ( +
  • +
    + setImpersonateName(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + handleImpersonate(impersonateName); // Call the impersonate API on "Enter" + } + }} + style={{ + backgroundColor: 'white', /* White background */ + border: '1px solid #24a0ed', /* Border */ + padding: '3px 8px', /* Reduced padding */ + height: '28px', /* Reduced height */ + fontSize: '14px', /* Reduced font size */ + width: '110px', /* Fixed width to fit placeholder */ + outline: 'none', /* Remove default outline */ + }} + /> + + + +
    +
  • + ); +}; + return ( { Anonymized View + {impersonateUserPayload && } {visible ? ( User: {auth.user.full_name} diff --git a/src/pages/Impersonate/ImpersonateUser.css b/src/pages/Impersonate/ImpersonateUser.css new file mode 100644 index 00000000..803cb23d --- /dev/null +++ b/src/pages/Impersonate/ImpersonateUser.css @@ -0,0 +1,29 @@ +/* Impersonate Button Spinner */ +.loader { + width: 32px; + aspect-ratio: 1; + display: grid; + border-radius: 50%; + background: linear-gradient(0deg, rgb(0 0 0/50%) 30%, #0000 0 70%, rgb(0 0 0/100%) 0) 50%/8% 100%, + linear-gradient(90deg, rgb(0 0 0/25%) 30%, #0000 0 70%, rgb(0 0 0/75%) 0) 50%/100% 8%; + background-repeat: no-repeat; + animation: l23 1s infinite steps(12); +} +.loader::before, +.loader::after { + content: ""; + grid-area: 1/1; + border-radius: 50%; + background: inherit; + opacity: 0.915; + transform: rotate(30deg); +} +.loader::after { + opacity: 0.83; + transform: rotate(60deg); +} +@keyframes l23 { + 100% { + transform: rotate(1turn); + } +} diff --git a/src/pages/Impersonate/ImpersonateUser.tsx b/src/pages/Impersonate/ImpersonateUser.tsx new file mode 100644 index 00000000..0ca1ba5f --- /dev/null +++ b/src/pages/Impersonate/ImpersonateUser.tsx @@ -0,0 +1,218 @@ +import React, { useState, useEffect, useMemo } from "react"; +import { Col, Row, InputGroup, Form, Button, Dropdown } from "react-bootstrap"; +import useAPI from "hooks/useAPI"; +import debounce from "lodash.debounce"; +import { useDispatch, useSelector } from "react-redux"; +import { RootState } from "../../store/store"; +import { alertActions } from "store/slices/alertSlice"; +import { authenticationActions } from "../../store/slices/authenticationSlice"; +import { useLocation, useNavigate } from "react-router-dom"; +import { setAuthToken } from "../../utils/auth"; +import masqueradeMask from "../../assets/masquerade-mask.png"; +import "./ImpersonateUser.css"; + +const ImpersonateUser: React.FC = () => { + const { data: userResponse, sendRequest: fetchUsers } = useAPI(); + const auth = useSelector( + (state: RootState) => state.authentication, + (prev, next) => prev.isAuthenticated === next.isAuthenticated + ); + const { data: fetchSelectedUser, sendRequest: selectedUser } = useAPI(); + const { error, data: impersonateUserResponse, sendRequest: impersonateUser } = useAPI(); + const [searchQuery, setSearchQuery] = useState(""); + const [debounceActive, setDebounceActive] = useState(false); + const [selectedValidUser, setSelectedValidUser] = useState(false); + const [typingTimeout, setTypingTimeout] = useState(null); + const dispatch = useDispatch(); + const navigate = useNavigate(); + const location = useLocation(); + + // Fetch user list once on component mount + useEffect(() => { + fetchUsers({ + method: "get", + url: `/users/${auth.user.id}/managed`, + }); + }, [fetchUsers, auth.user.id]); + + // Handle search query input change and trigger debounce + const handleSearchQueryInput = (e: React.ChangeEvent) => { + if (typingTimeout) clearTimeout(typingTimeout); + + setSearchQuery(e.target.value); + setDebounceActive(true); + + const timeout = setTimeout(() => { + setDebounceActive(false); + }, 300); + + setTypingTimeout(timeout); + }; + + // Debounce search query + const debouncedSearch = useMemo(() => { + return debounce(handleSearchQueryInput, 300); + }, []); + + // Cleanup debounce function when component unmounts + useEffect(() => { + return () => { + if (typingTimeout) clearTimeout(typingTimeout); + debouncedSearch.cancel(); + }; + }, [debouncedSearch, typingTimeout]); + + // Display user list after debounce (autocomplete functionality) + const displayUserList = () => { + if (!searchQuery.trim() || !userResponse?.data) { + return null; + } + + const userArray = Array.isArray(userResponse.data) ? userResponse.data : [userResponse.data]; + + const filteredUserArray = userArray.filter((user: any) => + user?.name?.toLowerCase().includes(searchQuery.toLowerCase()) + ); + + if (filteredUserArray.length > 0) { + return ( + + + {filteredUserArray.map((filteredUser: any) => ( + setSearchQuery(filteredUser.name)} + > + {filteredUser.name} + + ))} + + + ); + } else { + return ( + + + No Users Found + + + ); + } + }; + + // Fetch selected user based on the Search Query + useEffect(() => { + const userArray = Array.isArray(userResponse?.data) ? userResponse?.data : [userResponse?.data]; + + const validUser = userArray?.find( + (user: any) => searchQuery.toLowerCase() === (user?.name?.toLowerCase() || "") + ); + + // Don't initiate a GET if the searchQuery is empty + if (searchQuery.trim() && validUser) { + setSelectedValidUser(true); + selectedUser({ + method: "get", + url: `/impersonate/${encodeURIComponent(validUser.full_name)}`, + }); + } else { + setSelectedValidUser(false); + } + }, [selectedUser, searchQuery, userResponse]); + + // Impersonate user + const handleImpersonate = () => { + const fetchSelectedUserEntry = fetchSelectedUser?.data.userList[0]; + + // Store only the initial User's JWT token and information + if (!localStorage.getItem("originalUserToken")) { + localStorage.setItem("originalUserToken", auth.authToken); + } + + if (fetchSelectedUserEntry) { + impersonateUser({ + method: "post", + url: `/impersonate`, + data: { + impersonate_id: fetchSelectedUserEntry.id, + }, + }); + } else { + dispatch( + alertActions.showAlert({ + variant: "danger", + message: "Cannot impersonate the Super Adminstrator!", + }) + ); + } + }; + + // Set Impersonation Message after the impersonateUser POST API call is complete + useEffect(() => { + if (impersonateUserResponse?.status === 200) { + const fetchSelectedUserEntry = fetchSelectedUser?.data.userList[0]; + const impersonateMessage = + "impersonate..." + localStorage.setItem("impersonateBannerMessage", impersonateMessage); + } + }, [impersonateUserResponse]); + + // Handle any uncaught user impersonation errors + useEffect(() => { + if (error) { + dispatch(alertActions.showAlert({ variant: "danger", message: error })); + } + }, [error, dispatch]); + + // Impersonate user authentication + useEffect(() => { + if (impersonateUserResponse?.data && fetchSelectedUser?.data) { + dispatch( + authenticationActions.setAuthentication({ + authToken: impersonateUserResponse.data.token, + user: setAuthToken(impersonateUserResponse.data.token), + }) + ); + + navigate(location.state?.from ? location.state.from : "/"); + navigate(0); + } + }, [impersonateUserResponse]); + + return ( + <> + + +

    Impersonate User

    + +
    +
    +
    +
    + + + + + {debounceActive &&
    } +
    + {displayUserList()} +
    +
    + + ); +}; + +export default ImpersonateUser; \ No newline at end of file From 9ba4dc2b577ae84e7c4437983d0af7add98cf333 Mon Sep 17 00:00:00 2001 From: prathyu99 Date: Mon, 24 Mar 2025 22:53:52 -0400 Subject: [PATCH 2/2] impersonate user: changed the colour of revert button to red --- src/layout/Header.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/layout/Header.tsx b/src/layout/Header.tsx index 816013a7..c6a3aa99 100644 --- a/src/layout/Header.tsx +++ b/src/layout/Header.tsx @@ -185,7 +185,7 @@ const ImpersonateBanner = () => {