├── .nvmrc ├── .prettierignore ├── server ├── keys │ └── .gitkeep ├── src │ ├── declarations.d.ts │ ├── services │ │ ├── mail-providers │ │ │ ├── smtp-helpers.ts │ │ │ ├── abstract-provider.ts │ │ │ ├── sendgrid.ts │ │ │ ├── ses.ts │ │ │ └── smtp.ts │ │ ├── UnsubscribeToken.ts │ │ └── MailerService.ts │ ├── util │ │ ├── date.ts │ │ ├── sessions.ts │ │ ├── venue.ts │ │ ├── sanitizeTags.ts │ │ ├── tags.ts │ │ ├── auth0.ts │ │ ├── adminedChapters.ts │ │ ├── redact-secrets.ts │ │ └── chapterBans.ts │ ├── common-types │ │ ├── index.d.ts │ │ └── gql.ts │ ├── graphql-types │ │ ├── SponsoredEvent.ts │ │ ├── EventSponsor.ts │ │ ├── BaseObject.ts │ │ ├── TokenStatus.ts │ │ ├── Attendance.ts │ │ ├── Tag.ts │ │ ├── index.ts │ │ ├── EventReminder.ts │ │ ├── UserBan.ts │ │ ├── InstanceRole.ts │ │ ├── Sponsor.ts │ │ ├── ChapterRole.ts │ │ ├── ChapterUser.ts │ │ ├── Venue.ts │ │ ├── User.ts │ │ ├── Chapter.ts │ │ └── EventUser.ts │ ├── prisma.ts │ ├── controllers │ │ ├── Messages │ │ │ ├── Email.ts │ │ │ └── validators │ │ │ │ ├── isListEmpty.ts │ │ │ │ └── findDuplicateEmails.ts │ │ ├── User │ │ │ └── input.ts │ │ ├── Auth │ │ │ ├── resolver.ts │ │ │ └── check-jwt.ts │ │ ├── EventRole │ │ │ └── resolver.ts │ │ ├── ChapterRole │ │ │ └── resolver.ts │ │ ├── Sponsors │ │ │ ├── inputs.ts │ │ │ └── resolver.ts │ │ ├── InstanceRole │ │ │ └── resolver.ts │ │ ├── Venue │ │ │ └── inputs.ts │ │ ├── Chapter │ │ │ └── inputs.ts │ │ ├── Events │ │ │ └── inputs.ts │ │ ├── index.ts │ │ └── Calendar │ │ │ └── resolver.ts │ ├── authorization │ │ └── index.ts │ └── config │ │ └── index.ts ├── prisma │ ├── migrations │ │ ├── .prettierrc.js │ │ ├── 20221215172150_auto_subscribe_by_default │ │ │ └── migration.sql │ │ ├── 20221102132648_add_auto_subscribe │ │ │ └── migration.sql │ │ ├── migration_lock.toml │ │ ├── 20221127171207_add_is_valid_to_google_tokens │ │ │ └── migration.sql │ │ ├── 20230301175317_add_joined_date_to_event_users │ │ │ └── migration.sql │ │ ├── 20221028082936_update_image_urls │ │ │ └── migration.sql │ │ ├── 20230203174046_rename_rsvp_table │ │ │ └── migration.sql │ │ ├── 20221123213911_add_UsersView_to_chapter_permissions │ │ │ └── migration.sql │ │ ├── 20230205151755_rename_rsvp_permissions │ │ │ └── migration.sql │ │ ├── 20230124093945_remove_users_view_from_chapter_permissions │ │ │ └── migration.sql │ │ ├── 20230328184907_add_tags │ │ │ └── migration.sql │ │ └── 20221124155109_add_dashboard_view_permissions │ │ │ └── migration.sql │ └── seed │ │ ├── promoteToOwner.ts │ │ ├── lib │ │ ├── util.ts │ │ └── random.ts │ │ └── factories │ │ ├── sponsors.factory.ts │ │ ├── chapters.factory.ts │ │ └── venues.factory.ts ├── tests │ ├── testUtils │ │ ├── createSchema.ts │ │ ├── callSchema.ts │ │ └── App.ts │ ├── fixtures │ │ ├── venues.ts │ │ ├── longString.ts │ │ ├── events.ts │ │ └── chapterUsers.ts │ ├── smtp-helpers.test.ts │ └── tags.test.ts └── tsconfig.json ├── scripts ├── dev-data │ └── .gitkeep ├── change-user.js ├── check-migrations.sh ├── check-generated-code.sh ├── pullUpstream.sh └── postInstall.js ├── renovate.json ├── .prettierrc.json ├── global-setup.js ├── .husky └── pre-commit ├── client ├── src │ ├── modules │ │ ├── dashboard │ │ │ ├── Calendar │ │ │ │ ├── index.ts │ │ │ │ └── graphql │ │ │ │ │ ├── mutations.ts │ │ │ │ │ └── queries.ts │ │ │ ├── Users │ │ │ │ ├── index.ts │ │ │ │ └── graphql │ │ │ │ │ ├── mutations.ts │ │ │ │ │ └── queries.ts │ │ │ ├── Chapters │ │ │ │ ├── Users │ │ │ │ │ └── index.ts │ │ │ │ ├── index.ts │ │ │ │ ├── pages │ │ │ │ │ └── NewEventForChapterPage.tsx │ │ │ │ └── graphql │ │ │ │ │ └── queries.ts │ │ │ ├── Events │ │ │ │ ├── index.ts │ │ │ │ ├── components │ │ │ │ │ ├── Fields.tsx │ │ │ │ │ ├── DatePicker.tsx │ │ │ │ │ ├── EventCancelButton.tsx │ │ │ │ │ ├── EventVenue.tsx │ │ │ │ │ └── EventChapterSelect.tsx │ │ │ │ └── pages │ │ │ │ │ └── NewEventPage.tsx │ │ │ ├── Sponsors │ │ │ │ ├── index.ts │ │ │ │ ├── graphql │ │ │ │ │ ├── mutations.ts │ │ │ │ │ └── queries.ts │ │ │ │ └── components │ │ │ │ │ └── SponsorFormUtils.tsx │ │ │ ├── Venues │ │ │ │ ├── index.ts │ │ │ │ ├── pages │ │ │ │ │ ├── NewVenuePage.tsx │ │ │ │ │ └── ChapterNewVenuePage.tsx │ │ │ │ ├── graphql │ │ │ │ │ ├── mutations.ts │ │ │ │ │ └── queries.ts │ │ │ │ └── utils.ts │ │ │ └── shared │ │ │ │ └── components │ │ │ │ ├── DashboardLoading.tsx │ │ │ │ └── RoleChangeModal.tsx │ │ ├── profiles │ │ │ ├── index.tsx │ │ │ ├── graphql │ │ │ │ └── mutations.ts │ │ │ └── component │ │ │ │ └── ProfileFormUtils.tsx │ │ ├── events │ │ │ ├── index.ts │ │ │ ├── components │ │ │ │ └── UsersList.tsx │ │ │ └── graphql │ │ │ │ ├── mutations.ts │ │ │ │ └── queries.ts │ │ ├── chapters │ │ │ ├── index.ts │ │ │ ├── graphql │ │ │ │ ├── mutations.ts │ │ │ │ └── queries.ts │ │ │ └── pages │ │ │ │ └── chaptersPage.tsx │ │ ├── unsubscribe │ │ │ └── graphql │ │ │ │ └── mutations.ts │ │ ├── auth │ │ │ ├── graphql │ │ │ │ ├── mutations.ts │ │ │ │ └── queries.ts │ │ │ ├── context.tsx │ │ │ ├── providers │ │ │ │ ├── common-context.tsx │ │ │ │ ├── dev.tsx │ │ │ │ └── auth0.tsx │ │ │ └── user.tsx │ │ └── util │ │ │ └── pagination.tsx │ ├── pages │ │ ├── index.tsx │ │ ├── events │ │ │ ├── index.tsx │ │ │ └── [eventId].tsx │ │ ├── chapters │ │ │ ├── index.tsx │ │ │ └── [chapterId].tsx │ │ ├── profile │ │ │ └── index.ts │ │ ├── unsubscribe │ │ │ └── index.tsx │ │ ├── dashboard │ │ │ ├── calendar │ │ │ │ └── index.tsx │ │ │ ├── events │ │ │ │ ├── index.tsx │ │ │ │ ├── [id] │ │ │ │ │ ├── index.tsx │ │ │ │ │ ├── edit.tsx │ │ │ │ │ └── transfer.tsx │ │ │ │ └── new.tsx │ │ │ ├── users │ │ │ │ └── index.tsx │ │ │ ├── venues │ │ │ │ ├── index.tsx │ │ │ │ ├── new.tsx │ │ │ │ └── [id] │ │ │ │ │ └── index.tsx │ │ │ ├── chapters │ │ │ │ ├── index.tsx │ │ │ │ ├── new.tsx │ │ │ │ └── [id] │ │ │ │ │ ├── index.tsx │ │ │ │ │ ├── edit.tsx │ │ │ │ │ ├── new-venue.tsx │ │ │ │ │ ├── new-event.tsx │ │ │ │ │ ├── users │ │ │ │ │ └── index.tsx │ │ │ │ │ └── venues │ │ │ │ │ └── [venueId] │ │ │ │ │ └── edit.tsx │ │ │ ├── sponsors │ │ │ │ ├── new.tsx │ │ │ │ ├── index.tsx │ │ │ │ └── [id] │ │ │ │ │ ├── index.tsx │ │ │ │ │ └── edit.tsx │ │ │ └── index.tsx │ │ └── _document.tsx │ ├── styles │ │ ├── Page.module.css │ │ ├── Form.module.css │ │ ├── ButtonOutline.ts │ │ └── themes.ts │ ├── util │ │ ├── tags.ts │ │ ├── capitalize.ts │ │ ├── date.ts │ │ ├── venueType.ts │ │ ├── truncate.ts │ │ ├── getLocationString.ts │ │ └── check-permission.ts │ ├── hooks │ │ ├── useAlert.ts │ │ ├── useParam.ts │ │ ├── useDisableWhileSubmitting.ts │ │ └── useCancelEvent.ts │ └── components │ │ ├── Loading.tsx │ │ ├── Card.tsx │ │ ├── ProgressCardContent.tsx │ │ ├── Form │ │ ├── Form.tsx │ │ ├── TextArea.tsx │ │ ├── Select.tsx │ │ └── Input.tsx │ │ ├── PageLayout │ │ ├── component │ │ │ ├── HeaderContainer.tsx │ │ │ └── Footer.tsx │ │ └── index.tsx │ │ ├── TagsBox.tsx │ │ ├── Avatar.tsx │ │ ├── UserName.tsx │ │ ├── Alerts │ │ ├── AlertContext.tsx │ │ ├── AlertProvider.tsx │ │ └── Alerts.tsx │ │ ├── SharePopOver.tsx │ │ ├── SponsorsCard.tsx │ │ ├── SubscribeCheckbox.tsx │ │ └── InfoList.tsx ├── next-env.d.ts ├── next.config.js ├── .gitignore ├── babel.config.js ├── codegen.yml ├── tests │ ├── capitalize.test.ts │ ├── getLocationString.test.ts │ ├── venueType.test.ts │ ├── tags.test.ts │ ├── EventCard.test.tsx │ ├── date.test.ts │ └── truncate.test.ts ├── README.md └── tsconfig.json ├── .dockerignore ├── CODE_OF_CONDUCT.md ├── .lintstagedrc.js ├── cypress ├── fixtures │ ├── chapterRoles.json │ ├── instanceRoles.json │ ├── users.json │ ├── venues.json │ └── chapters.json ├── tsconfig.json ├── e2e │ ├── events │ │ └── events-index.cy.ts │ ├── universal-components │ │ └── menu.cy.ts │ ├── auth │ │ └── login.cy.ts │ ├── landing.cy.ts │ └── dashboard │ │ └── dashboard-index.cy.ts └── support │ └── util.ts ├── tsconfig.test.json ├── common ├── attendance.ts ├── roles.ts └── permissions.ts ├── .gitpod.dockerfile ├── docker-compose.production.yml ├── .eslintignore ├── .graphqlconfig ├── .github ├── ISSUE_TEMPLATE │ ├── discussion.md │ └── bug-report.md ├── CODEOWNERS ├── pull_request_template.md └── workflows │ ├── update-license.yaml │ ├── cypress.yaml │ ├── update-gh-pages.yaml │ └── upload-images.yaml ├── jest.config.js ├── docker-compose.ci.yml ├── docker-compose.override.yml ├── server.Dockerfile ├── docker-compose.yml ├── client.Dockerfile ├── .env.example ├── .gitpod.yml ├── LICENSE └── .gitignore /.nvmrc: -------------------------------------------------------------------------------- 1 | 18.16.0 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | *.snap -------------------------------------------------------------------------------- /server/keys/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /scripts/dev-data/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "enabled": false 3 | } 4 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "all", 3 | "singleQuote": true 4 | } 5 | -------------------------------------------------------------------------------- /global-setup.js: -------------------------------------------------------------------------------- 1 | module.exports = async () => { 2 | process.env.TZ = 'UTC'; 3 | }; 4 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx lint-staged 5 | -------------------------------------------------------------------------------- /server/src/declarations.d.ts: -------------------------------------------------------------------------------- 1 | declare module '@cypress/code-coverage/middleware/express'; 2 | -------------------------------------------------------------------------------- /client/src/modules/dashboard/Calendar/index.ts: -------------------------------------------------------------------------------- 1 | export { Calendar } from './pages/Calendar'; 2 | -------------------------------------------------------------------------------- /client/src/modules/dashboard/Users/index.ts: -------------------------------------------------------------------------------- 1 | export { UsersPage } from './pages/UsersPage'; 2 | -------------------------------------------------------------------------------- /client/src/modules/profiles/index.tsx: -------------------------------------------------------------------------------- 1 | export { UserProfilePage } from './pages/userProfile'; 2 | -------------------------------------------------------------------------------- /client/src/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import Home from '../modules/home'; 2 | 3 | export default Home; 4 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .git 2 | *Dockerfile* 3 | *docker-compose* 4 | **/node_modules 5 | */.next 6 | cypress -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | **Our Code of Conduct is available here: https://code-of-conduct.freecodecamp.org/** 2 | -------------------------------------------------------------------------------- /.lintstagedrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | '*.(js|ts|jsx|tsx)': ['eslint --fix', 'prettier --write'], 3 | }; 4 | -------------------------------------------------------------------------------- /client/src/pages/events/index.tsx: -------------------------------------------------------------------------------- 1 | import { EventsPage } from 'modules/events'; 2 | export default EventsPage; 3 | -------------------------------------------------------------------------------- /cypress/fixtures/chapterRoles.json: -------------------------------------------------------------------------------- 1 | { 2 | "ADMINISTRATOR": "administrator", 3 | "MEMBER": "member" 4 | } 5 | -------------------------------------------------------------------------------- /client/src/modules/dashboard/Chapters/Users/index.ts: -------------------------------------------------------------------------------- 1 | export { ChapterUsersPage } from './pages/ChapterUsersPage'; 2 | -------------------------------------------------------------------------------- /client/src/pages/events/[eventId].tsx: -------------------------------------------------------------------------------- 1 | import { EventPage } from 'modules/events'; 2 | export default EventPage; 3 | -------------------------------------------------------------------------------- /client/src/pages/chapters/index.tsx: -------------------------------------------------------------------------------- 1 | import { ChaptersPage } from 'modules/chapters'; 2 | export default ChaptersPage; 3 | -------------------------------------------------------------------------------- /client/src/pages/chapters/[chapterId].tsx: -------------------------------------------------------------------------------- 1 | import { ChapterPage } from 'modules/chapters'; 2 | export default ChapterPage; 3 | -------------------------------------------------------------------------------- /client/src/styles/Page.module.css: -------------------------------------------------------------------------------- 1 | .card { 2 | margin-top: 12px; 3 | } 4 | 5 | .error { 6 | margin: 15px 0; 7 | } 8 | -------------------------------------------------------------------------------- /client/src/pages/profile/index.ts: -------------------------------------------------------------------------------- 1 | import { UserProfilePage } from '../../modules/profiles'; 2 | export default UserProfilePage; 3 | -------------------------------------------------------------------------------- /client/src/modules/events/index.ts: -------------------------------------------------------------------------------- 1 | export { EventPage } from './pages/eventPage'; 2 | export { EventsPage } from './pages/eventsPage'; 3 | -------------------------------------------------------------------------------- /client/src/pages/unsubscribe/index.tsx: -------------------------------------------------------------------------------- 1 | import UnsubscribePage from '../../modules/unsubscribe'; 2 | export default UnsubscribePage; 3 | -------------------------------------------------------------------------------- /server/prisma/migrations/.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | ...require('../../../.prettierrc'), 3 | printWidth: 10000, 4 | }; 5 | -------------------------------------------------------------------------------- /client/src/pages/dashboard/calendar/index.tsx: -------------------------------------------------------------------------------- 1 | import { Calendar } from '../../../modules/dashboard/Calendar'; 2 | export default Calendar; 3 | -------------------------------------------------------------------------------- /client/src/pages/dashboard/events/index.tsx: -------------------------------------------------------------------------------- 1 | import { EventsPage } from '../../../modules/dashboard/Events'; 2 | export default EventsPage; 3 | -------------------------------------------------------------------------------- /client/src/pages/dashboard/users/index.tsx: -------------------------------------------------------------------------------- 1 | import { UsersPage } from '../../../modules/dashboard/Users/'; 2 | export default UsersPage; 3 | -------------------------------------------------------------------------------- /client/src/pages/dashboard/venues/index.tsx: -------------------------------------------------------------------------------- 1 | import { VenuesPage } from '../../../modules/dashboard/Venues/'; 2 | export default VenuesPage; 3 | -------------------------------------------------------------------------------- /client/src/modules/chapters/index.ts: -------------------------------------------------------------------------------- 1 | export { ChapterPage } from './pages/chapterPage'; 2 | export { ChaptersPage } from './pages/chaptersPage'; 3 | -------------------------------------------------------------------------------- /client/src/pages/dashboard/events/[id]/index.tsx: -------------------------------------------------------------------------------- 1 | import { EventPage } from '../../../../modules/dashboard/Events'; 2 | export default EventPage; 3 | -------------------------------------------------------------------------------- /client/src/pages/dashboard/events/new.tsx: -------------------------------------------------------------------------------- 1 | import { NewEventPage } from '../../../modules/dashboard/Events/'; 2 | export default NewEventPage; 3 | -------------------------------------------------------------------------------- /client/src/pages/dashboard/venues/new.tsx: -------------------------------------------------------------------------------- 1 | import { NewVenuePage } from '../../../modules/dashboard/Venues/'; 2 | export default NewVenuePage; 3 | -------------------------------------------------------------------------------- /client/src/pages/dashboard/chapters/index.tsx: -------------------------------------------------------------------------------- 1 | import { ChaptersPage } from '../../../modules/dashboard/Chapters/'; 2 | export default ChaptersPage; 3 | -------------------------------------------------------------------------------- /client/src/pages/dashboard/chapters/new.tsx: -------------------------------------------------------------------------------- 1 | import { NewChapterPage } from '../../../modules/dashboard/Chapters/'; 2 | export default NewChapterPage; 3 | -------------------------------------------------------------------------------- /client/src/pages/dashboard/sponsors/new.tsx: -------------------------------------------------------------------------------- 1 | import { NewSponsorPage } from '../../../modules/dashboard/Sponsors'; 2 | export default NewSponsorPage; 3 | -------------------------------------------------------------------------------- /client/src/pages/dashboard/venues/[id]/index.tsx: -------------------------------------------------------------------------------- 1 | import { VenuePage } from '../../../../modules/dashboard/Venues/'; 2 | export default VenuePage; 3 | -------------------------------------------------------------------------------- /cypress/fixtures/instanceRoles.json: -------------------------------------------------------------------------------- 1 | { 2 | "CHAPTER_ADMINISTRATOR": "chapter_administrator", 3 | "MEMBER": "member", 4 | "OWNER": "owner" 5 | } 6 | -------------------------------------------------------------------------------- /tsconfig.test.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./server/tsconfig.json", 3 | "compilerOptions": { "jsx": "react", "noEmit": true, "allowJs": false } 4 | } 5 | -------------------------------------------------------------------------------- /client/src/pages/dashboard/chapters/[id]/index.tsx: -------------------------------------------------------------------------------- 1 | import { ChapterPage } from '../../../../modules/dashboard/Chapters/'; 2 | export default ChapterPage; 3 | -------------------------------------------------------------------------------- /client/src/pages/dashboard/events/[id]/edit.tsx: -------------------------------------------------------------------------------- 1 | import { EditEventPage } from '../../../../modules/dashboard/Events'; 2 | export default EditEventPage; 3 | -------------------------------------------------------------------------------- /client/src/pages/dashboard/sponsors/index.tsx: -------------------------------------------------------------------------------- 1 | import { SponsorsPage } from '../../../modules/dashboard/Sponsors'; 2 | 3 | export default SponsorsPage; 4 | -------------------------------------------------------------------------------- /client/src/pages/dashboard/sponsors/[id]/index.tsx: -------------------------------------------------------------------------------- 1 | import { SponsorPage } from '../../../../modules/dashboard/Sponsors'; 2 | 3 | export default SponsorPage; 4 | -------------------------------------------------------------------------------- /client/src/styles/Form.module.css: -------------------------------------------------------------------------------- 1 | .form { 2 | display: flex; 3 | margin-block: 2em; 4 | flex-direction: column; 5 | max-width: 600px; 6 | } 7 | -------------------------------------------------------------------------------- /client/src/util/tags.ts: -------------------------------------------------------------------------------- 1 | export const parseTags = (tags: string) => 2 | tags 3 | .split(',') 4 | .map((tag) => tag.trim()) 5 | .filter(Boolean); 6 | -------------------------------------------------------------------------------- /common/attendance.ts: -------------------------------------------------------------------------------- 1 | export enum AttendanceNames { 2 | confirmed = 'yes', 3 | canceled = 'no', 4 | maybe = 'maybe', 5 | waitlist = 'waitlist', 6 | } 7 | -------------------------------------------------------------------------------- /client/src/pages/dashboard/chapters/[id]/edit.tsx: -------------------------------------------------------------------------------- 1 | import { EditChapterPage } from '../../../../modules/dashboard/Chapters/'; 2 | export default EditChapterPage; 3 | -------------------------------------------------------------------------------- /client/src/pages/dashboard/sponsors/[id]/edit.tsx: -------------------------------------------------------------------------------- 1 | import { EditSponsorPage } from '../../../../modules/dashboard/Sponsors'; 2 | 3 | export default EditSponsorPage; 4 | -------------------------------------------------------------------------------- /client/src/util/capitalize.ts: -------------------------------------------------------------------------------- 1 | export function capitalize(str?: string) { 2 | return str ? str.slice(0, 1).toUpperCase() + str.slice(1).toLowerCase() : ''; 3 | } 4 | -------------------------------------------------------------------------------- /.gitpod.dockerfile: -------------------------------------------------------------------------------- 1 | FROM gitpod/workspace-postgres 2 | 3 | RUN sudo apt-get update \ 4 | && sudo apt-get install netcat -y \ 5 | && sudo rm -rf /var/lib/apt/lists/* 6 | -------------------------------------------------------------------------------- /server/prisma/migrations/20221215172150_auto_subscribe_by_default/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "users" ALTER COLUMN "auto_subscribe" SET DEFAULT true; 3 | -------------------------------------------------------------------------------- /client/src/pages/dashboard/chapters/[id]/new-venue.tsx: -------------------------------------------------------------------------------- 1 | import { ChapterNewVenuePage } from '../../../../modules/dashboard/Venues'; 2 | export default ChapterNewVenuePage; 3 | -------------------------------------------------------------------------------- /server/prisma/migrations/20221102132648_add_auto_subscribe/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "users" ADD COLUMN "auto_subscribe" BOOLEAN NOT NULL DEFAULT false; 3 | -------------------------------------------------------------------------------- /server/prisma/migrations/migration_lock.toml: -------------------------------------------------------------------------------- 1 | # Please do not edit this file manually 2 | # It should be added in your version-control system (i.e. Git) 3 | provider = "postgresql" -------------------------------------------------------------------------------- /client/src/pages/dashboard/chapters/[id]/new-event.tsx: -------------------------------------------------------------------------------- 1 | import { NewEventForChapterPage } from '../../../../modules/dashboard/Chapters'; 2 | export default NewEventForChapterPage; 3 | -------------------------------------------------------------------------------- /client/src/pages/dashboard/chapters/[id]/users/index.tsx: -------------------------------------------------------------------------------- 1 | import { ChapterUsersPage } from '../../../../../modules/dashboard/Chapters/Users'; 2 | export default ChapterUsersPage; 3 | -------------------------------------------------------------------------------- /client/src/pages/dashboard/chapters/[id]/venues/[venueId]/edit.tsx: -------------------------------------------------------------------------------- 1 | import { EditVenuePage } from '../../../../../../modules/dashboard/Venues'; 2 | export default EditVenuePage; 3 | -------------------------------------------------------------------------------- /client/src/util/date.ts: -------------------------------------------------------------------------------- 1 | import { format } from 'date-fns'; 2 | 3 | export const formatDate = (date: Date) => { 4 | return format(new Date(date), 'E, LLL d @ HH:mm'); 5 | }; 6 | -------------------------------------------------------------------------------- /server/src/services/mail-providers/smtp-helpers.ts: -------------------------------------------------------------------------------- 1 | export const allValuesAreDefined = (values: Array) => 2 | values.filter((val: unknown) => !val && val !== 0).length === 0; 3 | -------------------------------------------------------------------------------- /server/src/util/date.ts: -------------------------------------------------------------------------------- 1 | import { format } from 'date-fns'; 2 | 3 | export const formatDate = (date: Date) => { 4 | return format(new Date(date), 'E, LLL d @ HH:mm OOOO'); 5 | }; 6 | -------------------------------------------------------------------------------- /client/src/pages/dashboard/events/[id]/transfer.tsx: -------------------------------------------------------------------------------- 1 | import { TransferEventPage } from '../../../../modules/dashboard/Events/pages/TransferEventPage'; 2 | export default TransferEventPage; 3 | -------------------------------------------------------------------------------- /server/prisma/migrations/20221127171207_add_is_valid_to_google_tokens/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "google_tokens" ADD COLUMN "is_valid" BOOLEAN NOT NULL DEFAULT true; 3 | -------------------------------------------------------------------------------- /docker-compose.production.yml: -------------------------------------------------------------------------------- 1 | # Use images from registry 2 | version: "3.7" 3 | services: 4 | server: 5 | image: freecodecamp/chapter-server 6 | 7 | client: 8 | image: freecodecamp/chapter-client 9 | -------------------------------------------------------------------------------- /server/prisma/migrations/20230301175317_add_joined_date_to_event_users/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "event_users" ADD COLUMN "joined_date" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP; 3 | -------------------------------------------------------------------------------- /server/src/common-types/index.d.ts: -------------------------------------------------------------------------------- 1 | import { User } from '../graphql-types'; 2 | 3 | declare global { 4 | namespace Express { 5 | export interface Request { 6 | user?: User; 7 | } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /client/src/modules/dashboard/Calendar/graphql/mutations.ts: -------------------------------------------------------------------------------- 1 | import { gql } from '@apollo/client'; 2 | 3 | export const INTEGRATION_TEST = gql` 4 | mutation calendarIntegrationTest { 5 | calendarIntegrationTest 6 | } 7 | `; 8 | -------------------------------------------------------------------------------- /client/src/modules/unsubscribe/graphql/mutations.ts: -------------------------------------------------------------------------------- 1 | import { gql } from '@apollo/client'; 2 | 3 | export const unsubscribeMutation = gql` 4 | mutation unsubscribe($token: String!) { 5 | unsubscribe(token: $token) 6 | } 7 | `; 8 | -------------------------------------------------------------------------------- /client/next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/basic-features/typescript for more information. 6 | -------------------------------------------------------------------------------- /server/src/graphql-types/SponsoredEvent.ts: -------------------------------------------------------------------------------- 1 | import { ObjectType, Field } from 'type-graphql'; 2 | import { Event } from './Event'; 3 | 4 | @ObjectType() 5 | export class SponsoredEvent { 6 | @Field(() => Event) 7 | event: Event; 8 | } 9 | -------------------------------------------------------------------------------- /client/next.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | eslint: { 3 | // TODO: the client and server need their own eslint configs 4 | ignoreDuringBuilds: true, 5 | }, 6 | experimental: { 7 | externalDir: true, 8 | }, 9 | }; 10 | -------------------------------------------------------------------------------- /server/src/graphql-types/EventSponsor.ts: -------------------------------------------------------------------------------- 1 | import { ObjectType, Field } from 'type-graphql'; 2 | import { Sponsor } from './Sponsor'; 3 | 4 | @ObjectType() 5 | export class EventSponsor { 6 | @Field(() => Sponsor) 7 | sponsor: Sponsor; 8 | } 9 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .next/ 3 | next.config.js 4 | client/@types/ 5 | 6 | server/src/**/*.js 7 | server/reminders/**/*.js 8 | server/prisma/**/*.js 9 | server/tests/**/*.js 10 | 11 | common/**/*.js 12 | 13 | client/src/generated/graphql.tsx -------------------------------------------------------------------------------- /client/src/modules/dashboard/Events/index.ts: -------------------------------------------------------------------------------- 1 | export { EventsPage } from './pages/EventsPage'; 2 | export { NewEventPage } from './pages/NewEventPage'; 3 | export { EditEventPage } from './pages/EditEventPage'; 4 | export { EventPage } from './pages/EventPage'; 5 | -------------------------------------------------------------------------------- /client/src/modules/profiles/graphql/mutations.ts: -------------------------------------------------------------------------------- 1 | import { gql } from '@apollo/client'; 2 | 3 | export const toggleAutoSubscribe = gql` 4 | mutation toggleAutoSubscribe { 5 | toggleAutoSubscribe { 6 | auto_subscribe 7 | } 8 | } 9 | `; 10 | -------------------------------------------------------------------------------- /server/src/graphql-types/BaseObject.ts: -------------------------------------------------------------------------------- 1 | import { ObjectType, Field, Int } from 'type-graphql'; 2 | 3 | @ObjectType() 4 | export class BaseObject { 5 | // TODO: Should this be a GraphQLID? It's a number in the db. 6 | @Field(() => Int) 7 | id: number; 8 | } 9 | -------------------------------------------------------------------------------- /client/src/modules/dashboard/Sponsors/index.ts: -------------------------------------------------------------------------------- 1 | export { SponsorPage } from './pages/SponsorPage'; 2 | export { SponsorsPage } from './pages/SponsorsPage'; 3 | export { NewSponsorPage } from './pages/NewSponsorPage'; 4 | export { EditSponsorPage } from './pages/EditSponsorPage'; 5 | -------------------------------------------------------------------------------- /client/src/util/venueType.ts: -------------------------------------------------------------------------------- 1 | import { VenueType } from '../generated/graphql'; 2 | 3 | export const isPhysical = (venue_type: VenueType) => 4 | venue_type !== VenueType.Online; 5 | export const isOnline = (venue_type: VenueType) => 6 | venue_type !== VenueType.Physical; 7 | -------------------------------------------------------------------------------- /server/src/graphql-types/TokenStatus.ts: -------------------------------------------------------------------------------- 1 | import { ObjectType, Field } from 'type-graphql'; 2 | 3 | @ObjectType() 4 | export class TokenStatus { 5 | @Field(() => String) 6 | redacted_email: string; 7 | 8 | @Field(() => Boolean) 9 | is_valid: boolean; 10 | } 11 | -------------------------------------------------------------------------------- /server/src/util/sessions.ts: -------------------------------------------------------------------------------- 1 | import { Request } from 'express'; 2 | 3 | export function getBearerToken(req: Request): string | undefined { 4 | const auth = req.headers.authorization; 5 | if (!auth) return undefined; 6 | const [, token] = auth.split(' '); 7 | return token; 8 | } 9 | -------------------------------------------------------------------------------- /client/src/util/truncate.ts: -------------------------------------------------------------------------------- 1 | export const truncate = (str: string, len = 100) => { 2 | if (str.length <= len) return str; 3 | const lastSpace = str.slice(0, len - 2).lastIndexOf(' '); 4 | return lastSpace === -1 5 | ? str.slice(0, len - 3) + '...' 6 | : str.slice(0, lastSpace) + '...'; 7 | }; 8 | -------------------------------------------------------------------------------- /client/src/hooks/useAlert.ts: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { AlertContext, AlertProps } from '../components/Alerts/AlertContext'; 4 | 5 | export const useAlert = () => { 6 | const { addAlert } = React.useContext(AlertContext); 7 | return (alert: Omit) => addAlert(alert); 8 | }; 9 | -------------------------------------------------------------------------------- /common/roles.ts: -------------------------------------------------------------------------------- 1 | // TODO: is common the best place for this? 2 | 3 | export enum ChapterRoles { 4 | administrator = 'administrator', 5 | member = 'member', 6 | } 7 | 8 | export enum InstanceRoles { 9 | chapter_administrator = 'chapter_administrator', 10 | member = 'member', 11 | owner = 'owner', 12 | } 13 | -------------------------------------------------------------------------------- /client/src/styles/ButtonOutline.ts: -------------------------------------------------------------------------------- 1 | import { defineStyleConfig } from '@chakra-ui/react'; 2 | 3 | export const buttonTheme = defineStyleConfig({ 4 | baseStyle: { 5 | _focusVisible: { 6 | outlineColor: 'blue.600', 7 | outlineOffset: '1px', 8 | boxShadow: 'none', 9 | }, 10 | }, 11 | }); 12 | -------------------------------------------------------------------------------- /server/src/graphql-types/Attendance.ts: -------------------------------------------------------------------------------- 1 | import { Field, ObjectType } from 'type-graphql'; 2 | import { BaseObject } from './BaseObject'; 3 | 4 | @ObjectType() 5 | export class Attendance extends BaseObject { 6 | @Field(() => Date) 7 | updated_at: Date; 8 | 9 | @Field(() => String) 10 | name: string; 11 | } 12 | -------------------------------------------------------------------------------- /client/src/modules/dashboard/Venues/index.ts: -------------------------------------------------------------------------------- 1 | export { VenuePage } from './pages/VenuePage'; 2 | export { VenuesPage } from './pages/VenuesPage'; 3 | export { NewVenuePage } from './pages/NewVenuePage'; 4 | export { ChapterNewVenuePage } from './pages/ChapterNewVenuePage'; 5 | export { EditVenuePage } from './pages/EditVenuePage'; 6 | -------------------------------------------------------------------------------- /server/prisma/migrations/20221028082936_update_image_urls/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "chapters" ALTER COLUMN "image_url" DROP NOT NULL, 3 | ADD COLUMN "logo_url" TEXT; 4 | ALTER TABLE "chapters" RENAME COLUMN "image_url" TO "banner_url"; 5 | 6 | -- AlterTable 7 | ALTER TABLE "users" ADD COLUMN "image_url" TEXT; 8 | -------------------------------------------------------------------------------- /server/src/util/venue.ts: -------------------------------------------------------------------------------- 1 | import { events_venue_type_enum } from '@prisma/client'; 2 | 3 | export const isPhysical = (venue_type: events_venue_type_enum) => 4 | venue_type !== events_venue_type_enum.Online; 5 | 6 | export const isOnline = (venue_type: events_venue_type_enum) => 7 | venue_type !== events_venue_type_enum.Physical; 8 | -------------------------------------------------------------------------------- /client/src/hooks/useParam.ts: -------------------------------------------------------------------------------- 1 | import { useRouter } from 'next/router'; 2 | 3 | export const useParam = (key = 'id') => { 4 | const router = useRouter(); 5 | 6 | const val = router.query[key]; 7 | const firstVal = Array.isArray(val) ? val[0] : val; 8 | return firstVal ? { param: parseInt(firstVal) } : { param: -1 }; 9 | }; 10 | -------------------------------------------------------------------------------- /server/src/graphql-types/Tag.ts: -------------------------------------------------------------------------------- 1 | import { ObjectType, Field } from 'type-graphql'; 2 | import { BaseObject } from './BaseObject'; 3 | 4 | @ObjectType() 5 | export class Tag extends BaseObject { 6 | @Field(() => String) 7 | name: string; 8 | } 9 | 10 | @ObjectType() 11 | export class Tags { 12 | @Field(() => Tag) 13 | tag: Tag; 14 | } 15 | -------------------------------------------------------------------------------- /client/src/modules/dashboard/Chapters/index.ts: -------------------------------------------------------------------------------- 1 | export { ChapterPage } from './pages/ChapterPage'; 2 | export { ChaptersPage } from './pages/ChaptersPage'; 3 | export { NewChapterPage } from './pages/NewChapterPage'; 4 | export { EditChapterPage } from './pages/EditChapterPage'; 5 | export { NewEventForChapterPage } from './pages/NewEventForChapterPage'; 6 | -------------------------------------------------------------------------------- /server/tests/testUtils/createSchema.ts: -------------------------------------------------------------------------------- 1 | import { buildSchema } from 'type-graphql'; 2 | import { authorizationChecker } from '../../src/authorization'; 3 | 4 | import { resolvers } from '../../src/controllers'; 5 | 6 | export const createSchema = () => 7 | buildSchema({ 8 | resolvers, 9 | authChecker: authorizationChecker, 10 | }); 11 | -------------------------------------------------------------------------------- /client/src/pages/dashboard/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { DashboardLayout } from '../../modules/dashboard/shared/components/DashboardLayout'; 3 | 4 | const Dashboard = () => { 5 | return ( 6 | 7 |

