From 6bfbb4662bcfbe2d2d9dd4eb8b9233ffaa388b6c Mon Sep 17 00:00:00 2001 From: Dev Singh Date: Sun, 28 Sep 2025 01:18:50 -0500 Subject: [PATCH 01/14] Return org roles from protected route --- src/api/routes/protected.ts | 22 +++++++++++++++++++++- src/common/types/organizations.ts | 1 - 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/src/api/routes/protected.ts b/src/api/routes/protected.ts index 6ca72ff4..65079927 100644 --- a/src/api/routes/protected.ts +++ b/src/api/routes/protected.ts @@ -1,6 +1,11 @@ import { FastifyPluginAsync } from "fastify"; import rateLimiter from "api/plugins/rateLimiter.js"; import { withRoles, withTags } from "api/components/index.js"; +import { getUserOrgRoles } from "api/functions/organizations.js"; +import { + UnauthenticatedError, + UnauthorizedError, +} from "common/errors/index.js"; const protectedRoute: FastifyPluginAsync = async (fastify, _options) => { await fastify.register(rateLimiter, { @@ -20,7 +25,22 @@ const protectedRoute: FastifyPluginAsync = async (fastify, _options) => { }, async (request, reply) => { const roles = await fastify.authorize(request, reply, [], false); - reply.send({ username: request.username, roles: Array.from(roles) }); + const { username, log: logger } = request; + const { dynamoClient } = fastify; + if (!username) { + throw new UnauthenticatedError({ message: "Username not found." }); + } + const orgRolesPromise = getUserOrgRoles({ + username, + dynamoClient, + logger, + }); + const orgRoles = await orgRolesPromise; + reply.send({ + username: request.username, + roles: Array.from(roles), + orgRoles, + }); }, ); }; diff --git a/src/common/types/organizations.ts b/src/common/types/organizations.ts index 8cde314f..7a6978a2 100644 --- a/src/common/types/organizations.ts +++ b/src/common/types/organizations.ts @@ -18,7 +18,6 @@ export const orgLinkEntry = z.object({ export const enforcedOrgLeadEntry = orgLeadEntry.extend({ name: z.string().min(1), title: z.string().min(1) }) - export const getOrganizationInfoResponse = z.object({ id: z.enum(AllOrganizationList), description: z.optional(z.string()), From 6806bab0aa956782d643f608cf2a160c2ead1fc6 Mon Sep 17 00:00:00 2001 From: Dev Singh Date: Sun, 28 Sep 2025 01:25:42 -0500 Subject: [PATCH 02/14] Expose orgRoles in auth context --- src/ui/components/AuthContext/index.tsx | 87 ++++++++++++++++++++++--- 1 file changed, 77 insertions(+), 10 deletions(-) diff --git a/src/ui/components/AuthContext/index.tsx b/src/ui/components/AuthContext/index.tsx index db8ee07d..4bca534d 100644 --- a/src/ui/components/AuthContext/index.tsx +++ b/src/ui/components/AuthContext/index.tsx @@ -14,23 +14,30 @@ import React, { useCallback, } from "react"; -import { CACHE_KEY_PREFIX, setCachedResponse } from "../AuthGuard/index.js"; +import { + CACHE_KEY_PREFIX, + setCachedResponse, + getCachedResponse, +} from "../AuthGuard/index.js"; import FullScreenLoader from "./LoadingScreen.js"; import { getRunEnvironmentConfig, ValidServices } from "@ui/config.js"; import { transformCommaSeperatedName } from "@common/utils.js"; import { useApi } from "@ui/util/api.js"; +import { OrgRoleDefinition } from "@common/roles.js"; interface AuthContextDataWrapper { isLoggedIn: boolean; userData: AuthContextData | null; + orgRoles: OrgRoleDefinition[]; loginMsal: CallableFunction; logout: CallableFunction; getToken: CallableFunction; logoutCallback: CallableFunction; getApiToken: CallableFunction; setLoginStatus: CallableFunction; + refreshOrgRoles: () => Promise; } export type AuthContextData = { @@ -54,27 +61,66 @@ export const AuthProvider: React.FC = ({ children }) => { const { instance, inProgress, accounts } = useMsal(); const [userData, setUserData] = useState(null); const [isLoggedIn, setIsLoggedIn] = useState(false); + const [orgRoles, setOrgRoles] = useState([]); const checkRoute = getRunEnvironmentConfig().ServiceConfiguration.core.authCheckRoute; if (!checkRoute) { throw new Error("no check route found!"); } + const api = useApi("core"); + const navigate = (path: string) => { window.location.href = path; }; + // Function to fetch and update org roles + const fetchOrgRoles = useCallback(async () => { + try { + // Check cache first + const cachedData = await getCachedResponse("core", checkRoute); + if (cachedData?.data?.orgRoles) { + setOrgRoles(cachedData.data.orgRoles || []); + return cachedData.data.orgRoles; + } + + // Fetch fresh data if not in cache + const result = await api.get(checkRoute); + await setCachedResponse("core", checkRoute, result.data); + + if (result.data?.orgRoles) { + setOrgRoles(result.data.orgRoles || []); + return result.data.orgRoles; + } + + return []; + } catch (error) { + console.error("Failed to fetch org roles:", error); + return []; + } + }, [api, checkRoute]); + + // Refresh org roles on demand + const refreshOrgRoles = useCallback(async () => { + // Clear cache to force fresh fetch + const cacheKey = `${CACHE_KEY_PREFIX}core_${checkRoute}`; + sessionStorage.removeItem(cacheKey); + await fetchOrgRoles(); + }, [checkRoute, fetchOrgRoles]); + useEffect(() => { const handleRedirect = async () => { const response = await instance.handleRedirectPromise(); if (response) { - handleMsalResponse(response); + await handleMsalResponse(response); } else if (accounts.length > 0) { setUserData({ email: accounts[0].username, name: transformCommaSeperatedName(accounts[0].name || ""), }); setIsLoggedIn(true); + // Fetch org roles when user is already logged in + await fetchOrgRoles(); } }; @@ -84,7 +130,7 @@ export const AuthProvider: React.FC = ({ children }) => { }, [inProgress, accounts, instance]); const handleMsalResponse = useCallback( - (response: AuthenticationResult) => { + async (response: AuthenticationResult) => { if (response?.account) { if (!accounts.length) { // If accounts array is empty, try silent authentication @@ -99,9 +145,15 @@ export const AuthProvider: React.FC = ({ children }) => { email: accounts[0].username, name: transformCommaSeperatedName(accounts[0].name || ""), }); - const api = useApi("core"); + + // Fetch and cache auth data including orgRoles const result = await api.get(checkRoute); await setCachedResponse("core", checkRoute, result.data); + + if (result.data?.orgRoles) { + setOrgRoles(result.data.orgRoles || []); + } + setIsLoggedIn(true); } }) @@ -112,10 +164,13 @@ export const AuthProvider: React.FC = ({ children }) => { email: accounts[0].username, name: transformCommaSeperatedName(accounts[0].name || ""), }); + + // Fetch org roles after successful authentication + await fetchOrgRoles(); setIsLoggedIn(true); } }, - [accounts, instance], + [accounts, instance, api, checkRoute, fetchOrgRoles], ); const getApiToken = useCallback( @@ -203,9 +258,15 @@ export const AuthProvider: React.FC = ({ children }) => { ...request, account: accounts[0], }); - const api = useApi("core"); + + // Fetch and cache auth data including orgRoles const result = await api.get(checkRoute); await setCachedResponse("core", checkRoute, result.data); + + if (result.data?.orgRoles) { + setOrgRoles(result.data.orgRoles || []); + } + setIsLoggedIn(true); } catch (error) { if (error instanceof InteractionRequiredAuthError) { @@ -224,7 +285,7 @@ export const AuthProvider: React.FC = ({ children }) => { }); } }, - [instance, checkRoute, setIsLoggedIn, setCachedResponse], + [instance, checkRoute, api], ); const setLoginStatus = useCallback((val: boolean) => { @@ -234,26 +295,32 @@ export const AuthProvider: React.FC = ({ children }) => { const logout = useCallback(async () => { try { clearAuthCache(); + setOrgRoles([]); // Clear org roles on logout await instance.logoutRedirect(); } catch (error) { console.error("Logout failed:", error); } - }, [instance, userData]); - const logoutCallback = () => { + }, [instance]); + + const logoutCallback = useCallback(() => { setIsLoggedIn(false); setUserData(null); - }; + setOrgRoles([]); // Clear org roles on logout callback + }, []); + return ( {inProgress !== InteractionStatus.None ? ( From 4911db5af573c406386183dc1fe37fb7dfb6dc5f Mon Sep 17 00:00:00 2001 From: Dev Singh Date: Sun, 28 Sep 2025 01:59:26 -0500 Subject: [PATCH 03/14] Support the at least one org manager role, show UI --- src/api/functions/authorization.ts | 23 +++++- src/api/plugins/auth.ts | 1 + src/common/roles.ts | 4 +- src/ui/Router.tsx | 5 ++ src/ui/components/AppShell/index.tsx | 10 ++- src/ui/components/AuthGuard/index.tsx | 21 ++++++ src/ui/pages/organization/OrgInfoPage.tsx | 86 +++++++++++++++++++++++ 7 files changed, 145 insertions(+), 5 deletions(-) create mode 100644 src/ui/pages/organization/OrgInfoPage.tsx diff --git a/src/api/functions/authorization.ts b/src/api/functions/authorization.ts index a19a09d0..03978954 100644 --- a/src/api/functions/authorization.ts +++ b/src/api/functions/authorization.ts @@ -25,6 +25,7 @@ import { getUserOrgRoles } from "./organizations.js"; export async function getUserRoles( dynamoClient: DynamoDBClient, userId: string, + logger: FastifyBaseLogger, ): Promise { const tableName = `${genericConfig.IAMTablePrefix}-assignments`; const command = new GetItemCommand({ @@ -39,17 +40,33 @@ export async function getUserRoles( message: "Could not get user roles", }); } + // get user org roles and return if they lead at least one org + let baseRoles: AppRoles[]; + try { + const orgRoles = await getUserOrgRoles({ + username: userId, + dynamoClient, + logger, + }); + const leadsOneOrg = orgRoles.filter((x) => x.role === "LEAD").length > 0; + baseRoles = leadsOneOrg ? [AppRoles.AT_LEAST_ONE_ORG_MANAGER] : []; + } catch (e) { + logger.error(e); + baseRoles = []; + } + if (!response.Item) { - return []; + return baseRoles; } const items = unmarshall(response.Item) as { roles: AppRoles[] | ["all"] }; if (!("roles" in items)) { - return []; + return baseRoles; } if (items.roles[0] === "all") { return allAppRoles; } - return items.roles as AppRoles[]; + + return [...new Set([...baseRoles, ...items.roles])] as AppRoles[]; } export async function getGroupRoles( diff --git a/src/api/plugins/auth.ts b/src/api/plugins/auth.ts index 5f00c346..90ea16b2 100644 --- a/src/api/plugins/auth.ts +++ b/src/api/plugins/auth.ts @@ -352,6 +352,7 @@ const authPlugin: FastifyPluginAsync = async (fastify, _options) => { const userAuth = await getUserRoles( fastify.dynamoClient, request.username, + request.log, ); for (const role of userAuth) { userRoles.add(role); diff --git a/src/common/roles.ts b/src/common/roles.ts index a7e93d7d..b2656929 100644 --- a/src/common/roles.ts +++ b/src/common/roles.ts @@ -20,7 +20,8 @@ export enum AppRoles { VIEW_INTERNAL_MEMBERSHIP_LIST = "view:internalMembershipList", VIEW_EXTERNAL_MEMBERSHIP_LIST = "view:externalMembershipList", MANAGE_EXTERNAL_MEMBERSHIP_LIST = "manage:externalMembershipList", - ALL_ORG_MANAGER = "manage:orgDefinitions" + ALL_ORG_MANAGER = "manage:orgDefinitions", + AT_LEAST_ONE_ORG_MANAGER = "manage:someOrg" } export const orgRoles = ["LEAD", "MEMBER"] as const; export type OrgRole = typeof orgRoles[number]; @@ -51,4 +52,5 @@ export const AppRoleHumanMapper: Record = { [AppRoles.VIEW_EXTERNAL_MEMBERSHIP_LIST]: "External Membership List Viewer", [AppRoles.MANAGE_EXTERNAL_MEMBERSHIP_LIST]: "External Membership List Manager", [AppRoles.ALL_ORG_MANAGER]: "Organization Definition Manager", + [AppRoles.AT_LEAST_ONE_ORG_MANAGER]: "Manager of at least one org", } diff --git a/src/ui/Router.tsx b/src/ui/Router.tsx index 30bbfc53..1a89220e 100644 --- a/src/ui/Router.tsx +++ b/src/ui/Router.tsx @@ -29,6 +29,7 @@ import { ViewLogsPage } from "./pages/logs/ViewLogs.page"; import { TermsOfService } from "./pages/tos/TermsOfService.page"; import { ManageApiKeysPage } from "./pages/apiKeys/ManageKeys.page"; import { ManageExternalMembershipPage } from "./pages/membershipLists/MembershipListsPage"; +import { OrgInfoPage } from "./pages/organization/OrgInfoPage"; const ProfileRediect: React.FC = () => { const location = useLocation(); @@ -208,6 +209,10 @@ const authenticatedRouter = createBrowserRouter([ path: "/apiKeys", element: , }, + { + path: "/orgInfo", + element: , + }, // Catch-all route for authenticated users shows 404 page { path: "*", diff --git a/src/ui/components/AppShell/index.tsx b/src/ui/components/AppShell/index.tsx index 117f2026..ceeb3093 100644 --- a/src/ui/components/AppShell/index.tsx +++ b/src/ui/components/AppShell/index.tsx @@ -23,6 +23,7 @@ import { IconKey, IconExternalLink, IconUser, + IconInfoCircle, } from "@tabler/icons-react"; import { ReactNode } from "react"; import { useNavigate } from "react-router-dom"; @@ -101,7 +102,7 @@ export const navItems = [ }, { link: "/membershipLists", - name: "Membership Lists", + name: "Membership", icon: IconUser, description: null, validRoles: [ @@ -109,6 +110,13 @@ export const navItems = [ AppRoles.MANAGE_EXTERNAL_MEMBERSHIP_LIST, ], }, + { + link: "/orgInfo", + name: "Organization Info", + icon: IconInfoCircle, + description: null, + validRoles: [AppRoles.AT_LEAST_ONE_ORG_MANAGER], + }, ]; export const extLinks = [ diff --git a/src/ui/components/AuthGuard/index.tsx b/src/ui/components/AuthGuard/index.tsx index cff61d68..a2ba9c81 100644 --- a/src/ui/components/AuthGuard/index.tsx +++ b/src/ui/components/AuthGuard/index.tsx @@ -84,6 +84,27 @@ export const clearAuthCache = () => { } }; +/** + * Retrieves the user's roles from the session cache for a specific service. + * @param service The service to check the cache for. + * @param route The authentication check route. + * @returns A promise that resolves to an array of roles, or null if not found in cache. + */ +export const getUserRoles = async ( + service: ValidService, +): Promise => { + const { authCheckRoute } = + getRunEnvironmentConfig().ServiceConfiguration[service]; + if (!authCheckRoute) { + throw new Error("no auth check route"); + } + const cachedData = await getCachedResponse(service, authCheckRoute); + if (cachedData?.data?.roles && Array.isArray(cachedData.data.roles)) { + return cachedData.data.roles; + } + return null; +}; + export const AuthGuard: React.FC< { resourceDef: ResourceDefinition; diff --git a/src/ui/pages/organization/OrgInfoPage.tsx b/src/ui/pages/organization/OrgInfoPage.tsx new file mode 100644 index 00000000..b0e88898 --- /dev/null +++ b/src/ui/pages/organization/OrgInfoPage.tsx @@ -0,0 +1,86 @@ +import { useState, useEffect } from "react"; +import { Title, Stack, Container, Grid } from "@mantine/core"; +import { AuthGuard, getUserRoles } from "@ui/components/AuthGuard"; +import { useApi } from "@ui/util/api"; +import { AppRoles } from "@common/roles"; +import { notifications } from "@mantine/notifications"; +import { IconAlertCircle } from "@tabler/icons-react"; +import FullScreenLoader from "@ui/components/AuthContext/LoadingScreen"; +import { AxiosError } from "axios"; +import { AllOrganizationList } from "@acm-uiuc/js-shared"; +import { useAuth } from "@ui/components/AuthContext"; + +type AcmOrg = (typeof AllOrganizationList)[number]; + +export const OrgInfoPage = () => { + const api = useApi("core"); + const { orgRoles } = useAuth(); + const [manageableOrgs, setManagableOrgs] = useState(null); + + const getCurrentInformation = async (org: AcmOrg) => { + try { + const result = await api.post<{ + members: string[]; + notMembers: string[]; + }>(`/api/v1/organization/${org}`); + return result.data; + } catch (error: any) { + console.error("Failed to check get org info:", error); + notifications.show({ + title: `Failed to get information for ${org}.`, + message: "Please try again or contact support.", + color: "red", + icon: , + }); + throw error; + } + }; + + useEffect(() => { + (async () => { + const appRoles = await getUserRoles("core"); + if (appRoles?.includes(AppRoles.ALL_ORG_MANAGER)) { + setManagableOrgs(AllOrganizationList); + return; + } + setManagableOrgs( + orgRoles.filter((x) => x.role === "LEAD").map((x) => x.org), + ); + })(); + }, [orgRoles]); + + if (!manageableOrgs) { + return ; + } + if (manageableOrgs.length === 0) { + // Need to show access denied. + return ( + + {null} + + ); + } + return ( + + + + + + Manage Organization Info + + + + + + ); +}; From 5aeccd9c82f949d6a7b710e07269419b7de421db Mon Sep 17 00:00:00 2001 From: Dev Singh Date: Sun, 28 Sep 2025 01:59:47 -0500 Subject: [PATCH 04/14] add a comment --- src/common/roles.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/common/roles.ts b/src/common/roles.ts index b2656929..3c0f9e07 100644 --- a/src/common/roles.ts +++ b/src/common/roles.ts @@ -21,7 +21,7 @@ export enum AppRoles { VIEW_EXTERNAL_MEMBERSHIP_LIST = "view:externalMembershipList", MANAGE_EXTERNAL_MEMBERSHIP_LIST = "manage:externalMembershipList", ALL_ORG_MANAGER = "manage:orgDefinitions", - AT_LEAST_ONE_ORG_MANAGER = "manage:someOrg" + AT_LEAST_ONE_ORG_MANAGER = "manage:someOrg" // THIS IS A FAKE ROLE - DO NOT ASSIGN IT MANUALLY } export const orgRoles = ["LEAD", "MEMBER"] as const; export type OrgRole = typeof orgRoles[number]; From 8f0b94a1e0e662ece69f702c217d2aff84acfe94 Mon Sep 17 00:00:00 2001 From: Dev Singh Date: Sun, 28 Sep 2025 02:01:03 -0500 Subject: [PATCH 05/14] remove at least one org manager from all roles --- src/common/roles.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/common/roles.ts b/src/common/roles.ts index 3c0f9e07..1f0f8e0e 100644 --- a/src/common/roles.ts +++ b/src/common/roles.ts @@ -21,8 +21,9 @@ export enum AppRoles { VIEW_EXTERNAL_MEMBERSHIP_LIST = "view:externalMembershipList", MANAGE_EXTERNAL_MEMBERSHIP_LIST = "manage:externalMembershipList", ALL_ORG_MANAGER = "manage:orgDefinitions", - AT_LEAST_ONE_ORG_MANAGER = "manage:someOrg" // THIS IS A FAKE ROLE - DO NOT ASSIGN IT MANUALLY + AT_LEAST_ONE_ORG_MANAGER = "manage:someOrg" // THIS IS A FAKE ROLE - DO NOT ASSIGN IT MANUALLY - only used for permissioning } +export const PSUEDO_ROLES = [AppRoles.AT_LEAST_ONE_ORG_MANAGER] export const orgRoles = ["LEAD", "MEMBER"] as const; export type OrgRole = typeof orgRoles[number]; export type OrgRoleDefinition = { @@ -32,7 +33,7 @@ export type OrgRoleDefinition = { export const allAppRoles = Object.values(AppRoles).filter( (value) => typeof value === "string", -); +).filter(value => !PSUEDO_ROLES.includes(value)); // don't assign psuedo roles by default export const AppRoleHumanMapper: Record = { [AppRoles.EVENTS_MANAGER]: "Events Manager", From 81cd435ee4b20cfc548986f58138ef4c8409f23d Mon Sep 17 00:00:00 2001 From: Dev Singh Date: Sun, 28 Sep 2025 02:06:14 -0500 Subject: [PATCH 06/14] Add select dropdown --- src/ui/pages/organization/OrgInfoPage.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/ui/pages/organization/OrgInfoPage.tsx b/src/ui/pages/organization/OrgInfoPage.tsx index b0e88898..df3a6a28 100644 --- a/src/ui/pages/organization/OrgInfoPage.tsx +++ b/src/ui/pages/organization/OrgInfoPage.tsx @@ -1,5 +1,5 @@ import { useState, useEffect } from "react"; -import { Title, Stack, Container, Grid } from "@mantine/core"; +import { Title, Stack, Container, Grid, Select } from "@mantine/core"; import { AuthGuard, getUserRoles } from "@ui/components/AuthGuard"; import { useApi } from "@ui/util/api"; import { AppRoles } from "@common/roles"; @@ -16,6 +16,7 @@ export const OrgInfoPage = () => { const api = useApi("core"); const { orgRoles } = useAuth(); const [manageableOrgs, setManagableOrgs] = useState(null); + const [selectedOrg, setSelectedOrg] = useState(null); const getCurrentInformation = async (org: AcmOrg) => { try { @@ -78,6 +79,13 @@ export const OrgInfoPage = () => { Manage Organization Info +