diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 98b34b615..25c751312 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -15,7 +15,7 @@ "portsAttributes": { "5173": { "label": "Admin Portal", - "onAutoForward": "openBrowserOnce" + "onAutoForward": "silent" }, "5001": { "label": "Refine Devtools", diff --git a/.vscode/launch.json b/.vscode/launch.json index c50603351..6094c5cf5 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -96,7 +96,6 @@ "env": { "REACT_EDITOR": "code" }, - "autoAttachChildProcesses": false }, { @@ -121,17 +120,19 @@ "request": "launch", "type": "firefox", "url": "http://localhost:5173", - "webRoot": "${workspaceFolder}/packages/portal/src", + "webRoot": "${workspaceFolder}/packages/portal", "presentation": { "group": "5portal" - } + }, + "keepProfileChanges": true, + "profile": "default" }, { "name": "Debug Portal (chrome)", "request": "launch", "type": "chrome", "url": "http://localhost:5173", - "webRoot": "${workspaceFolder}/packages/portal/src", + "webRoot": "${workspaceFolder}/packages/portal", "presentation": { "group": "5portal" } diff --git a/cspell.json b/cspell.json index 5e82ad45f..e9b5a173f 100644 --- a/cspell.json +++ b/cspell.json @@ -17,40 +17,38 @@ ], "words": [ "autoincrement", + "bbnvolved", "catchable", "citext", "codegen", "collapsable", "cooldown", "danceblue", - "ukdanceblue", - "luxon", - "typedi", - "ukdb", - "msal", - "tada", - "xdate", - "skia", - "hstore", - "minifiable", - "refinedev", "datasource", "ddns", "errorable", "freshgum", + "hstore", "jonasmerlin", "linkblue", "luxon", + "minifiable", + "msal", "phonathon", + "refinedev", + "skia", "spinnable", + "tada", "timestamptz", "typedi", "uk", "ukdanceblue", + "ukdb", "uky", "urql", "whatwg", - "wysimark" + "wysimark", + "xdate" ], "allowCompoundWords": true, "ignoreWords": [], diff --git a/eslint.config.js b/eslint.config.js index 6ca02ec30..d5ae4fa3a 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -171,7 +171,6 @@ export default eslintTs.config( "unicorn/prefer-regexp-test": "error", "unicorn/prefer-set-has": "error", "unicorn/prefer-set-size": "error", - "unicorn/prefer-spread": "error", "unicorn/prefer-string-replace-all": "error", "unicorn/prefer-string-starts-ends-with": "error", "unicorn/prefer-string-trim-start-end": "error", diff --git a/package.json b/package.json index 82f1130cb..f4c92710a 100644 --- a/package.json +++ b/package.json @@ -63,50 +63,50 @@ "urijs": "npm:uri-js-replace" }, "devDependencies": { - "@eslint/compat": "^1.2.3", + "@eslint/compat": "^1.2.4", "@eslint/config-inspector": "^0.5.6", "@eslint/eslintrc": "^3.2.0", - "@eslint/js": "^9.15.0", + "@eslint/js": "^9.16.0", "@expo/ngrok": "^4.1.3", "@graphql-codegen/cli": "^5.0.3", "@graphql-codegen/client-preset": "patch:@graphql-codegen/client-preset@npm%3A4.4.0#~/.yarn/patches/@graphql-codegen-client-preset-npm-4.4.0-d441b92060.patch", - "@graphql-eslint/eslint-plugin": "^4.2.0", + "@graphql-eslint/eslint-plugin": "^4.3.0", "@parcel/watcher": "^2.5.0", "@types/eslint-config-prettier": "^6.11.3", "@types/eslint__eslintrc": "^2.1.2", "@types/eslint__js": "^8.42.3", - "@types/react": "~18.3.12", - "@types/react-dom": "~18.3.1", - "@typescript-eslint/utils": "^8.14.0", - "@vitest/coverage-v8": "^2.1.5", - "@vitest/eslint-plugin": "^1.1.10", - "@vitest/ui": "^2.1.5", + "@types/react": "~18.3.14", + "@types/react-dom": "~18.3.2", + "@typescript-eslint/utils": "^8.17.0", + "@vitest/coverage-v8": "^2.1.8", + "@vitest/eslint-plugin": "^1.1.14", + "@vitest/ui": "^2.1.8", "@yarnpkg/types": "^4.0.0", "babel-cli": "^6.26.0", "chokidar-cli": "^3.0.0", - "eslint": "9.14.0", + "eslint": "9.16.0", "eslint-config-prettier": "^9.1.0", - "eslint-plugin-n": "^17.13.2", + "eslint-plugin-n": "^17.14.0", "eslint-plugin-react": "^7.37.2", - "eslint-plugin-react-hooks": "^5.0.0", - "eslint-plugin-react-refresh": "^0.4.14", + "eslint-plugin-react-hooks": "^5.1.0", + "eslint-plugin-react-refresh": "^0.4.16", "eslint-plugin-simple-import-sort": "^12.1.1", - "eslint-plugin-unicorn": "^56.0.0", - "globals": "^15.12.0", + "eslint-plugin-unicorn": "^56.0.1", + "globals": "^15.13.0", "graphql": "^16.9.0", - "graphql-scalars": "^1.23.0", - "node-gyp": "^10.2.0", - "prettier": "^3.3.3", - "sort-package-json": "^2.10.1", + "graphql-scalars": "^1.24.0", + "node-gyp": "^11.0.0", + "prettier": "^3.4.2", + "sort-package-json": "^2.12.0", "ts-node": "^10.9.2", - "typedoc": "^0.26.11", - "typedoc-plugin-dt-links": "^1.1.0", - "typedoc-plugin-mdn-links": "^4.0.1", + "typedoc": "^0.27.3", + "typedoc-plugin-dt-links": "^1.1.2", + "typedoc-plugin-mdn-links": "^4.0.4", "typedoc-plugin-missing-exports": "^3.1.0", "typedoc-plugin-zod": "^1.3.0", - "typescript": "^5.6.3", - "typescript-eslint": "^8.14.0", - "vitest": "^2.1.5" + "typescript": "^5.7.2", + "typescript-eslint": "^8.17.0", + "vitest": "^2.1.8" }, "packageManager": "yarn@4.5.3+sha512.3003a14012e2987072d244c720506549c1aab73ee728208f1b2580a9fd67b92d61ba6b08fe93f6dce68fd771e3af1e59a0afa28dd242dd0940d73b95fedd4e90" } diff --git a/packages/common/lib/api/params/LoginState.ts b/packages/common/lib/api/params/LoginState.ts index edec516b9..db699be07 100644 --- a/packages/common/lib/api/params/LoginState.ts +++ b/packages/common/lib/api/params/LoginState.ts @@ -1,5 +1,8 @@ -import { Field,ObjectType } from "type-graphql"; +import { PackRule } from "@casl/ability/extra"; +import { JSONResolver } from "graphql-scalars"; +import { Field, ObjectType } from "type-graphql"; +import { AppAbility } from "../../authorization/accessControl.js"; import { AccessLevel, Authorization, @@ -24,4 +27,7 @@ export class LoginState implements Authorization { @Field(() => [EffectiveCommitteeRole]) effectiveCommitteeRoles!: EffectiveCommitteeRole[]; + + @Field(() => [[JSONResolver]]) + abilityRules!: PackRule[]; } diff --git a/packages/common/lib/api/params/eventParams.ts b/packages/common/lib/api/params/eventParams.ts index a077dca28..5880dfd71 100644 --- a/packages/common/lib/api/params/eventParams.ts +++ b/packages/common/lib/api/params/eventParams.ts @@ -46,9 +46,10 @@ export class SetEventOccurrenceInput { @Field(() => GlobalIdScalar, { nullable: true, description: - "If updating an existing occurrence, the UUID of the occurrence to update", + "If updating an existing occurrence, the GlobalId of the occurrence to update", }) - uuid!: GlobalId | null; + id!: GlobalId | null; + @Field(() => IntervalISO) interval!: IntervalISO; @Field(() => Boolean) diff --git a/packages/common/lib/api/params/fundraisingAccess.ts b/packages/common/lib/api/params/fundraisingAccess.ts index dc79b60d7..a4dd59d7a 100644 --- a/packages/common/lib/api/params/fundraisingAccess.ts +++ b/packages/common/lib/api/params/fundraisingAccess.ts @@ -1,11 +1,4 @@ -import { Field,InputType } from "type-graphql"; - -import { AccessControlParam } from "../../authorization/accessControl.js"; -import { - CommitteeIdentifier, - CommitteeRole, -} from "../../authorization/structures.js"; -import { FundraisingAssignmentNode } from "../resources/Fundraising.js"; +import { Field, InputType } from "type-graphql"; @InputType() export class AssignEntryToPersonInput { @@ -18,12 +11,3 @@ export class UpdateFundraisingAssignmentInput { @Field() amount!: number; } -export const fundraisingAccess: AccessControlParam = - { - authRules: [ - { - minCommitteeRole: CommitteeRole.Coordinator, - committeeIdentifiers: [CommitteeIdentifier.fundraisingCommittee, CommitteeIdentifier.dancerRelationsCommittee], - }, - ], - }; diff --git a/packages/common/lib/api/resources/Event.ts b/packages/common/lib/api/resources/Event.ts index 72ab86fba..496ddd38c 100644 --- a/packages/common/lib/api/resources/Event.ts +++ b/packages/common/lib/api/resources/Event.ts @@ -1,4 +1,4 @@ -import { Field, ID, ObjectType } from "type-graphql"; +import { Field, ObjectType } from "type-graphql"; import { createNodeClasses, Node } from "../relay.js"; import type { GlobalId } from "../scalars/GlobalId.js"; @@ -45,26 +45,19 @@ export class EventNode extends TimestampedResource implements Node { implements: [], }) export class EventOccurrenceNode extends Resource { - @Field(() => ID) - id!: string; + @Field(() => GlobalIdScalar) + id!: GlobalId; @Field(() => IntervalISO) interval!: IntervalISO; @Field(() => Boolean) fullDay!: boolean; - public getUniqueId(): string { - return this.id; - } - public static init(init: { id: string; interval: IntervalISO; fullDay: boolean; }) { - const resource = this.createInstance(); - resource.id = init.id; - resource.interval = init.interval; - resource.fullDay = init.fullDay; + const resource = this.createInstance().withValues(init); return resource; } } diff --git a/packages/common/lib/api/resources/Notification.ts b/packages/common/lib/api/resources/Notification.ts index 62d6d6504..d212345f9 100644 --- a/packages/common/lib/api/resources/Notification.ts +++ b/packages/common/lib/api/resources/Notification.ts @@ -2,8 +2,7 @@ import { DateTimeISOResolver, URLResolver } from "graphql-scalars"; import type { DateTime } from "luxon"; import { Field, ObjectType } from "type-graphql"; -import { AccessControlAuthorized } from "../../authorization/accessControl.js"; -import { AccessLevel } from "../../authorization/structures.js"; +import { AccessControlAuthorized } from "../../authorization/AccessControlParam.js"; import { dateTimeFromSomething } from "../../utility/time/intervalTools.js"; import { createNodeClasses, Node } from "../relay.js"; import type { GlobalId } from "../scalars/GlobalId.js"; @@ -27,15 +26,15 @@ export class NotificationNode extends TimestampedResource implements Node { url?: URL | undefined | null; @Field(() => String, { nullable: true }) - @AccessControlAuthorized({ - accessLevel: AccessLevel.CommitteeChairOrCoordinator, - }) + @AccessControlAuthorized("get", "NotificationNode", ".deliveryIssue") deliveryIssue?: string | undefined | null; @Field(() => DateTimeISOResolver, { nullable: true }) - @AccessControlAuthorized({ - accessLevel: AccessLevel.CommitteeChairOrCoordinator, - }) + @AccessControlAuthorized( + "get", + "NotificationNode", + ".deliveryIssueAcknowledgedAt" + ) deliveryIssueAcknowledgedAt?: Date | undefined | null; get deliveryIssueAcknowledgedAtDateTime(): DateTime | null { return dateTimeFromSomething(this.deliveryIssueAcknowledgedAt ?? null); @@ -105,9 +104,11 @@ export class NotificationDeliveryNode description: "The time the server received a delivery receipt from the user.", }) - @AccessControlAuthorized({ - accessLevel: AccessLevel.CommitteeChairOrCoordinator, - }) + @AccessControlAuthorized( + "get", + "NotificationDeliveryNode", + ".receiptCheckedAt" + ) receiptCheckedAt?: Date | undefined | null; @Field(() => String, { @@ -115,9 +116,7 @@ export class NotificationDeliveryNode description: "A unique identifier corresponding the group of notifications this was sent to Expo with.", }) - @AccessControlAuthorized({ - accessLevel: AccessLevel.CommitteeChairOrCoordinator, - }) + @AccessControlAuthorized("get", "NotificationDeliveryNode", ".chunkUuid") chunkUuid?: string | undefined | null; @Field(() => String, { @@ -125,9 +124,7 @@ export class NotificationDeliveryNode description: "Any error message returned by Expo when sending the notification.", }) - @AccessControlAuthorized({ - accessLevel: AccessLevel.CommitteeChairOrCoordinator, - }) + @AccessControlAuthorized("get", "NotificationDeliveryNode", ".deliveryError") deliveryError?: string | undefined | null; public getUniqueId(): string { diff --git a/packages/common/lib/api/resources/Resource.ts b/packages/common/lib/api/resources/Resource.ts index 5db474bae..8f590e86d 100644 --- a/packages/common/lib/api/resources/Resource.ts +++ b/packages/common/lib/api/resources/Resource.ts @@ -3,7 +3,7 @@ import { Field, ObjectType } from "type-graphql"; import type { Class } from "utility-types"; import { dateTimeFromSomething } from "../../utility/time/intervalTools.js"; -import { GlobalId } from "../scalars/GlobalId.js"; +import { GlobalId, isGlobalId } from "../scalars/GlobalId.js"; @ObjectType() export abstract class Resource { @@ -17,6 +17,13 @@ export abstract class Resource { * implements it. */ public getUniqueId(): string { + if ("id" in this) { + if (isGlobalId(this.id)) { + return this.id.id; + } else if (typeof this.id === "string") { + return this.id; + } + } throw new Error(`Method not implemented by subclass.`); } diff --git a/packages/common/lib/api/resources/authorization.test.ts b/packages/common/lib/api/resources/authorization.test.ts deleted file mode 100644 index 52a0b9721..000000000 --- a/packages/common/lib/api/resources/authorization.test.ts +++ /dev/null @@ -1,271 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { checkAuthorization } from "../../authorization/accessControl.js"; -import type { Authorization } from "../../index.js"; -import { - AccessLevel, - CommitteeIdentifier, - CommitteeRole, - DbRole, -} from "../../index.js"; - -const techChair: Authorization = { - accessLevel: AccessLevel.Admin, - dbRole: DbRole.Committee, - effectiveCommitteeRoles: [ - { - identifier: CommitteeIdentifier.techCommittee, - role: CommitteeRole.Chair, - }, - ], -}; - -const techCoordinator: Authorization = { - accessLevel: AccessLevel.Admin, - dbRole: DbRole.Committee, - effectiveCommitteeRoles: [ - { - identifier: CommitteeIdentifier.techCommittee, - role: CommitteeRole.Coordinator, - }, - ], -}; - -const techMember: Authorization = { - accessLevel: AccessLevel.Admin, - dbRole: DbRole.Committee, - effectiveCommitteeRoles: [ - { - identifier: CommitteeIdentifier.techCommittee, - role: CommitteeRole.Member, - }, - ], -}; - -const overallChair: Authorization = { - accessLevel: AccessLevel.CommitteeChairOrCoordinator, - dbRole: DbRole.Committee, - effectiveCommitteeRoles: [ - { - identifier: CommitteeIdentifier.techCommittee, - role: CommitteeRole.Chair, - }, - { - identifier: CommitteeIdentifier.viceCommittee, - role: CommitteeRole.Chair, - }, - ], -}; - -const dancerRelationsChair: Authorization = { - accessLevel: AccessLevel.CommitteeChairOrCoordinator, - dbRole: DbRole.Committee, - effectiveCommitteeRoles: [ - { - identifier: CommitteeIdentifier.dancerRelationsCommittee, - role: CommitteeRole.Chair, - }, - ], -}; - -const dancerRelationsCoordinator: Authorization = { - accessLevel: AccessLevel.CommitteeChairOrCoordinator, - dbRole: DbRole.Committee, - effectiveCommitteeRoles: [ - { - identifier: CommitteeIdentifier.dancerRelationsCommittee, - role: CommitteeRole.Coordinator, - }, - ], -}; - -const dancerRelationsMember: Authorization = { - accessLevel: AccessLevel.Committee, - dbRole: DbRole.Committee, - effectiveCommitteeRoles: [ - { - identifier: CommitteeIdentifier.dancerRelationsCommittee, - role: CommitteeRole.Member, - }, - ], -}; - -const member: Authorization = { - accessLevel: AccessLevel.UKY, - dbRole: DbRole.UKY, - effectiveCommitteeRoles: [], -}; - -const publicAuth: Authorization = { - accessLevel: AccessLevel.Public, - dbRole: DbRole.Public, - effectiveCommitteeRoles: [], -}; - -const none: Authorization = { - accessLevel: AccessLevel.None, - dbRole: DbRole.None, - effectiveCommitteeRoles: [], -}; -describe("checkAuthorization", () => { - it("should return true when the user's access level matches the required access level", () => { - expect.hasAssertions(); - - expect( - checkAuthorization({ accessLevel: AccessLevel.Admin }, techChair) - ).toBe(true); - expect( - checkAuthorization({ accessLevel: AccessLevel.Admin }, techCoordinator) - ).toBe(true); - expect( - checkAuthorization({ accessLevel: AccessLevel.Admin }, techMember) - ).toBe(true); - expect( - checkAuthorization( - { accessLevel: AccessLevel.CommitteeChairOrCoordinator }, - overallChair - ) - ).toBe(true); - expect( - checkAuthorization( - { accessLevel: AccessLevel.CommitteeChairOrCoordinator }, - dancerRelationsChair - ) - ).toBe(true); - expect( - checkAuthorization( - { accessLevel: AccessLevel.CommitteeChairOrCoordinator }, - dancerRelationsCoordinator - ) - ).toBe(true); - expect( - checkAuthorization( - { accessLevel: AccessLevel.Committee }, - dancerRelationsMember - ) - ).toBe(true); - expect(checkAuthorization({ accessLevel: AccessLevel.UKY }, member)).toBe( - true - ); - expect( - checkAuthorization({ accessLevel: AccessLevel.Public }, publicAuth) - ).toBe(true); - expect(checkAuthorization({ accessLevel: AccessLevel.None }, none)).toBe( - true - ); - }); - - // TODO: Make the rest of these async - - it("should return true when the user's access level is higher than the required access level", () => { - expect.assertions(3); - expect( - checkAuthorization({ accessLevel: AccessLevel.Committee }, techChair) - ).toBe(true); - expect( - checkAuthorization( - { accessLevel: AccessLevel.Committee }, - techCoordinator - ) - ).toBe(true); - expect( - checkAuthorization({ accessLevel: AccessLevel.Committee }, techMember) - ).toBe(true); - }); - - it("should return false when the user's access level is lower than the required access level", () => { - expect.assertions(4); - expect( - checkAuthorization( - { accessLevel: AccessLevel.CommitteeChairOrCoordinator }, - dancerRelationsMember - ) - ).toBe(false); - expect( - checkAuthorization({ accessLevel: AccessLevel.UKY }, publicAuth) - ).toBe(false); - expect(checkAuthorization({ accessLevel: AccessLevel.Public }, none)).toBe( - false - ); - expect( - checkAuthorization( - { accessLevel: AccessLevel.CommitteeChairOrCoordinator }, - none - ) - ).toBe(false); - }); - - it("should work with committeeIdentifier matching", () => { - expect.assertions(2); - expect( - checkAuthorization( - { - committeeIdentifier: CommitteeIdentifier.techCommittee, - }, - techChair - ) - ).toBe(true); - expect( - checkAuthorization( - { - committeeIdentifier: CommitteeIdentifier.techCommittee, - }, - none - ) - ).toBe(false); - }); - - it("should work with committeeIdentifiers matching", () => { - expect.assertions(2); - expect( - checkAuthorization( - { - committeeIdentifiers: [ - CommitteeIdentifier.techCommittee, - CommitteeIdentifier.viceCommittee, - ], - }, - techChair - ) - ).toBe(true); - expect( - checkAuthorization( - { - committeeIdentifiers: [ - CommitteeIdentifier.techCommittee, - CommitteeIdentifier.viceCommittee, - ], - }, - none - ) - ).toBe(false); - }); - - it("should work with minimum committeeRole matching", () => { - expect.assertions(3); - expect( - checkAuthorization( - { - minCommitteeRole: CommitteeRole.Chair, - }, - techChair - ) - ).toBe(true); - expect( - checkAuthorization( - { - minCommitteeRole: CommitteeRole.Coordinator, - }, - techChair - ) - ).toBe(true); - expect( - checkAuthorization( - { - minCommitteeRole: CommitteeRole.Coordinator, - }, - none - ) - ).toBe(false); - }); -}); diff --git a/packages/common/lib/api/resources/index.ts b/packages/common/lib/api/resources/index.ts index e7394cd97..e55ba2d26 100644 --- a/packages/common/lib/api/resources/index.ts +++ b/packages/common/lib/api/resources/index.ts @@ -1,3 +1,27 @@ +import { CommitteeNode } from "./Committee.js"; +import { ConfigurationNode } from "./Configuration.js"; +import { + DailyDepartmentNotificationBatchNode, + DailyDepartmentNotificationNode, +} from "./DailyDepartmentNotification.js"; +import { DeviceNode } from "./Device.js"; +import { EventNode } from "./Event.js"; +import { FeedNode } from "./Feed.js"; +import { + FundraisingAssignmentNode, + FundraisingEntryNode, +} from "./Fundraising.js"; +import { ImageNode } from "./Image.js"; +import { MarathonNode } from "./Marathon.js"; +import { MarathonHourNode } from "./MarathonHour.js"; +import { MembershipNode } from "./Membership.js"; +import { NotificationDeliveryNode, NotificationNode } from "./Notification.js"; +import { PersonNode } from "./Person.js"; +import { PointEntryNode } from "./PointEntry.js"; +import { PointOpportunityNode } from "./PointOpportunity.js"; +import { SolicitationCodeNode } from "./SolicitationCode.js"; +import { TeamNode } from "./Team.js"; + export { Connection, Edge, @@ -30,3 +54,28 @@ export * from "./PointOpportunity.js"; export * from "./Resource.js"; export * from "./SolicitationCode.js"; export * from "./Team.js"; + +export const ResourceClasses = { + CommitteeNode, + ConfigurationNode, + DailyDepartmentNotificationNode, + DailyDepartmentNotificationBatchNode, + DeviceNode, + EventNode, + FeedNode, + FundraisingAssignmentNode, + FundraisingEntryNode, + ImageNode, + MarathonNode, + MarathonHourNode, + MembershipNode, + NotificationNode, + NotificationDeliveryNode, + PersonNode, + PointEntryNode, + PointOpportunityNode, + SolicitationCodeNode, + TeamNode, +} as const; +export type ResourceClasses = + (typeof ResourceClasses)[keyof typeof ResourceClasses]; diff --git a/packages/common/lib/authorization/AccessControlParam.ts b/packages/common/lib/authorization/AccessControlParam.ts new file mode 100644 index 000000000..81a70c9fc --- /dev/null +++ b/packages/common/lib/authorization/AccessControlParam.ts @@ -0,0 +1,93 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable @typescript-eslint/no-unsafe-function-type */ +import * as TypeGraphql from "type-graphql"; + +import type { AppAbility } from "./accessControl.js"; + +export type AccessControlParam = + AllowShortForm extends true + ? Parameters | [Parameters[0]] + : Parameters; + +export function getArrayFromOverloadedRest( + overloadedArray: (T | readonly T[])[] +): T[] { + const items: T[] = Array.isArray(overloadedArray[0]) + ? (overloadedArray[0] as T[]) + : (overloadedArray as T[]); + return items; +} + +const authSummary: Record< + string, + Record< + string, + { + action: string; + subject: string; + field: string; + } + > +> = {}; + +// setTimeout(() => { +// console.log( +// Object.entries(authSummary) +// .map( +// ([k, v]) => +// `

${k}

${Object.entries(v) +// .map( +// ([n, { action, subject, field }]) => +// `
${n}
${action} ${subject}${field}
` +// ) +// .join("")}
` +// ) +// .join("") +// ); +// }, 3000); + +export function AccessControlAuthorized( + ...check: AccessControlParam +): PropertyDecorator & MethodDecorator & ClassDecorator { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (!TypeGraphql.getMetadataStorage) { + return () => undefined; + } + + return ( + target: Function | object, + propertyKey?: string | symbol, + _descriptor?: TypedPropertyDescriptor + ) => { + if (propertyKey == null) { + TypeGraphql.getMetadataStorage().collectAuthorizedResolverMetadata({ + target: target as Function, + roles: [check], + }); + return; + } + + if (typeof propertyKey === "symbol") { + throw new TypeGraphql.SymbolKeysNotSupportedError(); + } + + authSummary[target.constructor.name] = { + ...authSummary[target.constructor.name], + [propertyKey]: { + action: check[0], + subject: check[1] + ? typeof check[1] === "string" + ? check[1] + : `${check[1].kind}[id=${check[1].id}]` + : target.constructor.name.replace(/Resolver$/, "Node"), + field: check[2] ?? ".", + }, + }; + + TypeGraphql.getMetadataStorage().collectAuthorizedFieldMetadata({ + target: target.constructor, + fieldName: propertyKey, + roles: [check], + }); + }; +} diff --git a/packages/common/lib/authorization/accessControl.test.ts b/packages/common/lib/authorization/accessControl.test.ts new file mode 100644 index 000000000..c7aa61a04 --- /dev/null +++ b/packages/common/lib/authorization/accessControl.test.ts @@ -0,0 +1,389 @@ +import { randomUUID } from "node:crypto"; + +import { describe, expect } from "vitest"; + +import { MembershipPositionType } from "../api/resources/Membership.js"; +import { TeamType } from "../api/resources/Team.js"; +import type { Action, AppAbility, Subject } from "./accessControl.js"; +import { + type CaslParam, + getAuthorizationFor, + SubjectStrings, +} from "./accessControl.js"; +import { AccessLevel } from "./structures.js"; + +describe("Unauthenticated user", (test) => { + const context: CaslParam = { + accessLevel: AccessLevel.None, + effectiveCommitteeRoles: [], + teamMemberships: [], + userId: randomUUID(), + }; + const ability = getAuthorizationFor(context); + + test("has no permissions for most resources", ({ expect }) => { + for (const resource of SubjectStrings) { + if (resource === "ConfigurationNode" || resource === "DeviceNode") { + continue; + } + + expect(ability).cannot("get", resource); + expect(ability).cannot("list", resource); + expect(ability).cannot("readActive", resource); + expect(ability).cannot("update", resource); + } + }); + + test("has permission to read active configuration", ({ expect }) => { + expect(ability).can("readActive", "ConfigurationNode"); + + expect(ability).cannot("get", "ConfigurationNode"); + expect(ability).cannot("list", "ConfigurationNode"); + expect(ability).cannot("update", "ConfigurationNode"); + }); + + test("has permission to read a device by uuid", ({ expect }) => { + expect(ability).can("get", "DeviceNode"); + + expect(ability).cannot("readActive", "DeviceNode"); + expect(ability).cannot("list", "DeviceNode"); + expect(ability).cannot("update", "DeviceNode"); + }); +}); + +describe("Super-admin authorization", (test) => { + const context: CaslParam = { + accessLevel: AccessLevel.SuperAdmin, + effectiveCommitteeRoles: [], + teamMemberships: [], + userId: randomUUID(), + }; + const ability = getAuthorizationFor(context); + + test("has all permissions", ({ expect }) => { + expect(ability).can("manage", "all"); + }); + + test("has all permissions individually", ({ expect }) => { + for (const resource of SubjectStrings) { + expect(ability).can("manage", resource, "."); + } + }); + + test("had no permission for non-existent fields", ({ expect }) => { + for (const resource of SubjectStrings) { + expect(ability).cannot( + "manage", + resource, + "~nonexistent~" as `.${string}` + ); + } + }); +}); + +describe("A normal user", (test) => { + const userId = randomUUID(); + const context: CaslParam = { + accessLevel: AccessLevel.UKY, + effectiveCommitteeRoles: [], + teamMemberships: [], + userId, + }; + const ability = getAuthorizationFor(context); + const self = { + kind: "PersonNode", + id: userId, + } as const; + const other = { + kind: "PersonNode", + id: randomUUID(), + } as const; + + test("can only read their own information", ({ expect }) => { + expect(ability).can("get", self); + expect(ability).cannot("list", "PersonNode"); + expect(ability).cannot("update", self); + expect(ability).cannot("get", other); + }); + + test("can only read their own memberships", ({ expect }) => { + expect(ability).can("list", self, ".memberships"); + expect(ability).cannot("update", self, ".memberships"); + expect(ability).cannot("list", other, ".memberships"); + }); + + test("can only read their own fundraising assignments", ({ expect }) => { + expect(ability).can("list", self, ".fundraisingAssignments"); + expect(ability).cannot("update", self, ".fundraisingAssignments"); + expect(ability).cannot("list", other, ".fundraisingAssignments"); + }); + + test("can only read the list of teams", ({ expect }) => { + expect(ability).can("list", "TeamNode"); + expect(ability).cannot("get", "TeamNode"); + expect(ability).cannot("update", "TeamNode"); + }); + + test("can read events", ({ expect }) => { + expect(ability).can("get", "EventNode"); + expect(ability).can("list", "EventNode"); + expect(ability).cannot("update", "EventNode"); + }); + + test("can read committees", ({ expect }) => { + expect(ability).can("get", "CommitteeNode"); + expect(ability).can("list", "CommitteeNode"); + expect(ability).cannot("update", "CommitteeNode"); + }); + + test("can read images", ({ expect }) => { + expect(ability).can("get", "ImageNode"); + expect(ability).can("list", "ImageNode"); + expect(ability).cannot("update", "ImageNode"); + }); + + test("can only read the active marathon", ({ expect }) => { + expect(ability).can("readActive", "MarathonNode"); + expect(ability).cannot("get", "MarathonNode"); + expect(ability).cannot("list", "MarathonNode"); + }); + + test("can only read the active marathon hour", ({ expect }) => { + expect(ability).can("readActive", "MarathonHourNode"); + expect(ability).cannot("get", "MarathonHourNode"); + expect(ability).cannot("list", "MarathonHourNode"); + }); +}); + +const team1 = { + kind: "TeamNode", + id: randomUUID(), +} as const; +const team2 = { + kind: "TeamNode", + id: randomUUID(), +} as const; +const team3 = { + kind: "TeamNode", + id: randomUUID(), +} as const; + +describe("Team authorization for team member", (test) => { + const userId = randomUUID(); + const context: CaslParam = { + accessLevel: AccessLevel.UKY, + effectiveCommitteeRoles: [], + teamMemberships: [ + { + position: MembershipPositionType.Member, + teamType: TeamType.Spirit, + teamId: team1.id, + }, + ], + userId, + }; + const ability = getAuthorizationFor(context); + + test("has correct permissions for team", ({ expect }) => { + expect(ability).can("get", team1); + expect(ability).can("list", team2); + expect(ability).cannot("update", team1); + }); + + test("has correct permissions for team members", ({ expect }) => { + expect(ability).can("list", team1, ".members"); + expect(ability).cannot("update", team1, ".members"); + expect(ability).cannot("list", team2, ".members"); + }); + + test("has correct permissions for team fundraising assignments", ({ + expect, + }) => { + expect(ability).cannot("list", team1, ".fundraisingAssignments"); + expect(ability).cannot("list", team2, ".fundraisingAssignments"); + }); + + test("has correct permissions for team solicitation code", ({ expect }) => { + expect(ability).cannot("get", team1, ".solicitationCode"); + expect(ability).cannot("get", team2, ".solicitationCode"); + expect(ability).cannot("update", team1, ".solicitationCode"); + }); + + test("has correct permissions for team fundraising total", ({ expect }) => { + expect(ability).can("get", team1, ".fundraisingTotal"); + expect(ability).cannot("get", team2, ".fundraisingTotal"); + }); +}); + +describe("Team authorization for team captain", (test) => { + const userId = randomUUID(); + const context: CaslParam = { + accessLevel: AccessLevel.UKY, + effectiveCommitteeRoles: [], + teamMemberships: [ + { + position: MembershipPositionType.Captain, + teamType: TeamType.Spirit, + teamId: team1.id, + }, + { + position: MembershipPositionType.Member, + teamType: TeamType.Spirit, + teamId: team2.id, + }, + ], + userId, + }; + const ability = getAuthorizationFor(context); + + test("has correct permissions for team", ({ expect }) => { + expect(ability).can("get", team1); + expect(ability).can("get", team2); + expect(ability).cannot("get", team3); + }); + + test("has correct permissions for team members", ({ expect }) => { + expect(ability).can("list", team1, ".members"); + expect(ability).cannot("update", team1, ".members"); + expect(ability).can("list", team2, ".members"); + expect(ability).cannot("list", team3, ".members"); + }); + + test("has correct permissions for team fundraising assignments", ({ + expect, + }) => { + expect(ability).can("modify", team1, ".fundraisingAssignments"); + expect(ability).cannot("modify", team2, ".fundraisingAssignments"); + expect(ability).cannot("modify", team3, ".fundraisingAssignments"); + }); + + test("has correct permissions for team solicitation code", ({ expect }) => { + expect(ability).can("get", team1, ".solicitationCode"); + expect(ability).cannot("update", team1, ".solicitationCode"); + expect(ability).cannot("get", team2, ".solicitationCode"); + expect(ability).cannot("get", team3, ".solicitationCode"); + }); + + test("has correct permissions for team fundraising total", ({ expect }) => { + expect(ability).can("get", team1, ".fundraisingTotal"); + expect(ability).can("get", team2, ".fundraisingTotal"); + expect(ability).cannot("get", team3, ".fundraisingTotal"); + }); +}); + +describe("Admin authorization", (test) => { + const userId = randomUUID(); + const context: CaslParam = { + accessLevel: AccessLevel.Admin, + effectiveCommitteeRoles: [], + teamMemberships: [], + userId, + }; + const ability = getAuthorizationFor(context); + + test("has correct permissions for admin", ({ expect }) => { + expect(ability).can("manage", "ConfigurationNode", "."); + expect(ability).can("manage", "MarathonNode", "."); + expect(ability).can("manage", "MarathonHourNode", "."); + expect(ability).can("manage", "FundraisingAssignmentNode", "."); + expect(ability).can("manage", "FundraisingEntryNode", "."); + expect(ability).can("deploy", "NotificationNode", "."); + }); +}); + +describe("Committee chair or coordinator authorization", (test) => { + const userId = randomUUID(); + const context: CaslParam = { + accessLevel: AccessLevel.CommitteeChairOrCoordinator, + effectiveCommitteeRoles: [], + teamMemberships: [], + userId, + }; + const ability = getAuthorizationFor(context); + + test("has correct permissions for committee chair or coordinator", ({ + expect, + }) => { + expect(ability).can("manage", "EventNode", "."); + expect(ability).can("manage", "FeedNode", "."); + expect(ability).can("manage", "ImageNode", "."); + expect(ability).can("manage", "MembershipNode", "."); + expect(ability).can("manage", "PersonNode", "."); + expect(ability).can("manage", "PersonNode", ".memberships"); + expect(ability).can("read", "MarathonHourNode", "."); + expect(ability).can("read", "MarathonNode", "."); + expect(ability).can("modify", "NotificationNode", "."); + expect(ability).can("create", "NotificationNode", "."); + expect(ability).can("read", "NotificationNode", ".deliveryIssue"); + expect(ability).can( + "read", + "NotificationNode", + ".deliveryIssueAcknowledgedAt" + ); + expect(ability).can( + "read", + "NotificationDeliveryNode", + ".receiptCheckedAt" + ); + expect(ability).can("read", "NotificationDeliveryNode", ".chunkUuid"); + expect(ability).can("read", "NotificationDeliveryNode", ".deliveryError"); + }); +}); + +function canMessage( + not: boolean, + action: string, + subject: Subject, + field: string +) { + return `expected ${not ? "not " : ""}to be able to ${action} ${typeof subject === "string" ? subject : `${subject.kind}[id=${subject.id}]`}${field}`; +} + +expect.extend({ + can( + received: AppAbility, + ...[action, subject, field]: Parameters + ) { + const { isNot } = this; + + const result = received.can(action, subject, field ?? "."); + const message = () => canMessage(isNot, action, subject, field ?? "."); + return { + pass: result, + message, + }; + }, + cannot( + received: AppAbility, + ...[action, subject, field]: Parameters + ) { + const { isNot } = this; + + const result = received.cannot(action, subject, field ?? "."); + const message = () => canMessage(!isNot, action, subject, field ?? "."); + return { + pass: result, + message, + }; + }, +}); + +interface CustomMatchers { + can( + action: Omit, + subject: Subject, + field?: `.${string}` + ): R; + cannot( + action: Omit, + subject: Subject, + field?: `.${string}` + ): R; +} + +declare module "vitest" { + // eslint-disable-next-line @typescript-eslint/no-empty-object-type, @typescript-eslint/no-explicit-any + interface Assertion extends CustomMatchers {} + // eslint-disable-next-line @typescript-eslint/no-empty-object-type + interface AsymmetricMatchersContaining extends CustomMatchers {} +} diff --git a/packages/common/lib/authorization/accessControl.ts b/packages/common/lib/authorization/accessControl.ts index ee5ef1eba..6cab4f2d7 100644 --- a/packages/common/lib/authorization/accessControl.ts +++ b/packages/common/lib/authorization/accessControl.ts @@ -1,302 +1,360 @@ -import type { Result } from "ts-results-es"; -import { Err, Ok } from "ts-results-es"; -import type { ArgsDictionary } from "type-graphql"; -import { Authorized } from "type-graphql"; -import type { Primitive } from "utility-types"; - -import { parseGlobalId } from "../api/scalars/GlobalId.js"; -import type { InvalidArgumentError } from "../error/direct.js"; -import { InvariantError } from "../error/direct.js"; +import type { + AbilityOptionsOf, + ExtractSubjectType, + InferSubjects, + MongoAbility, +} from "@casl/ability"; import { - type Authorization, - type CommitteeIdentifier, - type CommitteeRole, - type GlobalId, - type MembershipPositionType, + AbilityBuilder, + createAliasResolver, + createMongoAbility, +} from "@casl/ability"; +import validator from "validator"; + +import type { EffectiveCommitteeRole } from "../api/resources/index.js"; +import { + parseGlobalId, type PersonNode, - type TeamType, - type UserData, -} from "../index.js"; -import type { AccessLevel } from "./structures.js"; + type ResourceClasses, +} from "../api/resources/index.js"; +import { MembershipPositionType } from "../api/resources/Membership.js"; +import type { TeamType } from "../api/resources/Team.js"; +import type { Authorization } from "./structures.js"; import { - committeeNames, - compareCommitteeRole, - stringifyAccessLevel, + AccessLevel, + CommitteeIdentifier, + CommitteeRole, } from "./structures.js"; -export interface AuthorizationRule { - /** - * The minimum access level required to access this resource - */ - accessLevel?: AccessLevel; - // /** - // * Exact committee role, cannot be used with minCommitteeRole - // */ - // committeeRole?: CommitteeRole; - /** - * Minimum committee role, cannot be used with committeeRole - */ - minCommitteeRole?: CommitteeRole; - /** - * The committee's identifier, currently these are not normalized in an enum or the database - * so just go off what's used elsewhere in the codebase - * - * Cannot be used with committeeIdentifiers - */ - committeeIdentifier?: CommitteeIdentifier; - /** - * Same as committeeIdentifier, but allows any of the listed identifiers - * - * Cannot be used with committeeIdentifier - */ - committeeIdentifiers?: readonly CommitteeIdentifier[]; -} - -export function prettyPrintAuthorizationRule(rule: AuthorizationRule): string { - const parts: string[] = []; - if (rule.accessLevel != null) { - parts.push( - `have an access level of at least ${stringifyAccessLevel(rule.accessLevel)}` - ); - } - if (rule.minCommitteeRole != null) { - parts.push(`have a committee role of at least ${rule.minCommitteeRole}`); - } - if (rule.committeeIdentifier != null) { - parts.push(`be a member of ${committeeNames[rule.committeeIdentifier]}`); - } - if (rule.committeeIdentifiers != null) { - parts.push( - `be a member of one of the following committees: ${rule.committeeIdentifiers - .map((id) => committeeNames[id]) - .join(", ")}` - ); - } - return parts.join(" and "); -} - -export interface AccessControlContext { - authenticatedUser: PersonNode | null; - teamMemberships: SimpleTeamMembership[]; - userData: UserData; - authorization: Authorization; -} - -/** - * An AccessControlParam accepts a user if: - * - * 1. The user's access level is greater than or equal to the access level specified (AccessLevel.None by default) - * 2. The user's role matches one of the specified authorization rules - * 3. The resolver arguments match ALL of the specified argument matchers - * 4. The root object matches ALL of the specified root matchers - * 5. The custom authorization rule returns true - */ -export interface AccessControlParam { - authRules?: - | readonly AuthorizationRule[] - | ((root: RootType) => readonly AuthorizationRule[]); - accessLevel?: AccessLevel; - argumentMatch?: { - argument: string | ((args: ArgsDictionary) => Primitive | Primitive[]); - extractor: (param: AccessControlContext) => Primitive | Primitive[]; - }[]; - rootMatch?: { - root: string | ((root: RootType) => Primitive | Primitive[]); - extractor: (param: AccessControlContext) => Primitive | Primitive[]; - }[]; -} - export interface SimpleTeamMembership { teamType: TeamType; teamId: string; position: MembershipPositionType; } -export interface AuthorizationContext { +const NEVER = {} as never; +const extraFieldsByResource = { + PersonNode: { + [".fundraisingAssignments"]: NEVER, + [".memberships"]: NEVER, + }, + TeamNode: { + [".fundraisingAssignments"]: NEVER, + [".members"]: NEVER, + [".solicitationCode"]: NEVER, + [".fundraisingTotal"]: NEVER, + }, + FundraisingAssignmentNode: { + [".withinTeamIds"]: NEVER, + }, + NotificationNode: { + [".deliveryIssue"]: NEVER, + [".deliveryIssueAcknowledgedAt"]: NEVER, + }, + NotificationDeliveryNode: { + [".receiptCheckedAt"]: NEVER, + [".chunkUuid"]: NEVER, + [".deliveryError"]: NEVER, + }, + FundraisingEntryNode: {}, + CommitteeNode: {}, + ConfigurationNode: {}, + DailyDepartmentNotificationNode: {}, + DailyDepartmentNotificationBatchNode: {}, + ImageNode: {}, + MarathonNode: {}, + MarathonHourNode: {}, + PointEntryNode: {}, + PointOpportunityNode: {}, + SolicitationCodeNode: {}, + DeviceNode: {}, + EventNode: {}, + FeedNode: {}, + MembershipNode: {}, +}; + +type ResourceSubject = { + [resource in keyof typeof ResourceClasses]: { + kind: resource; + id?: string; + + // When allowing access to a resource, passing in no fields will allow access to all fields, + // so by passing in this $ field, we can change that behavior to default to no fields. + ["."]?: never; + } & Partial<(typeof extraFieldsByResource)[resource]>; +}; + +type SubjectValue = ResourceSubject[keyof ResourceSubject]; +export type Subject = InferSubjects; + +export const SubjectStrings = [ + ...(Object.keys( + extraFieldsByResource + ) as (keyof typeof extraFieldsByResource)[]), + "all", +] as const; + +export type Action = + | "create" + | "get" + | "list" + | "read" + | "update" + | "delete" + | "modify" + | "manage" + | "readActive" + | "deploy"; + +const resolveAction = createAliasResolver({ + ["modify"]: ["read", "update", "delete"], + ["read"]: ["get", "list"], +}); + +export type AppAbility = MongoAbility<[Action, Subject]>; + +export interface AuthorizationContext extends Authorization { authenticatedUser: PersonNode | null; teamMemberships: SimpleTeamMembership[]; - userData: UserData; - authorization: Authorization; } -export function checkAuthorization( - { - accessLevel, - committeeIdentifier, - committeeIdentifiers, - // committeeRole, - minCommitteeRole, - }: AuthorizationRule, - authorization: Authorization -) { - if (committeeIdentifier != null && committeeIdentifiers != null) { - throw new TypeError( - `Cannot specify both committeeIdentifier and committeeIdentifiers.` - ); - } +export const caslOptions: AbilityOptionsOf = { + resolveAction, + detectSubjectType(subject) { + return ((subject as { __typename?: Subject }).__typename ?? + subject.kind) as ExtractSubjectType; + }, +}; + +export interface CaslParam + extends Pick< + AuthorizationContext, + "accessLevel" | "teamMemberships" | "effectiveCommitteeRoles" + > { + userId: string | null; +} - let matches = true; +export function getAuthorizationFor({ + accessLevel, + userId, + teamMemberships, + effectiveCommitteeRoles, +}: CaslParam): AppAbility { + const { can: allow, build } = new AbilityBuilder( + createMongoAbility + ); - // Access Level - if (accessLevel != null) { - matches &&= authorization.accessLevel >= accessLevel; - } + // All users may read active configurations and get device information (with that device's uuid) + allow("readActive", ["ConfigurationNode"], "."); + allow("get", ["DeviceNode"], "."); - if (minCommitteeRole != null) { - if (authorization.effectiveCommitteeRoles.length === 0) { - matches = false; + if (accessLevel > AccessLevel.None) { + if (accessLevel === AccessLevel.SuperAdmin) { + // Super admins may manage all resources + allow( + "manage", + "all", + Object.values(extraFieldsByResource).reduce( + (acc, fields) => acc.concat(Object.keys(fields)), + ["."] + ) + ); } else { - matches &&= authorization.effectiveCommitteeRoles.some( - (committee) => - compareCommitteeRole(committee.role, minCommitteeRole) >= 0 + // All users may read active feeds and marathon info + allow( + "readActive", + ["FeedNode", "MarathonNode", "MarathonHourNode"], + "." ); - } - } + // All users may read committees, events, and images + allow("read", ["CommitteeNode", "EventNode", "ImageNode"], "."); + // All users may list teams + allow("list", ["TeamNode"], "."); - // Committee identifier(s) - if (committeeIdentifier != null) { - matches &&= authorization.effectiveCommitteeRoles.some( - (committee) => committee.identifier === committeeIdentifier - ); - } - if (committeeIdentifiers != null) { - matches &&= authorization.effectiveCommitteeRoles.some((committee) => - committeeIdentifiers.includes(committee.identifier) - ); + applyAccessLevelPermissions(accessLevel, allow); + applyCommitteePermissions(effectiveCommitteeRoles, allow); + + if (userId != null) applyUserPermissions(userId, allow); + + if (teamMemberships.length > 0) + applyTeamPermissions(teamMemberships, allow); + } } - return matches; + return build(caslOptions); } -export function checkParam( - rule: AccessControlParam, - authorization: Authorization, - root: RootType, - args: ArgsDictionary, - context: AccessControlContext -): Result { - if (rule.accessLevel != null) { - if (rule.accessLevel > authorization.accessLevel) { - return Ok(false); +function applyTeamPermissions( + teamMemberships: SimpleTeamMembership[], + allow: AbilityBuilder["can"] +) { + const authTeamMemberships: string[] = []; + const authTeamCaptaincies: string[] = []; + for (const membership of teamMemberships) { + if (membership.position === MembershipPositionType.Captain) { + authTeamCaptaincies.push(membership.teamId); } + authTeamMemberships.push(membership.teamId); } - if (rule.authRules != null) { - const authRules = - typeof rule.authRules === "function" - ? rule.authRules(root) - : rule.authRules; - if (authRules.length === 0) { - return Err( - new InvariantError("Resource has no allowed authorization rules.") - ); - } - let matches = false; - for (const authRule of authRules) { - matches = checkAuthorization(authRule, authorization); - if (matches) { - break; - } + // Members of a team may read the team's information and fundraising total + allow("get", "TeamNode", [".", ".fundraisingTotal"], { + id: { $in: authTeamMemberships }, + }); + // Members of a team may read the team's members + allow("list", "TeamNode", [".members"], { + id: { $in: authTeamMemberships }, + }); + + // Captains of a team may manage the team's fundraising assignments + allow(["modify", "create"], "TeamNode", [".fundraisingAssignments"], { + id: { $in: authTeamCaptaincies }, + }); + // Captains of a team may get the team's solicitation code + allow("get", "TeamNode", [".solicitationCode"], { + id: { $in: authTeamCaptaincies }, + }); +} + +function applyUserPermissions( + userId: string, + allow: AbilityBuilder["can"] +) { + const parsedUserId = validator.isUUID(userId) + ? userId + : parseGlobalId(userId).unwrap().id; + + // Users may read their own information + allow("get", "PersonNode", ["."], { + id: { + $eq: parsedUserId, + }, + }); + // Users may read their own memberships and fundraising assignments + allow("list", "PersonNode", [".memberships", ".fundraisingAssignments"], { + id: { + $eq: parsedUserId, + }, + }); +} + +function applyCommitteePermissions( + effectiveCommitteeRoles: EffectiveCommitteeRole[], + allow: AbilityBuilder["can"] +) { + for (const { identifier, role } of effectiveCommitteeRoles) { + // Members of vice committee may read all members and teams + // Coords/Chairs of vice committee may manage all members and teams + if (identifier === CommitteeIdentifier.viceCommittee) { + allow(role === CommitteeRole.Member ? "read" : "manage", "PersonNode", [ + ".", + ".memberships", + ]); + allow(role === CommitteeRole.Member ? "read" : "manage", "TeamNode", [ + ".", + ".members", + ]); + allow("list", "PointEntryNode", "."); } - if (!matches) { - return Ok(false); + + // Coords/Chairs of vice, community, tech, and marketing committees may deploy notifications + if ( + role !== CommitteeRole.Member && + (identifier === CommitteeIdentifier.viceCommittee || + identifier === CommitteeIdentifier.communityDevelopmentCommittee || + identifier === CommitteeIdentifier.techCommittee || + identifier === CommitteeIdentifier.marketingCommittee) + ) { + allow("deploy", "NotificationNode", "."); } - } - if (rule.argumentMatch != null) { - for (const match of rule.argumentMatch) { - let argValue: Primitive | Primitive[]; - if (match.argument === "id") { - // I think this code might be wrong, but I'm not 100% sure either way and don't have time to investigate - const parseReult = parseGlobalId(args.id as string).map( - ({ id: id }) => args[id] as Primitive | Primitive[] - ); - if (parseReult.isErr()) { - return parseReult; - } - argValue = parseReult.value; - } else if (typeof match.argument === "string") { - argValue = args[match.argument] as Primitive | Primitive[]; - } else { - argValue = match.argument(args); - } - if (argValue == null) { - return Err( - new InvariantError( - "FieldMatchAuthorized argument is null or undefined." - ) - ); - } - const expectedValue = match.extractor(context); - - if (Array.isArray(expectedValue)) { - if (Array.isArray(argValue)) { - if (argValue.some((v) => expectedValue.includes(v))) { - return Ok(false); - } - } else if (expectedValue.includes(argValue)) { - return Ok(false); - } - } else if (argValue !== expectedValue) { - if (Array.isArray(argValue)) { - if (argValue.includes(expectedValue)) { - return Ok(false); - } - } - } + // Members of dancer relations committee may manage point opportunities, point entries, and team members + if (identifier === CommitteeIdentifier.dancerRelationsCommittee) { + allow("manage", ["PointOpportunityNode", "PointEntryNode"], "."); + allow("manage", "TeamNode", ".members"); } - } - if (rule.rootMatch != null) { - let shouldContinue = false; - for (const match of rule.rootMatch) { - let rootValue: Primitive | Primitive[]; - if (match.root === "id") { - rootValue = (root as { id: GlobalId }).id.id; - } else if (typeof match.root === "string") { - rootValue = (root as Record)[ - match.root - ]; - } else { - rootValue = match.root(root); - } - if (rootValue == null) { - return Err( - new InvariantError("FieldMatchAuthorized root is null or undefined.") - ); - } - const expectedValue = match.extractor(context); - - if (Array.isArray(expectedValue)) { - if (Array.isArray(rootValue)) { - if (!rootValue.some((v) => expectedValue.includes(v))) { - shouldContinue = true; - break; - } - } else if (!expectedValue.includes(rootValue)) { - shouldContinue = true; - break; - } - } else if (Array.isArray(rootValue)) { - if (!rootValue.includes(expectedValue)) { - shouldContinue = true; - break; - } - } else if (rootValue !== expectedValue) { - shouldContinue = true; - break; - } + // Members of programming committee may manage marathon hours + if (identifier === CommitteeIdentifier.programmingCommittee) { + allow("manage", "MarathonHourNode", "."); } - if (shouldContinue) { - return Ok(false); + + // Members of fundraising committee may manage fundraising entries, daily department notifications, solicitation codes, and fundraising assignments + if (identifier === CommitteeIdentifier.fundraisingCommittee) { + allow( + "manage", + [ + "FundraisingEntryNode", + "DailyDepartmentNotificationNode", + "SolicitationCodeNode", + "FundraisingAssignmentNode", + ], + "." + ); + allow("manage", "TeamNode", [ + ".fundraisingAssignments", + ".solicitationCode", + ]); + allow("read", "TeamNode", ".fundraisingTotal"); } } - - return Ok(true); } -export function AccessControlAuthorized( - ...roles: readonly AccessControlParam[] -): PropertyDecorator & MethodDecorator & ClassDecorator { - return Authorized(...roles); +function applyAccessLevelPermissions( + accessLevel: number, + allow: AbilityBuilder["can"] +) { + // Coords/Chairs of any committee may: + if (accessLevel >= AccessLevel.CommitteeChairOrCoordinator) { + // Manage committees, events, feeds, images, and memberships + allow( + "manage", + ["EventNode", "FeedNode", "ImageNode", "MembershipNode"], + "." + ); + // Manage people and their memberships + allow("manage", "PersonNode", [".", ".memberships"]); + // Read marathon info + allow("read", ["MarathonHourNode", "MarathonNode"], "."); + // Create (but not send) notifications + allow(["modify", "create"], "NotificationNode", "."); + // Read notifications + allow("read", "NotificationNode", [ + ".", + ".deliveryIssue", + ".deliveryIssueAcknowledgedAt", + ]); + // Read notification deliveries + allow("read", "NotificationDeliveryNode", [ + ".", + ".receiptCheckedAt", + ".chunkUuid", + ".deliveryError", + ]); + } + + // Admins may: + if (accessLevel >= AccessLevel.Admin) { + // Manage configurations, marathons, marathon hours, fundraising assignments, and fundraising entries + allow( + "manage", + [ + "ConfigurationNode", + "MarathonNode", + "MarathonHourNode", + "FundraisingAssignmentNode", + "FundraisingEntryNode", + ], + "." + ); + // Manage teams and their assignments and solicitation code + allow("manage", "TeamNode", [ + ".fundraisingAssignments", + ".solicitationCode", + ]); + // Read fundraising totals + allow("read", "TeamNode", ".fundraisingTotal"); + // Deploy notifications + allow("deploy", "NotificationNode", "."); + } } diff --git a/packages/common/lib/authorization/customAccessControl.ts b/packages/common/lib/authorization/customAccessControl.ts deleted file mode 100644 index 5d64c0649..000000000 --- a/packages/common/lib/authorization/customAccessControl.ts +++ /dev/null @@ -1,147 +0,0 @@ -import { Err, None, Option, Result, Some } from "ts-results-es"; -import type { MiddlewareFn } from "type-graphql"; -import { UseMiddleware } from "type-graphql"; - -import { AccessControlError } from "../error/control.js"; -import type { ConcreteResult } from "../error/result.js"; -import type { - AccessControlContext, - AuthorizationContext, -} from "./accessControl.js"; -import { AccessLevel } from "./structures.js"; - -/** - * Custom authorization rule - * - * Should usually be avoided, but can be used for more complex authorization - * rules - * - * - If the custom rule returns a boolean the user is allowed access if the rule - * returns true and an error is thrown if the rule returns false. - * - If the custom rule returns null the field is set to null (make sure the - * field is nullable in the schema) - * - If one param returns false and another returns null, an error will be - * thrown and the null ignored. - */ -export type CustomQueryAuthorizationFunction = ( - root: RootType, - context: AccessControlContext, - result: Option, - args: Record -) => boolean | null | Promise; - -/** - * Custom mutation authorization function - * - * Same as CustomAuthorizationFunction, but without root or result - * - * Should usually be avoided, but can be used for more complex authorization - * rules - * - * - If the custom rule returns a boolean the user is allowed access if the rule - * returns true and an error is thrown if the rule returns false. - * - If the custom rule returns null the field is set to null (make sure the - * field is nullable in the schema) - * - If one param returns false and another returns null, an error will be - * thrown and the null ignored. - */ -export type CustomMutationAuthorizationFunction = ( - context: AccessControlContext, - args: Record -) => boolean | null | Promise; - -/** - * Adds an access control check to a query resolver as a middleware. - * - * Note that this middleware will run the protected resolver before the access - * control check, so the resolver should be written to be safe to run even if - * the user doesn't have access. The advantage is that you can inspect the - * result of the resolver to make access control decisions. - * - * @param func The custom authorization function - * - * @see CustomQueryAuthorizationFunction - */ -export function CustomQueryAccessControl< - RootType extends object = never, - ResultType extends object = never, ->( - func: CustomQueryAuthorizationFunction -): MethodDecorator { - const middleware: MiddlewareFn = async ( - resolverData, - next - ) => { - const { context, args, info } = resolverData; - const root = resolverData.root as RootType; - const { authorization } = context; - - if (authorization.accessLevel === AccessLevel.SuperAdmin) { - // Super admins have access to everything - // eslint-disable-next-line @typescript-eslint/no-unsafe-return - return next(); - } - - let result = (await next()) as - | ResultType - | Option - | ConcreteResult - | ConcreteResult>; - if (Result.isResult(result)) { - result = result.unwrapOr(None); - } - if (!Option.isOption(result)) { - result = Some(result); - } - - const customResult = await func(root, context, result, args); - - if (customResult === false) { - return Err(new AccessControlError(info)); - } else if (customResult === null) { - return null; - } - - return result; - }; - - return UseMiddleware(middleware); -} - -/** - * Adds an access control check to a mutation resolver as a middleware. - * - * This middleware will wait to run the protected resolver until after the access - * control check. This means the resolver will not run if the user does not have - * access. - */ -export function CustomMutationAccessControl( - func: CustomMutationAuthorizationFunction -): MethodDecorator { - const middleware: MiddlewareFn = async ( - resolverData, - next - ) => { - const { context, args, info } = resolverData; - const { authorization } = context; - - if (authorization.accessLevel === AccessLevel.SuperAdmin) { - // Super admins have access to everything - // eslint-disable-next-line @typescript-eslint/no-unsafe-return - return next(); - } - - const customResult = await func(context, args); - - if (customResult === false) { - return Err(new AccessControlError(info)); - } else if (customResult === null) { - return null; - } - - // eslint-disable-next-line @typescript-eslint/no-unsafe-return - return next(); - }; - - return UseMiddleware(middleware); -} diff --git a/packages/common/lib/authorization/role.test.ts b/packages/common/lib/authorization/role.test.ts deleted file mode 100644 index ce071cba0..000000000 --- a/packages/common/lib/authorization/role.test.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { describe, it } from "vitest"; - -import { - AccessLevel, - CommitteeIdentifier, - CommitteeRole, - DbRole, -} from "../index.js"; -import { roleToAccessLevel } from "./role.js"; - -// TODO test the committee hierarchy system (i.e. overall and vice roles vs other committees) - -describe("roleToAccessLevel", { todo: true }, () => { - it("returns the correct access level for a given role normally", ({ - expect, - }) => { - const chairRole = { - dbRole: DbRole.Committee, - committeeRole: CommitteeRole.Chair, - committeeIdentifier: CommitteeIdentifier.dancerRelationsCommittee, - }; - expect(roleToAccessLevel(chairRole)).toBe( - AccessLevel.CommitteeChairOrCoordinator - ); - - const coordinatorRole = { - dbRole: DbRole.Committee, - committeeRole: CommitteeRole.Coordinator, - committeeIdentifier: CommitteeIdentifier.dancerRelationsCommittee, - }; - expect(roleToAccessLevel(coordinatorRole)).toBe( - AccessLevel.CommitteeChairOrCoordinator - ); - - const memberRole = { - dbRole: DbRole.Committee, - committeeRole: CommitteeRole.Member, - committeeIdentifier: CommitteeIdentifier.dancerRelationsCommittee, - }; - expect(roleToAccessLevel(memberRole)).toBe(AccessLevel.Committee); - - const teamMemberRole = { - dbRole: DbRole.UKY, - }; - expect(roleToAccessLevel(teamMemberRole)).toBe(AccessLevel.UKY); - - const publicRole = { - dbRole: DbRole.Public, - }; - expect(roleToAccessLevel(publicRole)).toBe(AccessLevel.Public); - - const noneRole = { - dbRole: DbRole.None, - }; - expect(roleToAccessLevel(noneRole)).toBe(AccessLevel.None); - }); - - it("grants any member of the tech committee admin access", ({ expect }) => { - const chairRole = { - dbRole: DbRole.Committee, - committeeRole: CommitteeRole.Chair, - committeeIdentifier: CommitteeIdentifier.techCommittee, - }; - expect(roleToAccessLevel(chairRole)).toBe(AccessLevel.Admin); - - const coordinatorRole = { - dbRole: DbRole.Committee, - committeeRole: CommitteeRole.Coordinator, - committeeIdentifier: CommitteeIdentifier.techCommittee, - }; - expect(roleToAccessLevel(coordinatorRole)).toBe(AccessLevel.Admin); - - const memberRole = { - dbRole: DbRole.Committee, - committeeRole: CommitteeRole.Member, - committeeIdentifier: CommitteeIdentifier.techCommittee, - }; - expect(roleToAccessLevel(memberRole)).toBe(AccessLevel.Admin); - }); - - it("throws an error for an illegal role", ({ expect }) => { - const illegalRole = { - dbRole: "illegal" as DbRole, - }; - expect(() => roleToAccessLevel(illegalRole)).toThrow( - "Illegal DbRole: [Parsing of 'illegal' failed]" - ); - }); -}); diff --git a/packages/common/lib/authorization/role.ts b/packages/common/lib/authorization/role.ts index 6b74e0bfd..f9f55d984 100644 --- a/packages/common/lib/authorization/role.ts +++ b/packages/common/lib/authorization/role.ts @@ -1,8 +1,9 @@ +import type { EffectiveCommitteeRole } from "../api/types/EffectiveCommitteeRole.js"; import { AccessLevel, + AuthSource, CommitteeIdentifier, CommitteeRole, - DbRole, } from "./structures.js"; /** @@ -12,63 +13,46 @@ import { * @return The equivalent AccessLevel * @throws Error if the DbRole is not a valid member of the DbRole enum */ -export function roleToAccessLevel({ - dbRole, - effectiveCommitteeRoles, -}: { - dbRole: DbRole; - effectiveCommitteeRoles?: { - identifier: CommitteeIdentifier; - role: CommitteeRole; - }[]; -}): AccessLevel { - switch (dbRole) { - case DbRole.None: { - return AccessLevel.None; - } - case DbRole.Public: { - return AccessLevel.Public; - } - case DbRole.UKY: { - return AccessLevel.UKY; - } - case DbRole.Committee: { - let maxLevel: AccessLevel | null = null; - for (const committee of effectiveCommitteeRoles ?? []) { - let thisLevel: AccessLevel; +export function roleToAccessLevel( + effectiveCommitteeRoles: EffectiveCommitteeRole[], + authSource: AuthSource +): AccessLevel { + if (authSource === AuthSource.None) { + return AccessLevel.None; + } + if (authSource === AuthSource.Anonymous) { + return AccessLevel.Public; + } - if (committee.identifier === CommitteeIdentifier.techCommittee) { - thisLevel = - committee.role === CommitteeRole.Chair - ? AccessLevel.SuperAdmin - : AccessLevel.Admin; - } else if ( - committee.role === CommitteeRole.Coordinator || - committee.role === CommitteeRole.Chair - ) { - thisLevel = AccessLevel.CommitteeChairOrCoordinator; - } else { - thisLevel = AccessLevel.Committee; - } + if (effectiveCommitteeRoles.length > 0) { + let maxLevel: AccessLevel | null = null; + for (const committee of effectiveCommitteeRoles) { + let thisLevel: AccessLevel; - if (maxLevel === null || thisLevel > maxLevel) { - maxLevel = thisLevel; - } + if (committee.identifier === CommitteeIdentifier.techCommittee) { + thisLevel = + committee.role === CommitteeRole.Chair + ? AccessLevel.SuperAdmin + : AccessLevel.Admin; + } else if ( + committee.role === CommitteeRole.Coordinator || + committee.role === CommitteeRole.Chair + ) { + thisLevel = AccessLevel.CommitteeChairOrCoordinator; + } else { + thisLevel = AccessLevel.Committee; } - if (maxLevel === null) { - throw new Error("No committee roles found when DbRole was Committee"); + + if (maxLevel === null || thisLevel > maxLevel) { + maxLevel = thisLevel; } - return maxLevel; } - default: { - dbRole satisfies never; - try { - throw new Error(`Illegal DbRole: ${String(dbRole)}`); - } catch (error) { - throw new Error( - `Illegal DbRole: [Parsing of '${String(dbRole)}' failed]` - ); - } + if (maxLevel === null) { + throw new Error("No committee roles found when DbRole was Committee"); } + return maxLevel; + } else { + authSource satisfies typeof AuthSource.LinkBlue | typeof AuthSource.Demo; + return AccessLevel.UKY; } } diff --git a/packages/common/lib/authorization/structures.ts b/packages/common/lib/authorization/structures.ts index 969e67f18..fb0eb5708 100644 --- a/packages/common/lib/authorization/structures.ts +++ b/packages/common/lib/authorization/structures.ts @@ -282,9 +282,11 @@ export const committeeNames: Record = { }; export interface Authorization { - dbRole: DbRole; + /** @deprecated */ + dbRole?: DbRole; effectiveCommitteeRoles: EffectiveCommitteeRole[]; accessLevel: AccessLevel; + authSource: AuthSource; } /** @@ -296,6 +298,7 @@ export const defaultAuthorization = { dbRole: DbRole.None, accessLevel: AccessLevel.None, effectiveCommitteeRoles: [], + authSource: AuthSource.None, } satisfies Authorization; // Registering the enum types with TypeGraphQL diff --git a/packages/common/lib/error/control.ts b/packages/common/lib/error/control.ts index dd8aeef31..4496cea5c 100644 --- a/packages/common/lib/error/control.ts +++ b/packages/common/lib/error/control.ts @@ -1,8 +1,6 @@ import type { GraphQLResolveInfo } from "graphql"; import type { Path } from "graphql/jsutils/Path.js"; -import type { AuthorizationRule } from "../authorization/accessControl.js"; -import { prettyPrintAuthorizationRule } from "../authorization/accessControl.js"; import { ConcreteError } from "./error.js"; import * as ErrorCode from "./errorCode.js"; @@ -27,12 +25,9 @@ export abstract class ControlError extends ConcreteError { export class AuthorizationRuleFailedError extends ControlError { readonly message = "Unauthorized"; - constructor(protected readonly requiredAuthorization: AuthorizationRule[]) { - super(); - } - + // eslint-disable-next-line @typescript-eslint/class-literal-property-style get detailedMessage() { - return `Unauthorized: ${this.requiredAuthorization.map(prettyPrintAuthorizationRule).join(", ")}`; + return `You do not have access to this resource`; } get tag(): ErrorCode.AuthorizationRuleFailed { diff --git a/packages/common/lib/error/errorCode.ts b/packages/common/lib/error/errorCode.ts index d68a3e8a7..a70efbbda 100644 --- a/packages/common/lib/error/errorCode.ts +++ b/packages/common/lib/error/errorCode.ts @@ -36,3 +36,5 @@ export const ExpoPushFailureError = Symbol("ExpoError"); export type ExpoPushFailureError = typeof ExpoPushFailureError; export const LuxonError = Symbol("LuxonError"); export type LuxonError = typeof LuxonError; +export const FetchError = Symbol("FetchError"); +export type FetchError = typeof FetchError; diff --git a/packages/common/lib/error/fetch.ts b/packages/common/lib/error/fetch.ts new file mode 100644 index 000000000..a92b49173 --- /dev/null +++ b/packages/common/lib/error/fetch.ts @@ -0,0 +1,62 @@ +import type { Result } from "ts-results-es"; +import { Err, Ok } from "ts-results-es"; + +import type { BasicError } from "./error.js"; +import { ConcreteError, toBasicError } from "./error.js"; +import { ErrorCode } from "./index.js"; + +export class FetchError extends ConcreteError { + readonly #response: Response; + readonly #url: string | undefined = undefined; + #responseText: string | undefined = undefined; + + constructor(response: Response, url?: string | URL) { + super(); + this.#response = response; + this.#url = url?.toString(); + } + + get message(): string { + return `Fetch failed with status ${this.#response.status}: ${this.#response.statusText}`; + } + + get detailedMessage(): string { + return `Fetch for ${this.#url ?? "unknown URL"} failed with status ${this.#response.status}: ${this.#response.statusText}`; + } + + async getResponseText(): Promise { + if (this.#responseText != null) { + return this.#responseText; + } else if (this.#response.bodyUsed) { + return undefined; + } else { + this.#responseText = await this.#response.clone().text(); + return this.#responseText; + } + } + + readonly expose = false; + + get tag(): ErrorCode.FetchError { + return ErrorCode.FetchError; + } + + static async safeFetch( + request: string | URL | Request, + init?: RequestInit + ): Promise> { + try { + const response = await fetch(request, init); + return !response.ok + ? Err( + new FetchError( + response, + request instanceof Request ? request.url : request + ) + ) + : Ok(response); + } catch (error) { + return Err(toBasicError(error)); + } + } +} diff --git a/packages/common/lib/error/index.ts b/packages/common/lib/error/index.ts index d3d4fecd1..816bbd30a 100644 --- a/packages/common/lib/error/index.ts +++ b/packages/common/lib/error/index.ts @@ -3,6 +3,7 @@ export * from "./control.js"; export * from "./direct.js"; export * from "./error.js"; export * as ErrorCode from "./errorCode.js"; +export * from "./fetch.js"; export * from "./http.js"; export * from "./luxon.js"; export * from "./option.js"; diff --git a/packages/common/lib/index.ts b/packages/common/lib/index.ts index eccfcefe0..0b60e1112 100644 --- a/packages/common/lib/index.ts +++ b/packages/common/lib/index.ts @@ -24,7 +24,7 @@ export * from "./api/params/index.js"; export * from "./api/resources/index.js"; export * from "./api/standardResolver.js"; export * from "./authorization/accessControl.js"; -export * from "./authorization/customAccessControl.js"; +export * from "./authorization/AccessControlParam.js"; export * from "./authorization/role.js"; export * from "./authorization/structures.js"; export * from "./utility/errors/ApiError.js"; diff --git a/packages/common/package.json b/packages/common/package.json index 736df6766..d9166c27b 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -56,10 +56,11 @@ "watch": "tsc -w" }, "dependencies": { + "@casl/ability": "^6.7.2", "@graphql-typed-document-node/core": "^3.2.0", "class-validator": "^0.14.1", "graphql": "^16.9.0", - "graphql-scalars": "^1.23.0", + "graphql-scalars": "^1.24.0", "htmlparser2": "^9.1.0", "http-status-codes": "^2.3.0", "luxon": "^3.5.0", @@ -72,10 +73,10 @@ }, "devDependencies": { "@types/luxon": "^3.4.2", - "@types/react": "~18.3.12", + "@types/react": "~18.3.14", "@types/validator": "^13.12.2", - "typescript": "^5.6.3", - "vitest": "^2.1.5" + "typescript": "^5.7.2", + "vitest": "^2.1.8" }, "packageManager": "yarn@4.5.3+sha512.3003a14012e2987072d244c720506549c1aab73ee728208f1b2580a9fd67b92d61ba6b08fe93f6dce68fd771e3af1e59a0afa28dd242dd0940d73b95fedd4e90" } diff --git a/packages/mobile/package.json b/packages/mobile/package.json index 87f614551..b6c58eca2 100644 --- a/packages/mobile/package.json +++ b/packages/mobile/package.json @@ -83,10 +83,11 @@ "expo-updates": "~0.26.7", "expo-web-browser": "~14.0.1", "graphql": "^16.9.0", - "graphql-scalars": "^1.23.0", + "graphql-scalars": "^1.24.0", "lodash": "^4.17.21", "luxon": "^3.5.0", "lz-string": "^1.5.0", + "markdown-it": "^14.1.0", "native-base": "patch:native-base@npm%3A3.4.28#../../.yarn/patches/native-base-npm-3.4.28-a8ecceae4d.patch", "react": "18.3.1", "react-dom": "~18.3.1", @@ -111,7 +112,7 @@ "reflect-metadata": "^0.2.2", "type-graphql": "^2.0.0-rc.2", "typedi": "^0.10.0", - "typescript": "^5.6.3", + "typescript": "^5.7.2", "urql": "^4.2.1", "utility-types": "^3.11.0", "validator": "^13.12.0" @@ -130,18 +131,18 @@ "@babel/plugin-transform-runtime": "^7.25.9", "@babel/plugin-transform-shorthand-properties": "^7.25.9", "@babel/plugin-transform-template-literals": "^7.25.9", - "@faker-js/faker": "^9.2.0", + "@faker-js/faker": "^9.3.0", "@testing-library/react-native": "^12.8.1", "@types/babel__core": "^7.20.5", "@types/lodash": "^4.17.13", "@types/luxon": "^3.4.2", "@types/lz-string": "^1.5.0", - "@types/markdown-it": "^14.1.2", - "@types/react": "~18.3.12", - "@types/react-dom": "~18.3.1", + "@types/markdown-it": "^14", + "@types/react": "~18.3.14", + "@types/react-dom": "~18.3.2", "@types/react-native-rss-parser": "^1.4.3", "@types/react-native-web": "^0.19.0", - "@types/react-test-renderer": "^18.3.0", + "@types/react-test-renderer": "^18.3.1", "@types/uuid": "^10.0.0", "@types/xdate": "^0.8.35", "babel-plugin-module-resolver": "^5.0.2", diff --git a/packages/mobile/src/common/components/CountdownView/CountdownViewNew.tsx b/packages/mobile/src/common/components/CountdownView/CountdownViewNew.tsx index 57ea9a267..b7b98bc4d 100644 --- a/packages/mobile/src/common/components/CountdownView/CountdownViewNew.tsx +++ b/packages/mobile/src/common/components/CountdownView/CountdownViewNew.tsx @@ -1,6 +1,6 @@ import { DateTime, Duration, Interval } from "luxon"; import { View } from "native-base"; -import { useEffect, useState } from "react"; +import React, { useEffect, useState } from "react"; import { useThemeColors } from "../../customHooks"; import { CountdownNumber } from "./CountdownNumber"; diff --git a/packages/mobile/src/common/components/CustomImageRenderer.tsx b/packages/mobile/src/common/components/CustomImageRenderer.tsx index 939716ec7..7f5c13dc7 100644 --- a/packages/mobile/src/common/components/CustomImageRenderer.tsx +++ b/packages/mobile/src/common/components/CustomImageRenderer.tsx @@ -1,6 +1,7 @@ import type { ASTNode } from "@ukdanceblue/react-native-markdown-display"; import type { Key } from "react"; import { useEffect, useState } from "react"; +import type { ImageStyle, StyleProp } from "react-native"; import type { IFitImageProps } from "react-native-fit-image"; import FitImage from "react-native-fit-image"; @@ -54,14 +55,14 @@ export const CustomImageRenderer = ({ ); } setImageProps({ - style: styles._VIEW_SAFE_image, + style: styles._VIEW_SAFE_image as StyleProp, accessibilityLabel: alt ?? title, source: { uri: `${defaultImageHandler}${srcWithoutProtocol}` }, }); } } else { setImageProps({ - style: styles._VIEW_SAFE_image, + style: styles._VIEW_SAFE_image as StyleProp, accessibilityLabel: alt ?? title, source: { uri: src }, }); diff --git a/packages/mobile/src/common/components/ErrorBoundary.tsx b/packages/mobile/src/common/components/ErrorBoundary.tsx index d89ed75d6..731451b50 100644 --- a/packages/mobile/src/common/components/ErrorBoundary.tsx +++ b/packages/mobile/src/common/components/ErrorBoundary.tsx @@ -6,6 +6,7 @@ import { import { debugStringify } from "@ukdanceblue/common"; import { openURL } from "expo-linking"; import type { ReactNode } from "react"; +import React from "react"; import { Button, SafeAreaView, ScrollView, Text, View } from "react-native"; import { universalCatch } from "../logging"; diff --git a/packages/mobile/src/common/components/NativeBaseMarkdown/NativeBaseMarkdown.tsx b/packages/mobile/src/common/components/NativeBaseMarkdown/NativeBaseMarkdown.tsx index 5f5a4ed03..cfa852cae 100644 --- a/packages/mobile/src/common/components/NativeBaseMarkdown/NativeBaseMarkdown.tsx +++ b/packages/mobile/src/common/components/NativeBaseMarkdown/NativeBaseMarkdown.tsx @@ -40,7 +40,6 @@ const NativeBaseMarkdown = ({ style={style} markdownit={ // This is caused by using a reexport - MarkdownIt({ linkify: true, typographer: true, html: true }) } > diff --git a/packages/mobile/src/common/components/Place/Place.tsx b/packages/mobile/src/common/components/Place/Place.tsx index ac47ed225..47b6b015d 100644 --- a/packages/mobile/src/common/components/Place/Place.tsx +++ b/packages/mobile/src/common/components/Place/Place.tsx @@ -2,6 +2,7 @@ import { FontAwesome5 } from "@expo/vector-icons"; import { Divider, Heading, Text, View } from "native-base"; import type { ReactElement } from "react"; +import React from "react"; /** * A row-based component showing a target name, their rank (if applicable), and their points diff --git a/packages/mobile/src/common/components/Standings/Standings.tsx b/packages/mobile/src/common/components/Standings/Standings.tsx index 6a06c28f7..f22d83c12 100644 --- a/packages/mobile/src/common/components/Standings/Standings.tsx +++ b/packages/mobile/src/common/components/Standings/Standings.tsx @@ -1,6 +1,7 @@ import { Text, View } from "native-base"; import type { ReactElement } from "react"; import { useEffect, useState } from "react"; +import React from "react"; import { ActivityIndicator, StyleSheet, TouchableOpacity } from "react-native"; import type { StandingType } from "../../../types/StandingType"; diff --git a/packages/mobile/src/common/markdownRules.tsx b/packages/mobile/src/common/markdownRules.tsx index 493a8af59..d9b1881ae 100644 --- a/packages/mobile/src/common/markdownRules.tsx +++ b/packages/mobile/src/common/markdownRules.tsx @@ -5,7 +5,7 @@ import { } from "@ukdanceblue/react-native-markdown-display"; import { Platform } from "expo-modules-core"; import { Box, Divider, Heading, Link, Row, Text, VStack } from "native-base"; -import type { FlexAlignType, TextStyle } from "react-native"; +import type { DimensionValue, FlexAlignType, TextStyle } from "react-native"; import { StyleSheet } from "react-native"; import { CustomImageRenderer } from "./components/CustomImageRenderer"; @@ -44,7 +44,7 @@ export interface MarkdownRuleStyle { marginBottom: number; flexWrap: "wrap" | "nowrap" | "wrap-reverse"; alignItems: FlexAlignType; - width: string; + width: DimensionValue; } const markdownTextStyleKeys = new Set>([ @@ -265,9 +265,7 @@ export const rules: typeof renderRules = { ); const orderedList = parent[orderedListIndex]; - const listItemNumber = ( - orderedList.attributes - )?.start + const listItemNumber = orderedList.attributes?.start ? Number(orderedList.attributes.start) + (node.index ?? Number.NaN) : (node.index ?? Number.NaN) + 1; diff --git a/packages/mobile/src/navigation/NavigationContainer.tsx b/packages/mobile/src/navigation/NavigationContainer.tsx index f8ec8804e..992a4b7cb 100644 --- a/packages/mobile/src/navigation/NavigationContainer.tsx +++ b/packages/mobile/src/navigation/NavigationContainer.tsx @@ -10,6 +10,7 @@ import { import { addNotificationResponseReceivedListener } from "expo-notifications"; import { useDisclose } from "native-base"; import { useRef, useState } from "react"; +import React from "react"; import { StatusBar } from "react-native"; import type { WebViewSource } from "react-native-webview/lib/WebViewTypes"; diff --git a/packages/mobile/src/navigation/root/Modals/SplashLogin.tsx b/packages/mobile/src/navigation/root/Modals/SplashLogin.tsx index 79841ca77..1bb34fd7b 100644 --- a/packages/mobile/src/navigation/root/Modals/SplashLogin.tsx +++ b/packages/mobile/src/navigation/root/Modals/SplashLogin.tsx @@ -1,6 +1,7 @@ import { AuthSource } from "@ukdanceblue/common"; import { Button, Center, Image, Text, View, ZStack } from "native-base"; import { useEffect, useState } from "react"; +import React from "react"; import type { ImageSourcePropType } from "react-native"; import { ActivityIndicator, Dimensions, StatusBar } from "react-native"; diff --git a/packages/mobile/src/navigation/root/NotificationScreen/NotificationScreen.tsx b/packages/mobile/src/navigation/root/NotificationScreen/NotificationScreen.tsx index 6aa78e620..65f969d11 100644 --- a/packages/mobile/src/navigation/root/NotificationScreen/NotificationScreen.tsx +++ b/packages/mobile/src/navigation/root/NotificationScreen/NotificationScreen.tsx @@ -5,6 +5,7 @@ import { setBadgeCountAsync } from "expo-notifications"; import { DateTime } from "luxon"; import { Button, SectionList, Text, useTheme, View } from "native-base"; import { useEffect, useMemo } from "react"; +import React from "react"; import { RefreshControl } from "react-native"; import JumbotronGeometric from "@/common/components/JumbotronGeometric"; diff --git a/packages/mobile/src/navigation/root/ProfileScreen/ProfileFooter.tsx b/packages/mobile/src/navigation/root/ProfileScreen/ProfileFooter.tsx index 091bdf679..b46fa6e54 100644 --- a/packages/mobile/src/navigation/root/ProfileScreen/ProfileFooter.tsx +++ b/packages/mobile/src/navigation/root/ProfileScreen/ProfileFooter.tsx @@ -12,6 +12,7 @@ import { View, } from "native-base"; import { useState } from "react"; +import React from "react"; import { TextInput } from "react-native"; import { useLogin, useLogOut } from "@/common/auth"; diff --git a/packages/mobile/src/navigation/root/ProfileScreen/ProfileScreen.tsx b/packages/mobile/src/navigation/root/ProfileScreen/ProfileScreen.tsx index fa2edbd5c..25f9c7672 100644 --- a/packages/mobile/src/navigation/root/ProfileScreen/ProfileScreen.tsx +++ b/packages/mobile/src/navigation/root/ProfileScreen/ProfileScreen.tsx @@ -17,13 +17,14 @@ import { VStack, } from "native-base"; import { useMemo } from "react"; +import React from "react"; import { useLogin } from "@/common/auth"; import JumbotronGeometric from "@/common/components/JumbotronGeometric"; import { useThemeFonts } from "@/common/customHooks"; import { universalCatch } from "@/common/logging"; import type { FragmentType } from "@/graphql/index"; -import { graphql,readFragment } from "@/graphql/index"; +import { graphql, readFragment } from "@/graphql/index"; import { ProfileFooter } from "./ProfileFooter"; diff --git a/packages/mobile/src/navigation/root/RootScreen.tsx b/packages/mobile/src/navigation/root/RootScreen.tsx index a08bf81e4..1f2a523f3 100644 --- a/packages/mobile/src/navigation/root/RootScreen.tsx +++ b/packages/mobile/src/navigation/root/RootScreen.tsx @@ -2,6 +2,7 @@ import { createNativeStackNavigator } from "@react-navigation/native-stack"; import { DbRole } from "@ukdanceblue/common"; import { useTheme } from "native-base"; import { useEffect, useMemo, useState } from "react"; +import React from "react"; import { Alert, useWindowDimensions } from "react-native"; import { useQuery } from "urql"; @@ -9,7 +10,7 @@ import ErrorBoundary, { withErrorBoundary, } from "@/common/components/ErrorBoundary"; import { Logger } from "@/common/logger/Logger"; -import { graphql,readFragment } from "@/graphql/index"; +import { graphql, readFragment } from "@/graphql/index"; import { useColorModeValue } from "../../common/customHooks"; import { useLoading } from "../../context"; diff --git a/packages/mobile/src/navigation/root/tab/EventListScreen/EventListPage.tsx b/packages/mobile/src/navigation/root/tab/EventListScreen/EventListPage.tsx index e0497687e..aefd56429 100644 --- a/packages/mobile/src/navigation/root/tab/EventListScreen/EventListPage.tsx +++ b/packages/mobile/src/navigation/root/tab/EventListScreen/EventListPage.tsx @@ -1,6 +1,7 @@ import type { DateTime } from "luxon"; import { Column, Divider, Spinner, Text } from "native-base"; import { useEffect, useMemo, useRef, useState } from "react"; +import React from "react"; import { FlatList } from "react-native"; import { Calendar } from "react-native-calendars"; import type { DateData, MarkedDates } from "react-native-calendars/src/types"; diff --git a/packages/mobile/src/navigation/root/tab/EventListScreen/EventRow/EventRow.tsx b/packages/mobile/src/navigation/root/tab/EventListScreen/EventRow/EventRow.tsx index 35e91f4bf..91f7d0ace 100644 --- a/packages/mobile/src/navigation/root/tab/EventListScreen/EventRow/EventRow.tsx +++ b/packages/mobile/src/navigation/root/tab/EventListScreen/EventRow/EventRow.tsx @@ -2,6 +2,7 @@ import { FontAwesome } from "@expo/vector-icons"; import type { Interval } from "luxon"; import { DateTime } from "luxon"; import { Heading, Icon, Row, Text } from "native-base"; +import React from "react"; import { useMemo } from "react"; /** diff --git a/packages/mobile/src/navigation/root/tab/ExplorerScreen/ExplorerItem.tsx b/packages/mobile/src/navigation/root/tab/ExplorerScreen/ExplorerItem.tsx index 3f67198d7..91564f262 100644 --- a/packages/mobile/src/navigation/root/tab/ExplorerScreen/ExplorerItem.tsx +++ b/packages/mobile/src/navigation/root/tab/ExplorerScreen/ExplorerItem.tsx @@ -5,6 +5,7 @@ import { Image } from "expo-image"; import { openURL } from "expo-linking"; import { Box, Button, HStack, Text, View } from "native-base"; import { useEffect, useState } from "react"; +import React from "react"; import { PixelRatio, useWindowDimensions } from "react-native"; import AudioPlayer from "@/common/components/AudioPlayer"; diff --git a/packages/mobile/src/navigation/root/tab/ExplorerScreen/ExplorerScreen.tsx b/packages/mobile/src/navigation/root/tab/ExplorerScreen/ExplorerScreen.tsx index 8b3ce9b02..ece16ba6c 100644 --- a/packages/mobile/src/navigation/root/tab/ExplorerScreen/ExplorerScreen.tsx +++ b/packages/mobile/src/navigation/root/tab/ExplorerScreen/ExplorerScreen.tsx @@ -1,5 +1,6 @@ import { Button, Text, VStack } from "native-base"; import { useEffect, useState } from "react"; +import React from "react"; import { RefreshControl, ScrollView } from "react-native-gesture-handler"; import type { FeedSortingItem } from "./combineFeeds"; diff --git a/packages/mobile/src/navigation/root/tab/FundrasingScreen/HomeScreen.tsx b/packages/mobile/src/navigation/root/tab/FundrasingScreen/HomeScreen.tsx index dbb9ef056..c2f501a4c 100644 --- a/packages/mobile/src/navigation/root/tab/FundrasingScreen/HomeScreen.tsx +++ b/packages/mobile/src/navigation/root/tab/FundrasingScreen/HomeScreen.tsx @@ -2,6 +2,7 @@ import { FontAwesome, FontAwesome5 } from "@expo/vector-icons"; import { openURL } from "expo-linking"; import { openBrowserAsync } from "expo-web-browser"; import { Box, Button, HStack, Text, VStack } from "native-base"; +import React from "react"; import { PixelRatio, StatusBar, useWindowDimensions } from "react-native"; import { TouchableOpacity } from "react-native-gesture-handler"; diff --git a/packages/mobile/src/navigation/root/tab/HomeScreen/HomeScreen.tsx b/packages/mobile/src/navigation/root/tab/HomeScreen/HomeScreen.tsx index 3e0ac3772..097d1a10a 100644 --- a/packages/mobile/src/navigation/root/tab/HomeScreen/HomeScreen.tsx +++ b/packages/mobile/src/navigation/root/tab/HomeScreen/HomeScreen.tsx @@ -2,6 +2,7 @@ import { FontAwesome, FontAwesome5 } from "@expo/vector-icons"; import { openURL } from "expo-linking"; import { openBrowserAsync } from "expo-web-browser"; import { Box, Button, HStack, Text, VStack } from "native-base"; +import React from "react"; import { PixelRatio, StatusBar, diff --git a/packages/mobile/src/navigation/root/tab/InfoScreen/InfoScreen.tsx b/packages/mobile/src/navigation/root/tab/InfoScreen/InfoScreen.tsx index c75f2e5f7..0b6436cda 100644 --- a/packages/mobile/src/navigation/root/tab/InfoScreen/InfoScreen.tsx +++ b/packages/mobile/src/navigation/root/tab/InfoScreen/InfoScreen.tsx @@ -1,6 +1,7 @@ import { FontAwesome, FontAwesome5 } from "@expo/vector-icons"; import { openURL } from "expo-linking"; import { Box, Button, HStack, Text, VStack } from "native-base"; +import React from "react"; import { PixelRatio, Share, diff --git a/packages/mobile/src/navigation/root/tab/MarathonScreen/MarathonScreen.tsx b/packages/mobile/src/navigation/root/tab/MarathonScreen/MarathonScreen.tsx index 232eb4b5a..374368b8f 100644 --- a/packages/mobile/src/navigation/root/tab/MarathonScreen/MarathonScreen.tsx +++ b/packages/mobile/src/navigation/root/tab/MarathonScreen/MarathonScreen.tsx @@ -1,6 +1,7 @@ import { dateTimeFromSomething } from "@ukdanceblue/common"; import { Button, Modal, Text, View } from "native-base"; import { useEffect, useMemo, useState } from "react"; +import React from "react"; import { ActivityIndicator, TextInput, diff --git a/packages/mobile/src/navigation/root/tab/TabBarComponent.tsx b/packages/mobile/src/navigation/root/tab/TabBarComponent.tsx index 5df134797..e86161ba4 100644 --- a/packages/mobile/src/navigation/root/tab/TabBarComponent.tsx +++ b/packages/mobile/src/navigation/root/tab/TabBarComponent.tsx @@ -8,6 +8,7 @@ import type { TabNavigationState, } from "@react-navigation/native"; import { Box, useTheme, View, VStack, ZStack } from "native-base"; +import React from "react"; import { Text, TouchableOpacity, useWindowDimensions } from "react-native"; import { useColorModeValue, useThemeColors } from "@/common/customHooks"; diff --git a/packages/portal/graphql/graphql-env.d.ts b/packages/portal/graphql/graphql-env.d.ts index b3b4290e6..97c615927 100644 --- a/packages/portal/graphql/graphql-env.d.ts +++ b/packages/portal/graphql/graphql-env.d.ts @@ -39,7 +39,6 @@ export type introspection_types = { 'DailyDepartmentNotificationResolverOneOfFilterKeys': { name: 'DailyDepartmentNotificationResolverOneOfFilterKeys'; enumValues: 'BatchType' | 'SolicitationCodeNumber' | 'SolicitationCodePrefix'; }; 'DailyDepartmentNotificationResolverStringFilterKeys': { name: 'DailyDepartmentNotificationResolverStringFilterKeys'; enumValues: 'Comment' | 'Donor' | 'SolicitationCodeName'; }; 'DateTimeISO': unknown; - 'DbFundsTeamInfo': { kind: 'OBJECT'; name: 'DbFundsTeamInfo'; fields: { 'dbNum': { name: 'dbNum'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'Int'; ofType: null; }; } }; 'name': { name: 'name'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'String'; ofType: null; }; } }; }; }; 'DbRole': { name: 'DbRole'; enumValues: 'Committee' | 'None' | 'Public' | 'UKY'; }; 'DeviceNode': { kind: 'OBJECT'; name: 'DeviceNode'; fields: { 'createdAt': { name: 'createdAt'; type: { kind: 'SCALAR'; name: 'DateTimeISO'; ofType: null; } }; 'id': { name: 'id'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'GlobalId'; ofType: null; }; } }; 'lastLoggedInUser': { name: 'lastLoggedInUser'; type: { kind: 'OBJECT'; name: 'PersonNode'; ofType: null; } }; 'lastLogin': { name: 'lastLogin'; type: { kind: 'SCALAR'; name: 'DateTimeISO'; ofType: null; } }; 'notificationDeliveries': { name: 'notificationDeliveries'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'LIST'; name: never; ofType: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'NotificationDeliveryNode'; ofType: null; }; }; }; } }; 'updatedAt': { name: 'updatedAt'; type: { kind: 'SCALAR'; name: 'DateTimeISO'; ofType: null; } }; }; }; 'DeviceResolverAllKeys': { name: 'DeviceResolverAllKeys'; enumValues: 'createdAt' | 'expoPushToken' | 'lastSeen' | 'updatedAt'; }; @@ -52,7 +51,7 @@ export type introspection_types = { 'EffectiveCommitteeRole': { kind: 'OBJECT'; name: 'EffectiveCommitteeRole'; fields: { 'identifier': { name: 'identifier'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'ENUM'; name: 'CommitteeIdentifier'; ofType: null; }; } }; 'role': { name: 'role'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'ENUM'; name: 'CommitteeRole'; ofType: null; }; } }; }; }; 'EmailAddress': unknown; 'EventNode': { kind: 'OBJECT'; name: 'EventNode'; fields: { 'createdAt': { name: 'createdAt'; type: { kind: 'SCALAR'; name: 'DateTimeISO'; ofType: null; } }; 'description': { name: 'description'; type: { kind: 'SCALAR'; name: 'String'; ofType: null; } }; 'id': { name: 'id'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'GlobalId'; ofType: null; }; } }; 'images': { name: 'images'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'LIST'; name: never; ofType: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'ImageNode'; ofType: null; }; }; }; } }; 'location': { name: 'location'; type: { kind: 'SCALAR'; name: 'String'; ofType: null; } }; 'occurrences': { name: 'occurrences'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'LIST'; name: never; ofType: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'EventOccurrenceNode'; ofType: null; }; }; }; } }; 'summary': { name: 'summary'; type: { kind: 'SCALAR'; name: 'String'; ofType: null; } }; 'title': { name: 'title'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'String'; ofType: null; }; } }; 'updatedAt': { name: 'updatedAt'; type: { kind: 'SCALAR'; name: 'DateTimeISO'; ofType: null; } }; }; }; - 'EventOccurrenceNode': { kind: 'OBJECT'; name: 'EventOccurrenceNode'; fields: { 'fullDay': { name: 'fullDay'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'Boolean'; ofType: null; }; } }; 'id': { name: 'id'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'ID'; ofType: null; }; } }; 'interval': { name: 'interval'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'IntervalISO'; ofType: null; }; } }; }; }; + 'EventOccurrenceNode': { kind: 'OBJECT'; name: 'EventOccurrenceNode'; fields: { 'fullDay': { name: 'fullDay'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'Boolean'; ofType: null; }; } }; 'id': { name: 'id'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'GlobalId'; ofType: null; }; } }; 'interval': { name: 'interval'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'IntervalISO'; ofType: null; }; } }; }; }; 'EventResolverAllKeys': { name: 'EventResolverAllKeys'; enumValues: 'createdAt' | 'description' | 'location' | 'occurrence' | 'occurrenceEnd' | 'occurrenceStart' | 'summary' | 'title' | 'updatedAt'; }; 'EventResolverDateFilterKeys': { name: 'EventResolverDateFilterKeys'; enumValues: 'createdAt' | 'occurrence' | 'occurrenceEnd' | 'occurrenceStart' | 'updatedAt'; }; 'EventResolverKeyedDateFilterItem': { kind: 'INPUT_OBJECT'; name: 'EventResolverKeyedDateFilterItem'; isOneOf: false; inputFields: [{ name: 'comparison'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'ENUM'; name: 'NumericComparator'; ofType: null; }; }; defaultValue: null }, { name: 'field'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'ENUM'; name: 'EventResolverDateFilterKeys'; ofType: null; }; }; defaultValue: null }, { name: 'negate'; type: { kind: 'SCALAR'; name: 'Boolean'; ofType: null; }; defaultValue: "false" }, { name: 'value'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'DateTimeISO'; ofType: null; }; }; defaultValue: null }]; }; @@ -78,7 +77,6 @@ export type introspection_types = { 'GetConfigurationByUuidResponse': { kind: 'OBJECT'; name: 'GetConfigurationByUuidResponse'; fields: { 'data': { name: 'data'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'ConfigurationNode'; ofType: null; }; } }; }; }; 'GetDeviceByUuidResponse': { kind: 'OBJECT'; name: 'GetDeviceByUuidResponse'; fields: { 'data': { name: 'data'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'DeviceNode'; ofType: null; }; } }; }; }; 'GlobalId': unknown; - 'ID': unknown; 'ImageNode': { kind: 'OBJECT'; name: 'ImageNode'; fields: { 'alt': { name: 'alt'; type: { kind: 'SCALAR'; name: 'String'; ofType: null; } }; 'createdAt': { name: 'createdAt'; type: { kind: 'SCALAR'; name: 'DateTimeISO'; ofType: null; } }; 'height': { name: 'height'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'Int'; ofType: null; }; } }; 'id': { name: 'id'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'GlobalId'; ofType: null; }; } }; 'mimeType': { name: 'mimeType'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'String'; ofType: null; }; } }; 'thumbHash': { name: 'thumbHash'; type: { kind: 'SCALAR'; name: 'String'; ofType: null; } }; 'updatedAt': { name: 'updatedAt'; type: { kind: 'SCALAR'; name: 'DateTimeISO'; ofType: null; } }; 'url': { name: 'url'; type: { kind: 'SCALAR'; name: 'URL'; ofType: null; } }; 'width': { name: 'width'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'Int'; ofType: null; }; } }; }; }; 'ImageResolverAllKeys': { name: 'ImageResolverAllKeys'; enumValues: 'alt' | 'createdAt' | 'height' | 'updatedAt' | 'width'; }; 'ImageResolverDateFilterKeys': { name: 'ImageResolverDateFilterKeys'; enumValues: 'createdAt' | 'updatedAt'; }; @@ -93,6 +91,7 @@ export type introspection_types = { 'Int': unknown; 'IntervalISO': { kind: 'OBJECT'; name: 'IntervalISO'; fields: { 'end': { name: 'end'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'DateTimeISO'; ofType: null; }; } }; 'start': { name: 'start'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'DateTimeISO'; ofType: null; }; } }; }; }; 'IntervalISOInput': { kind: 'INPUT_OBJECT'; name: 'IntervalISOInput'; isOneOf: false; inputFields: [{ name: 'end'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'DateTimeISO'; ofType: null; }; }; defaultValue: null }, { name: 'start'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'DateTimeISO'; ofType: null; }; }; defaultValue: null }]; }; + 'JSON': unknown; 'ListDailyDepartmentNotificationsResponse': { kind: 'OBJECT'; name: 'ListDailyDepartmentNotificationsResponse'; fields: { 'data': { name: 'data'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'LIST'; name: never; ofType: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'DailyDepartmentNotificationNode'; ofType: null; }; }; }; } }; 'page': { name: 'page'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'PositiveInt'; ofType: null; }; } }; 'pageSize': { name: 'pageSize'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'NonNegativeInt'; ofType: null; }; } }; 'total': { name: 'total'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'NonNegativeInt'; ofType: null; }; } }; }; }; 'ListDevicesResponse': { kind: 'OBJECT'; name: 'ListDevicesResponse'; fields: { 'data': { name: 'data'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'LIST'; name: never; ofType: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'DeviceNode'; ofType: null; }; }; }; } }; 'page': { name: 'page'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'PositiveInt'; ofType: null; }; } }; 'pageSize': { name: 'pageSize'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'NonNegativeInt'; ofType: null; }; } }; 'total': { name: 'total'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'NonNegativeInt'; ofType: null; }; } }; }; }; 'ListEventsResponse': { kind: 'OBJECT'; name: 'ListEventsResponse'; fields: { 'data': { name: 'data'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'LIST'; name: never; ofType: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'EventNode'; ofType: null; }; }; }; } }; 'page': { name: 'page'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'PositiveInt'; ofType: null; }; } }; 'pageSize': { name: 'pageSize'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'NonNegativeInt'; ofType: null; }; } }; 'total': { name: 'total'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'NonNegativeInt'; ofType: null; }; } }; }; }; @@ -108,7 +107,7 @@ export type introspection_types = { 'ListSolicitationCodesResponse': { kind: 'OBJECT'; name: 'ListSolicitationCodesResponse'; fields: { 'data': { name: 'data'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'LIST'; name: never; ofType: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'SolicitationCodeNode'; ofType: null; }; }; }; } }; 'page': { name: 'page'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'PositiveInt'; ofType: null; }; } }; 'pageSize': { name: 'pageSize'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'NonNegativeInt'; ofType: null; }; } }; 'total': { name: 'total'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'NonNegativeInt'; ofType: null; }; } }; }; }; 'ListTeamsResponse': { kind: 'OBJECT'; name: 'ListTeamsResponse'; fields: { 'data': { name: 'data'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'LIST'; name: never; ofType: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'TeamNode'; ofType: null; }; }; }; } }; 'page': { name: 'page'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'PositiveInt'; ofType: null; }; } }; 'pageSize': { name: 'pageSize'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'NonNegativeInt'; ofType: null; }; } }; 'total': { name: 'total'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'NonNegativeInt'; ofType: null; }; } }; }; }; 'LocalDate': unknown; - 'LoginState': { kind: 'OBJECT'; name: 'LoginState'; fields: { 'accessLevel': { name: 'accessLevel'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'ENUM'; name: 'AccessLevel'; ofType: null; }; } }; 'authSource': { name: 'authSource'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'ENUM'; name: 'AuthSource'; ofType: null; }; } }; 'dbRole': { name: 'dbRole'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'ENUM'; name: 'DbRole'; ofType: null; }; } }; 'effectiveCommitteeRoles': { name: 'effectiveCommitteeRoles'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'LIST'; name: never; ofType: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'EffectiveCommitteeRole'; ofType: null; }; }; }; } }; 'loggedIn': { name: 'loggedIn'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'Boolean'; ofType: null; }; } }; }; }; + 'LoginState': { kind: 'OBJECT'; name: 'LoginState'; fields: { 'abilityRules': { name: 'abilityRules'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'LIST'; name: never; ofType: { kind: 'NON_NULL'; name: never; ofType: { kind: 'LIST'; name: never; ofType: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'JSON'; ofType: null; }; }; }; }; }; } }; 'accessLevel': { name: 'accessLevel'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'ENUM'; name: 'AccessLevel'; ofType: null; }; } }; 'authSource': { name: 'authSource'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'ENUM'; name: 'AuthSource'; ofType: null; }; } }; 'dbRole': { name: 'dbRole'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'ENUM'; name: 'DbRole'; ofType: null; }; } }; 'effectiveCommitteeRoles': { name: 'effectiveCommitteeRoles'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'LIST'; name: never; ofType: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'EffectiveCommitteeRole'; ofType: null; }; }; }; } }; 'loggedIn': { name: 'loggedIn'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'Boolean'; ofType: null; }; } }; }; }; 'MarathonHourNode': { kind: 'OBJECT'; name: 'MarathonHourNode'; fields: { 'createdAt': { name: 'createdAt'; type: { kind: 'SCALAR'; name: 'DateTimeISO'; ofType: null; } }; 'details': { name: 'details'; type: { kind: 'SCALAR'; name: 'String'; ofType: null; } }; 'durationInfo': { name: 'durationInfo'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'String'; ofType: null; }; } }; 'id': { name: 'id'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'GlobalId'; ofType: null; }; } }; 'mapImages': { name: 'mapImages'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'LIST'; name: never; ofType: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'ImageNode'; ofType: null; }; }; }; } }; 'shownStartingAt': { name: 'shownStartingAt'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'DateTimeISO'; ofType: null; }; } }; 'title': { name: 'title'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'String'; ofType: null; }; } }; 'updatedAt': { name: 'updatedAt'; type: { kind: 'SCALAR'; name: 'DateTimeISO'; ofType: null; } }; }; }; 'MarathonHourResolverAllKeys': { name: 'MarathonHourResolverAllKeys'; enumValues: 'createdAt' | 'details' | 'durationInfo' | 'marathonYear' | 'shownStartingAt' | 'title' | 'updatedAt'; }; 'MarathonHourResolverDateFilterKeys': { name: 'MarathonHourResolverDateFilterKeys'; enumValues: 'createdAt' | 'shownStartingAt' | 'updatedAt'; }; @@ -147,7 +146,7 @@ export type introspection_types = { 'NotificationResolverOneOfFilterKeys': { name: 'NotificationResolverOneOfFilterKeys'; enumValues: 'deliveryIssue'; }; 'NotificationResolverStringFilterKeys': { name: 'NotificationResolverStringFilterKeys'; enumValues: 'body' | 'title'; }; 'NumericComparator': { name: 'NumericComparator'; enumValues: 'EQUALS' | 'GREATER_THAN' | 'GREATER_THAN_OR_EQUAL_TO' | 'IS' | 'LESS_THAN' | 'LESS_THAN_OR_EQUAL_TO'; }; - 'PersonNode': { kind: 'OBJECT'; name: 'PersonNode'; fields: { 'assignedDonationEntries': { name: 'assignedDonationEntries'; type: { kind: 'OBJECT'; name: 'ListFundraisingEntriesResponse'; ofType: null; } }; 'committees': { name: 'committees'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'LIST'; name: never; ofType: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'CommitteeMembershipNode'; ofType: null; }; }; }; } }; 'createdAt': { name: 'createdAt'; type: { kind: 'SCALAR'; name: 'DateTimeISO'; ofType: null; } }; 'dbRole': { name: 'dbRole'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'ENUM'; name: 'DbRole'; ofType: null; }; } }; 'email': { name: 'email'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'String'; ofType: null; }; } }; 'fundraisingAssignments': { name: 'fundraisingAssignments'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'LIST'; name: never; ofType: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'FundraisingAssignmentNode'; ofType: null; }; }; }; } }; 'fundraisingTotalAmount': { name: 'fundraisingTotalAmount'; type: { kind: 'SCALAR'; name: 'Float'; ofType: null; } }; 'id': { name: 'id'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'GlobalId'; ofType: null; }; } }; 'linkblue': { name: 'linkblue'; type: { kind: 'SCALAR'; name: 'String'; ofType: null; } }; 'moraleTeams': { name: 'moraleTeams'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'LIST'; name: never; ofType: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'MembershipNode'; ofType: null; }; }; }; } }; 'name': { name: 'name'; type: { kind: 'SCALAR'; name: 'String'; ofType: null; } }; 'primaryCommittee': { name: 'primaryCommittee'; type: { kind: 'OBJECT'; name: 'CommitteeMembershipNode'; ofType: null; } }; 'primaryTeam': { name: 'primaryTeam'; type: { kind: 'OBJECT'; name: 'MembershipNode'; ofType: null; } }; 'teams': { name: 'teams'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'LIST'; name: never; ofType: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'MembershipNode'; ofType: null; }; }; }; } }; 'updatedAt': { name: 'updatedAt'; type: { kind: 'SCALAR'; name: 'DateTimeISO'; ofType: null; } }; }; }; + 'PersonNode': { kind: 'OBJECT'; name: 'PersonNode'; fields: { 'committees': { name: 'committees'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'LIST'; name: never; ofType: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'CommitteeMembershipNode'; ofType: null; }; }; }; } }; 'createdAt': { name: 'createdAt'; type: { kind: 'SCALAR'; name: 'DateTimeISO'; ofType: null; } }; 'dbRole': { name: 'dbRole'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'ENUM'; name: 'DbRole'; ofType: null; }; } }; 'email': { name: 'email'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'String'; ofType: null; }; } }; 'fundraisingAssignments': { name: 'fundraisingAssignments'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'LIST'; name: never; ofType: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'FundraisingAssignmentNode'; ofType: null; }; }; }; } }; 'fundraisingTotalAmount': { name: 'fundraisingTotalAmount'; type: { kind: 'SCALAR'; name: 'Float'; ofType: null; } }; 'id': { name: 'id'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'GlobalId'; ofType: null; }; } }; 'linkblue': { name: 'linkblue'; type: { kind: 'SCALAR'; name: 'String'; ofType: null; } }; 'moraleTeams': { name: 'moraleTeams'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'LIST'; name: never; ofType: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'MembershipNode'; ofType: null; }; }; }; } }; 'name': { name: 'name'; type: { kind: 'SCALAR'; name: 'String'; ofType: null; } }; 'primaryCommittee': { name: 'primaryCommittee'; type: { kind: 'OBJECT'; name: 'CommitteeMembershipNode'; ofType: null; } }; 'primaryTeam': { name: 'primaryTeam'; type: { kind: 'OBJECT'; name: 'MembershipNode'; ofType: null; } }; 'teams': { name: 'teams'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'LIST'; name: never; ofType: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'MembershipNode'; ofType: null; }; }; }; } }; 'updatedAt': { name: 'updatedAt'; type: { kind: 'SCALAR'; name: 'DateTimeISO'; ofType: null; } }; }; }; 'PersonResolverAllKeys': { name: 'PersonResolverAllKeys'; enumValues: 'committeeName' | 'committeeRole' | 'dbRole' | 'email' | 'linkblue' | 'name'; }; 'PersonResolverKeyedIsNullFilterItem': { kind: 'INPUT_OBJECT'; name: 'PersonResolverKeyedIsNullFilterItem'; isOneOf: false; inputFields: [{ name: 'field'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'ENUM'; name: 'PersonResolverAllKeys'; ofType: null; }; }; defaultValue: null }, { name: 'negate'; type: { kind: 'SCALAR'; name: 'Boolean'; ofType: null; }; defaultValue: "false" }]; }; 'PersonResolverKeyedOneOfFilterItem': { kind: 'INPUT_OBJECT'; name: 'PersonResolverKeyedOneOfFilterItem'; isOneOf: false; inputFields: [{ name: 'field'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'ENUM'; name: 'PersonResolverOneOfFilterKeys'; ofType: null; }; }; defaultValue: null }, { name: 'negate'; type: { kind: 'SCALAR'; name: 'Boolean'; ofType: null; }; defaultValue: "false" }, { name: 'value'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'LIST'; name: never; ofType: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'String'; ofType: null; }; }; }; }; defaultValue: null }]; }; @@ -169,13 +168,13 @@ export type introspection_types = { 'PointOpportunityResolverOneOfFilterKeys': { name: 'PointOpportunityResolverOneOfFilterKeys'; enumValues: 'marathonUuid' | 'type'; }; 'PointOpportunityResolverStringFilterKeys': { name: 'PointOpportunityResolverStringFilterKeys'; enumValues: 'name'; }; 'PositiveInt': unknown; - 'Query': { kind: 'OBJECT'; name: 'Query'; fields: { 'activeConfiguration': { name: 'activeConfiguration'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'GetConfigurationByUuidResponse'; ofType: null; }; } }; 'allConfigurations': { name: 'allConfigurations'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'LIST'; name: never; ofType: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'ConfigurationNode'; ofType: null; }; }; }; } }; 'auditLog': { name: 'auditLog'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'String'; ofType: null; }; } }; 'configuration': { name: 'configuration'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'ConfigurationNode'; ofType: null; }; } }; 'currentMarathon': { name: 'currentMarathon'; type: { kind: 'OBJECT'; name: 'MarathonNode'; ofType: null; } }; 'currentMarathonHour': { name: 'currentMarathonHour'; type: { kind: 'OBJECT'; name: 'MarathonHourNode'; ofType: null; } }; 'dailyDepartmentNotification': { name: 'dailyDepartmentNotification'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'DailyDepartmentNotificationNode'; ofType: null; }; } }; 'dailyDepartmentNotificationBatch': { name: 'dailyDepartmentNotificationBatch'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'DailyDepartmentNotificationBatchNode'; ofType: null; }; } }; 'dailyDepartmentNotifications': { name: 'dailyDepartmentNotifications'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'ListDailyDepartmentNotificationsResponse'; ofType: null; }; } }; 'dbFundsTeams': { name: 'dbFundsTeams'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'LIST'; name: never; ofType: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'DbFundsTeamInfo'; ofType: null; }; }; }; } }; 'device': { name: 'device'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'GetDeviceByUuidResponse'; ofType: null; }; } }; 'devices': { name: 'devices'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'ListDevicesResponse'; ofType: null; }; } }; 'event': { name: 'event'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'EventNode'; ofType: null; }; } }; 'events': { name: 'events'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'ListEventsResponse'; ofType: null; }; } }; 'feed': { name: 'feed'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'LIST'; name: never; ofType: { kind: 'NON_NULL'; name: never; ofType: { kind: 'INTERFACE'; name: 'FeedItem'; ofType: null; }; }; }; } }; 'feedItem': { name: 'feedItem'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'FeedNode'; ofType: null; }; } }; 'fundraisingAssignment': { name: 'fundraisingAssignment'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'FundraisingAssignmentNode'; ofType: null; }; } }; 'fundraisingEntries': { name: 'fundraisingEntries'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'ListFundraisingEntriesResponse'; ofType: null; }; } }; 'fundraisingEntry': { name: 'fundraisingEntry'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'FundraisingEntryNode'; ofType: null; }; } }; 'image': { name: 'image'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'ImageNode'; ofType: null; }; } }; 'images': { name: 'images'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'ListImagesResponse'; ofType: null; }; } }; 'latestMarathon': { name: 'latestMarathon'; type: { kind: 'OBJECT'; name: 'MarathonNode'; ofType: null; } }; 'loginState': { name: 'loginState'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'LoginState'; ofType: null; }; } }; 'marathon': { name: 'marathon'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'MarathonNode'; ofType: null; }; } }; 'marathonForYear': { name: 'marathonForYear'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'MarathonNode'; ofType: null; }; } }; 'marathonHour': { name: 'marathonHour'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'MarathonHourNode'; ofType: null; }; } }; 'marathonHours': { name: 'marathonHours'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'ListMarathonHoursResponse'; ofType: null; }; } }; 'marathons': { name: 'marathons'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'ListMarathonsResponse'; ofType: null; }; } }; 'me': { name: 'me'; type: { kind: 'OBJECT'; name: 'PersonNode'; ofType: null; } }; 'node': { name: 'node'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'INTERFACE'; name: 'Node'; ofType: null; }; } }; 'notification': { name: 'notification'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'NotificationNode'; ofType: null; }; } }; 'notificationDeliveries': { name: 'notificationDeliveries'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'ListNotificationDeliveriesResponse'; ofType: null; }; } }; 'notifications': { name: 'notifications'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'ListNotificationsResponse'; ofType: null; }; } }; 'people': { name: 'people'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'ListPeopleResponse'; ofType: null; }; } }; 'person': { name: 'person'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'PersonNode'; ofType: null; }; } }; 'personByLinkBlue': { name: 'personByLinkBlue'; type: { kind: 'OBJECT'; name: 'PersonNode'; ofType: null; } }; 'pointEntries': { name: 'pointEntries'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'ListPointEntriesResponse'; ofType: null; }; } }; 'pointEntry': { name: 'pointEntry'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'PointEntryNode'; ofType: null; }; } }; 'pointOpportunities': { name: 'pointOpportunities'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'ListPointOpportunitiesResponse'; ofType: null; }; } }; 'pointOpportunity': { name: 'pointOpportunity'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'PointOpportunityNode'; ofType: null; }; } }; 'rawFundraisingEntries': { name: 'rawFundraisingEntries'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'String'; ofType: null; }; } }; 'rawFundraisingTotals': { name: 'rawFundraisingTotals'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'String'; ofType: null; }; } }; 'report': { name: 'report'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'Report'; ofType: null; }; } }; 'searchPeopleByName': { name: 'searchPeopleByName'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'LIST'; name: never; ofType: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'PersonNode'; ofType: null; }; }; }; } }; 'solicitationCode': { name: 'solicitationCode'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'SolicitationCodeNode'; ofType: null; }; } }; 'solicitationCodes': { name: 'solicitationCodes'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'ListSolicitationCodesResponse'; ofType: null; }; } }; 'team': { name: 'team'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'TeamNode'; ofType: null; }; } }; 'teams': { name: 'teams'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'ListTeamsResponse'; ofType: null; }; } }; }; }; + 'Query': { kind: 'OBJECT'; name: 'Query'; fields: { 'activeConfiguration': { name: 'activeConfiguration'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'GetConfigurationByUuidResponse'; ofType: null; }; } }; 'allConfigurations': { name: 'allConfigurations'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'LIST'; name: never; ofType: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'ConfigurationNode'; ofType: null; }; }; }; } }; 'auditLog': { name: 'auditLog'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'String'; ofType: null; }; } }; 'configuration': { name: 'configuration'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'ConfigurationNode'; ofType: null; }; } }; 'currentMarathon': { name: 'currentMarathon'; type: { kind: 'OBJECT'; name: 'MarathonNode'; ofType: null; } }; 'currentMarathonHour': { name: 'currentMarathonHour'; type: { kind: 'OBJECT'; name: 'MarathonHourNode'; ofType: null; } }; 'dailyDepartmentNotification': { name: 'dailyDepartmentNotification'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'DailyDepartmentNotificationNode'; ofType: null; }; } }; 'dailyDepartmentNotificationBatch': { name: 'dailyDepartmentNotificationBatch'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'DailyDepartmentNotificationBatchNode'; ofType: null; }; } }; 'dailyDepartmentNotifications': { name: 'dailyDepartmentNotifications'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'ListDailyDepartmentNotificationsResponse'; ofType: null; }; } }; 'device': { name: 'device'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'GetDeviceByUuidResponse'; ofType: null; }; } }; 'devices': { name: 'devices'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'ListDevicesResponse'; ofType: null; }; } }; 'event': { name: 'event'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'EventNode'; ofType: null; }; } }; 'events': { name: 'events'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'ListEventsResponse'; ofType: null; }; } }; 'feed': { name: 'feed'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'LIST'; name: never; ofType: { kind: 'NON_NULL'; name: never; ofType: { kind: 'INTERFACE'; name: 'FeedItem'; ofType: null; }; }; }; } }; 'feedItem': { name: 'feedItem'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'FeedNode'; ofType: null; }; } }; 'fundraisingAssignment': { name: 'fundraisingAssignment'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'FundraisingAssignmentNode'; ofType: null; }; } }; 'fundraisingEntries': { name: 'fundraisingEntries'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'ListFundraisingEntriesResponse'; ofType: null; }; } }; 'fundraisingEntry': { name: 'fundraisingEntry'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'FundraisingEntryNode'; ofType: null; }; } }; 'image': { name: 'image'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'ImageNode'; ofType: null; }; } }; 'images': { name: 'images'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'ListImagesResponse'; ofType: null; }; } }; 'latestMarathon': { name: 'latestMarathon'; type: { kind: 'OBJECT'; name: 'MarathonNode'; ofType: null; } }; 'loginState': { name: 'loginState'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'LoginState'; ofType: null; }; } }; 'marathon': { name: 'marathon'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'MarathonNode'; ofType: null; }; } }; 'marathonForYear': { name: 'marathonForYear'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'MarathonNode'; ofType: null; }; } }; 'marathonHour': { name: 'marathonHour'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'MarathonHourNode'; ofType: null; }; } }; 'marathonHours': { name: 'marathonHours'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'ListMarathonHoursResponse'; ofType: null; }; } }; 'marathons': { name: 'marathons'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'ListMarathonsResponse'; ofType: null; }; } }; 'me': { name: 'me'; type: { kind: 'OBJECT'; name: 'PersonNode'; ofType: null; } }; 'node': { name: 'node'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'INTERFACE'; name: 'Node'; ofType: null; }; } }; 'notification': { name: 'notification'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'NotificationNode'; ofType: null; }; } }; 'notificationDeliveries': { name: 'notificationDeliveries'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'ListNotificationDeliveriesResponse'; ofType: null; }; } }; 'notifications': { name: 'notifications'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'ListNotificationsResponse'; ofType: null; }; } }; 'people': { name: 'people'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'ListPeopleResponse'; ofType: null; }; } }; 'person': { name: 'person'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'PersonNode'; ofType: null; }; } }; 'personByLinkBlue': { name: 'personByLinkBlue'; type: { kind: 'OBJECT'; name: 'PersonNode'; ofType: null; } }; 'pointEntries': { name: 'pointEntries'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'ListPointEntriesResponse'; ofType: null; }; } }; 'pointEntry': { name: 'pointEntry'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'PointEntryNode'; ofType: null; }; } }; 'pointOpportunities': { name: 'pointOpportunities'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'ListPointOpportunitiesResponse'; ofType: null; }; } }; 'pointOpportunity': { name: 'pointOpportunity'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'PointOpportunityNode'; ofType: null; }; } }; 'rawFundraisingEntries': { name: 'rawFundraisingEntries'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'String'; ofType: null; }; } }; 'rawFundraisingTotals': { name: 'rawFundraisingTotals'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'String'; ofType: null; }; } }; 'report': { name: 'report'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'Report'; ofType: null; }; } }; 'searchPeopleByName': { name: 'searchPeopleByName'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'LIST'; name: never; ofType: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'PersonNode'; ofType: null; }; }; }; } }; 'solicitationCode': { name: 'solicitationCode'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'SolicitationCodeNode'; ofType: null; }; } }; 'solicitationCodes': { name: 'solicitationCodes'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'ListSolicitationCodesResponse'; ofType: null; }; } }; 'team': { name: 'team'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'TeamNode'; ofType: null; }; } }; 'teams': { name: 'teams'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'ListTeamsResponse'; ofType: null; }; } }; }; }; 'RegisterDeviceInput': { kind: 'INPUT_OBJECT'; name: 'RegisterDeviceInput'; isOneOf: false; inputFields: [{ name: 'deviceId'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'String'; ofType: null; }; }; defaultValue: null }, { name: 'expoPushToken'; type: { kind: 'SCALAR'; name: 'String'; ofType: null; }; defaultValue: null }, { name: 'lastUserId'; type: { kind: 'SCALAR'; name: 'GlobalId'; ofType: null; }; defaultValue: null }, { name: 'verifier'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'String'; ofType: null; }; }; defaultValue: null }]; }; 'RegisterDeviceResponse': { kind: 'OBJECT'; name: 'RegisterDeviceResponse'; fields: { 'data': { name: 'data'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'DeviceNode'; ofType: null; }; } }; 'ok': { name: 'ok'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'Boolean'; ofType: null; }; } }; }; }; 'Report': { kind: 'OBJECT'; name: 'Report'; fields: { 'pages': { name: 'pages'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'LIST'; name: never; ofType: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'ReportPage'; ofType: null; }; }; }; } }; }; }; 'ReportPage': { kind: 'OBJECT'; name: 'ReportPage'; fields: { 'header': { name: 'header'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'LIST'; name: never; ofType: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'String'; ofType: null; }; }; }; } }; 'rows': { name: 'rows'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'LIST'; name: never; ofType: { kind: 'NON_NULL'; name: never; ofType: { kind: 'LIST'; name: never; ofType: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'String'; ofType: null; }; }; }; }; }; } }; 'title': { name: 'title'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'NonEmptyString'; ofType: null; }; } }; }; }; 'SetEventInput': { kind: 'INPUT_OBJECT'; name: 'SetEventInput'; isOneOf: false; inputFields: [{ name: 'description'; type: { kind: 'SCALAR'; name: 'NonEmptyString'; ofType: null; }; defaultValue: null }, { name: 'location'; type: { kind: 'SCALAR'; name: 'NonEmptyString'; ofType: null; }; defaultValue: null }, { name: 'occurrences'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'LIST'; name: never; ofType: { kind: 'NON_NULL'; name: never; ofType: { kind: 'INPUT_OBJECT'; name: 'SetEventOccurrenceInput'; ofType: null; }; }; }; }; defaultValue: null }, { name: 'summary'; type: { kind: 'SCALAR'; name: 'NonEmptyString'; ofType: null; }; defaultValue: null }, { name: 'title'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'String'; ofType: null; }; }; defaultValue: null }]; }; - 'SetEventOccurrenceInput': { kind: 'INPUT_OBJECT'; name: 'SetEventOccurrenceInput'; isOneOf: false; inputFields: [{ name: 'fullDay'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'Boolean'; ofType: null; }; }; defaultValue: null }, { name: 'interval'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'INPUT_OBJECT'; name: 'IntervalISOInput'; ofType: null; }; }; defaultValue: null }, { name: 'uuid'; type: { kind: 'SCALAR'; name: 'GlobalId'; ofType: null; }; defaultValue: null }]; }; + 'SetEventOccurrenceInput': { kind: 'INPUT_OBJECT'; name: 'SetEventOccurrenceInput'; isOneOf: false; inputFields: [{ name: 'fullDay'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'Boolean'; ofType: null; }; }; defaultValue: null }, { name: 'id'; type: { kind: 'SCALAR'; name: 'GlobalId'; ofType: null; }; defaultValue: null }, { name: 'interval'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'INPUT_OBJECT'; name: 'IntervalISOInput'; ofType: null; }; }; defaultValue: null }]; }; 'SetFeedInput': { kind: 'INPUT_OBJECT'; name: 'SetFeedInput'; isOneOf: false; inputFields: [{ name: 'textContent'; type: { kind: 'SCALAR'; name: 'NonEmptyString'; ofType: null; }; defaultValue: null }, { name: 'title'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'NonEmptyString'; ofType: null; }; }; defaultValue: null }]; }; 'SetFundraisingEntryInput': { kind: 'INPUT_OBJECT'; name: 'SetFundraisingEntryInput'; isOneOf: false; inputFields: [{ name: 'amountOverride'; type: { kind: 'SCALAR'; name: 'Float'; ofType: null; }; defaultValue: null }, { name: 'batchTypeOverride'; type: { kind: 'ENUM'; name: 'BatchType'; ofType: null; }; defaultValue: null }, { name: 'donatedByOverride'; type: { kind: 'SCALAR'; name: 'NonEmptyString'; ofType: null; }; defaultValue: null }, { name: 'donatedOnOverride'; type: { kind: 'SCALAR'; name: 'LocalDate'; ofType: null; }; defaultValue: null }, { name: 'donatedToOverride'; type: { kind: 'SCALAR'; name: 'NonEmptyString'; ofType: null; }; defaultValue: null }, { name: 'notes'; type: { kind: 'SCALAR'; name: 'NonEmptyString'; ofType: null; }; defaultValue: null }, { name: 'solicitationCodeOverrideId'; type: { kind: 'SCALAR'; name: 'GlobalId'; ofType: null; }; defaultValue: null }]; }; 'SetMarathonHourInput': { kind: 'INPUT_OBJECT'; name: 'SetMarathonHourInput'; isOneOf: false; inputFields: [{ name: 'details'; type: { kind: 'SCALAR'; name: 'NonEmptyString'; ofType: null; }; defaultValue: null }, { name: 'durationInfo'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'NonEmptyString'; ofType: null; }; }; defaultValue: null }, { name: 'shownStartingAt'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'DateTimeISO'; ofType: null; }; }; defaultValue: null }, { name: 'title'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'NonEmptyString'; ofType: null; }; }; defaultValue: null }]; }; @@ -198,7 +197,7 @@ export type introspection_types = { 'String': unknown; 'StringComparator': { name: 'StringComparator'; enumValues: 'ENDS_WITH' | 'EQUALS' | 'GREATER_THAN' | 'GREATER_THAN_OR_EQUAL_TO' | 'IS' | 'LESS_THAN' | 'LESS_THAN_OR_EQUAL_TO' | 'STARTS_WITH' | 'SUBSTRING'; }; 'TeamLegacyStatus': { name: 'TeamLegacyStatus'; enumValues: 'DemoTeam' | 'NewTeam' | 'ReturningTeam'; }; - 'TeamNode': { kind: 'OBJECT'; name: 'TeamNode'; fields: { 'captains': { name: 'captains'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'LIST'; name: never; ofType: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'MembershipNode'; ofType: null; }; }; }; } }; 'committeeIdentifier': { name: 'committeeIdentifier'; type: { kind: 'ENUM'; name: 'CommitteeIdentifier'; ofType: null; } }; 'createdAt': { name: 'createdAt'; type: { kind: 'SCALAR'; name: 'DateTimeISO'; ofType: null; } }; 'dbFundsTeam': { name: 'dbFundsTeam'; type: { kind: 'OBJECT'; name: 'DbFundsTeamInfo'; ofType: null; } }; 'fundraisingEntries': { name: 'fundraisingEntries'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'ListFundraisingEntriesResponse'; ofType: null; }; } }; 'fundraisingTotalAmount': { name: 'fundraisingTotalAmount'; type: { kind: 'SCALAR'; name: 'Float'; ofType: null; } }; 'id': { name: 'id'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'GlobalId'; ofType: null; }; } }; 'legacyStatus': { name: 'legacyStatus'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'ENUM'; name: 'TeamLegacyStatus'; ofType: null; }; } }; 'marathon': { name: 'marathon'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'MarathonNode'; ofType: null; }; } }; 'members': { name: 'members'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'LIST'; name: never; ofType: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'MembershipNode'; ofType: null; }; }; }; } }; 'name': { name: 'name'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'String'; ofType: null; }; } }; 'pointEntries': { name: 'pointEntries'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'LIST'; name: never; ofType: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'PointEntryNode'; ofType: null; }; }; }; } }; 'solicitationCode': { name: 'solicitationCode'; type: { kind: 'OBJECT'; name: 'SolicitationCodeNode'; ofType: null; } }; 'totalPoints': { name: 'totalPoints'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'Int'; ofType: null; }; } }; 'type': { name: 'type'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'ENUM'; name: 'TeamType'; ofType: null; }; } }; 'updatedAt': { name: 'updatedAt'; type: { kind: 'SCALAR'; name: 'DateTimeISO'; ofType: null; } }; }; }; + 'TeamNode': { kind: 'OBJECT'; name: 'TeamNode'; fields: { 'committeeIdentifier': { name: 'committeeIdentifier'; type: { kind: 'ENUM'; name: 'CommitteeIdentifier'; ofType: null; } }; 'createdAt': { name: 'createdAt'; type: { kind: 'SCALAR'; name: 'DateTimeISO'; ofType: null; } }; 'fundraisingEntries': { name: 'fundraisingEntries'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'ListFundraisingEntriesResponse'; ofType: null; }; } }; 'fundraisingTotalAmount': { name: 'fundraisingTotalAmount'; type: { kind: 'SCALAR'; name: 'Float'; ofType: null; } }; 'id': { name: 'id'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'GlobalId'; ofType: null; }; } }; 'legacyStatus': { name: 'legacyStatus'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'ENUM'; name: 'TeamLegacyStatus'; ofType: null; }; } }; 'marathon': { name: 'marathon'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'MarathonNode'; ofType: null; }; } }; 'members': { name: 'members'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'LIST'; name: never; ofType: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'MembershipNode'; ofType: null; }; }; }; } }; 'name': { name: 'name'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'String'; ofType: null; }; } }; 'pointEntries': { name: 'pointEntries'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'LIST'; name: never; ofType: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'PointEntryNode'; ofType: null; }; }; }; } }; 'solicitationCode': { name: 'solicitationCode'; type: { kind: 'OBJECT'; name: 'SolicitationCodeNode'; ofType: null; } }; 'totalPoints': { name: 'totalPoints'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'Int'; ofType: null; }; } }; 'type': { name: 'type'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'ENUM'; name: 'TeamType'; ofType: null; }; } }; 'updatedAt': { name: 'updatedAt'; type: { kind: 'SCALAR'; name: 'DateTimeISO'; ofType: null; } }; }; }; 'TeamResolverAllKeys': { name: 'TeamResolverAllKeys'; enumValues: 'legacyStatus' | 'marathonId' | 'name' | 'type'; }; 'TeamResolverKeyedIsNullFilterItem': { kind: 'INPUT_OBJECT'; name: 'TeamResolverKeyedIsNullFilterItem'; isOneOf: false; inputFields: [{ name: 'field'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'ENUM'; name: 'TeamResolverAllKeys'; ofType: null; }; }; defaultValue: null }, { name: 'negate'; type: { kind: 'SCALAR'; name: 'Boolean'; ofType: null; }; defaultValue: "false" }]; }; 'TeamResolverKeyedOneOfFilterItem': { kind: 'INPUT_OBJECT'; name: 'TeamResolverKeyedOneOfFilterItem'; isOneOf: false; inputFields: [{ name: 'field'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'ENUM'; name: 'TeamResolverOneOfFilterKeys'; ofType: null; }; }; defaultValue: null }, { name: 'negate'; type: { kind: 'SCALAR'; name: 'Boolean'; ofType: null; }; defaultValue: "false" }, { name: 'value'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'LIST'; name: never; ofType: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'String'; ofType: null; }; }; }; }; defaultValue: null }]; }; diff --git a/packages/portal/package.json b/packages/portal/package.json index 7977f2cd9..40449f773 100644 --- a/packages/portal/package.json +++ b/packages/portal/package.json @@ -39,35 +39,39 @@ "preview": "vite preview" }, "dependencies": { - "@ant-design/icons": "^5.5.1", + "@ant-design/icons": "^5.5.2", + "@casl/ability": "^6.7.2", + "@mdxeditor/editor": "^3.20.0", "@refinedev/antd": "^5.44.0", "@refinedev/core": "^4.56.0", "@refinedev/devtools": "^1.2.10", "@sentry/react": "^8.38.0", "@sentry/vite-plugin": "^2.22.6", - "@tanstack/react-form": "^0.36.0", - "@tanstack/react-router": "^1.81.14", + "@tanstack/react-form": "^0.39.0", + "@tanstack/react-router": "^1.87.0", "@ukdanceblue/common": "workspace:^", "@urql/core": "^5.0.8", "@urql/devtools": "^2.0.3", "@wysimark/react": "^3.0.20", - "antd": "^5.22.1", + "antd": "^5.22.3", "camelcase": "^8.0.0", "class-validator": "^0.14.1", "gql.tada": "^1.8.10", "graphql": "^16.9.0", - "graphql-scalars": "^1.23.0", + "graphql-scalars": "^1.24.0", "lodash.isequal": "^4.5.0", "luxon": "^3.5.0", "normalize.css": "^8.0.1", "pluralize": "^8.0.0", - "rc-picker": "^4.8.2", + "rc-picker": "^4.8.3", "react": "18.3.1", "react-dom": "~18.3.1", - "react-hook-form": "^7.53.2", + "react-hook-form": "^7.54.0", + "react-markdown": "^9.0.1", "react-to-print": "^3.0.2", "reflect-metadata": "^0.2.2", "thumbhash": "^0.1.1", + "ts-results-es": "^4.2.0", "type-graphql": "^2.0.0-rc.2", "typedi": "^0.10.0", "urql": "^4.2.1", @@ -80,18 +84,18 @@ }, "devDependencies": { "@refinedev/cli": "^2.16.40", - "@tanstack/router-devtools": "^1.81.14", - "@tanstack/router-plugin": "^1.81.9", + "@tanstack/router-devtools": "^1.87.0", + "@tanstack/router-plugin": "^1.86.0", "@types/lodash.isequal": "^4.5.8", "@types/luxon": "^3.4.2", "@types/pluralize": "^0.0.33", - "@types/react": "~18.3.12", - "@types/react-dom": "~18.3.1", - "@vitejs/plugin-react-swc": "^3.7.1", - "cypress": "^13.15.2", + "@types/react": "~18.3.14", + "@types/react-dom": "~18.3.2", + "@vitejs/plugin-react-swc": "^3.7.2", + "cypress": "^13.16.1", "cypress-vite": "^1.5.0", - "typescript": "^5.6.3", - "vite": "^5.4.11" + "typescript": "^5.7.2", + "vite": "^6.0.3" }, "packageManager": "yarn@4.5.3+sha512.3003a14012e2987072d244c720506549c1aab73ee728208f1b2580a9fd67b92d61ba6b08fe93f6dce68fd771e3af1e59a0afa28dd242dd0940d73b95fedd4e90", "refine": { diff --git a/packages/portal/src/config/ant.tsx b/packages/portal/src/config/ant.tsx index ee86786e5..2e42b1f11 100644 --- a/packages/portal/src/config/ant.tsx +++ b/packages/portal/src/config/ant.tsx @@ -1,18 +1,18 @@ import type { ThemeConfig } from "antd"; import { ConfigProvider, theme } from "antd"; -import { useContext, useState } from "react"; +import { useState } from "react"; import { themeConfigContext } from "./antThemeConfig.js"; function makeAntDesignTheme({ dark }: { dark: boolean }): ThemeConfig { return { token: { - colorPrimary: "#0032A0", - colorBgBase: dark ? "#000810" : "#eef", - borderRadiusXS: 2, - borderRadiusSM: 4, - borderRadius: 8, - borderRadiusLG: 12, + colorPrimary: "#0032a0", + colorSuccess: "#ffc72c", + colorWarning: "#fa8c16", + colorBgBase: dark ? "#000810" : "#f4fcff", + colorTextBase: dark ? "#f4fcff" : "#000810", + borderRadius: 4, }, algorithm: dark ? theme.darkAlgorithm : theme.defaultAlgorithm, }; @@ -38,13 +38,3 @@ export function ThemeConfigProvider({ ); } - -export function AntConfigProvider({ children }: { children: React.ReactNode }) { - const { dark } = useContext(themeConfigContext); - - return ( - - {children} - - ); -} diff --git a/packages/portal/src/config/marathon.tsx b/packages/portal/src/config/marathon.tsx index ba2a5c71f..92ca83d1d 100644 --- a/packages/portal/src/config/marathon.tsx +++ b/packages/portal/src/config/marathon.tsx @@ -1,4 +1,4 @@ -import { AccessLevel, dateTimeFromSomething } from "@ukdanceblue/common"; +import { dateTimeFromSomething } from "@ukdanceblue/common"; import { useEffect, useMemo, useState } from "react"; import { useQuery } from "urql"; @@ -33,7 +33,7 @@ const allMarathonsDocument = graphql(/* GraphQL */ ` const selectedMarathonDocument = graphql(/* GraphQL */ ` query SelectedMarathon($marathonId: GlobalId!) { - marathon(uuid: $marathonId) { + marathon(id: $marathonId) { id year startDate @@ -49,7 +49,10 @@ export const MarathonConfigProvider = ({ children: React.ReactNode; valueOverride?: Pick; }) => { - const canSeeMarathonList = useAuthorizationRequirement(AccessLevel.Committee); + const canSeeMarathonList = useAuthorizationRequirement( + "list", + "MarathonNode" + ); const [marathonId, setMarathonId] = useState(null); diff --git a/packages/portal/src/config/refine/authentication.ts b/packages/portal/src/config/refine/authentication.ts index 005142b05..2718322a9 100644 --- a/packages/portal/src/config/refine/authentication.ts +++ b/packages/portal/src/config/refine/authentication.ts @@ -4,6 +4,7 @@ import type { CheckResponse, OnErrorResponse, } from "@refinedev/core"; +import { notification } from "antd"; import { API_BASE_URL, urqlClient } from "#config/api.ts"; import { getLoginState, refreshLoginState } from "#hooks/useLoginState.ts"; @@ -48,9 +49,9 @@ export const authProvider: AuthProvider = { login: async (): Promise => { try { const result = await openAuthPopup("login"); - await refreshLoginState(urqlClient); + const loginState = await refreshLoginState(urqlClient); return { - success: result === "success", + success: result === "success" && loginState.isOk(), }; } catch (error) { console.error(error); @@ -61,16 +62,20 @@ export const authProvider: AuthProvider = { } }, check: (): Promise => { - const { loggedIn } = getLoginState(urqlClient); + const loginState = getLoginState(urqlClient) + .map((loginState) => ({ authenticated: loginState.loggedIn ?? false })) + .mapErr((error) => ({ error, authenticated: false })); - return Promise.resolve({ authenticated: loggedIn ?? false }); + return Promise.resolve( + loginState.isOk() ? loginState.value : loginState.error + ); }, logout: async (): Promise => { try { const result = await openAuthPopup("logout"); - await refreshLoginState(urqlClient); + const loginState = await refreshLoginState(urqlClient); return { - success: result === "success", + success: result === "success" && loginState.isOk(), }; } catch (error) { console.error(error); @@ -82,6 +87,9 @@ export const authProvider: AuthProvider = { } }, onError: (error): Promise => { + notification.error({ + message: String(error), + }); return Promise.resolve({ error }); }, // optional methods diff --git a/packages/portal/src/config/refine/authorization.ts b/packages/portal/src/config/refine/authorization.ts index 2f0f9330d..7fd856e39 100644 --- a/packages/portal/src/config/refine/authorization.ts +++ b/packages/portal/src/config/refine/authorization.ts @@ -1,16 +1,56 @@ -// import type { AccessControlProvider } from "@refinedev/core"; +import type { AccessControlProvider } from "@refinedev/core"; +import type { Action } from "@ukdanceblue/common"; -// const accessControlProvider: AccessControlProvider = { -// can: async ({ resource, action, params }) => { -// return { can: true }; -// }, -// options: { -// buttons: { -// enableAccessControl: true, -// hideIfUnauthorized: false, -// }, -// queryOptions: { -// // ... default global query options -// }, -// }, -// }; +import { urqlClient } from "#config/api.ts"; +import { getLoginState } from "#hooks/useLoginState.ts"; + +export const accessControlProvider: AccessControlProvider = { + can: ({ action, params }) => { + const loginState = getLoginState(urqlClient); + + if (loginState.isErr()) { + return Promise.resolve({ can: false }); + } + + const ok = loginState.value.ability.can( + action === "clone" + ? "create" + : action === "edit" + ? "update" + : action === "show" + ? "get" + : (action as Action), + params?.resource?.meta?.modelName + ? { + id: params.id ? String(params.id) : undefined, + kind: params.resource.meta.modelName as "FundraisingAssignmentNode", + } + : "all" + ); + + console.log("Checking access control", { + authorized: ok, + action, + subject: params?.resource?.meta?.modelName + ? { + id: params.id ? String(params.id) : undefined, + kind: params.resource.meta.modelName as "FundraisingAssignmentNode", + } + : "all", + params, + }); + + return Promise.resolve({ + can: ok, + }); + }, + options: { + buttons: { + enableAccessControl: true, + hideIfUnauthorized: false, + }, + queryOptions: { + // ... default global query options + }, + }, +}; diff --git a/packages/portal/src/config/refine/data.ts b/packages/portal/src/config/refine/data.ts index 16268175d..ec51840a2 100644 --- a/packages/portal/src/config/refine/data.ts +++ b/packages/portal/src/config/refine/data.ts @@ -1,5 +1,6 @@ +/* eslint-disable @typescript-eslint/only-throw-error */ /* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access */ -import type { DataProvider } from "@refinedev/core"; +import type { DataProvider, HttpError } from "@refinedev/core"; import type { Pagination } from "@refinedev/core"; import type { BooleanFilterItemInterface, @@ -12,6 +13,7 @@ import type { StringFilterItemInterface, } from "@ukdanceblue/common"; import { getCrudOperationNames } from "@ukdanceblue/common"; +import type { CombinedError } from "@urql/core"; import { gql } from "@urql/core"; import camelcase from "camelcase"; import { @@ -45,6 +47,31 @@ export interface FilterObject { booleanFilters: BooleanFilterItemInterface[]; } +function combinedToHttpError(error: CombinedError): HttpError { + if (error.networkError) { + return { + statusCode: 0, + message: error.networkError.message, + cause: error.networkError, + }; + } else { + const { code } = + error.graphQLErrors.find((e) => e.extensions.code)?.extensions ?? {}; + return { + statusCode: + typeof code === "number" + ? code + : code === "BAD_USER_INPUT" + ? 400 + : code === "UNAUTHORIZED" + ? 401 + : 500, + message: error.message, + cause: error, + }; + } +} + type ListQueryOptions = PaginationOptions & SortingOptions & FilterObject; // We alias gql to gqlButDifferentName to avoid the GraphQL plugin giving us an error about the invalid syntax @@ -69,7 +96,7 @@ export const dataProvider: Required = { .toPromise(); if (response.error) { - throw response.error; + throw combinedToHttpError(response.error); } const key = getOperationName(resource, "createOne"); @@ -90,21 +117,36 @@ export const dataProvider: Required = { getOne: async (params) => { const { meta, id, resource } = params; - const gqlOperation = meta?.gqlQuery ?? meta?.gqlMutation; - - if (!gqlOperation) { - throw new Error("Operation is required."); - } - const query = isMutation(gqlOperation) - ? gqlButDifferentName` - query Get${camelcase(singular(resource), { pascalCase: true })}($id: GlobalID!) { + let query; + if (meta?.gqlQuery) { + query = meta.gqlQuery; + } else if (meta?.gqlFragment) { + const fragmentDefinition = (meta.gqlFragment as Partial) + .definitions?.[0]; + if (fragmentDefinition?.kind === Kind.FRAGMENT_DEFINITION) { + query = gqlButDifferentName` + query Get${camelcase(singular(resource), { pascalCase: true })}($id: GlobalId!) { + ${getOperationName(resource, "getOne")}(id: $id) { + ...${fragmentDefinition.name.value} + } + } + ${meta.gqlFragment as DocumentNode} + `; + } + } else if (meta?.gqlMutation) { + query = gqlButDifferentName` + query Get${camelcase(singular(resource), { pascalCase: true })}($id: GlobalId!) { ${getOperationName(resource, "getOne")}(id: $id) { - ${getOperationFields(gqlOperation)} + ${getOperationFields(meta.gqlMutation)} } } - ` - : gqlOperation; + `; + } + + if (!query) { + throw new Error("Operation is required."); + } const response = await urqlClient .query(query, { @@ -114,7 +156,7 @@ export const dataProvider: Required = { .toPromise(); if (response.error) { - throw response.error; + throw combinedToHttpError(response.error); } const key = getOperationName(resource, "getOne"); @@ -160,7 +202,7 @@ export const dataProvider: Required = { .toPromise(); if (response.error) { - throw response.error; + throw combinedToHttpError(response.error); } const val = response.data[getOperationName(resource, "getList")]; @@ -212,7 +254,7 @@ export const dataProvider: Required = { const data = response.data?.[key]; if (response.error) { - throw response.error; + throw combinedToHttpError(response.error); } return { data }; @@ -227,20 +269,48 @@ export const dataProvider: Required = { }, deleteOne: async (params) => { - const { meta, id, resource } = params; + const { meta, id, variables, resource } = params; + + let query; + if (meta?.gqlMutation) { + query = meta.gqlMutation; + } else if (meta?.gqlFragment) { + const fragmentDefinition = (meta.gqlFragment as Partial) + .definitions?.[0]; + if (fragmentDefinition?.kind === Kind.FRAGMENT_DEFINITION) { + query = gqlButDifferentName` + mutation Delete${camelcase(singular(resource), { pascalCase: true })}($id: GlobalId!) { + ${getOperationName(resource, "deleteOne")}(id: $id) { + ...${fragmentDefinition.name.value} + } + } + ${meta.gqlFragment as DocumentNode} + `; + } + } else { + query = gqlButDifferentName` + mutation Delete${camelcase(singular(resource), { pascalCase: true })}($id: GlobalId!) { + ${getOperationName(resource, "deleteOne")}(id: $id) { + id + } + } + `; + } - if (!meta?.gqlMutation) { + if (!query) { throw new Error("Operation is required."); } const response = await urqlClient - .mutation(meta.gqlMutation, { - input: { id, ...meta.gqlVariables }, + .mutation(query, { + id, + ...variables, + ...meta?.gqlVariables, }) .toPromise(); if (response.error) { - throw response.error; + throw combinedToHttpError(response.error); } const key = getOperationName(resource, "deleteOne"); @@ -277,7 +347,7 @@ export const dataProvider: Required = { } if (response.error) { - throw response.error; + throw combinedToHttpError(response.error); } const { data } = response; diff --git a/packages/portal/src/config/refine/resources.tsx b/packages/portal/src/config/refine/resources.tsx index cec943e87..e40a46871 100644 --- a/packages/portal/src/config/refine/resources.tsx +++ b/packages/portal/src/config/refine/resources.tsx @@ -10,201 +10,229 @@ import { TeamOutlined, UserOutlined, } from "@ant-design/icons"; -import type { ResourceProps } from "@refinedev/core"; -import type { Register } from "@tanstack/react-router"; -import type { Authorization, AuthorizationRule } from "@ukdanceblue/common"; -import { checkAuthorization, defaultAuthorization } from "@ukdanceblue/common"; -import type { FileRoutesByFullPath } from "routeTree.gen"; +import type { Action, ResourceProps } from "@refinedev/core"; -import { useLoginState } from "#hooks/useLoginState.ts"; - -function shouldShowMenuItem( - authorizationRules: AuthorizationRule[] | null, - authorization: Authorization | undefined -): boolean { - if (!authorizationRules) { - return true; - } else { - let isAuthorized = false; - for (const authorizationRule of authorizationRules) { - if ( - checkAuthorization( - authorizationRule, - authorization ?? defaultAuthorization - ) - ) { - isAuthorized = true; - break; - } - } - return isAuthorized; - } -} - -export function useRefineResources({ router }: { router: Register["router"] }) { - const { authorization } = useLoginState(); - - const refineResources: ResourceProps[] = [ - { - name: "event", - meta: { - icon: , - label: "Events", - }, - create: "/events/create", - edit: "/events/:id/edit", - show: "/events/:id", - list: "/events", +export const refineResources: ResourceProps[] = [ + { + name: "event", + meta: { + icon: , + label: "Events", + modelName: "EventNode", + canDelete: true, }, - { - name: "team", - meta: { - icon: , - label: "Teams", - }, - create: "/teams/create", - edit: "/teams/:id/edit", - show: "/teams/:id", - list: "/teams", + create: "/events/create", + edit: "/events/:id/edit", + show: "/events/:id", + list: "/events", + }, + { + name: "team", + meta: { + icon: , + label: "Teams", + modelName: "TeamNode", + canDelete: true, }, - { - name: "fundraising", - meta: { - icon: , - label: "Fundraising", - }, - create: "/fundraising/create", - edit: "/fundraising/:id/edit", - show: "/fundraising/:id", - list: "/fundraising", + create: "/teams/create", + edit: "/teams/:id/edit", + show: "/teams/:id", + list: "/teams", + }, + { + name: "fundraising-group", + meta: { + icon: , + label: "Fundraising", + modelName: "FundraisingEntryNode", }, - { - name: "solicitationCode", - identifier: "solicitation-code", - meta: { - label: "Solicitation Codes", - parent: "fundraising", - }, - create: "/fundraising/solicitation-code/create", - edit: "/fundraising/solicitation-code/:id/edit", - show: "/fundraising/solicitation-code/:id", - list: "/fundraising/solicitation-code", + }, + { + name: "fundraising", + meta: { + label: "Fundraising", + modelName: "FundraisingEntryNode", + parent: "fundraising-group", }, - { - name: "ddn", - meta: { - label: "Uploaded DDNs", - parent: "fundraising", - }, - create: "/fundraising/ddn/create", - edit: "/fundraising/ddn/:id/edit", - show: "/fundraising/ddn/:id", - list: "/fundraising/ddn", + create: "/fundraising/create", + edit: "/fundraising/:id/edit", + show: "/fundraising/:id", + list: "/fundraising", + }, + { + name: "solicitationCode", + identifier: "solicitation-code", + meta: { + label: "Solicitation Codes", + parent: "fundraising-group", + modelName: "SolicitationCodeNode", }, - { - name: "dbfunds", - meta: { - label: "DB Funds (Legacy)", - parent: "fundraising", - }, - create: "/fundraising/dbfunds/create", - edit: "/fundraising/dbfunds/:id/edit", - show: "/fundraising/dbfunds/:id", - list: "/fundraising/dbfunds", + create: "/fundraising/solicitation-code/create", + edit: "/fundraising/solicitation-code/:id/edit", + show: "/fundraising/solicitation-code/:id", + list: "/fundraising/solicitation-code", + }, + { + name: "ddn", + meta: { + label: "Uploaded DDNs", + parent: "fundraising-group", + modelName: "DailyDepartmentNotificationNode", }, - { - name: "person", - meta: { - icon: , - label: "People", - }, - create: "/people/create", - edit: "/people/:id/edit", - show: "/people/:id", - list: "/people", + create: "/fundraising/ddn/create", + edit: "/fundraising/ddn/:id/edit", + show: "/fundraising/ddn/:id", + list: "/fundraising/ddn", + }, + { + name: "dbfunds", + meta: { + label: "DB Funds (Legacy)", + parent: "fundraising-group", + modelName: "FundraisingEntryNode", }, - { - name: "notification", - meta: { - icon: , - label: "Notifications", - }, - create: "/notifications/create", - edit: "/notifications/:id/edit", - show: "/notifications/:id", - list: "/notifications", + create: "/fundraising/dbfunds/create", + edit: "/fundraising/dbfunds/:id/edit", + show: "/fundraising/dbfunds/:id", + list: "/fundraising/dbfunds", + }, + { + name: "person", + meta: { + icon: , + label: "People", + modelName: "PersonNode", + canDelete: true, }, - { - name: "marathon", - meta: { - icon: , - label: "Marathon", - }, - create: "/marathon/create", - edit: "/marathon/:id/edit", - show: "/marathon/:id", - list: "/marathon", + create: "/people/create", + edit: "/people/:id/edit", + show: "/people/:id", + list: "/people", + }, + { + name: "notification", + meta: { + icon: , + label: "Notifications", + modelName: "NotificationNode", + canDelete: true, }, - { - name: "feed", - meta: { - icon: , - label: "Feed", - }, - create: "/feed/create", - edit: "/feed/:id/edit", - show: "/feed/:id", - list: "/feed", + create: "/notifications/create", + edit: "/notifications/:id/edit", + show: "/notifications/:id", + list: "/notifications", + }, + { + name: "marathon", + meta: { + icon: , + label: "Marathon", + modelName: "MarathonNode", + canDelete: true, }, - { - name: "image", - meta: { - icon: , - label: "Images", - }, - create: "/images/create", - edit: "/images/:id/edit", - show: "/images/:id", - list: "/images", + create: "/marathon/create", + edit: "/marathon/:id/edit", + show: "/marathon/:id", + list: "/marathon", + }, + { + name: "feed", + meta: { + icon: , + label: "Feed", + modelName: "FeedNode", + canDelete: true, }, - { - name: "config", - meta: { - icon: , - label: "Config", - }, - create: "/config/create", - edit: "/config/:id/edit", - show: "/config/:id", - list: "/config", + create: "/feed/create", + edit: "/feed/:id/edit", + show: "/feed/:id", + list: "/feed", + }, + { + name: "image", + meta: { + icon: , + label: "Images", + modelName: "ImageNode", + canDelete: true, }, - { - name: "log", - meta: { - icon: , - label: "Logs", - }, - create: "/admin/logs/create", - edit: "/admin/logs/:id/edit", - show: "/admin/logs/:id", - list: "/admin/logs", + create: "/images/create", + edit: "/images/:id/edit", + show: "/images/:id", + list: "/images", + }, + { + name: "config", + meta: { + icon: , + label: "Config", + modelName: "ConfigNode", }, - ]; - - for (const resource of refineResources) { - const route = router.routesByPath[ - String(resource.list) as keyof FileRoutesByFullPath - ] as (typeof router)["routesByPath"]["/"] | undefined; - if (!route) { - continue; - } + create: "/config/create", + edit: "/config/:id/edit", + show: "/config/:id", + list: "/config", + }, + { + name: "log", + meta: { + icon: , + label: "Logs", + modelName: "LogNode", + }, + create: "/admin/logs/create", + edit: "/admin/logs/:id/edit", + show: "/admin/logs/:id", + list: "/admin/logs", + }, +] as const; - const { authorizationRules } = route.options.staticData; - resource.meta = { - ...resource.meta, - hide: !shouldShowMenuItem(authorizationRules, authorization), - }; - } +// An array of objects containing pre-split paths for refine resources as well as the index of the resource in refineResources +const resourceUrlIndex = refineResources.map((resource, index) => { + const listUrl = resource.list?.toString().split("/").filter(Boolean); + const createUrl = resource.create?.toString().split("/").filter(Boolean); + const editUrl = resource.edit?.toString().split("/").filter(Boolean); + const showUrl = resource.show?.toString().split("/").filter(Boolean); + return { + listUrl, + createUrl, + editUrl, + showUrl, + index, + }; +}); - return refineResources; +function check(url: string[] | undefined, urlParts: string[]) { + return url?.every( + (part, i) => + part === urlParts[i] || + (part.startsWith(":") && urlParts[i]?.startsWith("$")) + ); } + +/** + * Accepts a url in the form /path/$param/path and returns the resource and action + */ +export const findResourceAction = ( + pathname: string +): { resource?: ResourceProps; action?: Action } => { + const urlParts = pathname.split("/").filter(Boolean); + for (const { + listUrl, + createUrl, + editUrl, + showUrl, + index, + } of resourceUrlIndex) { + if (check(editUrl, urlParts)) { + return { resource: refineResources[index], action: "edit" }; + } else if (check(showUrl, urlParts)) { + return { resource: refineResources[index], action: "show" }; + } else if (check(createUrl, urlParts)) { + return { resource: refineResources[index], action: "create" }; + } else if (check(listUrl, urlParts)) { + return { resource: refineResources[index], action: "list" }; + } + } + return {}; +}; diff --git a/packages/portal/src/config/refine/router.tsx b/packages/portal/src/config/refine/router.tsx new file mode 100644 index 000000000..d34275b13 --- /dev/null +++ b/packages/portal/src/config/refine/router.tsx @@ -0,0 +1,156 @@ +/* eslint-disable react-hooks/rules-of-hooks */ +import type { GoFunction } from "@refinedev/core"; +import { + type GoConfig, + type ParseResponse, + type RouterBindings, +} from "@refinedev/core"; +import { + Link, + useLocation, + useNavigate, + useParams, + useRouter, +} from "@tanstack/react-router"; +import qs from "qs"; +import React, { type ComponentProps } from "react"; +import { useCallback } from "react"; + +import { findResourceAction } from "./resources"; + +const stringifyConfig = { + addQueryPrefix: true, + skipNulls: true, + arrayFormat: "indices" as const, + encode: false, + encodeValuesOnly: true, +}; + +export const routerBindings: RouterBindings = { + go: (): GoFunction => { + const { search: existingSearch, hash: existingHash } = useLocation(); + const navigate = useNavigate(); + + const fn = useCallback( + ({ + to, + type, + query, + hash, + options: { keepQuery, keepHash } = {}, + }: GoConfig) => { + /** Construct query params */ + const urlQuery = { + ...(keepQuery && + (existingSearch as string | undefined) && + qs.parse(existingSearch, { ignoreQueryPrefix: true })), + ...query, + }; + + if (urlQuery.to) { + urlQuery.to = encodeURIComponent( + String(urlQuery.to as string | number) + ); + } + + const hasUrlQuery = Object.keys(urlQuery).length > 0; + + /** Get hash */ + const urlHash = `#${(hash || (keepHash && existingHash) || "").replace( + /^#/, + "" + )}`; + + const hasUrlHash = urlHash.length > 1; + + const urlTo = to || ""; + + const fullPath = `${urlTo}${ + hasUrlQuery ? qs.stringify(urlQuery, stringifyConfig) : "" + }${hasUrlHash ? urlHash : ""}`; + + if (type === "path") { + return fullPath; + } + + /** Navigate to the url */ + navigate({ + to: fullPath, + replace: type === "replace", + }).catch((error) => { + console.error(error); + }); + + return fullPath; + }, + [existingHash, existingSearch, navigate] + ); + + return fn; + }, + back: () => { + const { + history: { back }, + } = useRouter(); + + return back; + }, + parse: () => { + const params: Record = useParams({ strict: false }); + const { pathname, search } = useLocation(); + + const { resource, action } = React.useMemo(() => { + return findResourceAction(pathname); + }, [pathname]); + + const fn = useCallback(() => { + const parsedSearch = qs.parse(search, { ignoreQueryPrefix: true }); + + const combinedParams = { + ...params, + ...parsedSearch, + }; + + const response: ParseResponse = { + ...(resource && { resource }), + ...(action && { action }), + ...(params.id && { id: decodeURIComponent(params.id) }), + // ...(params?.action && { action: params.action }), // lets see if there is a need for this + pathname, + params: { + ...combinedParams, + current: convertToNumberIfPossible( + combinedParams.current as string + ) as number | undefined, + pageSize: convertToNumberIfPossible( + combinedParams.pageSize as string + ) as number | undefined, + to: combinedParams.to + ? decodeURIComponent(combinedParams.to as string) + : undefined, + }, + }; + + return response; + }, [pathname, search, params, resource, action]); + + return fn; + }, + Link: React.forwardRef< + HTMLAnchorElement, + ComponentProps> + >((props, ref) => { + return ; + }), +}; + +const convertToNumberIfPossible = (value: string | undefined) => { + if (value === undefined) { + return value; + } + const num = Number(value); + if (`${num}` === value) { + return num; + } + return value; +}; diff --git a/packages/portal/src/documents/notification.ts b/packages/portal/src/documents/notification.ts index f12170c76..1366dca7f 100644 --- a/packages/portal/src/documents/notification.ts +++ b/packages/portal/src/documents/notification.ts @@ -19,30 +19,30 @@ export const createNotificationDocument = graphql(/* GraphQL */ ` `); export const cancelNotificationScheduleDocument = graphql(/* GraphQL */ ` - mutation CancelNotificationSchedule($uuid: GlobalId!) { - abortScheduledNotification(uuid: $uuid) { + mutation CancelNotificationSchedule($id: GlobalId!) { + abortScheduledNotification(id: $id) { id } } `); export const deleteNotificationDocument = graphql(/* GraphQL */ ` - mutation DeleteNotification($uuid: GlobalId!, $force: Boolean) { - deleteNotification(uuid: $uuid, force: $force) { + mutation DeleteNotification($id: GlobalId!, $force: Boolean) { + deleteNotification(id: $id, force: $force) { id } } `); export const sendNotificationDocument = graphql(/* GraphQL */ ` - mutation SendNotification($uuid: GlobalId!) { - sendNotification(uuid: $uuid) + mutation SendNotification($id: GlobalId!) { + sendNotification(id: $id) } `); export const scheduleNotificationDocument = graphql(/* GraphQL */ ` - mutation ScheduleNotification($uuid: GlobalId!, $sendAt: DateTimeISO!) { - scheduleNotification(uuid: $uuid, sendAt: $sendAt) { + mutation ScheduleNotification($id: GlobalId!, $sendAt: DateTimeISO!) { + scheduleNotification(id: $id, sendAt: $sendAt) { id } } diff --git a/packages/portal/src/documents/person.ts b/packages/portal/src/documents/person.ts index 61b7dbc75..3c37423c1 100644 --- a/packages/portal/src/documents/person.ts +++ b/packages/portal/src/documents/person.ts @@ -30,8 +30,8 @@ export const PersonEditorFragment = graphql(/* GraphQL */ ` `); export const personEditorDocument = graphql(/* GraphQL */ ` - mutation PersonEditor($uuid: GlobalId!, $input: SetPersonInput!) { - setPerson(uuid: $uuid, input: $input) { + mutation PersonEditor($id: GlobalId!, $input: SetPersonInput!) { + setPerson(id: $id, input: $input) { id } } diff --git a/packages/portal/src/documents/pointEntry.ts b/packages/portal/src/documents/pointEntry.ts index 437ed0fcf..205778dcf 100644 --- a/packages/portal/src/documents/pointEntry.ts +++ b/packages/portal/src/documents/pointEntry.ts @@ -35,8 +35,8 @@ export const createPointEntryAndAssignDocument = graphql(/* GraphQL */ ` `); export const getPersonByUuidDocument = graphql(/* GraphQL */ ` - query GetPersonByUuid($uuid: GlobalId!) { - person(uuid: $uuid) { + query GetPersonByUuid($id: GlobalId!) { + person(id: $id) { id name linkblue diff --git a/packages/portal/src/documents/team.ts b/packages/portal/src/documents/team.ts index c79a42dd8..4c609c8e2 100644 --- a/packages/portal/src/documents/team.ts +++ b/packages/portal/src/documents/team.ts @@ -7,7 +7,7 @@ import { PointEntryCreatorFragment } from "./pointEntry"; export const teamPageDocument = graphql( /* GraphQL */ ` query ViewTeamPage($teamUuid: GlobalId!) { - team(uuid: $teamUuid) { + team(id: $teamUuid) { ...PointEntryCreatorFragment ...TeamViewerFragment pointEntries { @@ -41,8 +41,8 @@ export const TeamEditorFragment = graphql(/* GraphQL */ ` `); export const teamEditorDocument = graphql(/* GraphQL */ ` - mutation TeamEditor($uuid: GlobalId!, $input: SetTeamInput!) { - setTeam(uuid: $uuid, input: $input) { + mutation TeamEditor($id: GlobalId!, $input: SetTeamInput!) { + setTeam(id: $id, input: $input) { id } } diff --git a/packages/portal/src/elements/components/event/EventDeletePopup.tsx b/packages/portal/src/elements/components/event/EventDeletePopup.tsx deleted file mode 100644 index 1a200c997..000000000 --- a/packages/portal/src/elements/components/event/EventDeletePopup.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import { Modal } from "antd"; -import useNotification from "antd/es/notification/useNotification.js"; -import { useEffect, useState } from "react"; -import { useMutation } from "urql"; - -import { graphql } from "#graphql/index.js"; - -const deleteEventDocument = graphql(/* GraphQL */ ` - mutation DeleteEvent($uuid: GlobalId!) { - deleteEvent(uuid: $uuid) { - id - } - } -`); - -export const useEventDeletePopup = ({ - uuid, - onDelete, -}: { - uuid: string; - onDelete?: () => void; -}) => { - const [{ info: showInfoMessage, error: showErrorMessage }, contextHolder] = - useNotification(); - const [{ fetching, data }, deleteEvent] = useMutation(deleteEventDocument); - const [open, setOpen] = useState(false); - - const showDelete = () => { - setOpen(true); - }; - - const handleCancel = () => { - setOpen(false); - }; - - useEffect(() => { - if (data?.deleteEvent.id) { - setOpen(false); - } - }, [data?.deleteEvent.id]); - - const EventDeletePopup = ( - <> - {contextHolder} - - deleteEvent({ uuid }).then((value) => { - if (value.data?.deleteEvent.id) { - showInfoMessage({ - message: "Event successfully deleted", - }); - onDelete?.(); - } - if (value.error) { - showErrorMessage({ - message: "Error deleting event", - description: value.error.message, - }); - } - }) - } - confirmLoading={fetching} - onCancel={handleCancel} - cancelButtonProps={{ disabled: fetching }} - > -

Are you sure you want to delete this event?

-
- - ); - - return { - EventDeletePopup, - showModal: showDelete, - }; -}; diff --git a/packages/portal/src/elements/components/event/EventOccurrencePicker.tsx b/packages/portal/src/elements/components/event/EventOccurrencePicker.tsx index f5ffb84cb..c52de6c59 100644 --- a/packages/portal/src/elements/components/event/EventOccurrencePicker.tsx +++ b/packages/portal/src/elements/components/event/EventOccurrencePicker.tsx @@ -1,114 +1,31 @@ import { DeleteOutlined } from "@ant-design/icons"; import { Button, Checkbox, Flex } from "antd"; -import type { DateTime } from "luxon"; +import { DateTime } from "luxon"; import { Interval } from "luxon"; -import { useEffect, useMemo, useRef, useState } from "react"; import { LuxonDatePicker } from "#elements/components/antLuxonComponents.js"; -export function EventOccurrencePicker< - XEventOccurrenceInput extends - | { uuid: string; interval: Interval; fullDay: boolean } - | { interval: Interval; fullDay: boolean }, ->({ - defaultOccurrence, - onChange, +export function EventOccurrencePicker({ + value = { + interval: Interval.invalid("No input"), + fullDay: false, + }, + onChange = () => undefined, onDelete, + id, }: { - defaultOccurrence: XEventOccurrenceInput; - onChange: (occurrence: XEventOccurrenceInput) => void; + value?: { id?: string; interval: Interval; fullDay: boolean }; + onChange?: (occurrence: { + id?: string; + interval: Interval; + fullDay: boolean; + }) => void; onDelete?: () => void; + id?: string; }) { - const [start, setStart] = useState( - defaultOccurrence.interval.start - ); - const [end, setEnd] = useState( - defaultOccurrence.interval.end - ); - const [fullDay, setFullDay] = useState(defaultOccurrence.fullDay); - - const oldDefaultOccurrence = useRef(defaultOccurrence); - const oldStart = useRef(start); - const oldEnd = useRef(end); - const oldFullDay = useRef(fullDay); - - useEffect(() => { - if (oldDefaultOccurrence.current !== defaultOccurrence) { - oldDefaultOccurrence.current = defaultOccurrence; - } - if (oldStart.current !== start) { - oldStart.current = start; - } - if (oldEnd.current !== end) { - oldEnd.current = end; - } - if (oldFullDay.current !== fullDay) { - oldFullDay.current = fullDay; - } - }, [defaultOccurrence, start, end, fullDay]); - - const uuid = useMemo(() => { - if ("uuid" in defaultOccurrence) { - return defaultOccurrence.uuid; - } - return undefined; - }, [defaultOccurrence]); - - useEffect(() => { - if ( - start && - end && - (!Interval.fromDateTimes(start, end).equals(defaultOccurrence.interval) || - fullDay !== defaultOccurrence.fullDay) - ) { - if (uuid) { - onChange({ - uuid, - interval: Interval.fromDateTimes(start, end), - fullDay, - } as XEventOccurrenceInput); - } else { - onChange({ - interval: Interval.fromDateTimes(start, end), - fullDay, - } as XEventOccurrenceInput); - } - } - }, [ - start, - end, - fullDay, - onChange, - uuid, - defaultOccurrence.fullDay, - defaultOccurrence.interval, - ]); - - useEffect(() => { - if (fullDay) { - if (start) { - setStart(start.startOf("day")); - } - if (end) { - setEnd(end.endOf("day")); - } - } - }, [fullDay, start, end]); - - const fullDayCheckbox = ( - { - setFullDay(e.target.checked); - }} - > - Full day - - ); - return ( - - {fullDay ? ( + + {/* {fullDay ? ( { @@ -126,8 +43,37 @@ export function EventOccurrencePicker< showTime format="YYYY-MM-DD HH:mm" /> - )} - {fullDayCheckbox} + )} */} + { + onChange({ + ...value, + interval: !dates + ? Interval.invalid("No input") + : Interval.fromDateTimes( + dates[0] ?? DateTime.invalid("No input"), + dates[1] ?? DateTime.invalid("No input") + ), + }); + }} + showSecond={false} + use12Hours + showTime={!value.fullDay} + format={!value.fullDay ? "YYYY-MM-DD t" : "YYYY-MM-DD"} + style={{ minWidth: "50ch" }} + /> + { + onChange({ + ...value, + fullDay: e.target.checked, + }); + }} + > + Full day + - + + + + + + + + + + {(fields, { add, remove }) => ( +
+ {fields.map((field, i) => ( + + remove(i)} /> + + ))} + + + +
)} - - ( - 0 ? "error" : ""} - help={ - field.state.meta.errors.length > 0 - ? field.state.meta.errors[0] - : undefined +
+ + {formProps.initialValues ? ( + + formProps.form?.setFieldValue("description", text) } - > - field.handleChange(e.target.value)} - /> - + plugins={[ + headingsPlugin(), + quotePlugin(), + listsPlugin(), + linkPlugin({ + validateUrl(url) { + try { + new URL(url); + return true; + } catch { + return false; + } + }, + }), + linkDialogPlugin(), + toolbarPlugin({ + toolbarContents: () => ( + <> + + + + + + + ), + }), + ]} + /> + ) : ( + + + )} - /> - - -
+ ); } diff --git a/packages/portal/src/elements/forms/event/edit/EventEditorGQL.ts b/packages/portal/src/elements/forms/event/edit/EventEditorGQL.ts index 625536c00..a38d67de0 100644 --- a/packages/portal/src/elements/forms/event/edit/EventEditorGQL.ts +++ b/packages/portal/src/elements/forms/event/edit/EventEditorGQL.ts @@ -25,10 +25,21 @@ export const EventEditorFragment = graphql(/* GraphQL */ ` } `); -export const eventEditorDocument = graphql( +export const eventEditorQueryDocument = graphql( /* GraphQL */ ` - mutation SaveEvent($uuid: GlobalId!, $input: SetEventInput!) { - setEvent(uuid: $uuid, input: $input) { + query GetEvent($id: GlobalId!) { + event(id: $id) { + ...EventEditorFragment + } + } + `, + [EventEditorFragment] +); + +export const eventEditorMutationDocument = graphql( + /* GraphQL */ ` + mutation SetEvent($id: GlobalId!, $input: SetEventInput!) { + setEvent(id: $id, input: $input) { ...EventEditorFragment } } diff --git a/packages/portal/src/elements/forms/event/edit/useEventEditorForm.ts b/packages/portal/src/elements/forms/event/edit/useEventEditorForm.ts deleted file mode 100644 index ab9775f0d..000000000 --- a/packages/portal/src/elements/forms/event/edit/useEventEditorForm.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { useForm } from "@tanstack/react-form"; -import { intervalFromSomething } from "@ukdanceblue/common"; -import type { Interval } from "luxon"; -import type { UseQueryExecute } from "urql"; -import { useMutation } from "urql"; - -import type { FragmentOf, VariablesOf } from "#graphql/index.js"; -import { readFragment } from "#graphql/index.js"; -import { useQueryStatusWatcher } from "#hooks/useQueryStatusWatcher.js"; - -import { eventEditorDocument, EventEditorFragment } from "./EventEditorGQL.js"; - -export function useEventEditorForm( - eventFragment: FragmentOf | undefined, - refetchEvent: UseQueryExecute | undefined -) { - const eventData = readFragment(EventEditorFragment, eventFragment); - - // Form - const [{ fetching, error }, setEvent] = useMutation(eventEditorDocument); - useQueryStatusWatcher({ - error, - fetching, - loadingMessage: "Saving event...", - }); - - const Form = useForm< - Omit["input"], "occurrences"> & { - occurrences: (Omit< - VariablesOf["input"]["occurrences"][number], - "uuid" | "interval" - > & { - uuid?: string; - interval: Interval; - })[]; - } - >({ - defaultValues: { - title: eventData?.title ?? "", - // Logical OR is intentional, we we want to replace empty strings with nulls - - summary: eventData?.summary || null, - - location: eventData?.location || null, - - description: eventData?.description || null, - occurrences: - eventData?.occurrences.map((occurrence) => ({ - uuid: occurrence.id, - interval: intervalFromSomething(occurrence.interval), - fullDay: occurrence.fullDay, - })) ?? [], - }, - onSubmit: async ({ value: values }) => { - if (!eventData) { - return; - } - - await setEvent({ - uuid: eventData.id, - input: { - title: values.title, - summary: values.summary ?? eventData.summary ?? null, - location: values.location ?? eventData.location ?? null, - description: values.description ?? eventData.description ?? null, - occurrences: values.occurrences.map((occurrence) => { - let retVal: Parameters< - typeof setEvent - >[0]["input"]["occurrences"][number] = { - interval: { - start: occurrence.interval.start!.toISO(), - end: occurrence.interval.end!.toISO(), - }, - fullDay: occurrence.fullDay, - }; - if (occurrence.uuid) { - retVal = { - ...retVal, - uuid: occurrence.uuid, - }; - } - return retVal; - }), - }, - }); - - refetchEvent?.(); - }, - }); - - return { formApi: Form }; -} diff --git a/packages/portal/src/elements/forms/marathon/useMarathonEditorForm.ts b/packages/portal/src/elements/forms/marathon/useMarathonEditorForm.ts index e1a66da7a..0fe8e56e8 100644 --- a/packages/portal/src/elements/forms/marathon/useMarathonEditorForm.ts +++ b/packages/portal/src/elements/forms/marathon/useMarathonEditorForm.ts @@ -16,7 +16,7 @@ export function useMarathonCreatorForm({ marathonId }: { marathonId: string }) { $input: SetMarathonInput! $marathonId: GlobalId! ) { - setMarathon(input: $input, uuid: $marathonId) { + setMarathon(input: $input, id: $marathonId) { id } } @@ -34,7 +34,7 @@ export function useMarathonCreatorForm({ marathonId }: { marathonId: string }) { ] = useQuery({ query: graphql(/* GraphQL */ ` query GetMarathon($marathonId: GlobalId!) { - marathon(uuid: $marathonId) { + marathon(id: $marathonId) { year startDate endDate diff --git a/packages/portal/src/elements/forms/notification/manage/useNotificationManager.ts b/packages/portal/src/elements/forms/notification/manage/useNotificationManager.ts index d91805db0..116ae3dc4 100644 --- a/packages/portal/src/elements/forms/notification/manage/useNotificationManager.ts +++ b/packages/portal/src/elements/forms/notification/manage/useNotificationManager.ts @@ -50,17 +50,18 @@ export const useNotificationManagerForm = ({ return uuid ? { - sendNotification: () => sendNotification({ uuid }), + sendNotification: () => sendNotification({ id: uuid }), scheduleNotification: (sendAt: DateTime) => { const sendAtISO = sendAt.toISO(); if (!sendAtISO) { throw new Error("Invalid sendAt date"); } - return scheduleNotification({ uuid, sendAt: sendAtISO }); + return scheduleNotification({ id: uuid, sendAt: sendAtISO }); }, - cancelNotificationSchedule: () => cancelNotificationSchedule({ uuid }), + cancelNotificationSchedule: () => + cancelNotificationSchedule({ id: uuid }), deleteNotification: (force = false) => - deleteNotification({ uuid, force }), + deleteNotification({ id: uuid, force }), } : undefined; }; diff --git a/packages/portal/src/elements/forms/person/edit/PersonEditor.tsx b/packages/portal/src/elements/forms/person/edit/PersonEditor.tsx index aefb7db58..3dbc91803 100644 --- a/packages/portal/src/elements/forms/person/edit/PersonEditor.tsx +++ b/packages/portal/src/elements/forms/person/edit/PersonEditor.tsx @@ -1,5 +1,5 @@ import type { Authorization } from "@ukdanceblue/common"; -import { AccessLevel, CommitteeRole } from "@ukdanceblue/common"; +import { CommitteeRole } from "@ukdanceblue/common"; import { App, AutoComplete, @@ -41,7 +41,8 @@ export function PersonEditor({ }) { const selectedMarathon = useMarathon(); - const isAdmin = useAuthorizationRequirement(AccessLevel.Admin); + // TODO: Make auth actually check for privallege escalation + const isAdmin = useAuthorizationRequirement("manage", "all"); const { message } = App.useApp(); diff --git a/packages/portal/src/elements/forms/person/edit/usePersonEditorForm.ts b/packages/portal/src/elements/forms/person/edit/usePersonEditorForm.ts index 832485a0b..6276b0649 100644 --- a/packages/portal/src/elements/forms/person/edit/usePersonEditorForm.ts +++ b/packages/portal/src/elements/forms/person/edit/usePersonEditorForm.ts @@ -104,7 +104,7 @@ export function usePersonEditorForm( } const { data } = await setPerson({ - uuid: personData.id, + id: personData.id, input: { name: values.name || null, diff --git a/packages/portal/src/elements/forms/point-entry/create/PointEntryCreator.tsx b/packages/portal/src/elements/forms/point-entry/create/PointEntryCreator.tsx index e6f99be78..917e3f3c6 100644 --- a/packages/portal/src/elements/forms/point-entry/create/PointEntryCreator.tsx +++ b/packages/portal/src/elements/forms/point-entry/create/PointEntryCreator.tsx @@ -194,7 +194,7 @@ export function PointEntryCreator({ {(personFromUuid) => ( field.handleChange(val.target.checked)} disabled={ !personFromUuid || diff --git a/packages/portal/src/elements/forms/point-entry/create/PointEntryPersonLookup.tsx b/packages/portal/src/elements/forms/point-entry/create/PointEntryPersonLookup.tsx index 0b7adf09d..219701d73 100644 --- a/packages/portal/src/elements/forms/point-entry/create/PointEntryPersonLookup.tsx +++ b/packages/portal/src/elements/forms/point-entry/create/PointEntryPersonLookup.tsx @@ -1,5 +1,8 @@ import { ClearOutlined, PlusOutlined, SearchOutlined } from "@ant-design/icons"; +import type { FormApi } from "@tanstack/react-form"; +import { Field, useField } from "@tanstack/react-form"; import { AutoComplete, Button, Descriptions, Flex, Form, Input } from "antd"; +import type { VariablesOf } from "gql.tada"; import type { LegacyRef } from "react"; import { useEffect, useState } from "react"; import { useMutation, useQuery } from "urql"; @@ -11,13 +14,13 @@ import { } from "#hooks/useAntFeedback.js"; import { useQueryStatusWatcher } from "#hooks/useQueryStatusWatcher.js"; +import type { createPointEntryAndAssignDocument } from "../../../../documents/pointEntry.js"; import { createPersonByLinkBlue, getPersonByLinkBlueDocument, getPersonByUuidDocument, searchPersonByNameDocument, } from "../../../../documents/pointEntry.js"; -import type { usePointEntryCreatorForm } from "./usePointEntryCreatorForm.js"; const generalLinkblueRegex = new RegExp(/^[A-Za-z]{3,4}\d{3}$/); export function PointEntryPersonLookup({ @@ -27,7 +30,12 @@ export function PointEntryPersonLookup({ selectedPersonRef, clearButtonRef, }: { - formApi: ReturnType["formApi"]; + formApi: FormApi< + Omit< + VariablesOf["input"], + "teamUuid" + > & { shouldAddToTeam: boolean } + >; nameFieldRef: LegacyRef; linkblueFieldRef: Parameters[0]["ref"]; selectedPersonRef: LegacyRef; @@ -35,7 +43,8 @@ export function PointEntryPersonLookup({ }) { const { showErrorMessage } = useUnknownErrorHandler(); // Form state (shared with parent) - const { state, setValue: setPersonFromUuid } = formApi.useField({ + const { state, setValue: setPersonFromUuid } = useField({ + form: formApi, name: "personFromUuid", }); const personFromUuid = state.value; @@ -43,7 +52,7 @@ export function PointEntryPersonLookup({ const [selectedPersonQuery, updateSelectedPerson] = useQuery({ query: getPersonByUuidDocument, pause: true, - variables: { uuid: personFromUuid ?? "" }, + variables: { id: personFromUuid ?? "" }, }); useQueryStatusWatcher({ fetching: selectedPersonQuery.fetching, @@ -53,7 +62,7 @@ export function PointEntryPersonLookup({ useEffect(() => { if (personFromUuid) { - updateSelectedPerson({ uuid: personFromUuid }); + updateSelectedPerson({ id: personFromUuid }); } }, [personFromUuid, updateSelectedPerson]); @@ -175,7 +184,8 @@ export function PointEntryPersonLookup({ }); return ( - ( <> diff --git a/packages/portal/src/elements/forms/team/edit/useTeamEditorForm.ts b/packages/portal/src/elements/forms/team/edit/useTeamEditorForm.ts index d34bb8d8e..ac3d10148 100644 --- a/packages/portal/src/elements/forms/team/edit/useTeamEditorForm.ts +++ b/packages/portal/src/elements/forms/team/edit/useTeamEditorForm.ts @@ -37,7 +37,7 @@ export function useTeamEditorForm( } const { data } = await setTeam({ - uuid: teamData.id, + id: teamData.id, input: { name: values.name ?? null, legacyStatus: values.legacyStatus ?? null, diff --git a/packages/portal/src/elements/singletons/NavigationMenu.tsx b/packages/portal/src/elements/singletons/ConfigModal.tsx similarity index 89% rename from packages/portal/src/elements/singletons/NavigationMenu.tsx rename to packages/portal/src/elements/singletons/ConfigModal.tsx index bb7a593e9..f1e001963 100644 --- a/packages/portal/src/elements/singletons/NavigationMenu.tsx +++ b/packages/portal/src/elements/singletons/ConfigModal.tsx @@ -1,6 +1,3 @@ -import "./NavigationMenu.css"; - -import { AccessLevel } from "@ukdanceblue/common"; import { Button, Modal, Select } from "antd"; import { useContext } from "react"; @@ -17,13 +14,13 @@ export const ConfigModal = ({ open: boolean; onClose: () => void; }) => { - const canMasquerade = useAuthorizationRequirement(AccessLevel.SuperAdmin); + const canMasquerade = useAuthorizationRequirement("manage", "all"); const { setMarathon, marathon, loading, marathons } = useContext(marathonContext); return ( - +

Select Marathon