Admin dashboard

8 |
9 | ); 10 | }; 11 | 12 | export default Dashboard; 13 | -------------------------------------------------------------------------------- /server/src/util/sanitizeTags.ts: -------------------------------------------------------------------------------- 1 | const sanitizeTags = (tags: string): string[] => { 2 | if (tags) { 3 | return tags.split(',').map((item: string) => 4 | item 5 | .split(' ') 6 | .filter((item) => item.length > 0) 7 | .join(''), 8 | ); 9 | } 10 | 11 | return []; 12 | }; 13 | 14 | export default sanitizeTags; 15 | -------------------------------------------------------------------------------- /server/src/util/tags.ts: -------------------------------------------------------------------------------- 1 | const getUniqueTags = (tags: string[]) => [ 2 | ...new Set(tags.map((tagName) => tagName.trim()).filter(Boolean)), 3 | ]; 4 | 5 | export const createTagsData = (tags: string[]) => ({ 6 | create: getUniqueTags(tags).map((name) => ({ 7 | tag: { connectOrCreate: { create: { name }, where: { name } } }, 8 | })), 9 | }); 10 | -------------------------------------------------------------------------------- /scripts/change-user.js: -------------------------------------------------------------------------------- 1 | // TODO: install ts-node and convert to typescript 2 | 3 | const fs = require('fs'); 4 | 5 | const contents = JSON.stringify({ email: process.argv[2] }); 6 | 7 | fs.writeFile('scripts/dev-data/current-user.json', contents, (err) => { 8 | if (err) throw err; 9 | console.log('The dev user is now: ' + process.argv[2]); 10 | }); 11 | -------------------------------------------------------------------------------- /client/src/modules/dashboard/Users/graphql/mutations.ts: -------------------------------------------------------------------------------- 1 | import { gql } from '@apollo/client'; 2 | 3 | export const changeInstanceUserRole = gql` 4 | mutation changeInstanceUserRole($roleName: String!, $userId: Int!) { 5 | changeInstanceUserRole(roleName: $roleName, id: $userId) { 6 | instance_role { 7 | name 8 | } 9 | } 10 | } 11 | `; 12 | -------------------------------------------------------------------------------- /scripts/check-migrations.sh: -------------------------------------------------------------------------------- 1 | set -o pipefail 2 | git add . 3 | git diff --exit-code HEAD > /dev/null 4 | if [ $? -eq 1 ]; then 5 | echo " 6 | Changes to the schema must be saved as migrations. 7 | 8 | Run npm run db:migrate:dev and follow the instructions to generate a new migration file. 9 | Once you've done that, commit and push the changes." 10 | exit 1 11 | fi 12 | -------------------------------------------------------------------------------- /server/tests/fixtures/venues.ts: -------------------------------------------------------------------------------- 1 | // All the authChecker needs to know is which chapter a given venue belongs to: 2 | export const venues = [ 3 | { 4 | id: 1, 5 | chapter_id: 1, 6 | }, 7 | { 8 | id: 2, 9 | chapter_id: 1, 10 | }, 11 | { 12 | id: 3, 13 | chapter_id: 2, 14 | }, 15 | { 16 | id: 4, 17 | chapter_id: 3, 18 | }, 19 | ]; 20 | -------------------------------------------------------------------------------- /.graphqlconfig: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Untitled GraphQL Schema", 3 | "schemaPath": "schema.graphql", 4 | "extensions": { 5 | "endpoints": { 6 | "Default GraphQL Endpoint": { 7 | "url": "http://localhost:5000/graphql", 8 | "headers": { 9 | "user-agent": "JS GraphQL" 10 | }, 11 | "introspect": true 12 | } 13 | } 14 | } 15 | } -------------------------------------------------------------------------------- /client/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | 25 | # vercel 26 | .vercel 27 | -------------------------------------------------------------------------------- /scripts/check-generated-code.sh: -------------------------------------------------------------------------------- 1 | set -o pipefail 2 | git diff --exit-code HEAD | cat 3 | if [ $? -eq 1 ]; then 4 | echo " 5 | Generated code must be commited. 6 | If the above diff is of graphql.tsx you'll need run 'npm run gen'. 7 | If it's anything else, a bit of investigation is required. 8 | 9 | Once you've done that, you can commit the changes and all should be well." 10 | exit 1 11 | fi 12 | -------------------------------------------------------------------------------- /client/src/modules/auth/graphql/mutations.ts: -------------------------------------------------------------------------------- 1 | import { gql } from '@apollo/client'; 2 | 3 | export const deleteMe = gql` 4 | mutation deleteMe { 5 | deleteMe { 6 | id 7 | } 8 | } 9 | `; 10 | 11 | export const updateMe = gql` 12 | mutation updateMe($data: UpdateUserInputs!) { 13 | updateMe(data: $data) { 14 | id 15 | name 16 | image_url 17 | } 18 | } 19 | `; 20 | -------------------------------------------------------------------------------- /client/src/modules/dashboard/Calendar/graphql/queries.ts: -------------------------------------------------------------------------------- 1 | import { gql } from '@apollo/client'; 2 | 3 | export const CALENDAR_INTEGRATION = gql` 4 | query calendarIntegrationStatus { 5 | calendarIntegrationStatus 6 | } 7 | `; 8 | 9 | export const TOKEN_STATUSES = gql` 10 | query tokenStatuses { 11 | tokenStatuses { 12 | redacted_email 13 | is_valid 14 | } 15 | } 16 | `; 17 | -------------------------------------------------------------------------------- /server/src/prisma.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from '@prisma/client'; 2 | 3 | // importing config so the .env gets parsed 4 | import './config'; 5 | 6 | export const prisma = new PrismaClient(); 7 | 8 | /* Prisma error codes: https://www.prisma.io/docs/reference/api-reference/error-reference#error-codes */ 9 | export const RECORD_MISSING = 'P2025'; 10 | export const UNIQUE_CONSTRAINT_FAILED = 'P2002'; 11 | -------------------------------------------------------------------------------- /client/src/components/Loading.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { Spinner } from '@chakra-ui/react'; 4 | 5 | type Props = { 6 | error?: Error; 7 | }; 8 | 9 | export const Loading = ({ error }: Props) => { 10 | if (error) { 11 | return ( 12 |
13 |

error...

14 |

{error?.message}

