From 08ac6782d17640259451b527f4aff238c661b1a0 Mon Sep 17 00:00:00 2001 From: Jake Turner Date: Wed, 18 Mar 2026 13:43:50 -0700 Subject: [PATCH] feat(CentralIdentity): add campus admin roles mgmt to user view --- client/src/api.ts | 26 +++ .../CampusAdminRolesSection.tsx | 165 +++++++++++++++ .../CentralIdentity/UserConductorData.tsx | 2 +- .../CentralIdentity/UserSupportTickets.tsx | 2 +- .../UsersManager/ManageUserRolesModal.tsx | 198 ------------------ .../CentralIdentityUserView.tsx | 83 ++++---- server/api.js | 4 +- server/api/organizations.ts | 5 + server/api/users.js | 14 +- 9 files changed, 252 insertions(+), 247 deletions(-) create mode 100644 client/src/components/controlpanel/CentralIdentity/CampusAdminRolesSection.tsx delete mode 100644 client/src/components/controlpanel/UsersManager/ManageUserRolesModal.tsx diff --git a/client/src/api.ts b/client/src/api.ts index 8d985f707..b9231e2c3 100644 --- a/client/src/api.ts +++ b/client/src/api.ts @@ -2001,6 +2001,32 @@ class API { } // user manager + async getUserRoles(uuid: string) { + const res = await axios.get< + { + user: { + uuid: string; + roles: { org: Pick; role: string; roleInternal: string }[]; + }; + } & ConductorBaseResponse + >("/user/roles", { params: { uuid } }); + return res; + } + + async updateUserRole(uuid: string, orgID: string, role: string) { + const res = await axios.put("/user/role/update", { + uuid, + orgID, + role, + }); + return res; + } + + async getAllOrganizations() { + const res = await axios.get<{ orgs: Organization[] } & ConductorBaseResponse>("/orgs"); + return res; + } + async deleteUserRole(orgID: string, uuid: string) { const res = await axios.delete(`/user/role/delete`, { data: { diff --git a/client/src/components/controlpanel/CentralIdentity/CampusAdminRolesSection.tsx b/client/src/components/controlpanel/CentralIdentity/CampusAdminRolesSection.tsx new file mode 100644 index 000000000..0cf04e27d --- /dev/null +++ b/client/src/components/controlpanel/CentralIdentity/CampusAdminRolesSection.tsx @@ -0,0 +1,165 @@ +import { useState } from "react"; +import { Header, Table, Button, Icon, Dropdown } from "semantic-ui-react"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import useGlobalError from "../../error/ErrorHooks"; +import { useNotifications } from "../../../context/NotificationContext"; +import { Organization } from "../../../types"; +import api from "../../../api"; + +interface Props { + uuid: string; +} + +type CampusAdminRole = { + org: Pick; + role: string; + roleInternal: string; +}; + +const CampusAdminRolesSection: React.FC = ({ uuid }) => { + const queryClient = useQueryClient(); + const { handleGlobalError } = useGlobalError(); + const { addNotification } = useNotifications(); + const [selectedOrg, setSelectedOrg] = useState(""); + + const { data: adminRoles = [], isFetching: rolesLoading } = useQuery({ + queryKey: ["user-campus-admin-roles", uuid], + queryFn: async () => { + try { + const res = await api.getUserRoles(uuid); + if (res.data.err) throw new Error(res.data.errMsg); + return res.data.user.roles.filter((r) => r.roleInternal === "campusadmin"); + } catch (err) { + handleGlobalError(err); + return []; + } + }, + enabled: !!uuid, + staleTime: 1000 * 60 * 5, + refetchOnWindowFocus: false, + }); + + const { data: allOrgs = [], isFetching: orgsLoading } = useQuery({ + queryKey: ["all-organizations"], + queryFn: async () => { + try { + const res = await api.getAllOrganizations(); + if (res.data.err) throw new Error(res.data.errMsg); + return res.data.orgs; + } catch (err) { + handleGlobalError(err); + return []; + } + }, + staleTime: 1000 * 60 * 10, + refetchOnWindowFocus: false, + }); + + const removeMutation = useMutation({ + mutationFn: async (orgID: string) => { + const res = await api.updateUserRole(uuid, orgID, "member"); + if (res.data.err) throw new Error(res.data.errMsg); + return res; + }, + onSuccess: () => { + addNotification({ message: "Campus admin role removed (demoted to member).", type: "success" }); + queryClient.invalidateQueries({ queryKey: ["user-campus-admin-roles", uuid] }); + }, + onError: (err) => handleGlobalError(err), + }); + + const grantMutation = useMutation({ + mutationFn: async (orgID: string) => { + const res = await api.updateUserRole(uuid, orgID, "campusadmin"); + if (res.data.err) throw new Error(res.data.errMsg); + return res; + }, + onSuccess: () => { + addNotification({ message: "Campus admin role granted.", type: "success" }); + setSelectedOrg(""); + queryClient.invalidateQueries({ queryKey: ["user-campus-admin-roles", uuid] }); + }, + onError: (err) => handleGlobalError(err), + }); + + const adminOrgIDs = new Set(adminRoles.map((r) => r.org.orgID)); + const grantableOrgs = allOrgs.filter((o) => !adminOrgIDs.has(o.orgID) && o.orgID !== "libretexts"); + const orgOptions = grantableOrgs.map((o) => ({ + key: o.orgID, + text: o.name, + value: o.orgID, + })); + + return ( +
+
+
+ Campus Admin Roles +
+
+ + + + Organization + Actions + + + + {adminRoles.length > 0 ? ( + adminRoles.map((r) => ( + + {r.org.name} + + + + + )) + ) : ( + + + {rolesLoading ? ( + Loading... + ) : ( + No campus admin roles found. + )} + + + )} + +
+ {!orgsLoading && grantableOrgs.length > 0 && ( +
+ setSelectedOrg(value as string)} + className="flex-1" + /> + +
+ )} +
+ ); +}; + +export default CampusAdminRolesSection; diff --git a/client/src/components/controlpanel/CentralIdentity/UserConductorData.tsx b/client/src/components/controlpanel/CentralIdentity/UserConductorData.tsx index 01cf64483..1639a655f 100644 --- a/client/src/components/controlpanel/CentralIdentity/UserConductorData.tsx +++ b/client/src/components/controlpanel/CentralIdentity/UserConductorData.tsx @@ -69,7 +69,7 @@ const UserConductorData: React.FC = ({ uuid }) => { }; return ( -
+
Conductor Projects diff --git a/client/src/components/controlpanel/CentralIdentity/UserSupportTickets.tsx b/client/src/components/controlpanel/CentralIdentity/UserSupportTickets.tsx index fa3a3f435..fb546dc01 100644 --- a/client/src/components/controlpanel/CentralIdentity/UserSupportTickets.tsx +++ b/client/src/components/controlpanel/CentralIdentity/UserSupportTickets.tsx @@ -67,7 +67,7 @@ const UserSupportTickets: React.FC = ({ uuid }) => { } return ( -
+
Support Tickets diff --git a/client/src/components/controlpanel/UsersManager/ManageUserRolesModal.tsx b/client/src/components/controlpanel/UsersManager/ManageUserRolesModal.tsx deleted file mode 100644 index acf94e77b..000000000 --- a/client/src/components/controlpanel/UsersManager/ManageUserRolesModal.tsx +++ /dev/null @@ -1,198 +0,0 @@ -import axios from "axios"; -import { Button, Dropdown, List, Loader, Modal, Icon } from "semantic-ui-react"; -import { Organization } from "../../../types"; -import React, { useEffect, useState } from "react"; -import useGlobalError from "../../error/ErrorHooks"; -import { UserRoleOptions } from "../../../utils/userHelpers"; -import api from "../../../api"; - -type ManageUserRolesModalProps = { - firstName: string; - isSuperAdmin: boolean; - lastName: string; - onClose: () => void; - orgID: string; - show: boolean; - uuid: string; -}; - -const ManageUserRolesModal: React.FC = ({ - firstName, - isSuperAdmin, - lastName, - onClose, - orgID, - show, - uuid, - ...props -}) => { - const { handleGlobalError } = useGlobalError(); - const [allOrganizations, setAllOrganizations] = useState([]); - const [loading, setLoading] = useState(false); - const [userRoles, setUserRoles] = useState< - { org: Organization; role: string; roleInternal: string }[] - >([]); - - const filteredRoles = (selectedOrgID: string) => { - return UserRoleOptions.filter((o) => { - if (o.value === "superadmin") return false; // no one can assign superadmin role - if (!isSuperAdmin && o.value === "harvester") return false; // only superadmins can assign harvester role - if (selectedOrgID !== "libretexts" && o.value === "harvester") return false; // only libretexts org can have harvester role - if (selectedOrgID === "libretexts" && o.value === "campusadmin") return false; // libretexts org cannot have campusadmin role - return true; - }); - }; - - async function getAllOrganizations() { - try { - setLoading(true); - const res = await axios.get("/orgs"); - if (res.data?.errMsg) { - handleGlobalError(res.data.errMsg); - return; - } - if (!Array.isArray(res.data?.orgs)) return; - const orgs = (res.data.orgs as Organization[]).sort((a, b) => - a.name.localeCompare(b.name) - ); - setAllOrganizations( - isSuperAdmin ? orgs : orgs.filter((org) => org.orgID === orgID) - ); - } catch (err) { - handleGlobalError(err); - } finally { - setLoading(false); - } - } - - async function getUserRoles() { - try { - setLoading(true); - const res = await axios.get("/user/roles", { - params: { - uuid, - }, - }); - if (res.data?.errMsg) { - handleGlobalError(res.data.errMsg); - return; - } - if (!res.data?.user || !Array.isArray(res.data?.user?.roles)) return; - setUserRoles(res.data.user.roles); - } catch (err) { - } finally { - setLoading(false); - } - } - - useEffect(() => { - if (show) { - getAllOrganizations(); - getUserRoles(); - } else { - setAllOrganizations([]); - setUserRoles([]); - } - }, [show]); - - async function updateUserRole(newRole: string, orgToUpdateID: string) { - try { - setLoading(true); - const res = await axios.put("/user/role/update", { - orgID: orgToUpdateID, - role: newRole, - uuid, - }); - if (res.data?.errMsg) { - handleGlobalError(res.data.errMsg); - return; - } - await getUserRoles(); - } catch (err) { - handleGlobalError(err); - } finally { - setLoading(false); - } - } - - async function deleteUserRole(orgID: string) { - try { - setLoading(true); - const res = await api.deleteUserRole(orgID, uuid); - await getUserRoles(); - } catch (err) { - handleGlobalError(err); - } finally { - setLoading(false); - } - } - - return ( - - - - {firstName} {lastName}: - {" "} - Manage User Roles - - - {loading && } - - {allOrganizations.map((org) => { - const currentRole = userRoles.find( - (r) => r.org?.orgID === org.orgID - )?.roleInternal; - const hasRole = !!currentRole; - return ( - -
- -
- { - if (typeof value !== "string") return; - const current = userRoles.find( - (r) => r.org?.orgID === org.orgID - )?.roleInternal; - if (value === current) return; - updateUserRole(value, org.orgID); - }} - options={filteredRoles(org.orgID)} - placeholder="No role set" - selection - value={currentRole || ""} - /> - -
-
-
- ); - })} -
-
- - - -
- ); -}; - -export default ManageUserRolesModal; diff --git a/client/src/screens/conductor/controlpanel/CentralIdentity/CentralIdentityUserView.tsx b/client/src/screens/conductor/controlpanel/CentralIdentity/CentralIdentityUserView.tsx index a36d6314d..daf9f60e4 100644 --- a/client/src/screens/conductor/controlpanel/CentralIdentity/CentralIdentityUserView.tsx +++ b/client/src/screens/conductor/controlpanel/CentralIdentity/CentralIdentityUserView.tsx @@ -63,6 +63,7 @@ const UserSupportTickets = lazy( import api from "../../../../api"; import UserConductorData from "../../../../components/controlpanel/CentralIdentity/UserConductorData"; +import CampusAdminRolesSection from "../../../../components/controlpanel/CentralIdentity/CampusAdminRolesSection"; import EditUserAcademyOnlineModal from "../../../../components/controlpanel/CentralIdentity/EditUserAcademyOnlineModal"; import { useModals } from "../../../../context/ModalContext"; import ConfirmModal from "../../../../components/ConfirmModal"; @@ -78,6 +79,7 @@ const CentralIdentityUserView = () => { "https://cdn.libretexts.net/DefaultImages/avatar.png"; const [loading, setLoading] = useState(false); + const [userLoading, setUserLoading] = useState(true); const [showAddAppModal, setShowAddAppModal] = useState(false); const [showDisableUserModal, setShowDisableUserModal] = useState(false); @@ -111,7 +113,7 @@ const CentralIdentityUserView = () => { try { setDeleteLoading(true); const res = await api.deleteCentralIdentityUser(uuid!); - + if (res.data.err) { throw new Error("Failed to delete user"); } @@ -167,7 +169,7 @@ const CentralIdentityUserView = () => { async function loadUser() { try { if (!uuid) return; - setLoading(true); + setUserLoading(true); const res = await api.getCentralIdentityUser(uuid); if (res.data.err) { @@ -179,14 +181,13 @@ const CentralIdentityUserView = () => { } catch (err) { handleGlobalError(err); } finally { - setLoading(false); + setUserLoading(false); } } async function loadUserLocalID() { try { if (!uuid) return; - setLoading(true); const res = await api.getUserFromCentralID(uuid); if (res.err) { @@ -200,15 +201,12 @@ const CentralIdentityUserView = () => { // handleGlobalError( // "User does not have a local Conductor record. This may or may not be expected." // ); - } finally { - setLoading(false); } } async function loadUserApps() { try { if (!uuid) return; - setLoading(true); const res = await api.getCentralIdentityUserApplications(uuid); if (res.data.err) { @@ -219,15 +217,12 @@ const CentralIdentityUserView = () => { setUserApps([...(res.data.applications as CentralIdentityApp[])]); } catch (err) { handleGlobalError(err); - } finally { - setLoading(false); } } async function loadUserAppLicenses() { try { if (!uuid) return; - setLoading(true); const res = await api.getCentralIdentityUserAppLicenses(uuid); if (res.data.err) { @@ -238,8 +233,6 @@ const CentralIdentityUserView = () => { setUserAppLicenses(res.data.licenses); } catch (err) { handleGlobalError(err); - } finally { - setLoading(false); } } @@ -469,7 +462,7 @@ const CentralIdentityUserView = () => { )}
- +
{
@@ -601,6 +599,7 @@ const CentralIdentityUserView = () => { rules={{ required: true }} fluid style={{ width: "100%" }} + loading={loading} />
@@ -617,6 +616,7 @@ const CentralIdentityUserView = () => { }} selection fluid + loading={loading} /> )} /> @@ -629,6 +629,7 @@ const CentralIdentityUserView = () => { control={control} fluid style={{ width: "100%" }} + loading={loading} />
)} @@ -647,6 +648,7 @@ const CentralIdentityUserView = () => { }} selection fluid + loading={loading} /> )} /> @@ -661,6 +663,7 @@ const CentralIdentityUserView = () => { placeholder="Bio URL..." fluid style={{ width: "100%" }} + loading={loading} />
)} @@ -673,7 +676,7 @@ const CentralIdentityUserView = () => { alignItems: "center", }} > - + {formState.isDirty && (
@@ -740,9 +743,9 @@ const CentralIdentityUserView = () => { {getValues("last_access") ? format( - parseISO(getValues("last_access") as string), - "MM/dd/yyyy hh:mm aa" - ) + parseISO(getValues("last_access") as string), + "MM/dd/yyyy hh:mm aa" + ) : "Unknown"}
@@ -751,17 +754,17 @@ const CentralIdentityUserView = () => { {getValues("last_password_change") ? format( - parseISO(getValues("last_password_change") as string), - "MM/dd/yyyy hh:mm aa" - ) + parseISO(getValues("last_password_change") as string), + "MM/dd/yyyy hh:mm aa" + ) : "Unknown"}
{userLocalID && }
-
- +
+
Organizations @@ -786,7 +789,7 @@ const CentralIdentityUserView = () => { {getValues("organizations") && - getValues("organizations")?.length > 0 ? ( + getValues("organizations")?.length > 0 ? ( getValues("organizations").map((org) => ( {org.name} @@ -821,7 +824,8 @@ const CentralIdentityUserView = () => {
- + {userLocalID && isSuperAdmin && } +
Application Licenses @@ -875,17 +879,16 @@ const CentralIdentityUserView = () => { {app.application_license.perpetual ? "Perpetual" - : `${ - isExpired ? "Expired " : "" - }${new Intl.DateTimeFormat("en-US", { - dateStyle: "short", - }).format(new Date(app.expires_at))}`} + : `${isExpired ? "Expired " : "" + }${new Intl.DateTimeFormat("en-US", { + dateStyle: "short", + }).format(new Date(app.expires_at))}`} {app.revoked && app.revoked_at ? new Intl.DateTimeFormat("en-US", { - dateStyle: "short", - }).format(new Date(app.revoked_at)) + dateStyle: "short", + }).format(new Date(app.revoked_at)) : "No"} {app.granted_by} @@ -918,7 +921,7 @@ const CentralIdentityUserView = () => {
- +
Application Security Access @@ -973,7 +976,7 @@ const CentralIdentityUserView = () => {
- +
Academy Online @@ -1003,7 +1006,7 @@ const CentralIdentityUserView = () => {
{userLocalID && } - +
diff --git a/server/api.js b/server/api.js index 850bcea9b..de8e90031 100644 --- a/server/api.js +++ b/server/api.js @@ -1412,7 +1412,7 @@ router .put( authAPI.verifyRequest, authAPI.getUserAttributes, - authAPI.checkHasRoleMiddleware(process.env.ORG_ID, "campusadmin"), + authAPI.checkHasRoleMiddleware("libretexts", "superadmin"), // Only superadmins can update roles usersAPI.validate("updateUserRole"), middleware.checkValidationErrors, usersAPI.updateUserRole @@ -1423,7 +1423,7 @@ router .delete( authAPI.verifyRequest, authAPI.getUserAttributes, - authAPI.checkHasRoleMiddleware(process.env.ORG_ID, "campusadmin"), + authAPI.checkHasRoleMiddleware("libretexts", "superadmin"), // Only superadmins can delete roles usersAPI.validate("deleteUserRole"), middleware.checkValidationErrors, usersAPI.deleteUserRole diff --git a/server/api/organizations.ts b/server/api/organizations.ts index 64760c816..81aae7b59 100644 --- a/server/api/organizations.ts +++ b/server/api/organizations.ts @@ -149,6 +149,11 @@ async function getAllOrganizations(_req: Request, res: Response) { updatedAt: 0, }, }, + { + $sort: { + name: 1, + }, + } ]); return res.send({ orgs, diff --git a/server/api/users.js b/server/api/users.js index 87926e747..a2e2172dd 100644 --- a/server/api/users.js +++ b/server/api/users.js @@ -891,12 +891,16 @@ const updateUserRole = (req, res) => { let isSuperAdmin = authAPI.checkHasRole(req.user, 'libretexts', 'superadmin'); if (!isSuperAdmin && (req.body.orgID !== process.env.ORG_ID)) { // Halt execution if Campus Admin is trying to assign a role for a different Organization - reject(new Error('unauth')); + // TODO: this is effectively now a dead branch since only superadmins can assign roles, but we'll keep here for now + return reject(new Error('unauth')); } - if (req.body.role === 'superadmin' && (req.body.orgID !== 'libretexts' || !isSuperAdmin)) { - // Halt execution if user is trying to elevate non-LibreTexts role to Super Admin, - // or if the requesting is not a Super Admin themselves - reject(new Error('invalid')); + if (req.body.role === 'superadmin') { + // Halt execution if user is trying to assign a Super Admin role (this must be done manually due to its elevated privileges) + return reject(new Error('invalid')); + } + if (req.body.role === 'campusadmin' && (req.body.orgID === 'libretexts')) { + // Halt execution if user is trying to assign Campus Admin role for the LibreTexts organization + return reject(new Error('invalid')); } resolve(User.findOne({ uuid: req.body.uuid }).lean()) }).then((user) => {