diff --git a/cloudformation/iam.yml b/cloudformation/iam.yml index 11b8e05a..38651036 100644 --- a/cloudformation/iam.yml +++ b/cloudformation/iam.yml @@ -78,6 +78,8 @@ Resources: - Fn::Sub: arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-core-api-room-requests-status - Fn::Sub: arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-core-api-linkry - Fn::Sub: arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-core-api-keys + - Fn::Sub: arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-core-api-sig-member-details + - Fn::Sub: arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-core-api-sig-details # Index accesses - Fn::Sub: arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-core-api-stripe-links/index/* - Fn::Sub: arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-core-api-events/index/* @@ -99,6 +101,14 @@ Resources: Resource: - Fn::Sub: arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-core-api-cache + - Sid: DynamoDBRateLimitTableAccess + Effect: Allow + Action: + - dynamodb:DescribeTable + - dynamodb:UpdateItem + Resource: + - Fn::Sub: arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-core-api-rate-limiter + - Sid: DynamoDBAuditLogTableAccess Effect: Allow Action: diff --git a/src/api/functions/siglead.ts b/src/api/functions/siglead.ts new file mode 100644 index 00000000..83316c96 --- /dev/null +++ b/src/api/functions/siglead.ts @@ -0,0 +1,172 @@ +import { + AttributeValue, + DynamoDBClient, + GetItemCommand, + PutItemCommand, + PutItemCommandInput, + QueryCommand, + ScanCommand, +} from "@aws-sdk/client-dynamodb"; +import { unmarshall } from "@aws-sdk/util-dynamodb"; +import { DatabaseInsertError } from "common/errors/index.js"; +import { OrganizationList, orgIds2Name } from "common/orgs.js"; +import { + SigDetailRecord, + SigMemberCount, + SigMemberRecord, + SigMemberUpdateRecord, +} from "common/types/siglead.js"; +import { transformSigLeadToURI } from "common/utils.js"; +import { KeyObject } from "crypto"; +import { string } from "zod"; + +export async function fetchMemberRecords( + sigid: string, + tableName: string, + dynamoClient: DynamoDBClient, +) { + const fetchSigMemberRecords = new QueryCommand({ + TableName: tableName, + KeyConditionExpression: "#sigid = :accessVal", + ExpressionAttributeNames: { + "#sigid": "sigGroupId", + }, + ExpressionAttributeValues: { + ":accessVal": { S: sigid }, + }, + ScanIndexForward: false, + }); + + const result = await dynamoClient.send(fetchSigMemberRecords); + + // Process the results + return (result.Items || []).map((item) => { + const unmarshalledItem = unmarshall(item); + return unmarshalledItem as SigMemberRecord; + }); +} + +export async function fetchSigDetail( + sigid: string, + tableName: string, + dynamoClient: DynamoDBClient, +) { + const fetchSigDetail = new QueryCommand({ + TableName: tableName, + KeyConditionExpression: "#sigid = :accessVal", + ExpressionAttributeNames: { + "#sigid": "sigid", + }, + ExpressionAttributeValues: { + ":accessVal": { S: sigid }, + }, + ScanIndexForward: false, + }); + + const result = await dynamoClient.send(fetchSigDetail); + + // Process the results + return (result.Items || [{}]).map((item) => { + const unmarshalledItem = unmarshall(item); + + // Strip '#' from access field + delete unmarshalledItem.leadGroupId; + delete unmarshalledItem.memberGroupId; + + return unmarshalledItem as SigDetailRecord; + })[0]; +} + +// select count(sigid) +// from table +// groupby sigid +export async function fetchSigCounts( + sigMemberTableName: string, + dynamoClient: DynamoDBClient, +) { + const scan = new ScanCommand({ + TableName: sigMemberTableName, + ProjectionExpression: "sigGroupId", + }); + + const result = await dynamoClient.send(scan); + + const counts: Record<string, number> = {}; + // Object.entries(orgIds2Name).forEach(([id, _]) => { + // counts[id] = 0; + // }); + + (result.Items || []).forEach((item) => { + const sigGroupId = item.sigGroupId?.S; + if (sigGroupId) { + counts[sigGroupId] = (counts[sigGroupId] || 0) + 1; + } + }); + + const countsArray: SigMemberCount[] = Object.entries(counts).map( + ([id, count]) => ({ + sigid: id, + signame: orgIds2Name[id], + count, + }), + ); + console.log(countsArray); + return countsArray; +} + +export async function addMemberToSigDynamo( + sigMemberTableName: string, + sigMemberUpdateRequest: SigMemberUpdateRecord, + dynamoClient: DynamoDBClient, +) { + const item: Record<string, AttributeValue> = {}; + Object.entries(sigMemberUpdateRequest).forEach(([k, v]) => { + item[k] = { S: v }; + }); + + // put into table + const put = new PutItemCommand({ + Item: item, + ReturnConsumedCapacity: "TOTAL", + TableName: sigMemberTableName, + }); + try { + const response = await dynamoClient.send(put); + console.log(response); + } catch (e) { + console.error("Put to dynamo db went wrong."); + throw e; + } + + // fetch from db and check if fetched item update time = input item update time + const validatePutQuery = new GetItemCommand({ + TableName: sigMemberTableName, + Key: { + sigGroupId: { S: sigMemberUpdateRequest.sigGroupId }, + email: { S: sigMemberUpdateRequest.email }, + }, + ProjectionExpression: "updatedAt", + }); + + try { + const response = await dynamoClient.send(validatePutQuery); + const item = response.Item; + + if (!item || !item.updatedAt?.S) { + throw new Error("Item not found or missing 'updatedAt'"); + } + + if (item.updatedAt.S !== sigMemberUpdateRequest.updatedAt) { + throw new DatabaseInsertError({ + message: "The member exists, but was updated by someone else!", + }); + } + } catch (e) { + console.error("Validate DynamoDB get went wrong.", e); + throw e; + } +} + +export async function addMemberToSigEntra() { + // uuid validation not implemented yet +} diff --git a/src/api/index.ts b/src/api/index.ts index 5cd222b5..bc1e682a 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -25,6 +25,7 @@ import * as dotenv from "dotenv"; import iamRoutes from "./routes/iam.js"; import ticketsPlugin from "./routes/tickets.js"; import linkryRoutes from "./routes/linkry.js"; +import sigleadRoutes from "./routes/siglead.js"; import { STSClient, GetCallerIdentityCommand } from "@aws-sdk/client-sts"; import NodeCache from "node-cache"; import { DynamoDBClient } from "@aws-sdk/client-dynamodb"; @@ -289,6 +290,7 @@ async function init(prettyPrint: boolean = false) { api.register(linkryRoutes, { prefix: "/linkry" }); api.register(mobileWalletRoute, { prefix: "/mobileWallet" }); api.register(stripeRoutes, { prefix: "/stripe" }); + api.register(sigleadRoutes, { prefix: "/siglead" }); api.register(roomRequestRoutes, { prefix: "/roomRequests" }); api.register(logsPlugin, { prefix: "/logs" }); api.register(apiKeyRoute, { prefix: "/apiKey" }); diff --git a/src/api/routes/siglead.ts b/src/api/routes/siglead.ts new file mode 100644 index 00000000..1b27a3e8 --- /dev/null +++ b/src/api/routes/siglead.ts @@ -0,0 +1,151 @@ +import { FastifyPluginAsync } from "fastify"; +import { DatabaseFetchError } from "../../common/errors/index.js"; + +import { genericConfig } from "../../common/config.js"; + +import { + SigDetailRecord, + SigleadGetRequest, + SigMemberCount, + SigMemberRecord, + SigMemberUpdateRecord, +} from "common/types/siglead.js"; +import { + addMemberToSigDynamo, + fetchMemberRecords, + fetchSigCounts, + fetchSigDetail, +} from "api/functions/siglead.js"; +import { intersection } from "api/plugins/auth.js"; +import { request } from "http"; + +const sigleadRoutes: FastifyPluginAsync = async (fastify, _options) => { + const limitedRoutes: FastifyPluginAsync = async (fastify) => { + /*fastify.register(rateLimiter, { + limit: 30, + duration: 60, + rateLimitIdentifier: "linkry", + });*/ + + fastify.get<SigleadGetRequest>( + "/sigmembers/:sigid", + { + onRequest: async (request, reply) => { + /*await fastify.authorize(request, reply, [ + AppRoles.LINKS_MANAGER, + AppRoles.LINKS_ADMIN, + ]);*/ + }, + }, + async (request, reply) => { + const { sigid } = request.params; + const tableName = genericConfig.SigleadDynamoSigMemberTableName; + + // First try-catch: Fetch owner records + let memberRecords: SigMemberRecord[]; + try { + memberRecords = await fetchMemberRecords( + sigid, + tableName, + fastify.dynamoClient, + ); + } catch (error) { + request.log.error( + `Failed to fetch member records: ${error instanceof Error ? error.toString() : "Unknown error"}`, + ); + throw new DatabaseFetchError({ + message: "Failed to fetch member records from Dynamo table.", + }); + } + + // Send the response + reply.code(200).send(memberRecords); + }, + ); + + fastify.get<SigleadGetRequest>( + "/sigdetail/:sigid", + { + onRequest: async (request, reply) => { + /*await fastify.authorize(request, reply, [ + AppRoles.LINKS_MANAGER, + AppRoles.LINKS_ADMIN, + ]);*/ + }, + }, + async (request, reply) => { + const { sigid } = request.params; + const tableName = genericConfig.SigleadDynamoSigDetailTableName; + + // First try-catch: Fetch owner records + let sigDetail: SigDetailRecord; + try { + sigDetail = await fetchSigDetail( + sigid, + tableName, + fastify.dynamoClient, + ); + } catch (error) { + request.log.error( + `Failed to fetch sig detail record: ${error instanceof Error ? error.toString() : "Unknown error"}`, + ); + throw new DatabaseFetchError({ + message: "Failed to fetch sig detail record from Dynamo table.", + }); + } + + // Send the response + reply.code(200).send(sigDetail); + }, + ); + + // fetch sig count + fastify.get<SigleadGetRequest>("/sigcount", async (request, reply) => { + // First try-catch: Fetch owner records + let sigMemCounts: SigMemberCount[]; + try { + sigMemCounts = await fetchSigCounts( + genericConfig.SigleadDynamoSigMemberTableName, + fastify.dynamoClient, + ); + } catch (error) { + request.log.error( + `Failed to fetch sig member counts record: ${error instanceof Error ? error.toString() : "Unknown error"}`, + ); + throw new DatabaseFetchError({ + message: + "Failed to fetch sig member counts record from Dynamo table.", + }); + } + + // Send the response + reply.code(200).send(sigMemCounts); + }); + + // add member + fastify.post<{ Body: SigMemberUpdateRecord }>( + "/addMember", + async (request, reply) => { + try { + await addMemberToSigDynamo( + genericConfig.SigleadDynamoSigMemberTableName, + request.body, + fastify.dynamoClient, + ); + } catch (error) { + request.log.error( + `Failed to add member: ${error instanceof Error ? error.toString() : "Unknown error"}`, + ); + throw new DatabaseFetchError({ + message: "Failed to add sig member record to Dynamo table.", + }); + } + reply.code(200); + }, + ); + }; + + fastify.register(limitedRoutes); +}; + +export default sigleadRoutes; diff --git a/src/common/config.ts b/src/common/config.ts index 0076dfb9..23f85782 100644 --- a/src/common/config.ts +++ b/src/common/config.ts @@ -48,6 +48,10 @@ export type GenericConfigType = { EntraReadOnlySecretName: string; AuditLogTable: string; ApiKeyTable: string; + + RateLimiterDynamoTableName: string; + SigleadDynamoSigDetailTableName: string; + SigleadDynamoSigMemberTableName: string; }; type EnvironmentConfigType = { @@ -63,6 +67,8 @@ export const commChairsTestingGroupId = "d714adb7-07bb-4d4d-a40a-b035bc2a35a3"; export const commChairsGroupId = "105e7d32-7289-435e-a67a-552c7f215507"; export const miscTestingGroupId = "ff25ec56-6a33-420d-bdb0-51d8a3920e46"; +export const orgsGroupId = "0b3be7c2-748e-46ce-97e7-cf86f9ca7337"; + const genericConfig: GenericConfigType = { EventsDynamoTableName: "infra-core-api-events", StripeLinksDynamoTableName: "infra-core-api-stripe-links", @@ -86,6 +92,10 @@ const genericConfig: GenericConfigType = { RoomRequestsStatusTableName: "infra-core-api-room-requests-status", AuditLogTable: "infra-core-api-audit-log", ApiKeyTable: "infra-core-api-keys", + + RateLimiterDynamoTableName: "infra-core-api-rate-limiter", + SigleadDynamoSigDetailTableName: "infra-core-api-sig-details", + SigleadDynamoSigMemberTableName: "infra-core-api-sig-member-details", } as const; const environmentConfig: EnvironmentConfigType = { diff --git a/src/common/roles.ts b/src/common/roles.ts index cb99cf6f..acbeee73 100644 --- a/src/common/roles.ts +++ b/src/common/roles.ts @@ -3,6 +3,7 @@ export const runEnvironments = ["dev", "prod"] as const; export type RunEnvironment = (typeof runEnvironments)[number]; export enum AppRoles { EVENTS_MANAGER = "manage:events", + SIGLEAD_MANAGER = "manage:siglead", TICKETS_SCANNER = "scan:tickets", TICKETS_MANAGER = "manage:tickets", IAM_ADMIN = "admin:iam", @@ -17,5 +18,8 @@ export enum AppRoles { MANAGE_ORG_API_KEYS = "manage:orgApiKey" } export const allAppRoles = Object.values(AppRoles).filter( - (value) => typeof value === "string", + (value) => typeof value === "string", ); + + + \ No newline at end of file diff --git a/src/common/types/siglead.ts b/src/common/types/siglead.ts new file mode 100644 index 00000000..9da5b696 --- /dev/null +++ b/src/common/types/siglead.ts @@ -0,0 +1,34 @@ +export type SigDetailRecord = { + sigid: string; + signame: string; + description: string; +}; + +export type SigMemberRecord = { + sigGroupId: string; + email: string; + designation: string; + memberName: string; +}; + +export type SigleadGetRequest = { + Params: { sigid: string }; + Querystring: undefined; + Body: undefined; +}; + +export type SigMemberCount = { + sigid: string; + signame: string; + count: number; +}; + +export type SigMemberUpdateRecord = { + sigGroupId: string; + email: string; + id: string; + memberName: string; + designation: string; + createdAt: string; + updatedAt: string; +} \ No newline at end of file diff --git a/src/common/utils.ts b/src/common/utils.ts index 786c998f..5efddccc 100644 --- a/src/common/utils.ts +++ b/src/common/utils.ts @@ -12,3 +12,60 @@ export function transformCommaSeperatedName(name: string) { } return name; } + +const notUnreservedCharsRegex = /[^a-zA-Z0-9\-._~]/g; +const reservedCharsRegex = /[:\/?#\[\]@!$&'()*+,;=]/g; +/** + * Transforms an organization name (sig lead) into a URI-friendly format. + * The function performs the following transformations: + * - Removes characters that are reserved or not unreserved. + * - Adds spaces between camel case words. + * - Converts reserved characters to spaces. + * - Converts all characters to lowercase and replaces all types of whitespace with hyphens. + * - Replaces any sequence of repeated hyphens with a single hyphen. + * - Refer to RFC 3986 https://datatracker.ietf.org/doc/html/rfc3986#section-2.3 + * + * @param {string} org - The organization (sig lead) name to be transformed. + * @returns {string} - The transformed organization name, ready for use as a URL. + */ +export function transformSigLeadToURI(org: string) { + // console.log(`org\t${org}`) + org = org + // change not reserved chars to spaces + .trim() + .replace(notUnreservedCharsRegex, " ") + .trim() + .replace(/\s/g, "-") + + // remove all that is reserved or not unreserved + .replace(reservedCharsRegex, "") + + // convert SIG -> sig for camel case + .replace(/SIG/g, "sig") + + // add hyphen for camel case + .replace(/([a-z])([A-Z])/g, "$1-$2") + + // lower + .toLowerCase() + + // add spaces between chars and numbers (seq2seq -> seq-2-seq) + .replace(/(?<=[a-z])([0-9]+)(?=[a-z])/g, "-$1-") + + // remove duplicate hyphens + .replace(/-{2,}/g, "-"); + + return org === "-" ? "" : org; +} + +export function getTimeInFormat() { + const date = new Date(); + const year = date.getUTCFullYear(); + const month = String(date.getUTCMonth() + 1).padStart(2, '0'); + const day = String(date.getUTCDate()).padStart(2, '0'); + const hours = String(date.getUTCHours()).padStart(2, '0'); + const minutes = String(date.getUTCMinutes()).padStart(2, '0'); + const seconds = String(date.getUTCSeconds()).padStart(2, '0'); + + return `${year}-${month}-${day}T${hours}:${minutes}:${seconds}Z`; +} diff --git a/src/ui/Router.tsx b/src/ui/Router.tsx index 0276bb31..28927859 100644 --- a/src/ui/Router.tsx +++ b/src/ui/Router.tsx @@ -5,6 +5,7 @@ import { RouterProvider, useLocation, } from "react-router-dom"; + import { AcmAppShell } from "./components/AppShell"; import { useAuth } from "./components/AuthContext"; import AuthCallback from "./components/AuthContext/AuthCallbackHandler.page"; @@ -24,6 +25,12 @@ import { ManageIamPage } from "./pages/iam/ManageIam.page"; import { ManageProfilePage } from "./pages/profile/ManageProfile.page"; import { ManageStripeLinksPage } from "./pages/stripe/ViewLinks.page"; import { ManageRoomRequestsPage } from "./pages/roomRequest/RoomRequestLanding.page"; +import { EditSigLeadsPage } from "./pages/siglead/EditSigLeads.page"; +import { ManageSigLeadsPage } from "./pages/siglead/ManageSigLeads.page"; +import { + AddMemberToSigPage, + ViewSigLeadPage, +} from "./pages/siglead/ViewSigLead.page"; import { ViewRoomRequest } from "./pages/roomRequest/ViewRoomRequest.page"; import { ViewLogsPage } from "./pages/logs/ViewLogs.page"; import { TermsOfService } from "./pages/tos/TermsOfService.page"; @@ -187,6 +194,22 @@ const authenticatedRouter = createBrowserRouter([ path: "/stripe", element: <ManageStripeLinksPage />, }, + { + path: "/siglead-management", + element: <ManageSigLeadsPage />, + }, + { + path: "/siglead-management/edit", + element: <EditSigLeadsPage />, + }, + { + path: "/siglead-management/:sigId", + element: <ViewSigLeadPage />, + }, + { + path: "/siglead-management/:sigId/addMember", + element: <AddMemberToSigPage />, + }, { path: "/roomRequests", element: <ManageRoomRequestsPage />, diff --git a/src/ui/components/AppShell/index.tsx b/src/ui/components/AppShell/index.tsx index 335f0b00..59ca6bfe 100644 --- a/src/ui/components/AppShell/index.tsx +++ b/src/ui/components/AppShell/index.tsx @@ -21,6 +21,7 @@ import { IconDoor, IconHistory, IconKey, + IconUsers, } from "@tabler/icons-react"; import { ReactNode } from "react"; import { useNavigate } from "react-router-dom"; @@ -76,6 +77,13 @@ export const navItems = [ description: null, validRoles: [AppRoles.ROOM_REQUEST_CREATE, AppRoles.ROOM_REQUEST_UPDATE], }, + { + link: "/siglead-management", + name: "SigLead", + icon: IconUsers, + description: null, + validRoles: [AppRoles.SIGLEAD_MANAGER], + }, { link: "/linkry", name: "Link Shortener", diff --git a/src/ui/pages/siglead/EditSigLeads.page.tsx b/src/ui/pages/siglead/EditSigLeads.page.tsx new file mode 100644 index 00000000..c5c03d38 --- /dev/null +++ b/src/ui/pages/siglead/EditSigLeads.page.tsx @@ -0,0 +1,116 @@ +import { Title, TextInput, Button, Container, Group } from "@mantine/core"; +import { useForm, zodResolver } from "@mantine/form"; +import { notifications } from "@mantine/notifications"; +import React, { useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { z } from "zod"; +import { AuthGuard } from "@ui/components/AuthGuard"; +import { useApi } from "@ui/util/api"; +import { AppRoles } from "@common/roles"; +import { transformSigLeadToURI } from "@common/utils"; + +const baseSigSchema = z.object({ + signame: z + .string() + .min(1, "Title is required") + .regex( + /^[a-zA-Z0-9]+$/, + "Sig name should only contain alphanumeric characters", + ), + description: z.string().min(1, "Description is required"), +}); + +type SigPostRequest = z.infer<typeof baseSigSchema>; + +export const EditSigLeadsPage: React.FC = () => { + const [isSubmitting, setIsSubmitting] = useState<boolean>(false); + const navigate = useNavigate(); + const api = useApi("core"); + + const form = useForm<SigPostRequest>({ + validate: zodResolver(baseSigSchema), + initialValues: { + signame: "", + description: "", + }, + }); + + const checkSigId = async (signame: string) => { + try { + const sigid = transformSigLeadToURI(signame); + const result = await api.get(`/api/v1/siglead/sigdetail/${sigid}`); + return result.data; + } catch (error) { + console.error("Error validating if sigid already exists", error); + notifications.show({ + message: `Error validating if sigid already exists`, + }); + } + }; + + const handleSubmit = async (sigdetails: SigPostRequest) => { + try { + setIsSubmitting(true); + const found = await checkSigId(sigdetails.signame); + if (found) { + form.setErrors({ + signame: "This signame is reserved already.", + }); + setIsSubmitting(false); + return; + } + notifications.show({ + message: `This will eventually make to a post request with signame: + ${sigdetails.signame} and description: ${sigdetails.description} + `, + }); + //Post... + navigate("/siglead-management"); + } catch (error) { + setIsSubmitting(false); + console.error("Error creating sig:", error); + notifications.show({ + message: "Failed to create sig, please try again.", + }); + } + }; + + return ( + <AuthGuard + resourceDef={{ service: "core", validRoles: [AppRoles.IAM_ADMIN] }} + > + <Container> + <Title order={1}>Registering a new Sig</Title> + <form onSubmit={form.onSubmit(handleSubmit)}> + <TextInput + label="SIG Name" + description="Enter your sig name" + withAsterisk + mt="xl" + {...form.getInputProps("signame")} + /> + <TextInput + label="Description" + description="Enter the description of your SIG" + withAsterisk + mt="xl" + {...form.getInputProps("description")} + /> + <Group mt="xl"> + <Button + variant="outline" + onClick={() => navigate("/siglead-management")} + > + {" "} + Cancel{" "} + </Button> + <Button type="submit" variant="gradient"> + {" "} + Submit{" "} + </Button> + </Group> + </form> + </Container> + </AuthGuard> + ); +}; diff --git a/src/ui/pages/siglead/ManageSigLeads.page.tsx b/src/ui/pages/siglead/ManageSigLeads.page.tsx new file mode 100644 index 00000000..ced9bccd --- /dev/null +++ b/src/ui/pages/siglead/ManageSigLeads.page.tsx @@ -0,0 +1,67 @@ +import { Title, Button, Container, Group } from "@mantine/core"; +import { useDisclosure } from "@mantine/hooks"; +import { DateTimePicker } from "@mantine/dates"; +import { useForm, zodResolver } from "@mantine/form"; +import { notifications } from "@mantine/notifications"; +import dayjs from "dayjs"; +import React, { useEffect, useState } from "react"; +import { useNavigate, useParams } from "react-router-dom"; +import { z } from "zod"; +import { AuthGuard } from "@ui/components/AuthGuard"; +import { getRunEnvironmentConfig } from "@ui/config"; +import { useApi } from "@ui/util/api"; +import { OrganizationList as orgList } from "@common/orgs"; +import { AppRoles } from "@common/roles"; +import { ScreenComponent } from "./SigScreenComponents"; +import { transformSigLeadToURI } from "@common/utils"; +import { + SigDetailRecord, + SigleadGetRequest, + SigMemberCount, + SigMemberRecord, +} from "@common/types/siglead"; + +export const ManageSigLeadsPage: React.FC = () => { + const [SigMemberCounts, setSigMemberCounts] = useState<SigMemberCount[]>([]); + const navigate = useNavigate(); + const api = useApi("core"); + + useEffect(() => { + const getMemberCounts = async () => { + try { + const sigMemberCountsRequest = await api.get( + `/api/v1/siglead/sigcount`, + ); + setSigMemberCounts(sigMemberCountsRequest.data); + } catch (error) { + console.error("Error fetching sig member counts:", error); + notifications.show({ + message: "Failed to fetch sig member counts, please try again.", + }); + } + }; + getMemberCounts(); + }, []); + + return ( + <AuthGuard + resourceDef={{ service: "core", validRoles: [AppRoles.IAM_ADMIN] }} + > + <Container> + <Group flex="auto"> + <Title order={2}>SigLead Management System</Title> + <Button + ml="auto" + variant="gradient" + onClick={() => navigate("/siglead-management/edit")} + > + Add a Sig + </Button> + </Group> + + <ScreenComponent SigMemberCounts={SigMemberCounts} /> + {/* <SigTable /> */} + </Container> + </AuthGuard> + ); +}; diff --git a/src/ui/pages/siglead/SigScreenComponents.tsx b/src/ui/pages/siglead/SigScreenComponents.tsx new file mode 100644 index 00000000..50d45eaf --- /dev/null +++ b/src/ui/pages/siglead/SigScreenComponents.tsx @@ -0,0 +1,76 @@ +import React, { useEffect, useMemo, useState } from "react"; +import { OrganizationList } from "@common/orgs"; +import { NavLink, Paper } from "@mantine/core"; +import { IconUsersGroup } from "@tabler/icons-react"; +import { useNavigate } from "react-router-dom"; +import { SigMemberCount } from "@common/types/siglead"; + +const renderSigLink = (sigMemCount: SigMemberCount, index: number) => { + const navigate = useNavigate(); + + const color = + "light-dark(var(--mantine-color-black), var(--mantine-color-white))"; + const size = "18px"; + const name = sigMemCount.signame; + const id = sigMemCount.sigid; + const count = sigMemCount.count; + return ( + <NavLink + onClick={() => navigate(`./${id}`)} + active={index % 2 === 0} + label={name} + color="var(--mantine-color-blue-light)" + variant="filled" + rightSection={ + <div + style={{ + display: "flex", + alignItems: "center", + gap: "4px", + color: `${color}`, + fontSize: `${size}`, + }} + > + <span>{count}</span> + <IconUsersGroup /> + </div> + } + styles={{ + label: { + color: `${color}`, + fontSize: `${size}`, + }, + }} + /> + ); +}; + +type props = { + SigMemberCounts: SigMemberCount[]; +}; + +export const ScreenComponent: React.FC<props> = ({ SigMemberCounts }) => { + return ( + <> + <Paper + shadow="xs" + p="sm" + style={{ + display: "flex", + justifyContent: "space-between", + alignItems: "center", + fontWeight: "bold", + borderRadius: "8px", + padding: "10px 16px", + marginBottom: "8px", + fontSize: "22px", + }} + > + <span>Organization</span> + <span>Member Count</span> + </Paper> + {/* {OrganizationList.map(renderSigLink)} */} + {SigMemberCounts.map(renderSigLink)} + </> + ); +}; diff --git a/src/ui/pages/siglead/ViewSigLead.page.tsx b/src/ui/pages/siglead/ViewSigLead.page.tsx new file mode 100644 index 00000000..5e5db651 --- /dev/null +++ b/src/ui/pages/siglead/ViewSigLead.page.tsx @@ -0,0 +1,266 @@ +import { + Title, + Box, + Button, + Container, + Transition, + useMantineColorScheme, + Table, + Group, + Stack, +} from "@mantine/core"; + +import { notifications } from "@mantine/notifications"; +import React, { FC, useEffect, useState } from "react"; +import { useNavigate, useParams } from "react-router-dom"; +import { AuthGuard } from "@ui/components/AuthGuard"; +import { useApi } from "@ui/util/api"; +import { AppRoles } from "@common/roles"; +import { + SigDetailRecord, + SigMemberRecord, + SigMemberUpdateRecord, +} from "@common/types/siglead.js"; +import { getTimeInFormat } from "@common/utils"; +import { orgIds2Name } from "@common/orgs"; + +export const ViewSigLeadPage: React.FC = () => { + const navigate = useNavigate(); + const api = useApi("core"); + const { colorScheme } = useMantineColorScheme(); + const { sigId } = useParams(); + const [sigMembers, setSigMembers] = useState<SigMemberRecord[]>([]); + const [sigDetails, setSigDetails] = useState<SigDetailRecord>({ + sigid: sigId || "", + signame: "Default Sig", + description: + "A cool Sig with a lot of money and members. Founded in 1999 by Sir Charlie of Edinburgh. Focuses on making money and helping others earn more money via education.", + }); + + useEffect(() => { + // Fetch sig data and populate form + const getSig = async () => { + try { + /*const formValues = { + }; + form.setValues(formValues);*/ + const sigMemberRequest = await api.get( + `/api/v1/siglead/sigmembers/${sigId}`, + ); + setSigMembers(sigMemberRequest.data); + + const sigDetailRequest = await api.get( + `/api/v1/siglead/sigdetail/${sigId}`, + ); + setSigDetails(sigDetailRequest.data); + } catch (error) { + console.error("Error fetching sig data:", error); + notifications.show({ + message: "Failed to fetch sig data, please try again.", + }); + } + }; + getSig(); + }, [sigId]); + + const renderSigMember = (member: SigMemberRecord, index: number) => { + const shouldShow = true; + return ( + <Transition + mounted={shouldShow} + transition="fade" + duration={10000} + timingFunction="ease" + > + {(styles) => ( + <tr + style={{ + ...styles, + display: shouldShow ? "table-row" : "none", + backgroundColor: + colorScheme === "dark" + ? index % 2 === 0 + ? "#333333" + : "#444444" + : index % 2 === 0 + ? "#f0f8ff" + : "#ffffff", + }} + > + <Table.Td>{member.memberName}</Table.Td> + <Table.Td>{member.email}</Table.Td> + <Table.Td>{member.designation}</Table.Td> + </tr> + )} + </Transition> + ); + }; + + /* + const form = useForm<EventPostRequest>({ + validate: zodResolver(requestBodySchema), + initialValues: { + title: '', + description: '', + start: new Date(), + end: new Date(new Date().valueOf() + 3.6e6), // 1 hr later + location: 'ACM Room (Siebel CS 1104)', + locationLink: 'https://maps.app.goo.gl/dwbBBBkfjkgj8gvA8', + host: 'ACM', + featured: false, + repeats: undefined, + repeatEnds: undefined, + paidEventId: undefined, + }, + }); + /* + const handleSubmit = async (values: EventPostRequest) => { + try { + setIsSubmitting(true); + const realValues = { + ...values, + start: dayjs(values.start).format('YYYY-MM-DD[T]HH:mm:00'), + end: values.end ? dayjs(values.end).format('YYYY-MM-DD[T]HH:mm:00') : undefined, + repeatEnds: + values.repeatEnds && values.repeats + ? dayjs(values.repeatEnds).format('YYYY-MM-DD[T]HH:mm:00') + : undefined, + repeats: values.repeats ? values.repeats : undefined, + }; + + const eventURL = isEditing ? `/api/v1/events/${eventId}` : '/api/v1/events'; + const response = await api.post(eventURL, realValues); + notifications.show({ + title: isEditing ? 'Event updated!' : 'Event created!', + message: isEditing ? undefined : `The event ID is "${response.data.id}".`, + }); + navigate('/events/manage'); + } catch (error) { + setIsSubmitting(false); + console.error('Error creating/editing event:', error); + notifications.show({ + message: 'Failed to create/edit event, please try again.', + }); + } + };*/ + + return ( + <AuthGuard + resourceDef={{ service: "core", validRoles: [AppRoles.SIGLEAD_MANAGER] }} + > + <Container> + <Group align="flex-start"> + <Box style={{ flex: 8 }}> + <Title order={1}>{sigDetails.signame}</Title> + {sigDetails.description || ""} + </Box> + <Box style={{ flex: 1, textAlign: "right", alignItems: "right" }}> + <Stack> + <Button variant="white">Member Count: {sigMembers.length}</Button> + + <Button onClick={() => navigate("./addMember")}> + Add Member + </Button> + <Button + onClick={() => navigate("../siglead-management")} + variant="outline" + color="gray" + > + Back + </Button> + </Stack> + </Box> + </Group> + <div style={{ width: "100%", overflowX: "auto" }}> + <Table style={{ tableLayout: "fixed", width: "100%" }}> + <Table.Thead> + <Table.Tr> + <Table.Th>Name</Table.Th> + <Table.Th>Email</Table.Th> + <Table.Th>Roles</Table.Th> + </Table.Tr> + </Table.Thead> + <Table.Tbody> + {sigMembers.length > 0 ? sigMembers.map(renderSigMember) : <></>} + </Table.Tbody> + </Table> + </div> + </Container> + </AuthGuard> + ); +}; + +export const AddMemberToSigPage: FC = () => { + // const { sigId } = useParams(); + const api = useApi("core"); + + async function handleSubmit(event: React.FormEvent<HTMLFormElement>) { + event.preventDefault(); + const formData = new FormData(event.currentTarget); + // console.log(formData) + const data = Object.fromEntries(formData.entries()) as { + groupid: string; + aid: string; + rid: string; + }; + + try { + const response = await api.patch(`/api/v1/iam/groups/${data.groupid}`, { + add: data.aid !== "" ? [data.aid] : [], + remove: data.rid !== "" ? [data.rid] : [], + }); + + console.warn(`GRAPH API RESPONSE: ${response}`); + notifications.show({ + message: JSON.stringify(response), + }); + } catch (error) { + notifications.show({ + message: JSON.stringify(error), + }); + } + + // console.log(response); + } + + // async function testAddGroup() { + // await api.patch( + // `/api/v1/iam/groups/:e37a2420-1030-48da-9d17-f7e201b446e1`, + // { add: ["d115c8cb-2520-4ba4-bc36-dd55af69c590"], remove: [] }, + // ); + // } + + return ( + <AuthGuard + resourceDef={{ service: "core", validRoles: [AppRoles.SIGLEAD_MANAGER] }} + > + <form id="form" onSubmit={handleSubmit}> + <label htmlFor="groupid">group id: </label> + <input + type="text" + name="groupid" + id="groupid" + placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + /> + <br /> + <label htmlFor="id">add uuid: </label> + <input + type="text" + name="aid" + id="aid" + placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + /> + <br /> + <label htmlFor="id">remove uuid: </label> + <input + type="text" + name="rid" + id="rid" + placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + /> + <br /> + <button type="submit">Submit</button> + </form> + </AuthGuard> + ); +}; diff --git a/tests/unit/common/utils.test.ts b/tests/unit/common/utils.test.ts index 15177175..e22d642c 100644 --- a/tests/unit/common/utils.test.ts +++ b/tests/unit/common/utils.test.ts @@ -1,5 +1,5 @@ import { expect, test, describe } from "vitest"; -import { transformCommaSeperatedName } from "../../../src/common/utils.js"; +import { transformCommaSeperatedName, transformSigLeadToURI } from "../../../src/common/utils.js"; describe("Comma-seperated name transformer tests", () => { test("Already-transformed names are returned as-is", () => { @@ -27,3 +27,146 @@ describe("Comma-seperated name transformer tests", () => { expect(output).toEqual(", Test"); }); }); + +describe("transformSigLeadToURI tests", () => { + + // Basic Functionality Tests + test("should convert simple names with spaces to lowercase hyphenated", () => { + const output = transformSigLeadToURI("SIG Network"); + expect(output).toEqual("sig-network"); + }); + + test("should convert simple names to lowercase", () => { + const output = transformSigLeadToURI("Testing"); + expect(output).toEqual("testing"); + }); + + test("should handle names already in the desired format", () => { + const output = transformSigLeadToURI("already-transformed-name"); + expect(output).toEqual("already-transformed-name"); + }); + + // Camel Case Tests + test("should add hyphens between camelCase words", () => { + const output = transformSigLeadToURI("SIGAuth"); + expect(output).toEqual("sig-auth"); + }); + + test("should handle multiple camelCase words", () => { + const output = transformSigLeadToURI("SuperCamelCaseProject"); + expect(output).toEqual("super-camel-case-project"); + }); + + test("should handle mixed camelCase and spaces", () => { + const output = transformSigLeadToURI("SIG ContribEx"); // SIG Contributor Experience + expect(output).toEqual("sig-contrib-ex"); + }); + + test("should handle camelCase starting with lowercase", () => { + const output = transformSigLeadToURI("myCamelCaseName"); + expect(output).toEqual("my-camel-case-name"); + }); + + // Reserved Character Tests (RFC 3986 gen-delims and sub-delims) + test("should convert reserved characters like & to hyphens", () => { + const output = transformSigLeadToURI("SIG Storage & Backup"); + expect(output).toEqual("sig-storage-backup"); // & -> space -> hyphen + }); + + test("should convert reserved characters like / and : to hyphens", () => { + const output = transformSigLeadToURI("Project:Alpha/Beta"); + expect(output).toEqual("project-alpha-beta"); // : -> space, / -> space, space+space -> hyphen + }); + + test("should convert reserved characters like () and + to hyphens", () => { + const output = transformSigLeadToURI("My Project (Test+Alpha)"); + expect(output).toEqual("my-project-test-alpha"); + }); + + test("should convert various reserved characters #[]@?$, to hyphens", () => { + const output = transformSigLeadToURI("Special#Chars[Test]?@Value,$"); + expect(output).toEqual("special-chars-test-value"); + }); + + // Non-Allowed Character Removal Tests + test("should remove characters not unreserved or reserved (e.g., ™, ©)", () => { + const output = transformSigLeadToURI("MyOrg™ With © Symbols"); + expect(output).toEqual("my-org-with-symbols"); + }); + + test("should remove emoji", () => { + const output = transformSigLeadToURI("Project ✨ Fun"); + expect(output).toEqual("project-fun"); + }); + + + // Whitespace and Hyphen Collapsing Tests + test("should handle multiple spaces between words", () => { + const output = transformSigLeadToURI("SIG UI Project"); + expect(output).toEqual("sig-ui-project"); + }); + + test("should handle leading/trailing whitespace", () => { + const output = transformSigLeadToURI(" Leading and Trailing "); + expect(output).toEqual("leading-and-trailing"); + }); + + test("should handle mixed whitespace (tabs, newlines)", () => { + const output = transformSigLeadToURI("Mix\tOf\nWhite Space"); + expect(output).toEqual("mix-of-white-space"); + }); + + test("should collapse multiple hyphens resulting from transformations", () => { + const output = transformSigLeadToURI("Test--Multiple / Spaces"); + expect(output).toEqual("test-multiple-spaces"); + }); + + test("should collapse hyphens from start/end after transformations", () => { + const output = transformSigLeadToURI("&Another Test!"); + expect(output).toEqual("another-test"); + }); + + // Unreserved Character Tests (RFC 3986) + test("should keep unreserved characters: hyphen, period, underscore, tilde", () => { + const output = transformSigLeadToURI("Keep.These-Chars_Okay~123"); + expect(output).toEqual("keep.these-chars_okay~123"); + }); + + test("should handle unreserved chars next to reserved chars", () => { + const output = transformSigLeadToURI("Test._~&Stuff"); + expect(output).toEqual("test._~-stuff"); + }); + + + // Edge Case Tests + test("should return an empty string for an empty input", () => { + const output = transformSigLeadToURI(""); + expect(output).toEqual(""); + }); + + test("should return an empty string for input with only spaces", () => { + const output = transformSigLeadToURI(" "); + expect(output).toEqual(""); + }); + + test("should return an empty string for input with only reserved/non-allowed chars and spaces", () => { + const output = transformSigLeadToURI(" & / # ™ © "); + expect(output).toEqual(""); + }); + + test("should handle numbers correctly", () => { + const output = transformSigLeadToURI("ProjectApollo11"); + expect(output).toEqual("project-apollo11"); // Number doesn't trigger camel case break after letter + }); + + test("should handle numbers triggering camel case break", () => { + const output = transformSigLeadToURI("Project11Apollo"); + expect(output).toEqual("project-11-apollo"); // Letter after number triggers camel case break + }); + + test("should handle names starting with lowercase", () => { + const output = transformSigLeadToURI("myOrg"); + expect(output).toEqual("my-org"); + }); + +});