15 |
16 | ); 17 | } 18 | return ; 19 | }; 20 | -------------------------------------------------------------------------------- /scripts/pullUpstream.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -e 4 | 5 | if [[ $(git remote -v | grep 'upstream' | wc -l) -eq 0 ]]; then 6 | echo "Upstream remote not found, adding one" 7 | git remote add upstream https://github.com/freeCodeCamp/chapter.git 8 | fi 9 | 10 | git fetch upstream 11 | 12 | git checkout main 13 | git reset --hard upstream/main 14 | 15 | echo "" 16 | echo "Git remotes have been setup successfully" 17 | -------------------------------------------------------------------------------- /cypress/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6", 4 | "lib": ["es6", "dom"], 5 | "jsx": "preserve", 6 | "moduleResolution": "node", 7 | "types": [ 8 | "cypress", 9 | "node", 10 | "@testing-library/cypress", 11 | "cypress-mailhog/types", 12 | "cypress-mailhog/types/mailhog" 13 | ], 14 | "noEmit": true 15 | }, 16 | "include": ["**/*.ts"] 17 | } 18 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/discussion.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 'Discussion: Discuss ideas for the Chapter project' 3 | about: This is for discussing ideas, features, UI/UX design, libraries, frameworks, APIs and for sharing your views on the project. 4 | title: '' 5 | labels: 'type: discussion' 6 | assignees: '' 7 | 8 | --- 9 | 10 | ### Discuss your ideas or share your views: 11 | Make sure to tell how your idea will benefit this project. 12 | -------------------------------------------------------------------------------- /client/babel.config.js: -------------------------------------------------------------------------------- 1 | var config = { 2 | presets: ['next/babel'], 3 | plugins: [['@babel/plugin-proposal-decorators', { legacy: true }]], 4 | env: { 5 | test: { 6 | plugins: ['istanbul'], 7 | }, 8 | }, 9 | }; 10 | 11 | if (process.env.NODE_ENV === 'development' && process.env.CODESEE === 'true') { 12 | config.plugins.push(['@codesee/instrument', { hosted: true }]); 13 | } 14 | 15 | module.exports = config; 16 | -------------------------------------------------------------------------------- /server/src/controllers/Messages/Email.ts: -------------------------------------------------------------------------------- 1 | import { ObjectType, Field } from 'type-graphql'; 2 | 3 | @ObjectType() 4 | export class Email { 5 | @Field(() => String) 6 | ourEmail: string; 7 | 8 | @Field(() => [String]) 9 | emailList: string[]; 10 | 11 | @Field(() => String) 12 | subject: string; 13 | 14 | @Field(() => String) 15 | htmlEmail: string; 16 | 17 | @Field(() => String) 18 | backupText: string; 19 | } 20 | -------------------------------------------------------------------------------- /client/src/modules/dashboard/Users/graphql/queries.ts: -------------------------------------------------------------------------------- 1 | import { gql } from '@apollo/client'; 2 | 3 | export const InstanceRoles = gql` 4 | query instanceRoles { 5 | instanceRoles { 6 | id 7 | name 8 | } 9 | } 10 | `; 11 | 12 | export const Users = gql` 13 | query users { 14 | users { 15 | id 16 | name 17 | instance_role { 18 | id 19 | name 20 | } 21 | } 22 | } 23 | `; 24 | -------------------------------------------------------------------------------- /server/src/services/mail-providers/abstract-provider.ts: -------------------------------------------------------------------------------- 1 | export interface MailerData { 2 | emailList: Array; 3 | subject: string; 4 | htmlEmail: string; 5 | backupText?: string; 6 | } 7 | 8 | export abstract class MailProvider { 9 | ourEmail: string; 10 | 11 | constructor() { 12 | this.ourEmail = process.env.CHAPTER_EMAIL || 'ourEmail@placeholder.place'; 13 | } 14 | abstract send(data: MailerData): Promise; 15 | } 16 | -------------------------------------------------------------------------------- /server/src/controllers/User/input.ts: -------------------------------------------------------------------------------- 1 | import { InputType, Field } from 'type-graphql'; 2 | import { User } from '../../graphql-types'; 3 | 4 | @InputType() 5 | export class UpdateUserInputs implements Omit { 6 | @Field(() => String, { nullable: true }) 7 | name: string; 8 | 9 | @Field(() => Boolean) 10 | auto_subscribe: boolean; 11 | 12 | @Field(() => String, { nullable: true }) 13 | image_url?: string | null; 14 | } 15 | -------------------------------------------------------------------------------- /server/src/controllers/Auth/resolver.ts: -------------------------------------------------------------------------------- 1 | import { Resolver, Query, Ctx } from 'type-graphql'; 2 | 3 | import { ResolverCtx } from '../../common-types/gql'; 4 | import { UserWithPermissions } from '../../graphql-types'; 5 | 6 | @Resolver() 7 | export class AuthResolver { 8 | @Query(() => UserWithPermissions, { nullable: true }) 9 | async me(@Ctx() ctx: ResolverCtx): Promise { 10 | return ctx.user ?? null; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /client/src/components/Card.tsx: -------------------------------------------------------------------------------- 1 | import { Box, BoxProps } from '@chakra-ui/react'; 2 | import React from 'react'; 3 | 4 | interface Props { 5 | className?: BoxProps['className']; 6 | children: React.ReactNode; 7 | mt?: BoxProps['mt']; 8 | } 9 | 10 | export const Card = ({ children, ...rest }: Props) => { 11 | return ( 12 | 13 | {children} 14 | 15 | ); 16 | }; 17 | -------------------------------------------------------------------------------- /client/src/components/ProgressCardContent.tsx: -------------------------------------------------------------------------------- 1 | import { CircularProgress, Box } from '@chakra-ui/react'; 2 | import React from 'react'; 3 | 4 | interface Props { 5 | loading?: boolean; 6 | children: React.ReactNode; 7 | } 8 | 9 | const ProgressCardContent = ({ loading = false, children }: Props) => ( 10 | 11 | {loading ? : children} 12 | 13 | ); 14 | 15 | export default ProgressCardContent; 16 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | testEnvironment: 'node', 4 | roots: [''], 5 | moduleDirectories: ['node_modules', 'server'], 6 | globals: { 7 | 'ts-jest': { 8 | tsconfig: './tsconfig.test.json', 9 | }, 10 | }, 11 | watchPathIgnorePatterns: ['/node_modules'], 12 | moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'], 13 | testMatch: ['/**/*.test.(ts|tsx)'], 14 | globalSetup: './global-setup.js', 15 | }; 16 | -------------------------------------------------------------------------------- /server/src/common-types/gql.ts: -------------------------------------------------------------------------------- 1 | import { Request as ExpressRequest, Response } from 'express'; 2 | 3 | import type { User, Events, Venues } from '../controllers/Auth/middleware'; 4 | 5 | export interface ResolverCtx { 6 | user?: User; 7 | events?: Events; 8 | venues?: Venues; 9 | res: Response; 10 | req: ExpressRequest; 11 | } 12 | 13 | export interface Request extends ExpressRequest { 14 | user?: User; 15 | events?: Events; 16 | venues?: Venues; 17 | } 18 | -------------------------------------------------------------------------------- /client/src/modules/auth/context.tsx: -------------------------------------------------------------------------------- 1 | import { useContext } from 'react'; 2 | 3 | import { DevAuthProvider } from './providers/dev'; 4 | import { AuthProvider as Auth0Provider } from './providers/auth0'; 5 | import { AuthContext } from './providers/common-context'; 6 | 7 | const needsDevLogin = process.env.NEXT_PUBLIC_USE_AUTH0 === 'false'; 8 | 9 | export const AuthProvider = needsDevLogin ? DevAuthProvider : Auth0Provider; 10 | export const useAuth = () => useContext(AuthContext); 11 | -------------------------------------------------------------------------------- /client/src/modules/dashboard/Chapters/pages/NewEventForChapterPage.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { useParam } from '../../../../hooks/useParam'; 4 | import { NextPageWithLayout } from '../../../../pages/_app'; 5 | import { NewEventPage } from '../../Events/pages/NewEventPage'; 6 | 7 | export const NewEventForChapterPage: NextPageWithLayout = () => { 8 | const { param: chapterId } = useParam('id'); 9 | 10 | return ; 11 | }; 12 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug-report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 'Bug Report: Issues with Chapter software' 3 | about: Report an issue or flaw in chapter software's schema, architecture, APIs, UI or anything related to our software. 4 | title: '' 5 | labels: 'type: bug' 6 | assignees: '' 7 | 8 | --- 9 | 10 | ### Describe the bug: 11 | 12 | 13 | 14 | ### Tell us about your browser and operating system: 15 | - Browser(s) name and version: 16 | - Operating System: 17 | -------------------------------------------------------------------------------- /server/src/graphql-types/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Attendance'; 2 | export * from './Chapter'; 3 | export * from './ChapterUser'; 4 | export * from './ChapterRole'; 5 | export * from './Event'; 6 | export * from './EventReminder'; 7 | export * from './EventSponsor'; 8 | export * from './EventUser'; 9 | export * from './InstanceRole'; 10 | export * from './Sponsor'; 11 | export * from './Tag'; 12 | export * from './TokenStatus'; 13 | export * from './User'; 14 | export * from './UserBan'; 15 | export * from './Venue'; 16 | -------------------------------------------------------------------------------- /server/prisma/migrations/20230203174046_rename_rsvp_table/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "event_users" RENAME CONSTRAINT "event_users_rsvp_id_fkey" TO "event_users_attendance_id_fkey"; 3 | ALTER TABLE "event_users" RENAME COLUMN "rsvp_id" TO "attendance_id"; 4 | ALTER TABLE "rsvp" RENAME CONSTRAINT "rsvp_pkey" TO "attendance_pkey"; 5 | ALTER TABLE "rsvp" RENAME TO "attendance"; 6 | ALTER TABLE "rsvp_id_seq" RENAME TO "attendance_id_seq"; 7 | 8 | -- AlterIndex 9 | ALTER INDEX "rsvp_name_key" RENAME TO "attendance_name_key"; 10 | -------------------------------------------------------------------------------- /server/src/controllers/EventRole/resolver.ts: -------------------------------------------------------------------------------- 1 | import { Query, Resolver } from 'type-graphql'; 2 | 3 | import { prisma } from '../../prisma'; 4 | 5 | import { EventRole } from '../../graphql-types/EventUser'; 6 | 7 | @Resolver() 8 | export class EventRoleResolver { 9 | @Query(() => [EventRole]) 10 | async eventRoles(): Promise { 11 | return await prisma.event_roles.findMany({ 12 | include: { 13 | event_role_permissions: { include: { event_permission: true } }, 14 | }, 15 | }); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /server/src/services/UnsubscribeToken.ts: -------------------------------------------------------------------------------- 1 | import { sign } from 'jsonwebtoken'; 2 | 3 | import { getConfig } from '../config'; 4 | 5 | export enum UnsubscribeType { 6 | Chapter = 'chapter', 7 | Event = 'event', 8 | } 9 | 10 | export type UnsubscribeToken = { 11 | type: UnsubscribeType; 12 | id: number; 13 | userId: number; 14 | iat: number; 15 | }; 16 | 17 | export const generateToken = ( 18 | type: UnsubscribeType, 19 | id: number, 20 | userId: number, 21 | ) => sign({ type, id, userId }, getConfig('UNSUBSCRIBE_SECRET')); 22 | -------------------------------------------------------------------------------- /cypress/fixtures/users.json: -------------------------------------------------------------------------------- 1 | { 2 | "bannedAdmin": { 3 | "email": "banned@chapter.admin", 4 | "name": "Banned Chapter Admin" 5 | }, 6 | "chapter1Admin": { 7 | "email": "admin@of.chapter.one", 8 | "name": "Chapter One Admin" 9 | }, 10 | "chapter2Admin": { 11 | "email": "admin@of.chapter.two", 12 | "name": "Chapter Two Admin" 13 | }, 14 | "owner": { 15 | "email": "foo@bar.com", 16 | "name": "The Owner" 17 | }, 18 | "testUser": { 19 | "email": "test@user.org", 20 | "name": "Test User" 21 | } 22 | } -------------------------------------------------------------------------------- /client/codegen.yml: -------------------------------------------------------------------------------- 1 | overwrite: true 2 | schema: 'http://localhost:5000/graphql' 3 | documents: ['src/**/queries.ts', 'src/**/mutations.ts'] 4 | generates: 5 | src/generated/graphql.tsx: 6 | plugins: 7 | - 'typescript' 8 | - 'typescript-operations' 9 | - 'typescript-react-apollo' 10 | # - "typescript-graphql-files-modules" 11 | # - "typescript-document-nodes" 12 | - 'fragment-matcher' 13 | ./graphql.schema.json: 14 | plugins: 15 | - 'introspection' 16 | hooks: 17 | afterAllFileWrite: 18 | - prettier --write -------------------------------------------------------------------------------- /server/src/controllers/ChapterRole/resolver.ts: -------------------------------------------------------------------------------- 1 | import { Query, Resolver } from 'type-graphql'; 2 | 3 | import { prisma } from '../../prisma'; 4 | 5 | import { ChapterRole } from '../../graphql-types/ChapterRole'; 6 | 7 | @Resolver() 8 | export class ChapterRoleResolver { 9 | @Query(() => [ChapterRole]) 10 | async chapterRoles(): Promise { 11 | return await prisma.chapter_roles.findMany({ 12 | include: { 13 | chapter_role_permissions: { include: { chapter_permission: true } }, 14 | }, 15 | }); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /client/src/util/getLocationString.ts: -------------------------------------------------------------------------------- 1 | import { Venue } from '../generated/graphql'; 2 | 3 | export type PartialLocation = Pick< 4 | Venue, 5 | 'street_address' | 'city' | 'postal_code' | 'region' | 'country' 6 | >; 7 | 8 | const getLocationString = ( 9 | venue: PartialLocation, 10 | withCity = false, 11 | ): string => { 12 | const base = `${venue.region}, ${venue.country}, ${venue.postal_code}`; 13 | 14 | if (withCity) { 15 | return `${base}, ${venue.city}`; 16 | } 17 | 18 | return base; 19 | }; 20 | 21 | export default getLocationString; 22 | -------------------------------------------------------------------------------- /client/tests/capitalize.test.ts: -------------------------------------------------------------------------------- 1 | import { capitalize } from '../src/util/capitalize'; 2 | 3 | describe('capitalize', () => { 4 | it('should capitalize a string', () => { 5 | expect(capitalize('hello world')).toBe('Hello world'); 6 | expect(capitalize('heLlo woRld')).toBe('Hello world'); 7 | expect(capitalize('a')).toBe('A'); 8 | expect(capitalize('A')).toBe('A'); 9 | expect(capitalize('')).toBe(''); 10 | }); 11 | it('should return an empty string if no string is passed', () => { 12 | expect(capitalize()).toBe(''); 13 | }); 14 | }); 15 | -------------------------------------------------------------------------------- /client/src/modules/auth/providers/common-context.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-empty-function */ 2 | import React from 'react'; 3 | 4 | type AuthContextType = { 5 | isAuthenticated: boolean; 6 | login: () => void; 7 | logout: () => void; 8 | getToken: () => Promise; 9 | authError: Error | null; 10 | }; 11 | 12 | export const AuthContext = React.createContext({ 13 | isAuthenticated: false, 14 | login: () => {}, 15 | logout: () => {}, 16 | getToken: () => Promise.resolve(''), 17 | authError: null, 18 | }); 19 | -------------------------------------------------------------------------------- /server/prisma/migrations/20221123213911_add_UsersView_to_chapter_permissions/migration.sql: -------------------------------------------------------------------------------- 1 | INSERT INTO chapter_permissions (name, created_at, updated_at) 2 | VALUES 3 | ('users-view', NOW(), NOW()); 4 | 5 | -- Insert users-view into the join table for the administrator role. 6 | 7 | INSERT INTO chapter_role_permissions (chapter_role_id, chapter_permissions_id, created_at, updated_at) 8 | VALUES 9 | ((SELECT id FROM chapter_roles WHERE name = 'administrator'), 10 | (SELECT id FROM chapter_permissions WHERE name = 'users-view'), 11 | NOW(), 12 | NOW()); 13 | -------------------------------------------------------------------------------- /server/src/graphql-types/EventReminder.ts: -------------------------------------------------------------------------------- 1 | import { Field, Int, ObjectType } from 'type-graphql'; 2 | import { EventWithChapterAndVenue, User } from '.'; 3 | 4 | @ObjectType() 5 | export class EventReminder { 6 | @Field(() => Int) 7 | user_id: number; 8 | 9 | @Field(() => Int) 10 | event_id: number; 11 | 12 | @Field(() => Date) 13 | remind_at: Date; 14 | 15 | @Field(() => User) 16 | user: User; 17 | 18 | @Field(() => EventWithChapterAndVenue) 19 | event: EventWithChapterAndVenue; 20 | 21 | @Field(() => Boolean) 22 | notifying: boolean; 23 | } 24 | -------------------------------------------------------------------------------- /server/prisma/migrations/20230205151755_rename_rsvp_permissions/migration.sql: -------------------------------------------------------------------------------- 1 | UPDATE chapter_permissions SET name = 'attendee-attend' WHERE name = 'rsvp'; 2 | UPDATE chapter_permissions SET name = 'attendee-delete' WHERE name = 'rsvp-delete'; 3 | UPDATE chapter_permissions SET name = 'attendee-confirm' WHERE name = 'rsvp-confirm'; 4 | 5 | UPDATE instance_permissions SET name = 'attendee-attend' WHERE name = 'rsvp'; 6 | UPDATE instance_permissions SET name = 'attendee-delete' WHERE name = 'rsvp-delete'; 7 | UPDATE instance_permissions SET name = 'attendee-confirm' WHERE name = 'rsvp-confirm'; -------------------------------------------------------------------------------- /server/prisma/seed/promoteToOwner.ts: -------------------------------------------------------------------------------- 1 | import { InstanceRoles } from '../../../common/roles'; 2 | import { prisma } from '../../src/prisma'; 3 | 4 | prisma.instance_roles 5 | .findUniqueOrThrow({ where: { name: InstanceRoles.owner } }) 6 | .then((ownerRole) => { 7 | if (!ownerRole) { 8 | console.log('Owner role not found'); 9 | } else { 10 | console.log('Promoting user to owner'); 11 | return prisma.users 12 | .updateMany({ 13 | data: { instance_role_id: ownerRole.id }, 14 | }) 15 | .then(() => console.log('Done')); 16 | } 17 | }); 18 | -------------------------------------------------------------------------------- /server/src/graphql-types/UserBan.ts: -------------------------------------------------------------------------------- 1 | import { ObjectType, Field } from 'type-graphql'; 2 | import { Chapter } from './Chapter'; 3 | import { User } from './User'; 4 | 5 | @ObjectType() 6 | export class UserBan { 7 | @Field(() => Number) 8 | chapter_id: number; 9 | 10 | @Field(() => Number) 11 | user_id: number; 12 | } 13 | 14 | @ObjectType() 15 | export class UserBanChapters extends UserBan { 16 | @Field(() => Chapter) 17 | chapter: Chapter; 18 | } 19 | 20 | @ObjectType() 21 | export class UserBanWithRelations extends UserBanChapters { 22 | @Field(() => User) 23 | user: User; 24 | } 25 | -------------------------------------------------------------------------------- /client/src/pages/_document.tsx: -------------------------------------------------------------------------------- 1 | import Document, { Head, Html, Main, NextScript } from 'next/document'; 2 | import * as React from 'react'; 3 | 4 | export default class MyDocument extends Document { 5 | render() { 6 | return ( 7 | 8 | 9 | 13 | 14 | 15 |
16 | 17 | 18 | 19 | ); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /server/tests/testUtils/callSchema.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata'; 2 | import { graphql, GraphQLSchema } from 'graphql'; 3 | import { Maybe } from 'graphql/jsutils/Maybe'; 4 | import { createSchema } from './createSchema'; 5 | 6 | interface Options { 7 | source: string; 8 | variableValues?: Maybe<{ 9 | [key: string]: any; 10 | }>; 11 | } 12 | 13 | let schema: GraphQLSchema; 14 | 15 | export const callSchema = async ({ source, variableValues }: Options) => { 16 | schema = await createSchema(); 17 | return graphql({ 18 | schema, 19 | source, 20 | variableValues, 21 | }); 22 | }; 23 | -------------------------------------------------------------------------------- /client/src/modules/dashboard/Sponsors/graphql/mutations.ts: -------------------------------------------------------------------------------- 1 | import { gql } from '@apollo/client'; 2 | 3 | export const createSponsor = gql` 4 | mutation createSponsor($data: CreateSponsorInputs!) { 5 | createSponsor(data: $data) { 6 | name 7 | website 8 | logo_path 9 | type 10 | } 11 | } 12 | `; 13 | 14 | export const updateSponsor = gql` 15 | mutation updateSponsor($data: UpdateSponsorInputs!, $updateSponsorId: Int!) { 16 | updateSponsor(data: $data, id: $updateSponsorId) { 17 | name 18 | website 19 | logo_path 20 | type 21 | } 22 | } 23 | `; 24 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------- 2 | # CODEOWNERS - For automated review request for 3 | # high impact files. 4 | # 5 | # Important: The order in this file cascades. 6 | # 7 | # https://help.github.com/articles/about-codeowners 8 | # ------------------------------------------------- 9 | 10 | # ------------------------------------------------- 11 | # All files are owned by dev team 12 | # ------------------------------------------------- 13 | 14 | * @freecodecamp/chapter-moderators 15 | 16 | # --- Owned by none (negate rule above) --- 17 | 18 | package.json 19 | package-lock.json 20 | -------------------------------------------------------------------------------- /client/README.md: -------------------------------------------------------------------------------- 1 | This example shows how to integrate the TypeScript type system into Next.js. Since TypeScript is supported out of the box with Next.js, all we have to do is to install TypeScript. 2 | 3 | This example shows how to properly export and import typescript types without getting the 4 | 5 | ```js 6 | Attempted import error: 'TypeA' is not exported from './package-1'. 7 | ``` 8 | 9 | error as raised in [vercel/next.js#7882](https://github.com/vercel/next.js/issues/7882). 10 | 11 | ## Useful links 12 | 13 | [Add import type and export type support to TypeScript](https://github.com/babel/babel/pull/11171) 14 | -------------------------------------------------------------------------------- /server/src/graphql-types/InstanceRole.ts: -------------------------------------------------------------------------------- 1 | import { ObjectType, Field } from 'type-graphql'; 2 | 3 | @ObjectType() 4 | export class InstancePermission { 5 | @Field(() => String) 6 | name: string; 7 | } 8 | 9 | @ObjectType() 10 | export class InstanceRolePermission { 11 | @Field(() => InstancePermission) 12 | instance_permission: InstancePermission; 13 | } 14 | 15 | @ObjectType() 16 | export class InstanceRole { 17 | @Field(() => Number) 18 | id: number; 19 | 20 | @Field(() => String) 21 | name: string; 22 | 23 | @Field(() => [InstanceRolePermission]) 24 | instance_role_permissions: InstanceRolePermission[]; 25 | } 26 | -------------------------------------------------------------------------------- /server/src/graphql-types/Sponsor.ts: -------------------------------------------------------------------------------- 1 | import { ObjectType, Field } from 'type-graphql'; 2 | import { BaseObject } from './BaseObject'; 3 | import { SponsoredEvent } from './SponsoredEvent'; 4 | 5 | @ObjectType() 6 | export class Sponsor extends BaseObject { 7 | @Field(() => String) 8 | name: string; 9 | 10 | @Field(() => String) 11 | website: string; 12 | 13 | @Field(() => String) 14 | logo_path: string; 15 | 16 | @Field(() => String) 17 | type: string; 18 | } 19 | 20 | @ObjectType() 21 | export class SponsorWithEvents extends Sponsor { 22 | @Field(() => [SponsoredEvent]) 23 | event_sponsors: SponsoredEvent[]; 24 | } 25 | -------------------------------------------------------------------------------- /cypress/fixtures/venues.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name": "Test Venue", 4 | "street_address": "123 Main St", 5 | "city": "New York", 6 | "postal_code": "10001", 7 | "region": "NY", 8 | "country": "US", 9 | "latitude": 40.7128, 10 | "longitude": -74.006, 11 | "venue_tags": [] 12 | }, 13 | { 14 | "name": "Name goes here", 15 | "street_address": "10 Random Path", 16 | "city": "City it is based in", 17 | "postal_code": "2000", 18 | "region": "Location in the world", 19 | "country": "Some country", 20 | "latitude": -45, 21 | "longitude": 35, 22 | "venue_tags": "Tag, Test" 23 | } 24 | ] 25 | -------------------------------------------------------------------------------- /server/src/controllers/Sponsors/inputs.ts: -------------------------------------------------------------------------------- 1 | import { Field, InputType } from 'type-graphql'; 2 | 3 | @InputType() 4 | export class CreateSponsorInputs { 5 | @Field(() => String) 6 | name: string; 7 | 8 | @Field(() => String) 9 | website: string; 10 | 11 | @Field(() => String) 12 | logo_path: string; 13 | 14 | @Field(() => String) 15 | type: string; 16 | } 17 | 18 | @InputType() 19 | export class UpdateSponsorInputs { 20 | @Field(() => String) 21 | name: string; 22 | 23 | @Field(() => String) 24 | website: string; 25 | 26 | @Field(() => String) 27 | logo_path: string; 28 | 29 | @Field(() => String) 30 | type: string; 31 | } 32 | -------------------------------------------------------------------------------- /server/src/graphql-types/ChapterRole.ts: -------------------------------------------------------------------------------- 1 | import { Field, ObjectType } from 'type-graphql'; 2 | import { BaseObject } from './BaseObject'; 3 | 4 | @ObjectType() 5 | export class ChapterPermission extends BaseObject { 6 | @Field(() => String) 7 | name: string; 8 | } 9 | 10 | @ObjectType() 11 | export class ChapterRolePermission { 12 | @Field(() => ChapterPermission) 13 | chapter_permission: ChapterPermission; 14 | } 15 | 16 | @ObjectType() 17 | export class ChapterRole extends BaseObject { 18 | @Field(() => String) 19 | name: string; 20 | 21 | @Field(() => [ChapterRolePermission]) 22 | chapter_role_permissions: ChapterRolePermission[]; 23 | } 24 | -------------------------------------------------------------------------------- /client/src/components/Form/Form.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactNode } from 'react'; 2 | import { VStack } from '@chakra-ui/layout'; 3 | import styles from '../../styles/Form.module.css'; 4 | 5 | interface FormProps { 6 | children: ReactNode; 7 | submitLabel: string; 8 | FormHandling: () => unknown; 9 | } 10 | 11 | export const Form = ({ children, submitLabel, FormHandling }: FormProps) => { 12 | return ( 13 |
19 | 20 | {children} 21 | 22 |
23 | ); 24 | }; 25 | -------------------------------------------------------------------------------- /cypress/e2e/events/events-index.cy.ts: -------------------------------------------------------------------------------- 1 | describe('events page', () => { 2 | before(() => { 3 | cy.task('seedDb'); 4 | }); 5 | 6 | it('should contain a list of event cards', () => { 7 | cy.interceptGQL('PaginatedEventsWithTotal'); 8 | cy.visit('/events'); 9 | cy.wait('@GQLPaginatedEventsWithTotal'); 10 | cy.get('[data-cy="event-card"]') 11 | .should('be.visible') 12 | .should('have.length', 5); 13 | 14 | cy.get('[data-testid="pagination"]').click(); 15 | cy.get('[data-cy="event-card"]') 16 | .should('be.visible') 17 | .should('have.length', 10); 18 | 19 | cy.get('[data-testid="pagination"]').click().should('not.exist'); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /server/prisma/migrations/20230124093945_remove_users_view_from_chapter_permissions/migration.sql: -------------------------------------------------------------------------------- 1 | -- Reverts 2 | -- server/prisma/migrations/20221123213911_add_UsersView_to_chapter_permissions/migration.sql 3 | -- Rationale: only instance owners should be able to view instance users. 4 | -- Chapter administrators can already view chapter users via the chapter-edit 5 | -- permission. 6 | DELETE FROM 7 | chapter_role_permissions 8 | WHERE 9 | chapter_permissions_id = ( 10 | SELECT 11 | id 12 | FROM 13 | chapter_permissions 14 | WHERE 15 | name = 'users-view' 16 | ); 17 | 18 | DELETE FROM chapter_permissions WHERE name = 'users-view'; -------------------------------------------------------------------------------- /server/src/controllers/InstanceRole/resolver.ts: -------------------------------------------------------------------------------- 1 | import { Query, Resolver } from 'type-graphql'; 2 | 3 | import { prisma } from '../../prisma'; 4 | 5 | import { InstanceRole } from '../../graphql-types/InstanceRole'; 6 | import { InstanceRoles } from '../../../../common/roles'; 7 | 8 | @Resolver() 9 | export class InstanceRoleResolver { 10 | @Query(() => [InstanceRole]) 11 | async instanceRoles(): Promise { 12 | return await prisma.instance_roles.findMany({ 13 | include: { 14 | instance_role_permissions: { include: { instance_permission: true } }, 15 | }, 16 | where: { name: { not: InstanceRoles.chapter_administrator } }, 17 | }); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /client/src/styles/themes.ts: -------------------------------------------------------------------------------- 1 | import { extendTheme } from '@chakra-ui/react'; 2 | import { buttonTheme } from './ButtonOutline'; 3 | 4 | const chapterStyleVariables = { 5 | colors: { 6 | gray: { 7 | '00': '#ffffff', 8 | '05': '#f5f6f7', 9 | '10': '#dfdfe2', 10 | '15': '#d0d0d5', 11 | '45': '#858591', 12 | '75': '#3b3b4f', 13 | '80': '#2a2a40', 14 | '85': '#1b1b32', 15 | '90': '#0a0a23', 16 | }, 17 | yellow: { 18 | gold: '#ffbf00', 19 | light: '#ffc300', 20 | dark: '#4d3800', 21 | }, 22 | }, 23 | }; 24 | 25 | export const chapterTheme = extendTheme(chapterStyleVariables, { 26 | components: { Button: buttonTheme }, 27 | }); 28 | -------------------------------------------------------------------------------- /client/tests/getLocationString.test.ts: -------------------------------------------------------------------------------- 1 | import getLocationString from '../src/util/getLocationString'; 2 | 3 | const venue = { 4 | street_address: null, 5 | city: 'West Laurieburgh', 6 | postal_code: '06846-9497', 7 | region: 'Pennsylvania', 8 | country: 'Bolivia', 9 | }; 10 | 11 | describe('getLocationString', () => { 12 | it('returns correct string with region, country and postal-code', () => { 13 | expect(getLocationString(venue)).toBe('Pennsylvania, Bolivia, 06846-9497'); 14 | }); 15 | it('includes city, when withCity argument is true', () => { 16 | expect(getLocationString(venue, true)).toBe( 17 | 'Pennsylvania, Bolivia, 06846-9497, West Laurieburgh', 18 | ); 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /server/src/controllers/Venue/inputs.ts: -------------------------------------------------------------------------------- 1 | import { InputType, Field, Float } from 'type-graphql'; 2 | 3 | @InputType() 4 | export class VenueInputs { 5 | @Field(() => String) 6 | name: string; 7 | 8 | @Field(() => String, { nullable: true }) 9 | street_address?: string; 10 | 11 | @Field(() => String) 12 | city: string; 13 | 14 | @Field(() => String) 15 | postal_code: string; 16 | 17 | @Field(() => String) 18 | region: string; 19 | 20 | @Field(() => String) 21 | country: string; 22 | 23 | @Field(() => Float, { nullable: true }) 24 | latitude?: number; 25 | 26 | @Field(() => Float, { nullable: true }) 27 | longitude?: number; 28 | 29 | @Field(() => [String]) 30 | venue_tags: string[]; 31 | } 32 | -------------------------------------------------------------------------------- /client/src/modules/chapters/graphql/mutations.ts: -------------------------------------------------------------------------------- 1 | import { gql } from '@apollo/client'; 2 | 3 | export const joinChapter = gql` 4 | mutation joinChapter($chapterId: Int!, $subscribe: Boolean) { 5 | joinChapter(chapterId: $chapterId, subscribe: $subscribe) { 6 | chapter_role { 7 | name 8 | } 9 | } 10 | } 11 | `; 12 | 13 | export const leaveChapter = gql` 14 | mutation leaveChapter($chapterId: Int!) { 15 | leaveChapter(chapterId: $chapterId) { 16 | user_id 17 | } 18 | } 19 | `; 20 | 21 | export const chapterSubscribe = gql` 22 | mutation toggleChapterSubscription($chapterId: Int!) { 23 | toggleChapterSubscription(chapterId: $chapterId) { 24 | subscribed 25 | } 26 | } 27 | `; 28 | -------------------------------------------------------------------------------- /server/prisma/seed/lib/util.ts: -------------------------------------------------------------------------------- 1 | import { randomItems } from './random'; 2 | 3 | export const makeBooleanIterator = (flip = false) => { 4 | return { 5 | next() { 6 | flip = !flip; 7 | return { value: flip, done: false }; 8 | }, 9 | }; 10 | }; 11 | 12 | const tagNames = [ 13 | 'GraphQl', 14 | 'NodeJs', 15 | 'JavaScript', 16 | 'TypeScript', 17 | 'HTML', 18 | 'CSS', 19 | 'Cypress', 20 | 'Tailwind', 21 | 'Sass', 22 | 'BootStrap', 23 | 'React', 24 | 'Vue', 25 | 'NextJs', 26 | 'NuxtJs', 27 | 'Angular', 28 | 'Svelte', 29 | 'SvelteKit', 30 | 'Vite', 31 | 'Prisma', 32 | 'Ruby', 33 | 'Rust', 34 | ]; 35 | 36 | export const selectTags = (count: number) => randomItems(tagNames, count, true); 37 | -------------------------------------------------------------------------------- /client/src/modules/dashboard/Sponsors/graphql/queries.ts: -------------------------------------------------------------------------------- 1 | import { gql } from '@apollo/client'; 2 | 3 | export const DASHBOARD_SPONSOR = gql` 4 | query dashboardSponsor($sponsorId: Int!) { 5 | dashboardSponsor(id: $sponsorId) { 6 | id 7 | name 8 | website 9 | logo_path 10 | type 11 | } 12 | } 13 | `; 14 | 15 | export const SPONSOR_EVENTS = gql` 16 | query sponsorWithEvents($sponsorId: Int!) { 17 | sponsorWithEvents(sponsorId: $sponsorId) { 18 | id 19 | name 20 | website 21 | logo_path 22 | type 23 | event_sponsors { 24 | event { 25 | id 26 | name 27 | invite_only 28 | canceled 29 | } 30 | } 31 | } 32 | } 33 | `; 34 | -------------------------------------------------------------------------------- /client/src/components/PageLayout/component/HeaderContainer.tsx: -------------------------------------------------------------------------------- 1 | import React, { forwardRef } from 'react'; 2 | import { Flex, GridItemProps } from '@chakra-ui/react'; 3 | 4 | interface Props { 5 | children: React.ReactNode; 6 | justifyContent?: GridItemProps['justifyContent']; 7 | } 8 | 9 | export const HeaderContainer = forwardRef( 10 | (props, ref) => { 11 | return ( 12 | 25 | ); 26 | }, 27 | ); 28 | -------------------------------------------------------------------------------- /client/src/components/TagsBox.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Box, BoxProps, Tag, TagProps } from '@chakra-ui/react'; 3 | 4 | interface Tags extends TagProps { 5 | tags: { tag: { name: string } }[]; 6 | boxProps?: BoxProps; 7 | } 8 | 9 | export const TagsBox = ({ boxProps, tags, ...tagProps }: Tags) => { 10 | return ( 11 | 12 | {tags.map(({ tag: { name } }) => ( 13 | 22 | {name} 23 | 24 | ))} 25 | 26 | ); 27 | }; 28 | -------------------------------------------------------------------------------- /server/src/controllers/Auth/check-jwt.ts: -------------------------------------------------------------------------------- 1 | import { NextFunction, Request, Response, RequestHandler } from 'express'; 2 | import { auth } from 'express-oauth2-jwt-bearer'; 3 | import { isProd } from '../../config'; 4 | 5 | // Authorization middleware. When used, the Access Token must 6 | // exist and be verified against the Auth0 JSON Web Key Set. 7 | // TODO: test that this fails in production when the keys are missing. 8 | // or sign a dev JWT and validate that. 9 | // TODO: separate the dev and prod code. 10 | export const checkJwt: RequestHandler = isProd() 11 | ? auth({ 12 | audience: process.env.AUTH0_AUDIENCE, 13 | issuerBaseURL: `https:///${process.env.AUTH0_DOMAIN}/`, 14 | }) 15 | : (_req: Request, _res: Response, next: NextFunction) => next(); 16 | -------------------------------------------------------------------------------- /client/src/components/Avatar.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Avatar as ChakraAvatar, 3 | AvatarProps as ChakraAvatarProps, 4 | } from '@chakra-ui/react'; 5 | import React from 'react'; 6 | 7 | interface AvatarProps extends ChakraAvatarProps { 8 | user: { 9 | name: string; 10 | image_url?: string | null | undefined; 11 | }; 12 | } 13 | 14 | const Avatar = React.forwardRef( 15 | ({ user: { name, image_url }, ...avatarProps }: AvatarProps, ref) => { 16 | return ( 17 | 24 | ); 25 | }, 26 | ); 27 | 28 | export default Avatar; 29 | -------------------------------------------------------------------------------- /client/tests/venueType.test.ts: -------------------------------------------------------------------------------- 1 | import { isOnline, isPhysical } from '../src/util/venueType'; 2 | import { VenueType } from '../src/generated/graphql'; 3 | 4 | describe('venueType', () => { 5 | describe('isOnline', () => { 6 | it.each([ 7 | [VenueType.Online, true], 8 | [VenueType.Physical, false], 9 | [VenueType.PhysicalAndOnline, true], 10 | ])('%s should return %s', (type, expected) => { 11 | expect(isOnline(type)).toBe(expected); 12 | }); 13 | }); 14 | 15 | describe('isPhysical', () => { 16 | it.each([ 17 | [VenueType.Online, false], 18 | [VenueType.Physical, true], 19 | [VenueType.PhysicalAndOnline, true], 20 | ])('%s should return %s', (type, expected) => { 21 | expect(isPhysical(type)).toBe(expected); 22 | }); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | - [] I have read [Chapter's contributing guidelines](https://github.com/freeCodeCamp/chapter/blob/main/CONTRIBUTING.md). 4 | - [] My pull request has a descriptive title (not a vague title like `Update README.md`). 5 | - [] My pull request targets the `main` branch of Chapter. 6 | 7 | 8 | 9 | Closes #XXXXX 10 | 11 | 12 | -------------------------------------------------------------------------------- /cypress/fixtures/chapters.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name": "Name goes here", 4 | "description": "Summary of the chapter", 5 | "city": "City it is based in", 6 | "region": "Location in the world", 7 | "country": "Home country", 8 | "category": "Type of chapter", 9 | "logo_url": "https://example.com/new-image.jpg", 10 | "banner_url": "https://example.com/image.jpg", 11 | "chapter_tags": "Tag, Test" 12 | }, 13 | { 14 | "name": "New Chapter Name", 15 | "description": "New Description", 16 | "city": "New City", 17 | "region": "New Region", 18 | "country": "New Country", 19 | "category": "New Category", 20 | "logo_url": "https://example.com/new-image.jpg", 21 | "banner_url": "https://example.com/new-image.jpg", 22 | "chapter_tags": "Tag, Test" 23 | } 24 | ] 25 | -------------------------------------------------------------------------------- /server/src/controllers/Messages/validators/isListEmpty.ts: -------------------------------------------------------------------------------- 1 | import { 2 | registerDecorator, 3 | ValidationOptions, 4 | ValidatorConstraint, 5 | ValidatorConstraintInterface, 6 | } from 'class-validator'; 7 | 8 | @ValidatorConstraint({ async: true }) 9 | export class IsListEmptyConstraint implements ValidatorConstraintInterface { 10 | validate(emailList: string[]) { 11 | return emailList.length !== 0; 12 | } 13 | } 14 | 15 | export function IsListEmpty(validationOptions?: ValidationOptions) { 16 | return function (object: Record, propertyName: string) { 17 | registerDecorator({ 18 | target: object.constructor, 19 | propertyName: propertyName, 20 | options: validationOptions, 21 | constraints: [], 22 | validator: IsListEmptyConstraint, 23 | }); 24 | }; 25 | } 26 | -------------------------------------------------------------------------------- /.github/workflows/update-license.yaml: -------------------------------------------------------------------------------- 1 | name: Update copyright year(s) in license file 2 | 3 | on: 4 | workflow_dispatch: 5 | schedule: 6 | - cron: "0 3 1 1 *" 7 | 8 | jobs: 9 | run: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3 13 | with: 14 | fetch-depth: 0 15 | - uses: FantasticFiasco/action-update-license-year@26ffac173f956c25f7467aa8e6f750eed24a1b7a # v2 16 | with: 17 | token: ${{ secrets.GITHUB_TOKEN }} 18 | path: | 19 | README.md 20 | LICENSE 21 | commitTitle: "docs: update copyright years" 22 | prTitle: "docs: update copyright years" 23 | prBody: It's that time of the year, let's update the license 24 | labels: Documentation -------------------------------------------------------------------------------- /server/src/util/auth0.ts: -------------------------------------------------------------------------------- 1 | import { readFile } from 'fs/promises'; 2 | import { resolve } from 'path'; 3 | import fetch from 'node-fetch'; 4 | 5 | export function fetchUserInfo(token: string): Promise<{ email?: string }> { 6 | // TODO: the dev-only code is scattered across several files. Is it possible 7 | // to use, say, passport and confine the code to individual strategies? 8 | if (process.env.USE_AUTH0 === 'false') { 9 | const devDataFile = resolve( 10 | __dirname, 11 | '../../../scripts/dev-data/current-user.json', 12 | ); 13 | return readFile(devDataFile, 'utf8').then(JSON.parse); 14 | } else { 15 | return fetch(`https://${process.env.AUTH0_DOMAIN}/userinfo`, { 16 | headers: { 17 | Authorization: `Bearer ${token}`, 18 | }, 19 | }).then((res) => res.json()); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /server/src/controllers/Chapter/inputs.ts: -------------------------------------------------------------------------------- 1 | import { InputType, Field } from 'type-graphql'; 2 | 3 | import { Chapter } from '../../graphql-types'; 4 | 5 | @InputType() 6 | export class ChapterInputs implements Omit { 7 | @Field(() => String) 8 | name: string; 9 | 10 | @Field(() => String) 11 | description: string; 12 | 13 | @Field(() => String) 14 | category: string; 15 | 16 | @Field(() => String) 17 | city: string; 18 | 19 | @Field(() => String) 20 | region: string; 21 | 22 | @Field(() => String) 23 | country: string; 24 | 25 | @Field(() => String) 26 | banner_url: string; 27 | 28 | @Field(() => String) 29 | logo_url: string; 30 | 31 | @Field(() => String, { nullable: true }) 32 | chat_url?: string | null; 33 | 34 | @Field(() => [String]) 35 | chapter_tags: string[]; 36 | } 37 | -------------------------------------------------------------------------------- /client/src/components/UserName.tsx: -------------------------------------------------------------------------------- 1 | import { Text, TextProps } from '@chakra-ui/react'; 2 | import React from 'react'; 3 | 4 | import { useUser } from '../modules/auth/user'; 5 | 6 | interface UserNameProps extends TextProps { 7 | highlight?: boolean; 8 | user: { 9 | id: number; 10 | name: string; 11 | }; 12 | } 13 | 14 | export const getNameText = (name: string) => { 15 | return name || 'anonymous'; 16 | }; 17 | 18 | const UserName = ({ 19 | highlight = true, 20 | user: { id, name }, 21 | ...textProps 22 | }: UserNameProps) => { 23 | const { user } = useUser(); 24 | 25 | return ( 26 | 31 | {getNameText(name)} 32 | 33 | ); 34 | }; 35 | 36 | export default UserName; 37 | -------------------------------------------------------------------------------- /cypress/e2e/universal-components/menu.cy.ts: -------------------------------------------------------------------------------- 1 | describe('menu', () => { 2 | it('should show a link to the dashboard for an owner', () => { 3 | cy.login('foo@bar.com'); 4 | cy.visit('/'); 5 | cy.get('[data-cy=menu-button]').click(); 6 | cy.get('[data-cy=menu-dashboard-link]').should('be.visible'); 7 | }); 8 | 9 | it('should NOT show a link to the dashboard for a member', () => { 10 | cy.login('test@user.org'); 11 | cy.visit('/'); 12 | cy.get('[data-cy=menu-button]').click(); 13 | cy.get('[data-cy=menu-dashboard-link]').should('not.exist'); 14 | }); 15 | 16 | it('should show a link to the dashboard for an admin', () => { 17 | cy.login('admin@of.chapter.one'); 18 | cy.visit('/'); 19 | cy.get('[data-cy=menu-button]').click(); 20 | cy.get('[data-cy=menu-dashboard-link]').should('be.visible'); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /docker-compose.ci.yml: -------------------------------------------------------------------------------- 1 | # Production-like configuration for testing 2 | version: "3.7" 3 | services: 4 | db: 5 | image: postgres:14 6 | environment: 7 | - POSTGRES_USER=${DB_USER} 8 | - POSTGRES_PASSWORD=${DB_PASSWORD} 9 | - POSTGRES_DB=${DB_NAME} 10 | ports: 11 | - "54320:5432" 12 | server: 13 | depends_on: 14 | - db 15 | command: ["npm", "run", "start:coverage"] 16 | # TODO: ugly hack to get the dev login working, remove the volume once there is 17 | # a less silly dev login 18 | volumes: 19 | - ./scripts/dev-data/:/usr/chapter/scripts/dev-data/ 20 | environment: 21 | - DATABASE_URL=postgres://${DB_USER}:${DB_PASSWORD}@db:5432/${DB_NAME} 22 | - EMAIL_HOST=mailhog 23 | mailhog: 24 | restart: unless-stopped 25 | image: mailhog/mailhog 26 | ports: 27 | - "1025:1025" 28 | - "8025:8025" -------------------------------------------------------------------------------- /client/src/hooks/useDisableWhileSubmitting.ts: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | 3 | import { useAlert } from './useAlert'; 4 | 5 | export const useDisableWhileSubmitting = ({ 6 | onSubmit, 7 | enableOnSuccess = false, 8 | }: { 9 | onSubmit: (x: T) => Promise; 10 | enableOnSuccess?: boolean; 11 | }) => { 12 | const [loading, setLoading] = useState(false); 13 | const addAlert = useAlert(); 14 | return { 15 | loading, 16 | disableWhileSubmitting: async (data: T) => { 17 | setLoading(true); 18 | try { 19 | await onSubmit(data); 20 | } catch (err) { 21 | addAlert({ 22 | title: 'Something went wrong.', 23 | status: 'error', 24 | }); 25 | console.error(err); 26 | setLoading(false); 27 | } 28 | if (enableOnSuccess) { 29 | setLoading(false); 30 | } 31 | }, 32 | }; 33 | }; 34 | -------------------------------------------------------------------------------- /client/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "alwaysStrict": true, 4 | "esModuleInterop": true, 5 | "forceConsistentCasingInFileNames": true, 6 | "isolatedModules": true, 7 | "jsx": "preserve", 8 | "lib": ["dom", "es2017"], 9 | "baseUrl": "./src/", 10 | "paths": { 11 | "src/*": ["./src/*"] 12 | }, 13 | "module": "commonjs", 14 | "moduleResolution": "node", 15 | "noEmit": true, 16 | "noFallthroughCasesInSwitch": true, 17 | "noUnusedLocals": true, 18 | "noUnusedParameters": true, 19 | "resolveJsonModule": true, 20 | "skipLibCheck": true, 21 | "strict": true, 22 | "target": "esnext", 23 | "allowJs": true, 24 | "incremental": true, 25 | "strictPropertyInitialization": false, 26 | "experimentalDecorators": true 27 | }, 28 | "exclude": ["node_modules"], 29 | "include": ["**/*.ts", "**/*.tsx", "../common/**/*.ts"] 30 | } 31 | -------------------------------------------------------------------------------- /client/src/modules/dashboard/shared/components/DashboardLoading.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import styles from '../../../../styles/Page.module.css'; 4 | 5 | type Props = 6 | | { 7 | error?: Error; 8 | errors?: never; 9 | } 10 | | { 11 | error?: never; 12 | errors?: Error[]; 13 | }; 14 | 15 | export const DashboardLoading = ({ error, errors }: Props) => { 16 | if (error || errors?.length) { 17 | return ( 18 | <> 19 |

{'Error...'}

20 | {error && ( 21 |
22 | {error.message} 23 |
24 | )} 25 | {errors?.map(({ message }) => ( 26 |
27 | {message} 28 |
29 | ))} 30 | 31 | ); 32 | } 33 | return

{'Loading...'}

; 34 | }; 35 | -------------------------------------------------------------------------------- /client/src/util/check-permission.ts: -------------------------------------------------------------------------------- 1 | import { checker } from '../../../common/authorization'; 2 | import type { 3 | ChapterPermission, 4 | InstancePermission, 5 | } from '../../../common/permissions'; 6 | import { UserContextType } from '../modules/auth/user'; 7 | 8 | export const checkInstancePermission = ( 9 | user: UserContextType['user'], 10 | requiredPermission: InstancePermission, 11 | ) => { 12 | if (!user) return false; 13 | const context = { user, events: [], venues: [] }; 14 | return checker(context, requiredPermission, {}); 15 | }; 16 | 17 | export const checkChapterPermission = ( 18 | user: UserContextType['user'], 19 | requiredPermission: ChapterPermission, 20 | variableValues: { chapterId?: number; eventId?: number; venueId?: number }, 21 | ) => { 22 | if (!user) return false; 23 | const context = { user, events: [], venues: [] }; 24 | return checker(context, requiredPermission, variableValues); 25 | }; 26 | -------------------------------------------------------------------------------- /client/src/components/Alerts/AlertContext.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-empty-function */ 2 | import React from 'react'; 3 | 4 | import { 5 | AlertProps as ChakraAlertProps, 6 | AlertDescriptionProps, 7 | AlertIconProps, 8 | AlertTitleProps, 9 | CloseButtonProps, 10 | } from '@chakra-ui/react'; 11 | 12 | export interface AlertProps extends ChakraAlertProps { 13 | alertId: number; 14 | description?: string; 15 | descriptionProps?: AlertDescriptionProps; 16 | iconProps?: AlertIconProps; 17 | titleProps?: AlertTitleProps; 18 | closeButtonProps?: CloseButtonProps; 19 | } 20 | 21 | type AlertContextType = { 22 | alertList: AlertProps[]; 23 | removeAlert: (idToRemove: number) => void; 24 | addAlert: (alert: Omit) => void; 25 | }; 26 | 27 | export const AlertContext = React.createContext({ 28 | alertList: [], 29 | removeAlert: () => {}, 30 | addAlert: () => {}, 31 | }); 32 | -------------------------------------------------------------------------------- /client/src/modules/auth/providers/dev.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { AuthContext } from './common-context'; 4 | 5 | export const DevAuthProvider = ({ 6 | children, 7 | }: { 8 | children: React.ReactNode; 9 | }) => { 10 | const [isAuthenticated, setIsAuthenticated] = React.useState( 11 | typeof window !== 'undefined' && 12 | !!localStorage.getItem('dev-login-authenticated'), 13 | ); 14 | const login = () => { 15 | localStorage.setItem('dev-login-authenticated', 'true'); 16 | setIsAuthenticated(true); 17 | }; 18 | const logout = () => { 19 | localStorage.removeItem('dev-login-authenticated'); 20 | setIsAuthenticated(false); 21 | }; 22 | const getToken = () => Promise.resolve('fake-token'); 23 | return ( 24 | 27 | {children} 28 | 29 | ); 30 | }; 31 | -------------------------------------------------------------------------------- /server/src/util/adminedChapters.ts: -------------------------------------------------------------------------------- 1 | import { Permission } from '../../../common/permissions'; 2 | 3 | interface User { 4 | instance_role: { 5 | instance_role_permissions: { instance_permission: { name: string } }[]; 6 | }; 7 | } 8 | 9 | export const isAdminFromInstanceRole = ({ instance_role }: User) => 10 | instance_role.instance_role_permissions.some( 11 | ({ instance_permission }) => 12 | instance_permission.name === Permission.ChapterEdit, 13 | ); 14 | 15 | export const isChapterAdminWhere = (user_id: number) => ({ 16 | chapter_users: { 17 | some: { 18 | AND: [ 19 | { user_id }, 20 | { 21 | chapter_role: { 22 | chapter_role_permissions: { 23 | some: { 24 | chapter_permission: { name: Permission.ChapterEdit }, 25 | }, 26 | }, 27 | }, 28 | }, 29 | ], 30 | }, 31 | }, 32 | user_bans: { none: { user_id } }, 33 | }); 34 | -------------------------------------------------------------------------------- /docker-compose.override.yml: -------------------------------------------------------------------------------- 1 | # Dev specific configuration 2 | version: "3.7" 3 | services: 4 | db: 5 | image: postgres:14 6 | environment: 7 | - POSTGRES_USER=${DB_USER} 8 | - POSTGRES_PASSWORD=${DB_PASSWORD} 9 | - POSTGRES_DB=${DB_NAME} 10 | ports: 11 | - "54320:5432" 12 | server: 13 | restart: "no" 14 | depends_on: 15 | - db 16 | build: 17 | target: development 18 | command: sh -c './wait-for db:5432 -- npm run dev' 19 | volumes: 20 | - ./:/usr/chapter/ 21 | environment: 22 | - DATABASE_URL=postgres://${DB_USER}:${DB_PASSWORD}@db:5432/${DB_NAME} 23 | - EMAIL_HOST=mailhog 24 | 25 | client: 26 | restart: "no" 27 | build: 28 | target: development 29 | command: npm run dev 30 | volumes: 31 | - ./:/usr/chapter/ 32 | 33 | mailhog: 34 | depends_on: 35 | - server 36 | image: mailhog/mailhog 37 | ports: 38 | - "1025:1025" 39 | - "8025:8025" 40 | -------------------------------------------------------------------------------- /client/src/modules/events/components/UsersList.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Heading, HStack, List, ListItem } from '@chakra-ui/react'; 3 | import Avatar from '../../../components/Avatar'; 4 | import UserName from '../../../components/UserName'; 5 | 6 | export const UsersList = ({ 7 | text, 8 | users, 9 | }: { 10 | text: string; 11 | users: { 12 | user: { id: number; name: string; image_url?: string | null | undefined }; 13 | }[]; 14 | }) => ( 15 | <> 16 | 21 | {text}: 22 | 23 | 24 | {users.map(({ user }) => ( 25 | 26 | 27 | 28 | 29 | 30 | 31 | ))} 32 | 33 | 34 | ); 35 | -------------------------------------------------------------------------------- /cypress/e2e/auth/login.cy.ts: -------------------------------------------------------------------------------- 1 | // TODO: consider re-implementing this test as a programmatic call to Auth0 2 | // followed by a call to our server 3 | // https://auth0.com/blog/end-to-end-testing-with-cypress-and-auth0/ covers the 4 | // first part 5 | describe.skip('login', () => { 6 | beforeEach(() => { 7 | cy.task('seedDb'); 8 | cy.register('An User', 'an@user.com'); 9 | cy.mhDeleteAll(); 10 | }); 11 | 12 | it('should return an error if the user is no longer in the db', () => { 13 | const body = { 14 | operationName: 'me', 15 | query: 'query me { me {id} }', 16 | }; 17 | cy.request({ 18 | method: 'POST', 19 | url: Cypress.env('GQL_URL'), 20 | body, 21 | headers: { 22 | authorization: `Bearer ${Cypress.env('TOKEN_DELETED_USER')}`, 23 | }, 24 | failOnStatusCode: false, 25 | }).then((response) => { 26 | expect(response.status).to.eq(401); 27 | expect(response.body.message).to.eq('User not found'); 28 | }); 29 | }); 30 | }); 31 | -------------------------------------------------------------------------------- /server/tests/smtp-helpers.test.ts: -------------------------------------------------------------------------------- 1 | import assert from 'assert'; 2 | 3 | import { allValuesAreDefined } from '../src/services/mail-providers/smtp-helpers'; 4 | 5 | describe('allValuesAreDefined', () => { 6 | it('it should correctly catch undefined, null, and empty string values', () => { 7 | const pass1 = ['test', 1, 0]; 8 | const pass2 = ['test', 1, {}]; 9 | const pass3 = ['test', 1, 239]; 10 | 11 | const fail1 = ['', 'test', 234]; 12 | const fail2 = [undefined, 'testString', 70]; 13 | const fail3 = [null, 'test123', 2342]; 14 | const fail4 = [undefined, undefined, undefined]; 15 | 16 | assert.equal(allValuesAreDefined(pass1), true); 17 | assert.equal(allValuesAreDefined(pass2), true); 18 | assert.equal(allValuesAreDefined(pass3), true); 19 | assert.equal(allValuesAreDefined(fail1), false); 20 | assert.equal(allValuesAreDefined(fail2), false); 21 | assert.equal(allValuesAreDefined(fail3), false); 22 | assert.equal(allValuesAreDefined(fail4), false); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /server/src/graphql-types/ChapterUser.ts: -------------------------------------------------------------------------------- 1 | import { Field, ObjectType, Int } from 'type-graphql'; 2 | import { User } from './User'; 3 | import { ChapterRole } from './ChapterRole'; 4 | import { Chapter } from './Chapter'; 5 | 6 | @ObjectType() 7 | export class ChapterUser { 8 | @Field(() => Int) 9 | user_id: number; 10 | 11 | @Field(() => Int) 12 | chapter_id: number; 13 | 14 | @Field(() => Date) 15 | joined_date: Date; 16 | 17 | @Field(() => Boolean) 18 | subscribed: boolean; 19 | 20 | @Field(() => Boolean, { nullable: true }) 21 | is_bannable?: boolean; 22 | } 23 | 24 | @ObjectType() 25 | export class ChapterUserWithRole extends ChapterUser { 26 | @Field(() => ChapterRole) 27 | chapter_role: ChapterRole; 28 | } 29 | 30 | @ObjectType() 31 | export class ChapterUserWithRelations extends ChapterUserWithRole { 32 | @Field(() => User) 33 | user: User; 34 | } 35 | 36 | @ObjectType() 37 | export class UserChapter extends ChapterUserWithRole { 38 | @Field(() => Chapter) 39 | chapter: Chapter; 40 | } 41 | -------------------------------------------------------------------------------- /client/src/components/SharePopOver.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { 3 | Popover, 4 | PopoverTrigger, 5 | PopoverContent, 6 | PopoverBody, 7 | PopoverArrow, 8 | PopoverCloseButton, 9 | } from '@chakra-ui/popover'; 10 | import { Button } from '@chakra-ui/button'; 11 | import { CopyIcon } from '@chakra-ui/icons'; 12 | 13 | export const SharePopOver = ({ 14 | link, 15 | size, 16 | }: { 17 | link: string; 18 | size?: string | string[]; 19 | }) => { 20 | return ( 21 | 22 | 23 | 31 | 32 | 33 | 34 | 35 | Link copied to clipboard! 36 | 37 | 38 | ); 39 | }; 40 | -------------------------------------------------------------------------------- /client/src/modules/dashboard/Venues/pages/NewVenuePage.tsx: -------------------------------------------------------------------------------- 1 | import NextError from 'next/error'; 2 | import React, { ReactElement } from 'react'; 3 | 4 | import { DashboardLayout } from '../../shared/components/DashboardLayout'; 5 | import VenueForm from '../components/VenueForm'; 6 | 7 | import { NextPageWithLayout } from '../../../../pages/_app'; 8 | import { useSubmitVenue } from '../utils'; 9 | import { useUser } from '../../../auth/user'; 10 | 11 | export const NewVenuePage: NextPageWithLayout = () => { 12 | const { user } = useUser(); 13 | 14 | const onSubmit = useSubmitVenue(); 15 | 16 | if (!user) return ; 17 | 18 | return ( 19 | 25 | ); 26 | }; 27 | 28 | NewVenuePage.getLayout = function getLayout(page: ReactElement) { 29 | return {page}; 30 | }; 31 | -------------------------------------------------------------------------------- /client/src/modules/dashboard/Venues/graphql/mutations.ts: -------------------------------------------------------------------------------- 1 | import { gql } from '@apollo/client'; 2 | 3 | export const createVenue = gql` 4 | mutation createVenue($chapterId: Int!, $data: VenueInputs!) { 5 | createVenue(chapterId: $chapterId, data: $data) { 6 | id 7 | name 8 | street_address 9 | city 10 | postal_code 11 | region 12 | country 13 | latitude 14 | longitude 15 | } 16 | } 17 | `; 18 | 19 | export const updateVenue = gql` 20 | mutation updateVenue($venueId: Int!, $chapterId: Int!, $data: VenueInputs!) { 21 | updateVenue(id: $venueId, _onlyUsedForAuth: $chapterId, data: $data) { 22 | id 23 | name 24 | street_address 25 | city 26 | postal_code 27 | region 28 | country 29 | latitude 30 | longitude 31 | } 32 | } 33 | `; 34 | 35 | export const deleteVenue = gql` 36 | mutation deleteVenue($venueId: Int!, $chapterId: Int!) { 37 | deleteVenue(_onlyUsedForAuth: $chapterId, id: $venueId) { 38 | id 39 | } 40 | } 41 | `; 42 | -------------------------------------------------------------------------------- /client/tests/tags.test.ts: -------------------------------------------------------------------------------- 1 | import { parseTags } from '../src/util/tags'; 2 | 3 | describe('tags', () => { 4 | describe('parseTags', () => { 5 | it.each([ 6 | { string: '', expected: [], description: 'empty string' }, 7 | { 8 | string: 'Tag', 9 | expected: ['Tag'], 10 | description: 'single tag', 11 | }, 12 | { 13 | string: 'Tag, Test', 14 | expected: ['Tag', 'Test'], 15 | description: 'simple case', 16 | }, 17 | { 18 | string: ' Tag , Test ', 19 | expected: ['Tag', 'Test'], 20 | description: 'string with lots of empty space', 21 | }, 22 | { 23 | string: 'Tag, Another tag, Test', 24 | expected: ['Tag', 'Another tag', 'Test'], 25 | description: 'string with two-word tag', 26 | }, 27 | ])( 28 | 'should return array with expected tags for $description', 29 | ({ string, expected }) => { 30 | expect(parseTags(string)).toStrictEqual(expected); 31 | }, 32 | ); 33 | }); 34 | }); 35 | -------------------------------------------------------------------------------- /cypress/support/util.ts: -------------------------------------------------------------------------------- 1 | export function expectError(error: string) { 2 | return function (response) { 3 | expect(response.status).to.eq(200); 4 | const errors = response.body?.errors; 5 | expect(errors, 'Expected response to contain one error').to.have.length(1); 6 | expect(errors[0].message).to.eq(error); 7 | }; 8 | } 9 | 10 | export function expectToBeRejected(response) { 11 | expectError("Access denied! You don't have permission for this action!")( 12 | response, 13 | ); 14 | } 15 | 16 | export function expectNoErrors(response) { 17 | expect(response.status).to.eq(200); 18 | expect(response.body.errors, 'Expected response to not contain errors').not.to 19 | .exist; 20 | } 21 | 22 | export function gqlOptions(body: any, additionalOptions?: any) { 23 | return { 24 | method: 'POST', 25 | url: Cypress.env('GQL_URL'), 26 | body: body, 27 | ...additionalOptions, 28 | }; 29 | } 30 | 31 | export function getFirstPathParam(link: JQuery) { 32 | return parseInt(link.attr('href').match(/\d+/)[0]); 33 | } 34 | -------------------------------------------------------------------------------- /server/tests/fixtures/longString.ts: -------------------------------------------------------------------------------- 1 | const longString = 2 | 'xDosFbwbobkGtUcoT7ZjwamLg7AU9DXKl1JXAdffpxnDag44o052oY324pMcFD1x2UEIoHrJlhSdIsxp6w6ABJXG35usgQWIkpcqiIixO1dMrewnShe4gWYwaaIR77O91pxYhUJ09z8qRGCls2r8EAY5g6ZDRfzgcYa6iAqXKhHolq0pr1SSef6ish6CyXRj7MC6nuSVn2HHRG5gsPst9RUYYq6odsMqzDyVZmZrpWCY41Xa6p6TjC40YWSVjAab1xANo5p7evDHu2Cj3HbbPFN5iNix3Y4ngrSvRCczhMTdc2zVVKltW8WKKnX8YKeSw3oResrAdiuAHMUazwemse2m3XU3KTDyS23xrqzWDnN4S1JWziSaCAih4gzlOX7WOGhMcd2UmH5qaZT4b8ABt7xVcrvptxerTu2nUlb0r2782cx8Ur5cNxF4DKiSdqfU6sWRUOGYZ5f6Inv1o4N97SmZbBdtAael7EvwcCnKyyV5FkksjsXf6sORA8BAYb7Fi4oxUovujVjWAjLop8lrrFpVQkcMTzEsS5QRNhRByPkQHVCVQHfA1hHmeTJjN5gZlG6mgu1FHKSh3PhJSie9YHaogG2ybKvKdnMozsb5odIIdSc98xocVAatlWR1EQPQ4OmNqPOxTuUmsCJ8oBDGHnuLb9CN5K4NHqdd8bDowaf0kCFyuacB4XAzDsNscNjeBjpJPu0NMivrIdqwjQzxGXoQBwbdnQTId4OXlxkkYcU05e5pPcZNNHr3GVlSlCZRkbIqIucq56Y8ZtoqLhQSqj58As3OUfx0wZk1YH2GcrYscpDN1t8RlcGR9gavBomzZKXmAyyCAYzz1tuqQ5n5TQMXSuQyzsA8ZQEIHnKKWA1aczZYEttXzu0WR5qoNQQWy9DF98QldMzpZcgih5aPxI3tcMXp9uvx9H3QTpuES7VZTc2xJaMckPzXtev3oM8jyeXlYLw0xIEn7Lylw5h41YdE80BMdRrplXX6GAOC'; 3 | export default longString; 4 | -------------------------------------------------------------------------------- /client/src/modules/events/graphql/mutations.ts: -------------------------------------------------------------------------------- 1 | import { gql } from '@apollo/client'; 2 | 3 | // TODO: mutations must return something, so it returns confirmed_at for now. 4 | // Should it return something the client can use? 5 | export const attendEvent = gql` 6 | mutation attendEvent($eventId: Int!, $chapterId: Int!) { 7 | attendEvent(eventId: $eventId, chapterId: $chapterId) { 8 | updated_at 9 | attendance { 10 | name 11 | } 12 | } 13 | } 14 | `; 15 | 16 | export const cancelAttendance = gql` 17 | mutation cancelAttendance($eventId: Int!) { 18 | cancelAttendance(eventId: $eventId) { 19 | updated_at 20 | } 21 | } 22 | `; 23 | 24 | export const subscribeToEvent = gql` 25 | mutation subscribeToEvent($eventId: Int!) { 26 | subscribeToEvent(eventId: $eventId) { 27 | subscribed 28 | } 29 | } 30 | `; 31 | 32 | export const unsubscribeFromEvent = gql` 33 | mutation unsubscribeFromEvent($eventId: Int!) { 34 | unsubscribeFromEvent(eventId: $eventId) { 35 | subscribed 36 | } 37 | } 38 | `; 39 | -------------------------------------------------------------------------------- /server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowSyntheticDefaultImports": true, 4 | "jsx": "preserve", 5 | "lib": ["dom", "es2017"], 6 | "module": "commonjs", 7 | "moduleResolution": "node", 8 | "noUnusedLocals": true, 9 | "noUnusedParameters": true, 10 | "preserveConstEnums": true, 11 | "removeComments": false, 12 | "skipLibCheck": true, 13 | "sourceMap": true, 14 | "strict": true, 15 | "target": "ES2018", 16 | "experimentalDecorators": true, 17 | "emitDecoratorMetadata": true, 18 | "strictPropertyInitialization": false, 19 | "strictFunctionTypes": false, 20 | "resolveJsonModule": true, 21 | "forceConsistentCasingInFileNames": true, 22 | "esModuleInterop": true, 23 | "isolatedModules": true, 24 | "types": ["jest", "node"], 25 | "typeRoots": ["src/common-types/index.d.ts", "../node_modules/@types"], 26 | "incremental": true 27 | }, 28 | "include": ["src/**/*", "prisma/**/*", "reminders/**/*", "tests/**/*", "../common/**/*"], 29 | "exclude": ["node_modules"] 30 | } 31 | -------------------------------------------------------------------------------- /client/src/components/Alerts/AlertProvider.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | 3 | import { AlertContext, AlertProps } from './AlertContext'; 4 | 5 | export const AlertProvider = ({ children }: { children: React.ReactNode }) => { 6 | const [alerts, setAlerts] = useState<{ 7 | alertList: AlertProps[]; 8 | nextId: number; 9 | }>({ 10 | alertList: [], 11 | nextId: 0, 12 | }); 13 | 14 | const removeAlert = (idToRemove: number) => { 15 | setAlerts(({ alertList, nextId }) => ({ 16 | alertList: alertList.filter(({ alertId }) => alertId !== idToRemove), 17 | nextId, 18 | })); 19 | }; 20 | 21 | const addAlert = (alert: Omit) => { 22 | setAlerts(({ alertList, nextId }) => ({ 23 | alertList: [...alertList, { ...alert, alertId: nextId }], 24 | nextId: ++nextId, 25 | })); 26 | }; 27 | 28 | return ( 29 | 32 | {children} 33 | 34 | ); 35 | }; 36 | -------------------------------------------------------------------------------- /client/src/modules/dashboard/Events/components/Fields.tsx: -------------------------------------------------------------------------------- 1 | import { Link, Text } from '@chakra-ui/react'; 2 | import React from 'react'; 3 | import { Link as NextLink } from 'chakra-next-link'; 4 | 5 | export const LinkField = ({ 6 | children, 7 | isExternal = false, 8 | href, 9 | label, 10 | }: { 11 | children: React.ReactNode; 12 | isExternal?: boolean; 13 | href?: string; 14 | label: string; 15 | }) => { 16 | const LinkComponent = isExternal ? Link : NextLink; 17 | return ( 18 | 19 | {label}:{' '} 20 | 25 | {children} 26 | 27 | 28 | ); 29 | }; 30 | 31 | export const TextField = ({ 32 | children, 33 | label, 34 | }: { 35 | children: React.ReactNode; 36 | label: string; 37 | }) => ( 38 | 39 | {label}:{' '} 40 | 41 | {children} 42 | 43 | 44 | ); 45 | -------------------------------------------------------------------------------- /client/src/modules/dashboard/Venues/graphql/queries.ts: -------------------------------------------------------------------------------- 1 | import { gql } from '@apollo/client'; 2 | 3 | export const DASHBOARD_VENUES = gql` 4 | query dashboardVenues { 5 | dashboardVenues { 6 | id 7 | chapter_id 8 | name 9 | street_address 10 | city 11 | postal_code 12 | region 13 | country 14 | latitude 15 | longitude 16 | chapter { 17 | id 18 | name 19 | } 20 | } 21 | } 22 | `; 23 | 24 | export const DASHBOARD_VENUE = gql` 25 | query venue($venueId: Int!) { 26 | venue(venueId: $venueId) { 27 | id 28 | name 29 | street_address 30 | city 31 | postal_code 32 | region 33 | country 34 | latitude 35 | longitude 36 | chapter { 37 | id 38 | name 39 | events { 40 | id 41 | name 42 | canceled 43 | invite_only 44 | } 45 | } 46 | venue_tags { 47 | tag { 48 | id 49 | name 50 | } 51 | } 52 | } 53 | } 54 | `; 55 | -------------------------------------------------------------------------------- /server/tests/fixtures/events.ts: -------------------------------------------------------------------------------- 1 | import { events_venue_type_enum } from '@prisma/client'; 2 | 3 | export const mockEvent = { 4 | created_at: new Date('2022-06-01T15:19:24.874Z'), 5 | updated_at: new Date('2022-06-01T15:19:24.875Z'), 6 | id: 1, 7 | name: 'Marvin - Wisozk', 8 | description: 'eum incidunt nesciunt', 9 | url: 'https://common-jogging.info', 10 | streaming_url: null, 11 | venue_type: 'Physical' as events_venue_type_enum, 12 | start_at: new Date('2022-06-03T18:00:00.000Z'), 13 | ends_at: new Date('2022-06-03T18:00:00.000Z'), 14 | canceled: true, 15 | capacity: 193, 16 | invite_only: false, 17 | image_url: 'http://loremflickr.com/640/480/nature?15145', 18 | venue_id: 1, 19 | chapter_id: 1, 20 | }; 21 | 22 | // All the authChecker needs to know is which chapter a given event belongs to: 23 | export const events = [ 24 | { 25 | id: 1, 26 | chapter_id: 1, 27 | }, 28 | { 29 | id: 2, 30 | chapter_id: 1, 31 | }, 32 | { 33 | id: 3, 34 | chapter_id: 2, 35 | }, 36 | { 37 | id: 4, 38 | chapter_id: 3, 39 | }, 40 | ]; 41 | -------------------------------------------------------------------------------- /server/src/controllers/Messages/validators/findDuplicateEmails.ts: -------------------------------------------------------------------------------- 1 | import { 2 | registerDecorator, 3 | ValidationOptions, 4 | ValidatorConstraint, 5 | ValidatorConstraintInterface, 6 | } from 'class-validator'; 7 | 8 | @ValidatorConstraint({ async: true }) 9 | export class FindDuplicateEmailsConstraint 10 | implements ValidatorConstraintInterface 11 | { 12 | validate(emailList: string[]) { 13 | const checkedEmails: Record = {}; 14 | for (let i = 0; i <= emailList.length; i++) { 15 | if (emailList[i] in checkedEmails) { 16 | return false; 17 | } 18 | checkedEmails[emailList[i]] = 1; 19 | } 20 | return true; 21 | } 22 | } 23 | 24 | export function FindDuplicateEmails(validationOptions?: ValidationOptions) { 25 | return function (object: Record, propertyName: string) { 26 | registerDecorator({ 27 | target: object.constructor, 28 | propertyName: propertyName, 29 | options: validationOptions, 30 | constraints: [], 31 | validator: FindDuplicateEmailsConstraint, 32 | }); 33 | }; 34 | } 35 | -------------------------------------------------------------------------------- /client/src/modules/auth/graphql/queries.ts: -------------------------------------------------------------------------------- 1 | import { gql } from '@apollo/client'; 2 | 3 | export const meQuery = gql` 4 | query me { 5 | me { 6 | id 7 | name 8 | instance_role { 9 | instance_role_permissions { 10 | instance_permission { 11 | name 12 | } 13 | } 14 | } 15 | admined_chapters { 16 | id 17 | name 18 | has_calendar 19 | } 20 | auto_subscribe 21 | image_url 22 | user_bans { 23 | chapter_id 24 | } 25 | user_chapters { 26 | chapter_id 27 | chapter_role { 28 | chapter_role_permissions { 29 | chapter_permission { 30 | name 31 | } 32 | } 33 | name 34 | } 35 | subscribed 36 | } 37 | user_events { 38 | event_id 39 | event_role { 40 | event_role_permissions { 41 | event_permission { 42 | name 43 | } 44 | } 45 | } 46 | subscribed 47 | } 48 | } 49 | } 50 | `; 51 | -------------------------------------------------------------------------------- /client/src/components/SponsorsCard.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Heading, Flex, Link, Badge } from '@chakra-ui/react'; 2 | import React from 'react'; 3 | 4 | import { Sponsor } from '../generated/graphql'; 5 | 6 | interface SponsorsProps { 7 | sponsors: { sponsor: Sponsor }[]; 8 | } 9 | 10 | const SponsorsCard = ({ sponsors }: SponsorsProps) => { 11 | return ( 12 | 13 | 14 | Sponsors 15 | 16 | 17 | {sponsors.map(({ sponsor }) => ( 18 | 26 | 27 | {sponsor.name} 28 | 29 | {sponsor.type} 30 | 31 | ))} 32 | 33 | 34 | ); 35 | }; 36 | export default SponsorsCard; 37 | -------------------------------------------------------------------------------- /server/prisma/seed/factories/sponsors.factory.ts: -------------------------------------------------------------------------------- 1 | import { faker } from '@faker-js/faker'; 2 | import { Prisma } from '@prisma/client'; 3 | 4 | import { prisma } from '../../../src/prisma'; 5 | import { randomEnum } from '../lib/random'; 6 | 7 | const { company, internet, system } = faker; 8 | 9 | enum SponsorTypes { 10 | 'FOOD', 11 | 'VENUE', 12 | 'OTHER', 13 | } 14 | 15 | const createSponsors = async (): Promise => { 16 | const sponsors: number[] = []; 17 | 18 | for (let i = 0; i < 4; i++) { 19 | const name = company.companyName(); 20 | const website = internet.url(); 21 | const logo_path = system.commonFileName('png'); 22 | const type = String(randomEnum(SponsorTypes)); 23 | 24 | const sponsorData: Prisma.sponsorsCreateInput = { 25 | name, 26 | website, 27 | logo_path, 28 | type, 29 | }; 30 | 31 | // TODO: batch this once createMany returns the records. 32 | const sponsor = await prisma.sponsors.create({ data: sponsorData }); 33 | 34 | sponsors.push(sponsor.id); 35 | } 36 | 37 | return sponsors; 38 | }; 39 | 40 | export default createSponsors; 41 | -------------------------------------------------------------------------------- /server.Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:18.16 as development 2 | WORKDIR /usr/chapter/ 3 | 4 | RUN apt-get update && apt-get install netcat -y 5 | 6 | FROM development as build 7 | 8 | # Bundle app source 9 | COPY server ./server 10 | COPY common ./common 11 | COPY package*.json ./ 12 | 13 | RUN npm ci -w=server --ignore-scripts --include-workspace-root 14 | RUN npm -w=server run build 15 | 16 | FROM node:18-alpine3.17 as production 17 | WORKDIR /usr/chapter/ 18 | 19 | # Workaround for https://github.com/prisma/prisma/issues/16553 (prisma generate fails with openssl 3.0) 20 | RUN apk add openssl1.1-compat 21 | 22 | COPY package*.json ./ 23 | COPY server/package.json ./server/package.json 24 | RUN npm ci -w=server --ignore-scripts --include-workspace-root --omit=dev 25 | 26 | COPY --from=build /usr/chapter/common ./common 27 | COPY --from=build /usr/chapter/server/src ./server/src 28 | COPY --from=build /usr/chapter/server/prisma ./server/prisma 29 | COPY --from=build /usr/chapter/server/reminders ./server/reminders 30 | 31 | RUN npx -w=server prisma generate 32 | 33 | EXPOSE 5000 34 | 35 | WORKDIR /usr/chapter/server 36 | 37 | CMD [ "npm", "start" ] 38 | 39 | -------------------------------------------------------------------------------- /client/src/modules/dashboard/Venues/utils.ts: -------------------------------------------------------------------------------- 1 | import { useRouter } from 'next/router'; 2 | 3 | import { useCreateVenueMutation } from '../../../generated/graphql'; 4 | import { useAlert } from '../../../hooks/useAlert'; 5 | import { parseVenueData, VenueFormData } from './components/VenueFormUtils'; 6 | import { DASHBOARD_VENUES } from './graphql/queries'; 7 | 8 | export const useSubmitVenue = () => { 9 | const [createVenue] = useCreateVenueMutation({ 10 | refetchQueries: [{ query: DASHBOARD_VENUES }], 11 | }); 12 | const router = useRouter(); 13 | const addAlert = useAlert(); 14 | 15 | return async (data: VenueFormData) => { 16 | const { data: venueData, errors } = await createVenue({ 17 | variables: { chapterId: data.chapter_id, data: parseVenueData(data) }, 18 | }); 19 | // TODO: handle apollo errors centrally if possible 20 | if (errors) throw errors; 21 | if (venueData) { 22 | await router.replace(`/dashboard/venues/${venueData.createVenue.id}`); 23 | addAlert({ 24 | title: `Venue "${venueData.createVenue.name}" created!`, 25 | status: 'success', 26 | }); 27 | } 28 | }; 29 | }; 30 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.7" 2 | services: 3 | server: 4 | restart: unless-stopped 5 | env_file: .env 6 | build: 7 | context: . 8 | dockerfile: server.Dockerfile 9 | working_dir: /usr/chapter/server 10 | volumes: 11 | - ./server/keys/:/usr/chapter/server/keys/ 12 | ports: 13 | - "5000:5000" 14 | image: chapter-server 15 | client: 16 | restart: unless-stopped 17 | env_file: .env 18 | depends_on: 19 | - server 20 | build: 21 | context: . 22 | dockerfile: client.Dockerfile 23 | args: 24 | # on remote environments, NEXT_PUBLIC_SERVER_URL in .env (or shell 25 | # environment) needs to include the remote address. e.g. 26 | # https://example.com:5000 27 | NEXT_PUBLIC_SERVER_URL: 28 | NEXT_PUBLIC_DEPLOYMENT_ENVIRONMENT: 29 | NEXT_PUBLIC_USE_AUTH0: 30 | NEXT_PUBLIC_AUTH0_DOMAIN: 31 | NEXT_PUBLIC_AUTH0_CLIENT_ID: 32 | NEXT_PUBLIC_AUTH0_AUDIENCE: 33 | NEXT_PUBLIC_CLIENT_URL: 34 | NODE_ENV: 35 | ports: 36 | - "3000:3000" 37 | working_dir: /usr/chapter/client 38 | image: chapter-client -------------------------------------------------------------------------------- /client/src/hooks/useCancelEvent.ts: -------------------------------------------------------------------------------- 1 | import { useCancelEventMutation } from '../generated/graphql'; 2 | import { 3 | DASHBOARD_EVENT, 4 | DASHBOARD_EVENTS, 5 | } from '../modules/dashboard/Events/graphql/queries'; 6 | import { 7 | DATA_PAGINATED_EVENTS_TOTAL_QUERY, 8 | EVENT, 9 | } from '../modules/events/graphql/queries'; 10 | 11 | export const useCancelEvent = () => { 12 | const [cancelEvent] = useCancelEventMutation(); 13 | 14 | return async ({ eventId }: { eventId: number }) => { 15 | const { data, errors } = await cancelEvent({ 16 | variables: { eventId }, 17 | refetchQueries: [ 18 | { query: DASHBOARD_EVENTS }, 19 | { query: EVENT, variables: { eventId } }, 20 | { query: DASHBOARD_EVENT, variables: { eventId } }, 21 | { 22 | query: DATA_PAGINATED_EVENTS_TOTAL_QUERY, 23 | variables: { offset: 0, limit: 2 }, 24 | }, 25 | { 26 | query: DATA_PAGINATED_EVENTS_TOTAL_QUERY, 27 | variables: { offset: 0, limit: 5, showOnlyUpcoming: false }, 28 | }, 29 | ], 30 | }); 31 | if (errors) throw errors; 32 | 33 | return data; 34 | }; 35 | }; 36 | -------------------------------------------------------------------------------- /client/tests/EventCard.test.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * @jest-environment jsdom 3 | */ 4 | 5 | import { render } from '@testing-library/react'; 6 | import React from 'react'; 7 | import { EventCard } from '../src/components/EventCard'; 8 | 9 | const event = { 10 | chapter: { id: 1, name: 'foo' }, 11 | id: 1, 12 | name: 'bar', 13 | description: 'baz', 14 | start_at: 123, 15 | ends_at: 123, 16 | invite_only: true, 17 | canceled: false, 18 | image_url: 'http://example.com/image.png', 19 | event_tags: [], 20 | }; 21 | 22 | describe('EventCard', () => { 23 | it('should render', () => { 24 | const { container } = render(); 25 | expect(container).toMatchSnapshot(); 26 | }); 27 | 28 | it('should render with tags', () => { 29 | const { container } = render( 30 | , 40 | ); 41 | expect(container).toMatchSnapshot(); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /client/tests/date.test.ts: -------------------------------------------------------------------------------- 1 | import { formatDate } from '../src/util/date'; 2 | 3 | describe('date', () => { 4 | describe('formatDate', () => { 5 | it.each([ 6 | [new Date('2022-01-13T00:00:00'), 'Thu, Jan 13 @ 00:00'], 7 | [new Date('2022-02-04T01:05:00'), 'Fri, Feb 4 @ 01:05'], 8 | [new Date('2022-03-20T19:55:00'), 'Sun, Mar 20 @ 19:55'], 9 | [new Date('2022-04-13T10:10:00'), 'Wed, Apr 13 @ 10:10'], 10 | [new Date('2022-05-31T20:00:00'), 'Tue, May 31 @ 20:00'], 11 | [new Date('2022-06-01T04:05:00'), 'Wed, Jun 1 @ 04:05'], 12 | [new Date('2022-07-21T18:01:00'), 'Thu, Jul 21 @ 18:01'], 13 | [new Date('2022-08-24T14:41:00'), 'Wed, Aug 24 @ 14:41'], 14 | [new Date('2022-09-03T19:00:00'), 'Sat, Sep 3 @ 19:00'], 15 | [new Date('2022-10-05T12:00:00'), 'Wed, Oct 5 @ 12:00'], 16 | [new Date('2022-11-07T13:15:00'), 'Mon, Nov 7 @ 13:15'], 17 | [new Date('2022-12-15T23:45:00'), 'Thu, Dec 15 @ 23:45'], 18 | ])( 19 | 'returns formatted date when passing datetime string #%#', 20 | (date, expected) => { 21 | expect(formatDate(date)).toBe(expected); 22 | }, 23 | ); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /client/src/components/SubscribeCheckbox.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Checkbox as ChakraCheckbox, 3 | CheckboxProps as ChakraCheckboxProps, 4 | } from '@chakra-ui/react'; 5 | import React, { forwardRef } from 'react'; 6 | 7 | interface CheckboxProps extends ChakraCheckboxProps { 8 | label: string; 9 | } 10 | 11 | const SubscribeCheckbox = forwardRef( 12 | ({ label, defaultChecked, ...rest }, ref) => { 13 | const [subscribe, setSubscribe] = React.useState(defaultChecked); 14 | return ( 15 | setSubscribe(e.target.checked)} 19 | {...rest} 20 | > 21 | {label} 22 | 23 | ); 24 | }, 25 | ); 26 | 27 | export const useSubscribeCheckbox = (defaultChecked: boolean) => { 28 | const ref = React.useRef(null); 29 | return { 30 | getSubscribe: () => ref?.current?.checked, 31 | SubscribeCheckbox: (props: CheckboxProps) => ( 32 | 33 | ), 34 | }; 35 | }; 36 | 37 | export default SubscribeCheckbox; 38 | -------------------------------------------------------------------------------- /server/src/authorization/index.ts: -------------------------------------------------------------------------------- 1 | import { AuthChecker } from 'type-graphql'; 2 | 3 | import { ResolverCtx } from '../common-types/gql'; 4 | import { checker } from '../../../common/authorization'; 5 | 6 | function hasUserEventsAndVenues( 7 | ctx: ResolverCtx | Required, 8 | ): ctx is Required { 9 | return ( 10 | typeof ctx.user !== 'undefined' && 11 | typeof ctx.events !== 'undefined' && 12 | typeof ctx.venues !== 'undefined' 13 | ); 14 | } 15 | 16 | /** authorizationChecker allows or denies access to fields and resolvers based 17 | * on a user's role. It cannot affect what is returned by a resolver, it just 18 | * determines if the resolver is called or not. For fine-grained control, the 19 | * resolver itself must modify the response based on the user's roles */ 20 | export const authorizationChecker: AuthChecker< 21 | ResolverCtx | Required 22 | > = ({ context, info: { variableValues } }, requiredPermissions): boolean => { 23 | if (!hasUserEventsAndVenues(context)) return false; 24 | 25 | if (requiredPermissions.length !== 1) return false; 26 | const requiredPermission = requiredPermissions[0]; 27 | 28 | return checker(context, requiredPermission, variableValues); 29 | }; 30 | -------------------------------------------------------------------------------- /client/src/components/PageLayout/component/Footer.tsx: -------------------------------------------------------------------------------- 1 | import { Link, Grid } from '@chakra-ui/react'; 2 | import React from 'react'; 3 | 4 | export const Footer: React.FC = () => { 5 | return ( 6 | 15 | 16 | Chapters 17 | 18 | 19 | Events 20 | 21 | 26 | Policy 27 | 28 | 33 | Terms Of Service 34 | 35 | 40 | Code Of Conduct 41 | 42 | 43 | ); 44 | }; 45 | -------------------------------------------------------------------------------- /client.Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:18.16 as development 2 | WORKDIR /usr/chapter/ 3 | 4 | FROM development as build 5 | 6 | ARG NEXT_PUBLIC_DEPLOYMENT_ENVIRONMENT=production 7 | ARG NEXT_PUBLIC_USE_AUTH0=true 8 | ARG NEXT_PUBLIC_AUTH0_DOMAIN 9 | ARG NEXT_PUBLIC_AUTH0_CLIENT_ID 10 | ARG NEXT_PUBLIC_AUTH0_AUDIENCE 11 | ARG NEXT_PUBLIC_SERVER_URL 12 | ARG NEXT_PUBLIC_CLIENT_URL 13 | # We need the dev dependencies to build the app, but this needs to be configurable 14 | # so we can create test builds 15 | ARG NODE_ENV=development 16 | 17 | COPY client ./client 18 | COPY common ./common 19 | COPY package*.json ./ 20 | 21 | # Regardless of the NODE_ENV, we need to install dev dependencies to build the 22 | # app. They will not be included in the final image. 23 | RUN npm ci -w=client --ignore-scripts --include=dev 24 | RUN npm -w=client run build 25 | 26 | FROM node:18-alpine3.17 as production 27 | WORKDIR /usr/chapter/ 28 | 29 | COPY --from=build /usr/chapter/client/.next ./client/.next 30 | COPY client/public ./client/public 31 | COPY package*.json ./ 32 | COPY client/package.json ./client/package.json 33 | 34 | RUN npm ci -w=client --ignore-scripts --omit=dev 35 | 36 | EXPOSE 3000 37 | 38 | WORKDIR /usr/chapter/client 39 | 40 | CMD [ "npm", "run", "start" ] 41 | 42 | -------------------------------------------------------------------------------- /client/src/components/InfoList.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | ComponentWithAs, 3 | IconProps, 4 | List, 5 | ListIcon, 6 | ListItem, 7 | ListItemProps, 8 | ListProps, 9 | } from '@chakra-ui/react'; 10 | import { InfoIcon } from '@chakra-ui/icons'; 11 | import React from 'react'; 12 | 13 | type InfoListProps = { 14 | items: string[]; 15 | itemsWithCustomIcon?: { 16 | text: string; 17 | Icon?: ComponentWithAs<'svg', IconProps>; 18 | }[]; 19 | iconProps?: IconProps; 20 | listProps?: ListProps; 21 | listItemProps?: ListItemProps; 22 | }; 23 | 24 | export const InfoList = ({ 25 | items, 26 | itemsWithCustomIcon, 27 | iconProps, 28 | listProps, 29 | listItemProps, 30 | }: InfoListProps) => { 31 | return ( 32 | 33 | {items.map((text) => ( 34 | 35 | 36 | {text} 37 | 38 | ))} 39 | {itemsWithCustomIcon?.map(({ text, Icon }) => ( 40 | 41 | 42 | {text} 43 | 44 | ))} 45 | 46 | ); 47 | }; 48 | -------------------------------------------------------------------------------- /server/src/controllers/Events/inputs.ts: -------------------------------------------------------------------------------- 1 | import { IsOptional, IsPositive, IsUrl } from 'class-validator'; 2 | import { InputType, Field, Int } from 'type-graphql'; 3 | 4 | import { events_venue_type_enum } from '../../graphql-types'; 5 | 6 | @InputType() 7 | export class EventInputs { 8 | @Field(() => String) 9 | name: string; 10 | 11 | @Field(() => String) 12 | description: string; 13 | 14 | @Field(() => String, { nullable: true }) 15 | @IsUrl() 16 | @IsOptional() 17 | url?: string; 18 | 19 | @Field(() => String, { nullable: true }) 20 | @IsUrl() 21 | @IsOptional() 22 | streaming_url?: string; 23 | 24 | @Field(() => events_venue_type_enum, { nullable: true }) 25 | venue_type: events_venue_type_enum; 26 | 27 | @Field(() => Date) 28 | start_at: Date; 29 | 30 | @Field(() => Date) 31 | ends_at: Date; 32 | 33 | @IsPositive() 34 | @Field(() => Number) 35 | capacity: number; 36 | 37 | @Field(() => Int, { nullable: true }) 38 | venue_id: number; 39 | 40 | @Field(() => Boolean, { nullable: true }) 41 | invite_only: boolean; 42 | 43 | @Field(() => String) 44 | image_url: string; 45 | 46 | @Field(() => [Int]) 47 | sponsor_ids: number[]; 48 | 49 | @Field(() => [String]) 50 | event_tags: string[]; 51 | } 52 | -------------------------------------------------------------------------------- /server/src/services/mail-providers/sendgrid.ts: -------------------------------------------------------------------------------- 1 | import sendgrid, { MailDataRequired } from '@sendgrid/mail'; 2 | 3 | import { MailerData, MailProvider } from './abstract-provider'; 4 | 5 | export class SendgridProvider extends MailProvider { 6 | private sendgridKey: string; 7 | 8 | constructor() { 9 | super(); 10 | if (!process.env.SENDGRID_KEY) { 11 | throw new Error( 12 | 'Email service is set to SendGrid but missing required keys.', 13 | ); 14 | } 15 | this.sendgridKey = process.env.SENDGRID_KEY; 16 | sendgrid.setApiKey(this.sendgridKey); 17 | } 18 | 19 | async send(data: MailerData) { 20 | for (const email of data.emailList) { 21 | const opts: MailDataRequired = { 22 | to: email, 23 | from: this.ourEmail, 24 | subject: data.subject, 25 | html: data.htmlEmail || data.backupText || '', 26 | trackingSettings: { 27 | clickTracking: { 28 | enable: false, 29 | enableText: false, 30 | }, 31 | openTracking: { 32 | enable: false, 33 | }, 34 | subscriptionTracking: { 35 | enable: false, 36 | }, 37 | }, 38 | }; 39 | 40 | await sendgrid.send(opts); 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /server/src/controllers/index.ts: -------------------------------------------------------------------------------- 1 | import { AuthResolver } from './Auth/resolver'; 2 | import { CalendarResolver } from './Calendar/resolver'; 3 | import { ChapterResolver } from './Chapter/resolver'; 4 | import { ChapterRoleResolver } from './ChapterRole/resolver'; 5 | import { ChapterUserResolver } from './ChapterUser/resolver'; 6 | import { EventResolver } from './Events/resolver'; 7 | import { EventRoleResolver } from './EventRole/resolver'; 8 | import { EventUserResolver } from './EventUser/resolver'; 9 | import { InstanceRoleResolver } from './InstanceRole/resolver'; 10 | import { SponsorResolver } from './Sponsors/resolver'; 11 | import { VenueResolver } from './Venue/resolver'; 12 | import { UnsubscribeResolver } from './Unsubscribe/resolver'; 13 | import { UsersResolver } from './Users/resolver'; 14 | import { UserWithPermissionsResolver } from './User/resolver'; 15 | 16 | const resolvers = [ 17 | AuthResolver, 18 | CalendarResolver, 19 | ChapterResolver, 20 | ChapterRoleResolver, 21 | ChapterUserResolver, 22 | EventResolver, 23 | EventRoleResolver, 24 | EventUserResolver, 25 | InstanceRoleResolver, 26 | SponsorResolver, 27 | VenueResolver, 28 | UnsubscribeResolver, 29 | UsersResolver, 30 | UserWithPermissionsResolver, 31 | ] as const; 32 | 33 | export { resolvers }; 34 | -------------------------------------------------------------------------------- /cypress/e2e/landing.cy.ts: -------------------------------------------------------------------------------- 1 | describe('landing page', () => { 2 | it('should have lists of events and chapters', () => { 3 | cy.visit('/'); 4 | cy.contains('Upcoming events'); 5 | cy.get('[data-cy="event-card"]').should('have.length', 2); 6 | cy.contains('Click for more').click(); 7 | cy.get('[data-cy="event-card"]').should('have.length', 4); 8 | cy.contains('Chapters'); 9 | }); 10 | 11 | it('should have links to events', () => { 12 | cy.visit('/'); 13 | cy.get('[data-cy="event-card"] [data-cy="event-link"]') 14 | .first() 15 | .then(($eventLink) => { 16 | const eventTitle = $eventLink.text(); 17 | cy.wrap($eventLink).click(); 18 | cy.location('pathname').should('match', /^\/events\/\d+$/); 19 | cy.get('h1').should('have.text', eventTitle); 20 | }); 21 | }); 22 | 23 | it('should have links to chapters', () => { 24 | cy.visit('/'); 25 | cy.get('[data-cy="chapter-card"] [data-cy="chaptercard-name"]') 26 | .first() 27 | .then(($chapterLink) => { 28 | const chapterTitle = $chapterLink.text(); 29 | cy.wrap($chapterLink).click(); 30 | cy.location('pathname').should('match', /^\/chapters\/\d+$/); 31 | cy.get('h1').should('have.text', chapterTitle); 32 | }); 33 | }); 34 | }); 35 | -------------------------------------------------------------------------------- /client/src/modules/profiles/component/ProfileFormUtils.tsx: -------------------------------------------------------------------------------- 1 | import { classValidatorResolver } from '@hookform/resolvers/class-validator'; 2 | import { IsBoolean, IsString } from 'class-validator'; 3 | import { UpdateUserInputs } from '../../../generated/graphql'; 4 | import { IsOptionalUrl } from '../../util/form'; 5 | 6 | export interface ProfileFormProps { 7 | onSubmit: (data: UpdateUserInputs) => Promise; 8 | data: UpdateUserInputs; 9 | submitText: string; 10 | loadingText: string; 11 | } 12 | 13 | type Fields = { 14 | key: keyof UpdateUserInputs; 15 | placeholder: string; 16 | label: string; 17 | required: boolean; 18 | type: string; 19 | }; 20 | 21 | export class Profile { 22 | @IsString() 23 | name: string; 24 | 25 | @IsOptionalUrl() 26 | image_url: string; 27 | 28 | @IsBoolean() 29 | auto_subscribe: boolean; 30 | } 31 | 32 | export const resolver = classValidatorResolver(Profile); 33 | 34 | export const fields: Fields[] = [ 35 | { 36 | key: 'name', 37 | label: 'Name', 38 | placeholder: 'Add your name here', 39 | required: false, 40 | type: 'text', 41 | }, 42 | { 43 | key: 'image_url', 44 | label: 'Profile Picture', 45 | placeholder: 'Add a link to a profile image here', 46 | required: false, 47 | type: 'url', 48 | }, 49 | ]; 50 | -------------------------------------------------------------------------------- /server/src/graphql-types/Venue.ts: -------------------------------------------------------------------------------- 1 | import { Field, ObjectType, Float, Int } from 'type-graphql'; 2 | import { BaseObject } from './BaseObject'; 3 | import { Chapter, ChapterWithEvents } from './Chapter'; 4 | import { Tags } from './Tag'; 5 | 6 | @ObjectType() 7 | export class Venue extends BaseObject { 8 | @Field(() => Int) 9 | chapter_id: number; 10 | 11 | @Field(() => String) 12 | name: string; 13 | 14 | @Field(() => String, { nullable: true }) 15 | street_address?: string | null; 16 | 17 | @Field(() => String) 18 | city: string; 19 | 20 | @Field(() => String) 21 | postal_code: string; 22 | 23 | @Field(() => String) 24 | region: string; 25 | 26 | @Field(() => String) 27 | country: string; 28 | 29 | @Field(() => Float, { nullable: true }) 30 | latitude?: number | null; 31 | 32 | @Field(() => Float, { nullable: true }) 33 | longitude?: number | null; 34 | } 35 | 36 | @ObjectType() 37 | export class VenueWithTags extends Venue { 38 | @Field(() => [Tags]) 39 | venue_tags: Tags[]; 40 | } 41 | 42 | @ObjectType() 43 | export class VenueWithChapter extends Venue { 44 | @Field(() => Chapter) 45 | chapter: Chapter; 46 | } 47 | 48 | @ObjectType() 49 | export class VenueWithChapterEvents extends VenueWithTags { 50 | @Field(() => ChapterWithEvents) 51 | chapter: ChapterWithEvents; 52 | } 53 | -------------------------------------------------------------------------------- /client/src/modules/dashboard/Venues/pages/ChapterNewVenuePage.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement } from 'react'; 2 | 3 | import { useChapterQuery } from '../../../../generated/graphql'; 4 | import { DashboardLoading } from '../../shared/components/DashboardLoading'; 5 | import { DashboardLayout } from '../../shared/components/DashboardLayout'; 6 | import VenueForm from '../components/VenueForm'; 7 | import { useParam } from '../../../../hooks/useParam'; 8 | import { NextPageWithLayout } from '../../../../pages/_app'; 9 | import { useSubmitVenue } from '../utils'; 10 | 11 | export const ChapterNewVenuePage: NextPageWithLayout = () => { 12 | const { param: chapterId } = useParam('id'); 13 | 14 | const { loading, data, error } = useChapterQuery({ 15 | variables: { chapterId }, 16 | }); 17 | 18 | const onSubmit = useSubmitVenue(); 19 | 20 | const isLoading = loading || !data; 21 | 22 | if (isLoading || error) return ; 23 | 24 | return ( 25 | 32 | ); 33 | }; 34 | 35 | ChapterNewVenuePage.getLayout = function getLayout(page: ReactElement) { 36 | return {page}; 37 | }; 38 | -------------------------------------------------------------------------------- /client/src/modules/chapters/graphql/queries.ts: -------------------------------------------------------------------------------- 1 | import { gql } from '@apollo/client'; 2 | 3 | export const CHAPTER = gql` 4 | query chapter($chapterId: Int!) { 5 | chapter(id: $chapterId) { 6 | id 7 | name 8 | description 9 | category 10 | city 11 | region 12 | country 13 | logo_url 14 | banner_url 15 | chat_url 16 | events { 17 | id 18 | name 19 | description 20 | start_at 21 | ends_at 22 | image_url 23 | invite_only 24 | canceled 25 | event_tags { 26 | tag { 27 | id 28 | name 29 | } 30 | } 31 | } 32 | chapter_tags { 33 | tag { 34 | id 35 | name 36 | } 37 | } 38 | } 39 | } 40 | `; 41 | 42 | export const CHAPTERS = gql` 43 | query chapters { 44 | chapters { 45 | id 46 | name 47 | description 48 | logo_url 49 | banner_url 50 | events { 51 | id 52 | canceled 53 | start_at 54 | ends_at 55 | name 56 | invite_only 57 | } 58 | chapter_tags { 59 | tag { 60 | id 61 | name 62 | } 63 | } 64 | _count { 65 | chapter_users 66 | } 67 | } 68 | } 69 | `; 70 | -------------------------------------------------------------------------------- /common/permissions.ts: -------------------------------------------------------------------------------- 1 | enum ChapterPermission { 2 | AttendeeAttend = 'attendee-attend', 3 | AttendeeDelete = 'attendee-delete', 4 | AttendeeConfirm = 'attendee-confirm', 5 | ChapterEdit = 'chapter-edit', 6 | ChapterBanUser = 'chapter-ban-user', 7 | EventCreate = 'event-create', 8 | EventEdit = 'event-edit', 9 | EventDelete = 'event-delete', 10 | EventSendInvite = 'event-send-invite', 11 | EventSubscriptionManage = 'event-subscription-manage', 12 | VenueCreate = 'venue-create', 13 | VenueEdit = 'venue-edit', 14 | VenueDelete = 'venue-delete', 15 | } 16 | 17 | enum InstancePermission { 18 | ChapterCreate = 'chapter-create', 19 | ChapterJoin = 'chapter-join', 20 | ChapterDelete = 'chapter-delete', 21 | ChaptersView = 'chapters-view', 22 | EventsView = 'events-view', 23 | VenuesView = 'venues-view', 24 | ChapterSubscriptionManage = 'chapter-subscription-manage', 25 | ChapterUserRoleChange = 'chapter-user-role-change', 26 | SponsorManage = 'sponsor-manage', 27 | SponsorView = 'sponsor-view', 28 | UserInstanceRoleChange = 'user-instance-role-change', 29 | GoogleAuthenticate = 'google-authenticate', 30 | UsersView = 'users-view', 31 | } 32 | 33 | // Ideally this would be a new enum, but TS does not (to my knowledge) support 34 | // that yet. 35 | export const Permission = { ...InstancePermission, ...ChapterPermission }; 36 | export type { InstancePermission, ChapterPermission }; 37 | -------------------------------------------------------------------------------- /client/src/modules/dashboard/Events/components/DatePicker.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDatePicker from 'react-datepicker'; 3 | 4 | import 'react-datepicker/dist/react-datepicker.css'; 5 | 6 | import { Input } from '../../../../components/Form/Input'; 7 | 8 | interface DatePickerProps { 9 | date: Date; 10 | error: string; 11 | field: string; 12 | isRequired: boolean; 13 | label: string; 14 | loading: boolean; 15 | onChange: (date: Date | null) => void; 16 | } 17 | 18 | // TODO: figure out why only name and field from ReactDatePicker matter. The 19 | // ones in the custom input are ignored. 20 | const DatePicker = ({ 21 | date, 22 | error, 23 | field, 24 | isRequired, 25 | label, 26 | loading, 27 | onChange, 28 | }: DatePickerProps) => { 29 | return ( 30 | 49 | } 50 | /> 51 | ); 52 | }; 53 | 54 | export default DatePicker; 55 | -------------------------------------------------------------------------------- /server/prisma/migrations/20230328184907_add_tags/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "chapter_tags" ( 3 | "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 4 | "updated_at" TIMESTAMP(3) NOT NULL, 5 | "chapter_id" INTEGER NOT NULL, 6 | "tag_id" INTEGER NOT NULL, 7 | 8 | CONSTRAINT "chapter_tags_pkey" PRIMARY KEY ("chapter_id","tag_id") 9 | ); 10 | 11 | -- CreateTable 12 | CREATE TABLE "venue_tags" ( 13 | "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 14 | "updated_at" TIMESTAMP(3) NOT NULL, 15 | "venue_id" INTEGER NOT NULL, 16 | "tag_id" INTEGER NOT NULL, 17 | 18 | CONSTRAINT "venue_tags_pkey" PRIMARY KEY ("venue_id","tag_id") 19 | ); 20 | 21 | -- AddForeignKey 22 | ALTER TABLE "chapter_tags" ADD CONSTRAINT "chapter_tags_chapter_id_fkey" FOREIGN KEY ("chapter_id") REFERENCES "chapters"("id") ON DELETE CASCADE ON UPDATE NO ACTION; 23 | 24 | -- AddForeignKey 25 | ALTER TABLE "chapter_tags" ADD CONSTRAINT "chapter_tags_tag_id_fkey" FOREIGN KEY ("tag_id") REFERENCES "tags"("id") ON DELETE CASCADE ON UPDATE NO ACTION; 26 | 27 | -- AddForeignKey 28 | ALTER TABLE "venue_tags" ADD CONSTRAINT "venue_tags_venue_id_fkey" FOREIGN KEY ("venue_id") REFERENCES "venues"("id") ON DELETE CASCADE ON UPDATE NO ACTION; 29 | 30 | -- AddForeignKey 31 | ALTER TABLE "venue_tags" ADD CONSTRAINT "venue_tags_tag_id_fkey" FOREIGN KEY ("tag_id") REFERENCES "tags"("id") ON DELETE CASCADE ON UPDATE NO ACTION; 32 | -------------------------------------------------------------------------------- /client/src/modules/auth/user.tsx: -------------------------------------------------------------------------------- 1 | import React, { createContext, useContext, useEffect, useState } from 'react'; 2 | 3 | import { MeQuery, useMeQuery } from '../../generated/graphql'; 4 | import { useSession } from '../../hooks/useSession'; 5 | 6 | export interface UserContextType { 7 | user?: MeQuery['me']; 8 | loadingUser: boolean; 9 | isLoggedIn: boolean; 10 | } 11 | 12 | const UserContext = createContext<{ 13 | data: UserContextType; 14 | }>({ 15 | data: { loadingUser: true, isLoggedIn: false }, 16 | }); 17 | 18 | export const useUser = () => useContext(UserContext).data; 19 | 20 | export const UserProvider = ({ children }: { children: React.ReactNode }) => { 21 | const [data, setData] = useState({ 22 | loadingUser: true, 23 | isLoggedIn: false, 24 | }); 25 | const { loading: loadingMe, error, data: meData } = useMeQuery(); 26 | const { logout, isAuthenticated } = useSession(); 27 | 28 | useEffect(() => { 29 | if (!loadingMe && !error && meData) { 30 | setData({ 31 | user: meData.me, 32 | loadingUser: false, 33 | isLoggedIn: !!meData.me, 34 | }); 35 | } else if (error) { 36 | setData({ loadingUser: false, isLoggedIn: false }); 37 | logout(); 38 | } 39 | }, [loadingMe, error, meData, isAuthenticated]); 40 | 41 | return ( 42 | 47 | {children} 48 | 49 | ); 50 | }; 51 | -------------------------------------------------------------------------------- /client/src/modules/dashboard/Events/components/EventCancelButton.tsx: -------------------------------------------------------------------------------- 1 | import { Button, ButtonProps } from '@chakra-ui/react'; 2 | import { useConfirm } from 'chakra-confirm'; 3 | import React from 'react'; 4 | import { InfoIcon } from '@chakra-ui/icons'; 5 | 6 | import { Event } from '../../../../generated/graphql'; 7 | import { useCancelEvent } from '../../../../hooks/useCancelEvent'; 8 | 9 | interface EventCancelButtonProps { 10 | size?: ButtonProps['size']; 11 | buttonText: string; 12 | event: Pick; 13 | isDisabled?: boolean; 14 | } 15 | 16 | const EventCancelButton = (props: EventCancelButtonProps) => { 17 | const { 18 | isDisabled = false, 19 | buttonText, 20 | event: { id: eventId }, 21 | ...rest 22 | } = props; 23 | const cancel = useCancelEvent(); 24 | 25 | const confirmCancel = useConfirm({ 26 | title: 'Cancel this event?', 27 | body: ( 28 | <> 29 | Canceling event will send notification to 30 | subscribed attendees. 31 | 32 | ), 33 | buttonText: 'Cancel event', 34 | buttonColor: 'orange', 35 | }); 36 | 37 | const clickCancel = async () => { 38 | const ok = await confirmCancel(); 39 | if (ok) { 40 | await cancel({ eventId }); 41 | } 42 | }; 43 | return ( 44 | 47 | ); 48 | }; 49 | 50 | export default EventCancelButton; 51 | -------------------------------------------------------------------------------- /client/src/modules/dashboard/Events/components/EventVenue.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { VenueType } from '../../../../generated/graphql'; 4 | import getLocationString, { 5 | PartialLocation, 6 | } from '../../../../util/getLocationString'; 7 | import { isOnline, isPhysical } from '../../../../util/venueType'; 8 | import { LinkField, TextField } from './Fields'; 9 | 10 | interface EventVenueProps { 11 | event: { 12 | venue_type: VenueType; 13 | venue?: (PartialLocation & { name: string }) | null; 14 | streaming_url?: string | null | undefined; 15 | }; 16 | } 17 | 18 | export const EventVenue = ({ 19 | event: { venue_type, venue, streaming_url }, 20 | }: EventVenueProps) => { 21 | const { streamingText, StreamingComponent } = !streaming_url 22 | ? { streamingText: 'Undecided/TBD', StreamingComponent: TextField } 23 | : { streamingText: streaming_url, StreamingComponent: LinkField }; 24 | return ( 25 | <> 26 | {isPhysical(venue_type) && ( 27 | <> 28 | {venue?.name || 'Undecided/TBD'} 29 | {venue && ( 30 | 31 | {getLocationString(venue, true)} 32 | 33 | )} 34 | 35 | )} 36 | {isOnline(venue_type) && ( 37 | 38 | {streamingText} 39 | 40 | )} 41 | 42 | ); 43 | }; 44 | -------------------------------------------------------------------------------- /.github/workflows/cypress.yaml: -------------------------------------------------------------------------------- 1 | name: Cypress 2 | on: 3 | push: 4 | branches-ignore: 5 | - 'renovate/**' 6 | pull_request: 7 | 8 | jobs: 9 | cypress-run: 10 | name: Test 11 | runs-on: ubuntu-20.04 12 | strategy: 13 | matrix: 14 | browsers: [chrome, firefox] 15 | node-version: [18.x] 16 | steps: 17 | - name: Set Action Environment Variables 18 | run: | 19 | echo "CYPRESS_RECORD_KEY=${{ secrets.CYPRESS_RECORD_KEY }}" >> $GITHUB_ENV 20 | echo "GITHUB_TOKEN=${{ secrets.GITHUB_TOKEN }}" >> $GITHUB_ENV 21 | 22 | - name: Checkout Source Files 23 | uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3 24 | 25 | - name: Use Node.js ${{ matrix.node-version }} 26 | uses: actions/setup-node@64ed1c7eab4cce3362f8c340dee64e5eaeef8f7c # v3 27 | with: 28 | node-version: ${{ matrix.node-version }} 29 | cache: 'npm' 30 | 31 | - name: Set Chapter Environment Variables 32 | run: cp .env.example .env 33 | 34 | - name: Cypress run 35 | uses: cypress-io/github-action@v4 36 | with: 37 | record: ${{ env.CYPRESS_RECORD_KEY != 0 }} 38 | build: npm run build:coverage 39 | start: npm run start:ci 40 | wait-on: 'http://localhost:3000, http://localhost:5000/graphql' 41 | browser: ${{ matrix.browsers }} 42 | 43 | - name: Print Cypress coverage report 44 | run: npm run cypress:coverage:report -------------------------------------------------------------------------------- /client/src/components/Alerts/Alerts.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Alert, 3 | AlertDescription, 4 | AlertIcon, 5 | AlertTitle, 6 | CloseButton, 7 | Wrap, 8 | } from '@chakra-ui/react'; 9 | import React, { useContext } from 'react'; 10 | import { AlertContext } from './AlertContext'; 11 | 12 | export const Alerts = () => { 13 | const { alertList, removeAlert } = useContext(AlertContext); 14 | 15 | return ( 16 | <> 17 | {alertList.map( 18 | ({ 19 | alertId, 20 | status, 21 | title, 22 | closeButtonProps, 23 | description, 24 | descriptionProps, 25 | iconProps, 26 | titleProps, 27 | ...rest 28 | }) => ( 29 | 36 | 37 | 38 | {title} 39 | {description && ( 40 | 41 | {description} 42 | 43 | )} 44 | 45 | removeAlert(alertId)} 47 | {...closeButtonProps} 48 | /> 49 | 50 | ), 51 | )} 52 | 53 | ); 54 | }; 55 | -------------------------------------------------------------------------------- /server/src/config/index.ts: -------------------------------------------------------------------------------- 1 | import { join } from 'path'; 2 | import { config } from 'dotenv'; 3 | import { expand } from 'dotenv-expand'; 4 | 5 | const result = config({ path: join(__dirname, '../../../.env') }); 6 | 7 | if (result.error) { 8 | console.warn(` 9 | ---------------------------------------------------- 10 | Warning: .env file not found. 11 | ---------------------------------------------------- 12 | Please copy .env.example to .env 13 | You can ignore this warning if using a different way 14 | to setup this environment. 15 | ---------------------------------------------------- 16 | `); 17 | } else { 18 | expand(result); 19 | } 20 | 21 | export interface Environment { 22 | NODE_ENV: 'production' | 'development' | 'test' | undefined; 23 | UNSUBSCRIBE_SECRET: string; 24 | } 25 | 26 | export const getConfig = ( 27 | key: T, 28 | fallback?: Environment[T], 29 | ): Environment[T] => { 30 | const val = process.env[key]; 31 | 32 | if (!val) { 33 | if (!fallback) { 34 | throw new Error( 35 | `ENV variable ${key} is missing. This should be defined in your environment or .env file.`, 36 | ); 37 | } 38 | 39 | return fallback; 40 | } 41 | 42 | return val as Environment[T]; 43 | }; 44 | 45 | export const getEnv = () => getConfig('NODE_ENV', 'development'); 46 | export const isDev = () => getEnv() === 'development'; 47 | export const isTest = () => getEnv() === 'test'; 48 | export const isProd = () => !isDev() && !isTest(); 49 | -------------------------------------------------------------------------------- /client/src/modules/util/pagination.tsx: -------------------------------------------------------------------------------- 1 | import { ApolloQueryResult, FetchMoreQueryOptions } from '@apollo/client'; 2 | import { Button } from '@chakra-ui/react'; 3 | import React, { useState } from 'react'; 4 | 5 | interface FetchMoreArg { 6 | offset: number; 7 | limit: number; 8 | } 9 | 10 | interface PaginationProps { 11 | items: Array; 12 | fetchMore: ( 13 | options: FetchMoreQueryOptions, 14 | ) => Promise>; 15 | limit: number; 16 | total: number; 17 | displayOnEmpty?: React.ReactNode; 18 | } 19 | 20 | export const Pagination = ({ 21 | items, 22 | fetchMore, 23 | limit, 24 | total, 25 | displayOnEmpty, 26 | }: PaginationProps) => { 27 | const [isLoading, setLoading] = useState(false); 28 | const totalPages = Math.ceil(total / limit); 29 | const currentPage = Math.ceil(items.length / limit); 30 | const hasMorePages = totalPages > currentPage; 31 | const offset = currentPage * limit; 32 | return ( 33 | <> 34 | {items} 35 | {hasMorePages ? ( 36 | 48 | ) : ( 49 | displayOnEmpty 50 | )} 51 | 52 | ); 53 | }; 54 | -------------------------------------------------------------------------------- /client/src/modules/dashboard/Events/pages/NewEventPage.tsx: -------------------------------------------------------------------------------- 1 | import NextError from 'next/error'; 2 | import React, { ReactElement } from 'react'; 3 | 4 | import { DashboardLayout } from '../../shared/components/DashboardLayout'; 5 | import EventForm from '../components/EventForm'; 6 | import { EventFormData } from '../components/EventFormUtils'; 7 | import { NextPageWithLayout } from '../../../../pages/_app'; 8 | import { useUser } from '../../../auth/user'; 9 | import { useCreateEvent } from '../../../../hooks/useCreateEvent'; 10 | 11 | export const NewEventPage: NextPageWithLayout<{ 12 | chapterId?: number; 13 | }> = ({ chapterId }) => { 14 | const createEvent = useCreateEvent(); 15 | 16 | const { user } = useUser(); 17 | const chapter = user?.admined_chapters.find(({ id }) => id === chapterId); 18 | if ((chapterId && !chapter) || !user?.admined_chapters.length) { 19 | return ; 20 | } 21 | 22 | const onSubmit = async (data: EventFormData) => 23 | await createEvent({ 24 | data, 25 | success: (eventName) => `Event "${eventName}" created!`, 26 | }); 27 | 28 | return ( 29 | 37 | ); 38 | }; 39 | 40 | NewEventPage.getLayout = function getLayout(page: ReactElement) { 41 | return {page}; 42 | }; 43 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | ## SERVER VARIABLES ## 2 | DB_USER=postgres 3 | DB_PASSWORD=password 4 | DB_NAME=chapter 5 | DB_URL=localhost 6 | # If running a local setup make sure to set DB_PORT=5432 7 | DB_PORT=54320 8 | DATABASE_URL=postgres://${DB_USER}:${DB_PASSWORD}@${DB_URL}:${DB_PORT}/${DB_NAME} 9 | USE_AUTH0=false 10 | 11 | CHAPTER_EMAIL=ourEmail@placeholder.place 12 | EMAIL_USERNAME=project.1 13 | EMAIL_PASSWORD=secret.1 14 | EMAIL_SERVICE=emailServicePlaceholder 15 | EMAIL_HOST=localhost 16 | 17 | # Required if EMAIL_SERVICE is "ses" 18 | SES_ACCESS_KEY_ID= 19 | SES_SECRET_ACCESS_KEY= 20 | 21 | # Required if EMAIL_SERVICE is "sendgrid" 22 | SENDGRID_KEY= 23 | 24 | NODE_ENV=development 25 | 26 | # change this variable to at least 32 random characters 27 | # to enforce 256-bit cryto security on Auth0's JsonWebToken 28 | UNSUBSCRIBE_SECRET=SetThisTo32orMoreRandomCharacters 29 | 30 | # When running remotely, this should be the full url of the landing page 31 | CLIENT_LOCATION=http://localhost:3000 32 | 33 | AUTH0_AUDIENCE=IdentifierFromAuth0Dash 34 | AUTH0_DOMAIN=DomainFromAuth0Dash 35 | 36 | SESSION_SECRET=SetThisTo32orMoreRandomCharacters 37 | COOKIE_DOMAIN=localhost 38 | 39 | ## CLIENT VARIABLES ## 40 | NEXT_PUBLIC_USE_AUTH0=${USE_AUTH0} 41 | NEXT_PUBLIC_AUTH0_DOMAIN=${AUTH0_DOMAIN} 42 | NEXT_PUBLIC_AUTH0_CLIENT_ID=YOUR-AUTH0-APP-CLIENT-ID 43 | NEXT_PUBLIC_AUTH0_AUDIENCE=${AUTH0_AUDIENCE} 44 | NEXT_PUBLIC_SERVER_URL=http://localhost:5000 45 | NEXT_PUBLIC_CLIENT_URL=${CLIENT_LOCATION} 46 | NEXT_PUBLIC_DEPLOYMENT_ENVIRONMENT=staging 47 | # CODESEE=true -------------------------------------------------------------------------------- /server/src/graphql-types/User.ts: -------------------------------------------------------------------------------- 1 | import { ObjectType, Field } from 'type-graphql'; 2 | import { BaseObject } from './BaseObject'; 3 | import { 4 | ChapterUserWithRole, 5 | UserBan, 6 | EventUserWithRolePermissions, 7 | InstanceRole, 8 | UserChapter, 9 | UserEvent, 10 | UserBanChapters, 11 | } from '.'; 12 | 13 | @ObjectType() 14 | export class User extends BaseObject { 15 | @Field(() => String) 16 | name: string; 17 | 18 | @Field(() => Boolean) 19 | auto_subscribe: boolean; 20 | 21 | @Field(() => String, { nullable: true }) 22 | image_url?: string | null; 23 | } 24 | 25 | @ObjectType() 26 | export class UserWithInstanceRole extends User { 27 | @Field(() => InstanceRole) 28 | instance_role: InstanceRole; 29 | } 30 | 31 | @ObjectType() 32 | export class UserWithPermissions extends UserWithInstanceRole { 33 | @Field(() => [UserBan]) 34 | user_bans: UserBan[]; 35 | 36 | @Field(() => [ChapterUserWithRole]) 37 | user_chapters: ChapterUserWithRole[]; 38 | 39 | @Field(() => [EventUserWithRolePermissions]) 40 | user_events: EventUserWithRolePermissions[]; 41 | } 42 | 43 | @ObjectType() 44 | export class UserProfile extends UserWithInstanceRole { 45 | @Field(() => String) 46 | email: string; 47 | } 48 | 49 | @ObjectType() 50 | export class UserForDownload extends UserProfile { 51 | @Field(() => [UserBanChapters]) 52 | user_bans: UserBanChapters[]; 53 | 54 | @Field(() => [UserChapter]) 55 | user_chapters: UserChapter[]; 56 | 57 | @Field(() => [UserEvent]) 58 | user_events: UserEvent[]; 59 | } 60 | -------------------------------------------------------------------------------- /server/prisma/migrations/20221124155109_add_dashboard_view_permissions/migration.sql: -------------------------------------------------------------------------------- 1 | INSERT INTO instance_permissions (name, created_at, updated_at) 2 | VALUES 3 | ('chapters-view', NOW(), NOW()), 4 | ('events-view', NOW(), NOW()), 5 | ('venues-view', NOW(), NOW()); 6 | 7 | -- Insert new permission into the join table for the owner and chapter_administrator roles. 8 | 9 | INSERT INTO instance_role_permissions (instance_role_id, instance_permission_id, created_at, updated_at) 10 | VALUES 11 | ((SELECT id FROM instance_roles WHERE name = 'owner'), 12 | (SELECT id FROM instance_permissions WHERE name = 'chapters-view'), 13 | NOW(), 14 | NOW()), 15 | ((SELECT id FROM instance_roles WHERE name = 'owner'), 16 | (SELECT id FROM instance_permissions WHERE name = 'events-view'), 17 | NOW(), 18 | NOW()), 19 | ((SELECT id FROM instance_roles WHERE name = 'owner'), 20 | (SELECT id FROM instance_permissions WHERE name = 'venues-view'), 21 | NOW(), 22 | NOW()), 23 | ((SELECT id FROM instance_roles WHERE name = 'chapter_administrator'), 24 | (SELECT id FROM instance_permissions WHERE name = 'chapters-view'), 25 | NOW(), 26 | NOW()), 27 | ((SELECT id FROM instance_roles WHERE name = 'chapter_administrator'), 28 | (SELECT id FROM instance_permissions WHERE name = 'events-view'), 29 | NOW(), 30 | NOW()), 31 | ((SELECT id FROM instance_roles WHERE name = 'chapter_administrator'), 32 | (SELECT id FROM instance_permissions WHERE name = 'venues-view'), 33 | NOW(), 34 | NOW()); -------------------------------------------------------------------------------- /.gitpod.yml: -------------------------------------------------------------------------------- 1 | image: 2 | file: .gitpod.dockerfile 3 | ports: 4 | - port: 3000 5 | onOpen: notify 6 | - port: 5432 7 | onOpen: ignore 8 | - port: 5000 9 | onOpen: ignore 10 | visibility: public 11 | tasks: 12 | # both .bashrc and nvm settings get wiped when a workspace is restarted, so 13 | # they need setting here 14 | - before: | 15 | export NEXT_PUBLIC_SERVER_URL=$(gp url 5000) 16 | export CLIENT_LOCATION=$(gp url 3000) 17 | export COOKIE_DOMAIN=gitpod.io 18 | nvm install lts/gallium && nvm alias default lts/gallium 19 | # the rest get persisted, so we can save time and use init: 20 | init: | 21 | npm ci && npm install -g eslint 22 | pg_ctl restart 23 | command: | 24 | npm run -w=server build && npm run db:migrate:reset 25 | npm run both 26 | github: 27 | prebuilds: 28 | # enable for all branches in this repo (defaults to false) 29 | branches: true 30 | # enable for pull requests coming from this repo (defaults to true) 31 | pullRequests: true 32 | # enable for pull requests coming from forks (defaults to false) 33 | pullRequestsFromForks: true 34 | # add a check to pull requests (defaults to true) 35 | addCheck: true 36 | # add a "Review in Gitpod" button as a comment to pull requests (defaults to false) 37 | addComment: true 38 | # add a "Review in Gitpod" button to the pull request's description (defaults to false) 39 | addBadge: false 40 | # add a label once the prebuild is ready to pull requests (defaults to false) 41 | addLabel: false 42 | -------------------------------------------------------------------------------- /client/src/components/Form/TextArea.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | TextareaProps as ChakraTextareaProps, 3 | FormControl, 4 | FormLabel, 5 | FormErrorMessage, 6 | FormControlProps, 7 | Textarea as ChakraTextarea, 8 | } from '@chakra-ui/react'; 9 | import React, { forwardRef } from 'react'; 10 | 11 | import { capitalize } from '../../util/capitalize'; 12 | 13 | const allowed_types = ['text'] as const; 14 | 15 | type AllowedTypes = (typeof allowed_types)[number]; 16 | 17 | export interface TextAreaProps extends Omit { 18 | label?: string; 19 | noLabel?: boolean; 20 | error?: string; 21 | name: string; 22 | type?: AllowedTypes | string; 23 | outerProps?: FormControlProps; 24 | } 25 | 26 | export const TextArea = forwardRef((props, ref) => { 27 | const { 28 | name, 29 | isInvalid, 30 | isRequired, 31 | label, 32 | placeholder, 33 | outerProps, 34 | noLabel, 35 | ...rest 36 | } = props; 37 | const isError = isInvalid || !!props.error; 38 | return ( 39 | 40 | {!noLabel && ( 41 | {label || capitalize(name)} 42 | )} 43 | 51 | {props.error} 52 | 53 | ); 54 | }); 55 | -------------------------------------------------------------------------------- /server/tests/testUtils/App.ts: -------------------------------------------------------------------------------- 1 | import { Server } from 'http'; 2 | import express from 'express'; 3 | import { responseErrorHandler } from 'express-response-errors'; 4 | import getPort, { portNumbers } from 'get-port'; 5 | import request from 'supertest'; 6 | 7 | import { Request } from '../../src/common-types/gql'; 8 | import { User } from '../../src/controllers/Auth/middleware'; 9 | 10 | type InitProps = { 11 | withRouter?: express.Router; 12 | }; 13 | 14 | class App { 15 | server: express.Application; 16 | request: request.SuperTest; 17 | private _server: Server; 18 | authedUser: User | null = null; 19 | 20 | constructor() { 21 | const app = express(); 22 | this.server = app; 23 | } 24 | 25 | async initialize({ withRouter }: InitProps = {}) { 26 | this.server.use(express.json()); 27 | this.server.use(express.static('public')); 28 | 29 | if (withRouter) { 30 | this.server.use(withRouter); 31 | } 32 | 33 | this.server.use((req: Request, _, next) => { 34 | req.user = this.authedUser || undefined; 35 | next(); 36 | }); 37 | 38 | this.server.use(responseErrorHandler); 39 | 40 | const server = this.server.listen( 41 | await getPort({ port: portNumbers(9000, 10000) }), 42 | ); 43 | 44 | this._server = server; 45 | this.request = request(this.server); 46 | } 47 | 48 | destroy() { 49 | this._server.close(); 50 | } 51 | 52 | login(user: User) { 53 | this.authedUser = user; 54 | } 55 | 56 | logout() { 57 | this.authedUser = null; 58 | } 59 | } 60 | 61 | export default App; 62 | -------------------------------------------------------------------------------- /client/src/modules/auth/providers/auth0.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useAuth0, Auth0Provider } from '@auth0/auth0-react'; 3 | 4 | import { AuthContext } from './common-context'; 5 | 6 | const Wrapper = ({ children }: { children: React.ReactNode }) => { 7 | const { 8 | isAuthenticated, 9 | getAccessTokenSilently: getToken, 10 | loginWithPopup: login, 11 | logout, 12 | error, 13 | } = useAuth0(); 14 | 15 | return ( 16 | 25 | {children} 26 | 27 | ); 28 | }; 29 | 30 | export const AuthProvider = ({ children }: { children: React.ReactNode }) => { 31 | // TODO: create a module to handle environment variables 32 | const domain = process.env.NEXT_PUBLIC_AUTH0_DOMAIN as string; 33 | const clientId = process.env.NEXT_PUBLIC_AUTH0_CLIENT_ID as string; 34 | const audience = process.env.NEXT_PUBLIC_AUTH0_AUDIENCE as string; 35 | const clientURL = process.env.NEXT_PUBLIC_CLIENT_URL as string; 36 | 37 | // TODO: Can we conditionally use window.location.origin for the redirectUri 38 | // if we're in the browser? Or should we require site maintainers to supply 39 | // it? 40 | 41 | return ( 42 | 48 | {children} 49 | 50 | ); 51 | }; 52 | -------------------------------------------------------------------------------- /client/src/components/Form/Select.tsx: -------------------------------------------------------------------------------- 1 | import React, { forwardRef } from 'react'; 2 | import { 3 | FormLabel, 4 | FormControl, 5 | FormErrorMessage, 6 | FormControlProps, 7 | Select as ChakraSelect, 8 | SelectProps as ChakraSelectProps, 9 | } from '@chakra-ui/react'; 10 | 11 | interface Option { 12 | id: number | string; 13 | name: string; 14 | } 15 | 16 | interface SelectProps extends ChakraSelectProps { 17 | name: string; 18 | label: string; 19 | error?: string; 20 | noLabel?: false; 21 | options: Option[]; 22 | outerProps?: FormControlProps; 23 | } 24 | 25 | export const Select = forwardRef( 26 | (props, ref) => { 27 | const { 28 | name, 29 | isInvalid, 30 | isRequired, 31 | label, 32 | outerProps, 33 | noLabel, 34 | options, 35 | ...rest 36 | } = props; 37 | const isError = isInvalid || !!props.error; 38 | return ( 39 | 40 | {!noLabel && {label}} 41 | 49 | {options.map(({ id, name }) => ( 50 | 53 | ))} 54 | 55 | {props.error} 56 | 57 | ); 58 | }, 59 | ); 60 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2019-2023, freeCodeCamp.org 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | 1. Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | 2. Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | 3. Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /server/tests/fixtures/chapterUsers.ts: -------------------------------------------------------------------------------- 1 | import { merge } from 'lodash/fp'; 2 | 3 | import { chapter_roles, chapter_users } from '@prisma/client'; 4 | 5 | import { ChapterRoles } from '../../../common/roles'; 6 | 7 | type UserChapter = chapter_users & { 8 | chapter_role: chapter_roles; 9 | }; 10 | 11 | const baseChapterUser: UserChapter = { 12 | chapter_id: 1, 13 | chapter_role_id: 1, 14 | created_at: new Date(), 15 | joined_date: new Date(), 16 | subscribed: false, 17 | updated_at: new Date(), 18 | user_id: 1, 19 | chapter_role: { 20 | name: 'some-role', 21 | created_at: new Date(), 22 | updated_at: new Date(), 23 | id: 1, 24 | }, 25 | }; 26 | 27 | const administratorOfChapter1 = merge(baseChapterUser, { 28 | chapter_role: { name: ChapterRoles.administrator }, 29 | }); 30 | 31 | const administratorOfChapter2 = merge(baseChapterUser, { 32 | chapter_role: { name: ChapterRoles.administrator }, 33 | chapter_id: 2, 34 | }); 35 | 36 | const memberOfChapter1 = merge(baseChapterUser, { 37 | chapter_role: { name: ChapterRoles.member }, 38 | }); 39 | const memberOfChapter2 = merge(baseChapterUser, { 40 | chapter_role: { name: ChapterRoles.member }, 41 | chapter_id: 2, 42 | }); 43 | 44 | export const userChaptersWithChapter1Admin = [ 45 | administratorOfChapter1, 46 | memberOfChapter2, 47 | ]; 48 | export const userChaptersWithChapter2Admin = [ 49 | memberOfChapter1, 50 | administratorOfChapter2, 51 | ]; 52 | export const userChaptersWithTwoAdmins = [ 53 | administratorOfChapter1, 54 | administratorOfChapter2, 55 | ]; 56 | export const userChaptersWithTwoMembers = [memberOfChapter1, memberOfChapter2]; 57 | -------------------------------------------------------------------------------- /scripts/postInstall.js: -------------------------------------------------------------------------------- 1 | const { execSync } = require('child_process'); 2 | const { existsSync, copyFileSync } = require('fs'); 3 | 4 | console.log('--------------------------'); 5 | console.log('🎉 WELCOME TO CHAPTER 🎉'); 6 | console.log('--------------------------'); 7 | 8 | const CHAPTER_REMOTE = 'freeCodeCamp/chapter.git'; 9 | let IS_ERROR = false; 10 | 11 | function setup() { 12 | try { 13 | const rows = execSync('git remote -v', { 14 | encoding: 'utf-8', 15 | }).split('\n'); 16 | 17 | const isFork = rows 18 | .filter((item) => item.startsWith('origin')) 19 | .every((item) => !item.includes(CHAPTER_REMOTE)); 20 | 21 | const hasUpstreams = rows 22 | .filter((item) => item.startsWith('upstream')) 23 | .some((item) => item.includes(CHAPTER_REMOTE)); 24 | 25 | if (isFork && !hasUpstreams) { 26 | console.log( 27 | 'It seems like this is a fork, and you dont have upstream setup.', 28 | ); 29 | console.log('Run "scripts/pullUpstream.sh" to setup remotes.'); 30 | } 31 | } catch (e) { 32 | IS_ERROR = true; 33 | console.error(e); 34 | } 35 | 36 | if (!existsSync('.env')) { 37 | console.log("You don't have a .env\nCopying .env.example to .env"); 38 | 39 | try { 40 | copyFileSync('.env.example', '.env'); 41 | console.log('Copied!'); 42 | } catch (e) { 43 | IS_ERROR = true; 44 | console.error(`${e} occurred while copying .env file`); 45 | } 46 | } 47 | 48 | if (!IS_ERROR) { 49 | console.log( 50 | '\nCongratulations, its almost done 🙌🏼. Run `npm run both` to start the development server.', 51 | ); 52 | } 53 | } 54 | 55 | setup(); 56 | -------------------------------------------------------------------------------- /server/src/services/mail-providers/ses.ts: -------------------------------------------------------------------------------- 1 | import { 2 | SESClient, 3 | SESClientConfig, 4 | SendEmailCommand, 5 | } from '@aws-sdk/client-ses'; 6 | 7 | import { MailerData, MailProvider } from './abstract-provider'; 8 | 9 | export class SESProvider extends MailProvider { 10 | private SESClient: SESClient; 11 | 12 | constructor() { 13 | super(); 14 | if (!process.env.SES_ACCESS_KEY_ID || !process.env.SES_SECRET_ACCESS_KEY) { 15 | throw new Error('Email service is set to SES but missing required keys.'); 16 | } 17 | const awsConfig: SESClientConfig = { 18 | credentials: { 19 | accessKeyId: process.env.SES_ACCESS_KEY_ID, 20 | secretAccessKey: process.env.SES_SECRET_ACCESS_KEY, 21 | }, 22 | region: 'us-east-1', 23 | }; 24 | this.SESClient = new SESClient(awsConfig); 25 | } 26 | 27 | async send(data: MailerData) { 28 | // TODO: batch into 50 emails per request 29 | // https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/clients/client-ses/classes/sendemailcommand.html 30 | for (const email of data.emailList) { 31 | const opts = new SendEmailCommand({ 32 | Destination: { 33 | ToAddresses: [email], 34 | }, 35 | Message: { 36 | Subject: { 37 | Data: data.subject, 38 | Charset: 'UTF-8', 39 | }, 40 | Body: { 41 | Html: { 42 | Charset: 'UTF-8', 43 | Data: data.htmlEmail || data.backupText || '', 44 | }, 45 | }, 46 | }, 47 | Source: this.ourEmail, 48 | }); 49 | 50 | await this.SESClient.send(opts); 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /server/src/controllers/Calendar/resolver.ts: -------------------------------------------------------------------------------- 1 | import { Authorized, Mutation, Query, Resolver } from 'type-graphql'; 2 | 3 | import { prisma } from '../../prisma'; 4 | import { TokenStatus } from '../../graphql-types'; 5 | import { integrationStatus } from '../../util/calendar'; 6 | import { Permission } from '../../../../common/permissions'; 7 | import { testTokens } from '../../services/Google'; 8 | 9 | @Resolver() 10 | export class CalendarResolver { 11 | @Query(() => Boolean, { nullable: true }) 12 | async calendarIntegrationStatus(): Promise { 13 | return await integrationStatus(); 14 | } 15 | 16 | @Authorized(Permission.GoogleAuthenticate) 17 | @Query(() => [TokenStatus]) 18 | async tokenStatuses(): Promise { 19 | const statuses = await prisma.google_tokens.findMany({ 20 | select: { email: true, is_valid: true }, 21 | }); 22 | const replacer = ( 23 | _match: string, 24 | firstLetter: string, 25 | middle: string, 26 | lastLetterWithDomain: string, 27 | ) => `${firstLetter}${middle.replace(/./g, '*')}${lastLetterWithDomain}`; 28 | const statusesWithRedactedEmail = statuses.map((status) => { 29 | const { email, ...rest } = status; 30 | const redactedEmail = email.replace(/^(.)(.*)(.@.*)$/, replacer); 31 | return { ...rest, redacted_email: redactedEmail }; 32 | }); 33 | return statusesWithRedactedEmail; 34 | } 35 | 36 | @Authorized(Permission.GoogleAuthenticate) 37 | @Mutation(() => Boolean, { nullable: true }) 38 | async calendarIntegrationTest(): Promise { 39 | await testTokens(); 40 | return await integrationStatus(); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /cypress/e2e/dashboard/dashboard-index.cy.ts: -------------------------------------------------------------------------------- 1 | describe('dashboard', () => { 2 | let users; 3 | before(() => { 4 | cy.task('seedDb'); 5 | cy.fixture('users').then((fixture) => { 6 | users = fixture; 7 | }); 8 | cy.visit('/dashboard'); 9 | }); 10 | 11 | it('instance owner should have links to all the dashboard', () => { 12 | cy.login(); 13 | cy.get('a[href="/dashboard/events"]').should('be.visible'); 14 | cy.get('a[href="/dashboard/chapters"]').should('be.visible'); 15 | cy.get('a[href="/dashboard/venues"]').should('be.visible'); 16 | cy.get('a[href="/dashboard/users"]').should('be.visible'); 17 | cy.get('a[href="/dashboard/sponsors"]').should('be.visible'); 18 | }); 19 | 20 | it('chapter administrator should have links to the dashboards allowed for chapter administrator', () => { 21 | cy.login(users.chapter1Admin.email); 22 | cy.get('a[href="/dashboard/events"]').should('be.visible'); 23 | cy.get('a[href="/dashboard/chapters"]').should('be.visible'); 24 | cy.get('a[href="/dashboard/venues"]').should('be.visible'); 25 | cy.get('a[href="/dashboard/sponsors"]').should('be.visible'); 26 | 27 | cy.get('a[href="/dashboard/users"]').should('not.exist'); 28 | }); 29 | 30 | it(`member shouldn't have links to the dashboards`, () => { 31 | cy.login(users.testUser.email); 32 | cy.get('a[href="/dashboard/events"]').should('not.exist'); 33 | cy.get('a[href="/dashboard/chapters"]').should('not.exist'); 34 | cy.get('a[href="/dashboard/venues"]').should('not.exist'); 35 | cy.get('a[href="/dashboard/sponsors"]').should('not.exist'); 36 | cy.get('a[href="/dashboard/users"]').should('not.exist'); 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /client/src/modules/dashboard/Events/components/EventChapterSelect.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useMemo } from 'react'; 2 | import { useFormContext } from 'react-hook-form'; 3 | 4 | import { useUser } from '../../../auth/user'; 5 | import { Select } from '../../../../components/Form/Select'; 6 | 7 | interface Chapter { 8 | id: number; 9 | name: string; 10 | } 11 | interface EventChapterSelectProps { 12 | chapter?: Chapter; 13 | loading: boolean; 14 | } 15 | 16 | const EventChapterSelect: React.FC = ({ 17 | chapter, 18 | loading, 19 | }) => { 20 | const key = 'chapter_id'; 21 | const { user } = useUser(); 22 | const adminedChapters: Chapter[] = useMemo( 23 | () => (user?.admined_chapters ?? []).filter(({ id }) => id !== chapter?.id), 24 | [user?.admined_chapters, chapter?.id], 25 | ); 26 | 27 | const { 28 | register, 29 | resetField, 30 | setValue, 31 | formState: { errors }, 32 | } = useFormContext(); 33 | const error = errors[key]?.message as string; 34 | 35 | useEffect(() => { 36 | if (chapter?.id) { 37 | setValue(key, adminedChapters[0]?.id ?? -1); 38 | } else { 39 | resetField(key, { 40 | defaultValue: adminedChapters[0]?.id ?? -1, 41 | }); 42 | } 43 | }, [adminedChapters, chapter?.id]); 44 | 45 | return ( 46 | 57 | {roles.map(({ id, name }) => ( 58 | 61 | ))} 62 | 63 | 64 | ); 65 | }; 66 | -------------------------------------------------------------------------------- /client/src/components/PageLayout/index.tsx: -------------------------------------------------------------------------------- 1 | import { Box } from '@chakra-ui/layout'; 2 | import { 3 | Alert, 4 | AlertIcon, 5 | AlertDescription, 6 | AlertTitle, 7 | } from '@chakra-ui/react'; 8 | import React from 'react'; 9 | import { SkipNavContent } from '@chakra-ui/skip-nav'; 10 | 11 | import Link from 'next/link'; 12 | import { useCalendarIntegrationStatusQuery } from '../../generated/graphql'; 13 | import { useUser } from '../../modules/auth/user'; 14 | import { checkInstancePermission } from '../../util/check-permission'; 15 | import { Permission } from '../../../../common/permissions'; 16 | import { Alerts } from '../Alerts/Alerts'; 17 | import { Header } from './Header'; 18 | import { Footer } from './component/Footer'; 19 | 20 | const PageLayout = ({ children }: { children: React.ReactNode }) => { 21 | const { user } = useUser(); 22 | const canAuthenticateWithGoogle = checkInstancePermission( 23 | user, 24 | Permission.GoogleAuthenticate, 25 | ); 26 | 27 | const { data } = useCalendarIntegrationStatusQuery({ 28 | skip: !canAuthenticateWithGoogle, 29 | }); 30 | 31 | return ( 32 | <> 33 |
34 | {canAuthenticateWithGoogle && 35 | data?.calendarIntegrationStatus === null && ( 36 | 37 | 38 | Broken integration. 39 | 40 | Integration with Google Calendar is currently not working. 41 | Reauthenticate with Google Calendar in{' '} 42 | Calendar dashboard. 43 | 44 | 45 | )} 46 | 47 | 48 | 55 | {children} 56 | 57 |