Skip to content

Commit fae3673

Browse files
authored
Add org management UI (#310)
1 parent 9847fea commit fae3673

File tree

18 files changed

+1755
-27
lines changed

18 files changed

+1755
-27
lines changed

src/api/functions/authorization.ts

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import { getUserOrgRoles } from "./organizations.js";
2525
export async function getUserRoles(
2626
dynamoClient: DynamoDBClient,
2727
userId: string,
28+
logger: FastifyBaseLogger,
2829
): Promise<AppRoles[]> {
2930
const tableName = `${genericConfig.IAMTablePrefix}-assignments`;
3031
const command = new GetItemCommand({
@@ -39,17 +40,33 @@ export async function getUserRoles(
3940
message: "Could not get user roles",
4041
});
4142
}
43+
// get user org roles and return if they lead at least one org
44+
let baseRoles: AppRoles[];
45+
try {
46+
const orgRoles = await getUserOrgRoles({
47+
username: userId,
48+
dynamoClient,
49+
logger,
50+
});
51+
const leadsOneOrg = orgRoles.filter((x) => x.role === "LEAD").length > 0;
52+
baseRoles = leadsOneOrg ? [AppRoles.AT_LEAST_ONE_ORG_MANAGER] : [];
53+
} catch (e) {
54+
logger.error(e);
55+
baseRoles = [];
56+
}
57+
4258
if (!response.Item) {
43-
return [];
59+
return baseRoles;
4460
}
4561
const items = unmarshall(response.Item) as { roles: AppRoles[] | ["all"] };
4662
if (!("roles" in items)) {
47-
return [];
63+
return baseRoles;
4864
}
4965
if (items.roles[0] === "all") {
5066
return allAppRoles;
5167
}
52-
return items.roles as AppRoles[];
68+
69+
return [...new Set([...baseRoles, ...items.roles])] as AppRoles[];
5370
}
5471

5572
export async function getGroupRoles(

src/api/functions/organizations.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,11 @@ export async function getOrgInfo({
5757
};
5858
try {
5959
const responseMarshall = await dynamoClient.send(query);
60-
if (!responseMarshall.Items || responseMarshall.Items.length === 0) {
60+
if (
61+
!responseMarshall ||
62+
!responseMarshall.Items ||
63+
responseMarshall.Items.length === 0
64+
) {
6165
logger.debug(
6266
`Could not find SIG information for ${id}, returning default.`,
6367
);
@@ -126,7 +130,7 @@ export async function getUserOrgRoles({
126130
});
127131
try {
128132
const response = await dynamoClient.send(query);
129-
if (!response.Items) {
133+
if (!response || !response.Items) {
130134
return [];
131135
}
132136
const unmarshalled = response.Items.map((x) => unmarshall(x)).map(

src/api/plugins/auth.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -352,6 +352,7 @@ const authPlugin: FastifyPluginAsync = async (fastify, _options) => {
352352
const userAuth = await getUserRoles(
353353
fastify.dynamoClient,
354354
request.username,
355+
request.log,
355356
);
356357
for (const role of userAuth) {
357358
userRoles.add(role);

src/api/routes/organizations.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { withRoles, withTags } from "api/components/index.js";
55
import { z } from "zod/v4";
66
import {
77
getOrganizationInfoResponse,
8+
ORG_DATA_CACHED_DURATION,
89
patchOrganizationLeadsBody,
910
setOrganizationMetaBody,
1011
} from "common/types/organizations.js";
@@ -46,7 +47,6 @@ import { getRoleCredentials } from "api/functions/sts.js";
4647
import { SQSClient } from "@aws-sdk/client-sqs";
4748
import { sendSqsMessagesInBatches } from "api/functions/sqs.js";
4849

49-
export const ORG_DATA_CACHED_DURATION = 300;
5050
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`;
5151

5252
const organizationsPlugin: FastifyPluginAsync = async (fastify, _options) => {

src/api/routes/protected.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
import { FastifyPluginAsync } from "fastify";
22
import rateLimiter from "api/plugins/rateLimiter.js";
33
import { withRoles, withTags } from "api/components/index.js";
4+
import { getUserOrgRoles } from "api/functions/organizations.js";
5+
import {
6+
UnauthenticatedError,
7+
UnauthorizedError,
8+
} from "common/errors/index.js";
49

510
const protectedRoute: FastifyPluginAsync = async (fastify, _options) => {
611
await fastify.register(rateLimiter, {
@@ -20,7 +25,22 @@ const protectedRoute: FastifyPluginAsync = async (fastify, _options) => {
2025
},
2126
async (request, reply) => {
2227
const roles = await fastify.authorize(request, reply, [], false);
23-
reply.send({ username: request.username, roles: Array.from(roles) });
28+
const { username, log: logger } = request;
29+
const { dynamoClient } = fastify;
30+
if (!username) {
31+
throw new UnauthenticatedError({ message: "Username not found." });
32+
}
33+
const orgRolesPromise = getUserOrgRoles({
34+
username,
35+
dynamoClient,
36+
logger,
37+
});
38+
const orgRoles = await orgRolesPromise;
39+
reply.send({
40+
username: request.username,
41+
roles: Array.from(roles),
42+
orgRoles,
43+
});
2444
},
2545
);
2646
};

src/common/roles.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,10 @@ export enum AppRoles {
2020
VIEW_INTERNAL_MEMBERSHIP_LIST = "view:internalMembershipList",
2121
VIEW_EXTERNAL_MEMBERSHIP_LIST = "view:externalMembershipList",
2222
MANAGE_EXTERNAL_MEMBERSHIP_LIST = "manage:externalMembershipList",
23-
ALL_ORG_MANAGER = "manage:orgDefinitions"
23+
ALL_ORG_MANAGER = "manage:orgDefinitions",
24+
AT_LEAST_ONE_ORG_MANAGER = "manage:someOrg" // THIS IS A FAKE ROLE - DO NOT ASSIGN IT MANUALLY - only used for permissioning
2425
}
26+
export const PSUEDO_ROLES = [AppRoles.AT_LEAST_ONE_ORG_MANAGER]
2527
export const orgRoles = ["LEAD", "MEMBER"] as const;
2628
export type OrgRole = typeof orgRoles[number];
2729
export type OrgRoleDefinition = {
@@ -31,7 +33,7 @@ export type OrgRoleDefinition = {
3133

3234
export const allAppRoles = Object.values(AppRoles).filter(
3335
(value) => typeof value === "string",
34-
);
36+
).filter(value => !PSUEDO_ROLES.includes(value)); // don't assign psuedo roles by default
3537

3638
export const AppRoleHumanMapper: Record<AppRoles, string> = {
3739
[AppRoles.EVENTS_MANAGER]: "Events Manager",
@@ -51,4 +53,5 @@ export const AppRoleHumanMapper: Record<AppRoles, string> = {
5153
[AppRoles.VIEW_EXTERNAL_MEMBERSHIP_LIST]: "External Membership List Viewer",
5254
[AppRoles.MANAGE_EXTERNAL_MEMBERSHIP_LIST]: "External Membership List Manager",
5355
[AppRoles.ALL_ORG_MANAGER]: "Organization Definition Manager",
56+
[AppRoles.AT_LEAST_ONE_ORG_MANAGER]: "Manager of at least one org",
5457
}

src/common/types/organizations.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,16 @@ import { z } from "zod/v4";
55

66
export const orgLeadEntry = z.object({
77
name: z.optional(z.string()),
8-
username: z.email(),
8+
username: z.email().refine(
9+
(email) => email.endsWith('@illinois.edu'),
10+
{ message: 'Email must be from the @illinois.edu domain' }
11+
),
912
title: z.optional(z.string())
1013
})
1114

12-
export const validOrgLinkTypes = ["DISCORD", "CAMPUSWIRE", "SLACK", "NOTION", "MATRIX", "OTHER"] as const as [string, ...string[]];
15+
export type LeadEntry = z.infer<typeof orgLeadEntry>;
16+
17+
export const validOrgLinkTypes = ["DISCORD", "CAMPUSWIRE", "SLACK", "NOTION", "MATRIX", "INSTAGRAM", "OTHER"] as const as [string, ...string[]];
1318

1419
export const orgLinkEntry = z.object({
1520
type: z.enum(validOrgLinkTypes),
@@ -18,7 +23,6 @@ export const orgLinkEntry = z.object({
1823

1924
export const enforcedOrgLeadEntry = orgLeadEntry.extend({ name: z.string().min(1), title: z.string().min(1) })
2025

21-
2226
export const getOrganizationInfoResponse = z.object({
2327
id: z.enum(AllOrganizationList),
2428
description: z.optional(z.string()),
@@ -28,8 +32,10 @@ export const getOrganizationInfoResponse = z.object({
2832
leadsEntraGroupId: z.optional(z.string().min(1)).meta({ description: `Only returned for users with the ${AppRoleHumanMapper[AppRoles.ALL_ORG_MANAGER]} role.` })
2933
})
3034

31-
export const setOrganizationMetaBody = getOrganizationInfoResponse.omit({ id: true, leads: true });
35+
export const setOrganizationMetaBody = getOrganizationInfoResponse.omit({ id: true, leads: true, leadsEntraGroupId: true });
3236
export const patchOrganizationLeadsBody = z.object({
3337
add: z.array(enforcedOrgLeadEntry),
3438
remove: z.array(z.string())
3539
});
40+
41+
export const ORG_DATA_CACHED_DURATION = 300;

src/ui/Router.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import { ViewLogsPage } from "./pages/logs/ViewLogs.page";
2929
import { TermsOfService } from "./pages/tos/TermsOfService.page";
3030
import { ManageApiKeysPage } from "./pages/apiKeys/ManageKeys.page";
3131
import { ManageExternalMembershipPage } from "./pages/membershipLists/MembershipListsPage";
32+
import { OrgInfoPage } from "./pages/organization/OrgInfo.page";
3233

3334
const ProfileRediect: React.FC = () => {
3435
const location = useLocation();
@@ -126,7 +127,7 @@ const authenticatedRouter = createBrowserRouter([
126127
...commonRoutes,
127128
{
128129
path: "/",
129-
element: <AcmAppShell>{null}</AcmAppShell>,
130+
element: <Navigate to="/home" replace />,
130131
},
131132
{
132133
path: "/login",
@@ -208,6 +209,10 @@ const authenticatedRouter = createBrowserRouter([
208209
path: "/apiKeys",
209210
element: <ManageApiKeysPage />,
210211
},
212+
{
213+
path: "/orgInfo",
214+
element: <OrgInfoPage />,
215+
},
211216
// Catch-all route for authenticated users shows 404 page
212217
{
213218
path: "*",

src/ui/components/AppShell/index.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {
2323
IconKey,
2424
IconExternalLink,
2525
IconUser,
26+
IconInfoCircle,
2627
} from "@tabler/icons-react";
2728
import { ReactNode } from "react";
2829
import { useNavigate } from "react-router-dom";
@@ -101,14 +102,21 @@ export const navItems = [
101102
},
102103
{
103104
link: "/membershipLists",
104-
name: "Membership Lists",
105+
name: "Membership",
105106
icon: IconUser,
106107
description: null,
107108
validRoles: [
108109
AppRoles.VIEW_EXTERNAL_MEMBERSHIP_LIST,
109110
AppRoles.MANAGE_EXTERNAL_MEMBERSHIP_LIST,
110111
],
111112
},
113+
{
114+
link: "/orgInfo",
115+
name: "Organization Info",
116+
icon: IconInfoCircle,
117+
description: null,
118+
validRoles: [AppRoles.AT_LEAST_ONE_ORG_MANAGER],
119+
},
112120
];
113121

114122
export const extLinks = [

0 commit comments

Comments
 (0)