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/functions/organizations.ts b/src/api/functions/organizations.ts index b01eba97..d8361de5 100644 --- a/src/api/functions/organizations.ts +++ b/src/api/functions/organizations.ts @@ -57,7 +57,11 @@ export async function getOrgInfo({ }; try { const responseMarshall = await dynamoClient.send(query); - if (!responseMarshall.Items || responseMarshall.Items.length === 0) { + if ( + !responseMarshall || + !responseMarshall.Items || + responseMarshall.Items.length === 0 + ) { logger.debug( `Could not find SIG information for ${id}, returning default.`, ); @@ -126,7 +130,7 @@ export async function getUserOrgRoles({ }); try { const response = await dynamoClient.send(query); - if (!response.Items) { + if (!response || !response.Items) { return []; } const unmarshalled = response.Items.map((x) => unmarshall(x)).map( 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/api/routes/organizations.ts b/src/api/routes/organizations.ts index 6f041871..b58f5167 100644 --- a/src/api/routes/organizations.ts +++ b/src/api/routes/organizations.ts @@ -5,6 +5,7 @@ import { withRoles, withTags } from "api/components/index.js"; import { z } from "zod/v4"; import { getOrganizationInfoResponse, + ORG_DATA_CACHED_DURATION, patchOrganizationLeadsBody, setOrganizationMetaBody, } from "common/types/organizations.js"; @@ -46,7 +47,6 @@ import { getRoleCredentials } from "api/functions/sts.js"; import { SQSClient } from "@aws-sdk/client-sqs"; import { sendSqsMessagesInBatches } from "api/functions/sqs.js"; -export const ORG_DATA_CACHED_DURATION = 300; export const CLIENT_HTTP_CACHE_POLICY = `public, max-age=${ORG_DATA_CACHED_DURATION}, stale-while-revalidate=${Math.floor(ORG_DATA_CACHED_DURATION * 1.1)}, stale-if-error=3600`; const organizationsPlugin: FastifyPluginAsync = async (fastify, _options) => { 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/roles.ts b/src/common/roles.ts index a7e93d7d..1f0f8e0e 100644 --- a/src/common/roles.ts +++ b/src/common/roles.ts @@ -20,8 +20,10 @@ 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" // 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 = { @@ -31,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", @@ -51,4 +53,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/common/types/organizations.ts b/src/common/types/organizations.ts index 8cde314f..08ab6342 100644 --- a/src/common/types/organizations.ts +++ b/src/common/types/organizations.ts @@ -5,11 +5,16 @@ import { z } from "zod/v4"; export const orgLeadEntry = z.object({ name: z.optional(z.string()), - username: z.email(), + username: z.email().refine( + (email) => email.endsWith('@illinois.edu'), + { message: 'Email must be from the @illinois.edu domain' } + ), title: z.optional(z.string()) }) -export const validOrgLinkTypes = ["DISCORD", "CAMPUSWIRE", "SLACK", "NOTION", "MATRIX", "OTHER"] as const as [string, ...string[]]; +export type LeadEntry = z.infer; + +export const validOrgLinkTypes = ["DISCORD", "CAMPUSWIRE", "SLACK", "NOTION", "MATRIX", "INSTAGRAM", "OTHER"] as const as [string, ...string[]]; export const orgLinkEntry = z.object({ type: z.enum(validOrgLinkTypes), @@ -18,7 +23,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()), @@ -28,8 +32,10 @@ export const getOrganizationInfoResponse = z.object({ leadsEntraGroupId: z.optional(z.string().min(1)).meta({ description: `Only returned for users with the ${AppRoleHumanMapper[AppRoles.ALL_ORG_MANAGER]} role.` }) }) -export const setOrganizationMetaBody = getOrganizationInfoResponse.omit({ id: true, leads: true }); +export const setOrganizationMetaBody = getOrganizationInfoResponse.omit({ id: true, leads: true, leadsEntraGroupId: true }); export const patchOrganizationLeadsBody = z.object({ add: z.array(enforcedOrgLeadEntry), remove: z.array(z.string()) }); + +export const ORG_DATA_CACHED_DURATION = 300; diff --git a/src/ui/Router.tsx b/src/ui/Router.tsx index 30bbfc53..fad72141 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/OrgInfo.page"; const ProfileRediect: React.FC = () => { const location = useLocation(); @@ -126,7 +127,7 @@ const authenticatedRouter = createBrowserRouter([ ...commonRoutes, { path: "/", - element: {null}, + element: , }, { path: "/login", @@ -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/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 ? ( 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/ManageOrganizationForm.test.tsx b/src/ui/pages/organization/ManageOrganizationForm.test.tsx new file mode 100644 index 00000000..5e13fb80 --- /dev/null +++ b/src/ui/pages/organization/ManageOrganizationForm.test.tsx @@ -0,0 +1,816 @@ +import React from "react"; +import { render, screen, act, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { vi } from "vitest"; +import { MantineProvider } from "@mantine/core"; +import { notifications } from "@mantine/notifications"; +import { ManageOrganizationForm } from "./ManageOrganizationForm"; +import { MemoryRouter } from "react-router-dom"; + +// Mock the notifications module +vi.mock("@mantine/notifications", () => ({ + notifications: { + show: vi.fn(), + }, +})); + +describe("ManageOrganizationForm Tests", () => { + const getOrganizationDataMock = vi.fn(); + const updateOrganizationDataMock = vi.fn(); + + const mockOrgData = { + description: "Test organization description", + website: "https://test.example.com", + links: [ + { type: "DISCORD", url: "https://discord.gg/test" }, + { type: "SLACK", url: "https://slack.com/test" }, + ], + }; + + const renderComponent = async (props = {}) => { + await act(async () => { + render( + + + + + , + ); + }); + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("renders loading overlay initially", async () => { + getOrganizationDataMock.mockImplementation( + () => new Promise(() => {}), // Never resolves + ); + await renderComponent(); + + expect(screen.getByTestId("org-loading")).toBeInTheDocument(); + }); + + it("fetches and displays organization data", async () => { + getOrganizationDataMock.mockResolvedValue(mockOrgData); + await renderComponent(); + + await waitFor(() => { + expect(getOrganizationDataMock).toHaveBeenCalledWith("ACM"); + }); + + await waitFor(() => { + expect(screen.queryByTestId("org-loading")).not.toBeInTheDocument(); + }); + + expect( + screen.getByDisplayValue("Test organization description"), + ).toBeInTheDocument(); + expect( + screen.getByDisplayValue("https://test.example.com"), + ).toBeInTheDocument(); + expect( + screen.getByDisplayValue("https://discord.gg/test"), + ).toBeInTheDocument(); + expect( + screen.getByDisplayValue("https://slack.com/test"), + ).toBeInTheDocument(); + }); + + it("handles organization data fetch failure", async () => { + const notificationsMock = vi.spyOn(notifications, "show"); + getOrganizationDataMock.mockRejectedValue(new Error("Failed to fetch")); + + await renderComponent(); + + await waitFor(() => { + expect(notificationsMock).toHaveBeenCalledWith( + expect.objectContaining({ + color: "red", + message: "Failed to load organization data", + }), + ); + }); + + notificationsMock.mockRestore(); + }); + + it("allows editing form fields", async () => { + getOrganizationDataMock.mockResolvedValue(mockOrgData); + const user = userEvent.setup(); + await renderComponent(); + + await waitFor(() => { + expect( + screen.getByDisplayValue("Test organization description"), + ).toBeInTheDocument(); + }); + + const descriptionField = screen.getByLabelText("Description"); + await user.clear(descriptionField); + await user.type(descriptionField, "Updated description"); + + expect(screen.getByDisplayValue("Updated description")).toBeInTheDocument(); + }); + + it("submits form with updated data", async () => { + getOrganizationDataMock.mockResolvedValue(mockOrgData); + updateOrganizationDataMock.mockResolvedValue(undefined); + const user = userEvent.setup(); + await renderComponent(); + + await waitFor(() => { + expect( + screen.getByDisplayValue("Test organization description"), + ).toBeInTheDocument(); + }); + + const descriptionField = screen.getByLabelText("Description"); + await user.clear(descriptionField); + await user.type(descriptionField, "New description"); + + const submitButton = screen.getByRole("button", { name: "Save Changes" }); + await user.click(submitButton); + + await waitFor(() => { + expect(updateOrganizationDataMock).toHaveBeenCalledWith({ + description: "New description", + website: "https://test.example.com", + links: [ + { type: "DISCORD", url: "https://discord.gg/test" }, + { type: "SLACK", url: "https://slack.com/test" }, + ], + }); + }); + }); + + it("adds a new link", async () => { + getOrganizationDataMock.mockResolvedValue({ + description: "Test", + website: "https://test.com", + links: [], + }); + const user = userEvent.setup(); + await renderComponent(); + + await waitFor(() => { + expect( + screen.getByText( + 'No links added yet. Click "Add Link" to get started.', + ), + ).toBeInTheDocument(); + }); + + const addButton = screen.getByRole("button", { name: "Add Link" }); + await user.click(addButton); + + await waitFor(() => { + expect(screen.getByPlaceholderText("Select type")).toBeInTheDocument(); + expect(screen.getByPlaceholderText("https://...")).toBeInTheDocument(); + }); + }); + + it("removes a link", async () => { + getOrganizationDataMock.mockResolvedValue(mockOrgData); + const user = userEvent.setup(); + await renderComponent(); + + await waitFor(() => { + expect( + screen.getByDisplayValue("https://discord.gg/test"), + ).toBeInTheDocument(); + }); + + const removeButtons = screen.getAllByRole("button", { name: "" }); + const trashButton = removeButtons.find((btn) => btn.querySelector("svg")); + + if (trashButton) { + await user.click(trashButton); + } + + await waitFor(() => { + expect( + screen.queryByDisplayValue("https://discord.gg/test"), + ).not.toBeInTheDocument(); + }); + }); + + it("includes only non-empty links on submit", async () => { + getOrganizationDataMock.mockResolvedValue({ + description: "Test", + website: "https://test.com", + links: [{ type: "DISCORD", url: "https://discord.gg/valid" }], + }); + updateOrganizationDataMock.mockResolvedValue(undefined); + const user = userEvent.setup(); + await renderComponent(); + + await waitFor(() => { + expect( + screen.getByDisplayValue("https://discord.gg/valid"), + ).toBeInTheDocument(); + }); + + const submitButton = screen.getByRole("button", { name: "Save Changes" }); + await user.click(submitButton); + + await waitFor(() => { + expect(updateOrganizationDataMock).toHaveBeenCalledWith({ + description: "Test", + website: "https://test.com", + links: [{ type: "DISCORD", url: "https://discord.gg/valid" }], + }); + }); + }); + + it("resets form when organization ID changes", async () => { + getOrganizationDataMock.mockResolvedValue(mockOrgData); + const { rerender } = render( + + + + + , + ); + + await waitFor(() => { + expect( + screen.getByDisplayValue("Test organization description"), + ).toBeInTheDocument(); + }); + + // Change organization ID + const newOrgData = { + description: "Different org", + website: "https://different.com", + links: [], + }; + getOrganizationDataMock.mockResolvedValue(newOrgData); + + await act(async () => { + rerender( + + + + + , + ); + }); + + await waitFor(() => { + expect(getOrganizationDataMock).toHaveBeenCalledWith("SIGWeb"); + }); + + await waitFor(() => { + expect(screen.getByDisplayValue("Different org")).toBeInTheDocument(); + }); + }); + + it("handles missing optional fields", async () => { + getOrganizationDataMock.mockResolvedValue({ + description: undefined, + website: undefined, + links: undefined, + }); + await renderComponent(); + + await waitFor(() => { + expect(screen.queryByTestId("org-loading")).not.toBeInTheDocument(); + }); + + expect(screen.getByLabelText("Description")).toHaveValue(""); + expect(screen.getByLabelText("Website")).toHaveValue(""); + expect( + screen.getByText('No links added yet. Click "Add Link" to get started.'), + ).toBeInTheDocument(); + }); + + it("trims whitespace from fields on submit", async () => { + getOrganizationDataMock.mockResolvedValue({ + description: " Test with spaces ", + website: " https://test.com ", + links: [], + }); + updateOrganizationDataMock.mockResolvedValue(undefined); + const user = userEvent.setup(); + await renderComponent(); + + await waitFor(() => { + expect(screen.queryByTestId("org-loading")).not.toBeInTheDocument(); + }); + + const submitButton = screen.getByRole("button", { name: "Save Changes" }); + await user.click(submitButton); + + await waitFor(() => { + expect(updateOrganizationDataMock).toHaveBeenCalledWith({ + description: "Test with spaces", + website: "https://test.com", + links: undefined, + }); + }); + }); + + it("converts empty strings to undefined on submit", async () => { + getOrganizationDataMock.mockResolvedValue({ + description: "Test", + website: "https://test.com", + links: [], + }); + updateOrganizationDataMock.mockResolvedValue(undefined); + const user = userEvent.setup(); + await renderComponent(); + + await waitFor(() => { + expect(screen.queryByTestId("org-loading")).not.toBeInTheDocument(); + }); + + // Clear the fields + const descriptionField = screen.getByLabelText("Description"); + await user.clear(descriptionField); + + const websiteField = screen.getByLabelText("Website"); + await user.clear(websiteField); + + const submitButton = screen.getByRole("button", { name: "Save Changes" }); + await user.click(submitButton); + + await waitFor(() => { + expect(updateOrganizationDataMock).toHaveBeenCalledWith({ + description: undefined, + website: undefined, + links: undefined, + }); + }); + }); + + it("disables submit button while loading", async () => { + getOrganizationDataMock.mockResolvedValue(mockOrgData); + updateOrganizationDataMock.mockImplementation( + () => new Promise((resolve) => setTimeout(resolve, 1000)), + ); + const user = userEvent.setup(); + await renderComponent(); + + await waitFor(() => { + expect(screen.queryByTestId("org-loading")).not.toBeInTheDocument(); + }); + + const submitButton = screen.getByRole("button", { name: "Save Changes" }); + await user.click(submitButton); + + expect(submitButton).toBeDisabled(); + }); +}); + +describe("ManageOrganizationForm - Lead Management Tests", () => { + const getOrganizationDataMock = vi.fn(); + const updateOrganizationDataMock = vi.fn(); + const updateLeadsMock = vi.fn(); + + const mockOrgDataWithLeads = { + description: "Test organization", + website: "https://test.com", + links: [], + leads: [ + { + name: "John Doe", + username: "jdoe@illinois.edu", + title: "Chair", + }, + { + name: "Jane Smith", + username: "jsmith@illinois.edu", + title: "Vice Chair", + }, + ], + }; + + const renderComponent = async (props = {}) => { + await act(async () => { + render( + + + + + , + ); + }); + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("displays existing leads", async () => { + getOrganizationDataMock.mockResolvedValue(mockOrgDataWithLeads); + await renderComponent(); + + await waitFor(() => { + expect(screen.getByText("John Doe")).toBeInTheDocument(); + expect(screen.getByText("jdoe@illinois.edu")).toBeInTheDocument(); + expect(screen.getByText("Chair")).toBeInTheDocument(); + expect(screen.getByText("Jane Smith")).toBeInTheDocument(); + expect(screen.getByText("jsmith@illinois.edu")).toBeInTheDocument(); + expect(screen.getByText("Vice Chair")).toBeInTheDocument(); + }); + }); + + it("shows 'No leads found' when there are no leads", async () => { + getOrganizationDataMock.mockResolvedValue({ + description: "Test", + website: "https://test.com", + links: [], + leads: [], + }); + await renderComponent(); + + await waitFor(() => { + expect(screen.getByText("No leads found.")).toBeInTheDocument(); + }); + }); + + it("adds a new lead to the queue", async () => { + getOrganizationDataMock.mockResolvedValue(mockOrgDataWithLeads); + const user = userEvent.setup(); + await renderComponent(); + + await waitFor(() => { + expect(screen.getByText("John Doe")).toBeInTheDocument(); + }); + + // Fill in new lead form + await user.type(screen.getByLabelText("Lead Name"), "Bob Wilson"); + await user.type( + screen.getByLabelText("Lead Email"), + "bwilson@illinois.edu", + ); + await user.type(screen.getByLabelText("Lead Title"), "Treasurer"); + + // Click Add Lead button + const addButton = screen.getByRole("button", { name: "Add Lead" }); + await user.click(addButton); + + // Check that the lead is queued + await waitFor(() => { + expect(screen.getByText("Bob Wilson")).toBeInTheDocument(); + expect(screen.getByText("bwilson@illinois.edu")).toBeInTheDocument(); + expect(screen.getByText("Treasurer")).toBeInTheDocument(); + expect(screen.getByText("Queued for addition")).toBeInTheDocument(); + }); + }); + + it("validates email format when adding lead", async () => { + const notificationsMock = vi.spyOn(notifications, "show"); + getOrganizationDataMock.mockResolvedValue(mockOrgDataWithLeads); + const user = userEvent.setup(); + await renderComponent(); + + await waitFor(() => { + expect(screen.getByText("John Doe")).toBeInTheDocument(); + }); + + // Fill in form with invalid email + await user.type(screen.getByLabelText("Lead Name"), "Bob Wilson"); + await user.type(screen.getByLabelText("Lead Email"), "invalid-email"); + await user.type(screen.getByLabelText("Lead Title"), "Treasurer"); + + const addButton = screen.getByRole("button", { name: "Add Lead" }); + await user.click(addButton); + + await waitFor(() => { + expect(notificationsMock).toHaveBeenCalledWith( + expect.objectContaining({ + title: "Invalid Email", + message: "Please enter a valid email address.", + color: "orange", + }), + ); + }); + + notificationsMock.mockRestore(); + }); + + it("prevents adding duplicate leads", async () => { + const notificationsMock = vi.spyOn(notifications, "show"); + getOrganizationDataMock.mockResolvedValue(mockOrgDataWithLeads); + const user = userEvent.setup(); + await renderComponent(); + + await waitFor(() => { + expect(screen.getByText("John Doe")).toBeInTheDocument(); + }); + + // Try to add existing lead + await user.type(screen.getByLabelText("Lead Name"), "John Doe"); + await user.type(screen.getByLabelText("Lead Email"), "jdoe@illinois.edu"); + await user.type(screen.getByLabelText("Lead Title"), "Member"); + + const addButton = screen.getByRole("button", { name: "Add Lead" }); + await user.click(addButton); + + await waitFor(() => { + expect(notificationsMock).toHaveBeenCalledWith( + expect.objectContaining({ + title: "Duplicate Lead", + message: "This user is already a lead or queued for addition.", + color: "orange", + }), + ); + }); + + notificationsMock.mockRestore(); + }); + + it("queues a lead for removal", async () => { + getOrganizationDataMock.mockResolvedValue(mockOrgDataWithLeads); + const user = userEvent.setup(); + await renderComponent(); + + await waitFor(() => { + expect(screen.getByText("John Doe")).toBeInTheDocument(); + }); + + // Find and click the Remove button for John Doe + const removeButtons = screen.getAllByRole("button", { name: "Remove" }); + await user.click(removeButtons[0]); + + // Check that the lead is queued for removal + await waitFor(() => { + expect(screen.getByText("Queued for removal")).toBeInTheDocument(); + }); + }); + + it("cancels a queued removal", async () => { + getOrganizationDataMock.mockResolvedValue(mockOrgDataWithLeads); + const user = userEvent.setup(); + await renderComponent(); + + await waitFor(() => { + expect(screen.getByText("John Doe")).toBeInTheDocument(); + }); + + // Queue for removal + const removeButtons = screen.getAllByRole("button", { name: "Remove" }); + await user.click(removeButtons[0]); + + await waitFor(() => { + expect(screen.getByText("Queued for removal")).toBeInTheDocument(); + }); + + // Cancel the removal + const cancelButton = screen.getByRole("button", { name: "Cancel" }); + await user.click(cancelButton); + + await waitFor(() => { + expect(screen.queryByText("Queued for removal")).not.toBeInTheDocument(); + expect(screen.getAllByText("Active").length).toBeGreaterThan(0); + }); + }); + + it("cancels a queued addition", async () => { + getOrganizationDataMock.mockResolvedValue(mockOrgDataWithLeads); + const user = userEvent.setup(); + await renderComponent(); + + await waitFor(() => { + expect(screen.getByText("John Doe")).toBeInTheDocument(); + }); + + // Add a new lead + await user.type(screen.getByLabelText("Lead Name"), "Bob Wilson"); + await user.type( + screen.getByLabelText("Lead Email"), + "bwilson@illinois.edu", + ); + await user.type(screen.getByLabelText("Lead Title"), "Treasurer"); + await user.click(screen.getByRole("button", { name: "Add Lead" })); + + await waitFor(() => { + expect(screen.getByText("Bob Wilson")).toBeInTheDocument(); + }); + + // Cancel the addition + const cancelAddButton = screen.getByRole("button", { name: "Cancel Add" }); + await user.click(cancelAddButton); + + await waitFor(() => { + expect(screen.queryByText("Bob Wilson")).not.toBeInTheDocument(); + }); + }); + + it("opens confirmation modal when saving lead changes", async () => { + getOrganizationDataMock.mockResolvedValue(mockOrgDataWithLeads); + const user = userEvent.setup(); + await renderComponent(); + + await waitFor(() => { + expect(screen.getByText("John Doe")).toBeInTheDocument(); + }); + + // Add a new lead + await user.type(screen.getByLabelText("Lead Name"), "Bob Wilson"); + await user.type( + screen.getByLabelText("Lead Email"), + "bwilson@illinois.edu", + ); + await user.type(screen.getByLabelText("Lead Title"), "Treasurer"); + await user.click(screen.getByRole("button", { name: "Add Lead" })); + + // Click save lead changes + const saveButton = screen.getByRole("button", { + name: /Save Lead Changes/, + }); + await user.click(saveButton); + + // Check modal appears + await waitFor(() => { + expect(screen.getByText("Confirm Changes")).toBeInTheDocument(); + expect(screen.getByText("Leads to Add:")).toBeInTheDocument(); + }); + }); + + it("saves lead changes when confirmed", async () => { + getOrganizationDataMock.mockResolvedValue(mockOrgDataWithLeads); + updateLeadsMock.mockResolvedValue(undefined); + const user = userEvent.setup(); + await renderComponent(); + + await waitFor(() => { + expect(screen.getByText("John Doe")).toBeInTheDocument(); + }); + + // Add a new lead + await user.type(screen.getByLabelText("Lead Name"), "Bob Wilson"); + await user.type( + screen.getByLabelText("Lead Email"), + "bwilson@illinois.edu", + ); + await user.type(screen.getByLabelText("Lead Title"), "Treasurer"); + await user.click(screen.getByRole("button", { name: "Add Lead" })); + + // Queue a removal + const removeButtons = screen.getAllByRole("button", { name: "Remove" }); + await user.click(removeButtons[0]); + + // Open modal and confirm + const saveButton = screen.getByRole("button", { + name: /Save Lead Changes/, + }); + await user.click(saveButton); + + await waitFor(() => { + expect(screen.getByText("Confirm Changes")).toBeInTheDocument(); + }); + + const confirmButton = screen.getByRole("button", { + name: "Confirm and Save", + }); + await user.click(confirmButton); + + // Verify the update function was called correctly + await waitFor(() => { + expect(updateLeadsMock).toHaveBeenCalledWith( + [ + { + name: "Bob Wilson", + username: "bwilson@illinois.edu", + title: "Treasurer", + }, + ], + ["jdoe@illinois.edu"], + ); + }); + }); + + it("disables save button when no changes are queued", async () => { + getOrganizationDataMock.mockResolvedValue(mockOrgDataWithLeads); + await renderComponent(); + + await waitFor(() => { + expect(screen.getByText("John Doe")).toBeInTheDocument(); + }); + + const saveButton = screen.getByRole("button", { + name: /Save Lead Changes/, + }); + expect(saveButton).toBeDisabled(); + }); + + it("clears form fields after adding a lead", async () => { + getOrganizationDataMock.mockResolvedValue(mockOrgDataWithLeads); + const user = userEvent.setup(); + await renderComponent(); + + await waitFor(() => { + expect(screen.getByText("John Doe")).toBeInTheDocument(); + }); + + // Fill in and submit form + const nameInput = screen.getByLabelText("Lead Name"); + const emailInput = screen.getByLabelText("Lead Email"); + const titleInput = screen.getByLabelText("Lead Title"); + + await user.type(nameInput, "Bob Wilson"); + await user.type(emailInput, "bwilson@illinois.edu"); + await user.type(titleInput, "Treasurer"); + await user.click(screen.getByRole("button", { name: "Add Lead" })); + + // Check that fields are cleared + await waitFor(() => { + expect(nameInput).toHaveValue(""); + expect(emailInput).toHaveValue(""); + expect(titleInput).toHaveValue(""); + }); + }); + + it("shows error notification when all fields are not filled", async () => { + const notificationsMock = vi.spyOn(notifications, "show"); + getOrganizationDataMock.mockResolvedValue(mockOrgDataWithLeads); + const user = userEvent.setup(); + await renderComponent(); + + await waitFor(() => { + expect(screen.getByText("John Doe")).toBeInTheDocument(); + }); + + // Try to add without filling all fields + await user.type(screen.getByLabelText("Lead Name"), "Bob Wilson"); + await user.click(screen.getByRole("button", { name: "Add Lead" })); + + await waitFor(() => { + expect(notificationsMock).toHaveBeenCalledWith( + expect.objectContaining({ + title: "Invalid Input", + message: "All fields are required to add a lead.", + color: "orange", + }), + ); + }); + + notificationsMock.mockRestore(); + }); + + it("does not show lead management section when updateLeads is not provided", async () => { + getOrganizationDataMock.mockResolvedValue(mockOrgDataWithLeads); + await act(async () => { + render( + + + + + , + ); + }); + + await waitFor(() => { + expect(screen.queryByTestId("org-loading")).not.toBeInTheDocument(); + }); + + expect(screen.queryByText("Organization Leads")).not.toBeInTheDocument(); + }); +}); diff --git a/src/ui/pages/organization/ManageOrganizationForm.tsx b/src/ui/pages/organization/ManageOrganizationForm.tsx new file mode 100644 index 00000000..c1b21042 --- /dev/null +++ b/src/ui/pages/organization/ManageOrganizationForm.tsx @@ -0,0 +1,544 @@ +import React, { useEffect, useState } from "react"; +import { + TextInput, + Textarea, + Button, + Group, + Box, + LoadingOverlay, + Alert, + Title, + Text, + ActionIcon, + Stack, + Select, + Paper, + Table, + Badge, + Avatar, + Modal, + Divider, +} from "@mantine/core"; +import { useForm } from "@mantine/form"; +import { notifications } from "@mantine/notifications"; +import { IconPlus, IconTrash, IconUserPlus } from "@tabler/icons-react"; +import { + LeadEntry, + setOrganizationMetaBody, + validOrgLinkTypes, +} from "@common/types/organizations"; +import { zod4Resolver as zodResolver } from "mantine-form-zod-resolver"; +import * as z from "zod/v4"; + +type OrganizationData = z.infer; + +interface ManageOrganizationFormProps { + organizationId: string; + getOrganizationData: ( + orgId: string, + ) => Promise; + updateOrganizationData: (data: OrganizationData) => Promise; + updateLeads?: (toAdd: LeadEntry[], toRemove: string[]) => Promise; + firstTime?: boolean; +} + +export const ManageOrganizationForm: React.FC = ({ + organizationId, + getOrganizationData, + updateOrganizationData, + updateLeads, +}) => { + const [orgData, setOrgData] = useState( + undefined, + ); + const [loading, setLoading] = useState(false); + + // Lead management state + const [currentLeads, setCurrentLeads] = useState([]); + const [toAdd, setToAdd] = useState([]); + const [toRemove, setToRemove] = useState([]); + const [confirmModalOpen, setConfirmModalOpen] = useState(false); + + // New lead form state + const [newLeadName, setNewLeadName] = useState(""); + const [newLeadEmail, setNewLeadEmail] = useState(""); + const [newLeadTitle, setNewLeadTitle] = useState(""); + + const form = useForm({ + validate: zodResolver(setOrganizationMetaBody), + initialValues: { + description: undefined, + website: undefined, + links: undefined, + } as OrganizationData, + }); + + const fetchOrganizationData = async () => { + setLoading(true); + try { + const data = await getOrganizationData(organizationId); + setOrgData(data); + setCurrentLeads(data.leads || []); + setToAdd([]); + setToRemove([]); + + // Only extract the fields that are allowed in setOrganizationMetaBody + // (excludes id, leads, leadsEntraGroupId) + form.setValues({ + description: data.description, + website: data.website, + links: data.links || [], + }); + } catch (e) { + console.error(e); + setOrgData(null); + notifications.show({ + color: "red", + message: "Failed to load organization data", + }); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + // Reset form state when organization changes + setOrgData(undefined); + setLoading(false); + form.reset(); + setCurrentLeads([]); + setToAdd([]); + setToRemove([]); + setNewLeadName(""); + setNewLeadEmail(""); + setNewLeadTitle(""); + + // Fetch new organization data + fetchOrganizationData(); + }, [organizationId]); + + const handleAddLead = () => { + if (!newLeadName.trim() || !newLeadEmail.trim() || !newLeadTitle.trim()) { + notifications.show({ + title: "Invalid Input", + message: "All fields are required to add a lead.", + color: "orange", + }); + return; + } + + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(newLeadEmail)) { + notifications.show({ + title: "Invalid Email", + message: "Please enter a valid email address.", + color: "orange", + }); + return; + } + + if ( + currentLeads.some((lead) => lead.username === newLeadEmail) || + toAdd.some((lead) => lead.username === newLeadEmail) + ) { + notifications.show({ + title: "Duplicate Lead", + message: "This user is already a lead or queued for addition.", + color: "orange", + }); + return; + } + + setToAdd((prev) => [ + ...prev, + { + name: newLeadName.trim(), + username: newLeadEmail.trim(), + title: newLeadTitle.trim(), + }, + ]); + + setNewLeadName(""); + setNewLeadEmail(""); + setNewLeadTitle(""); + }; + + const handleQueueRemove = (email: string) => { + if (!toRemove.includes(email)) { + setToRemove((prev) => [...prev, email]); + } + }; + + const handleCancelRemove = (email: string) => { + setToRemove((prev) => prev.filter((e) => e !== email)); + }; + + const handleCancelAdd = (email: string) => { + setToAdd((prev) => prev.filter((lead) => lead.username !== email)); + }; + + const handleSaveLeads = async () => { + if (!updateLeads) { + notifications.show({ + title: "Feature Not Available", + message: "Lead management is not available for this organization.", + color: "orange", + }); + return; + } + + setLoading(true); + try { + await updateLeads(toAdd, toRemove); + await fetchOrganizationData(); + } catch (error) { + console.error(error); + } finally { + setLoading(false); + setConfirmModalOpen(false); + } + }; + + const handleSubmit = async () => { + if (!orgData) { + return; + } + setLoading(true); + try { + const values = form.values; + + // Only send the fields allowed by setOrganizationMetaBody schema + // (description, website, links - NO id, leads, or leadsEntraGroupId) + const cleanedData: OrganizationData = { + description: values.description?.trim() || undefined, + website: values.website?.trim() || undefined, + links: + values.links && values.links.length > 0 + ? values.links.filter((link) => link.url.trim() && link.type) + : undefined, + }; + + await updateOrganizationData(cleanedData); + } catch (e) { + console.error(e); + } finally { + setLoading(false); + } + }; + + const addLink = () => { + form.insertListItem("links", { type: "OTHER", url: "" }); + }; + + const removeLink = (index: number) => { + form.removeListItem("links", index); + }; + + const allDisplayLeads = [ + ...currentLeads.map((lead) => ({ ...lead, isNew: false })), + ...toAdd.map((lead) => ({ ...lead, isNew: true })), + ]; + + if (orgData === undefined) { + return ; + } + + return ( + <> + +
{ + e.preventDefault(); + handleSubmit(); + }} + > +