From f19cd60caa40f81550ffce3b2ab196d65c618224 Mon Sep 17 00:00:00 2001 From: Dan Ott Date: Wed, 28 Sep 2022 14:48:14 -0400 Subject: [PATCH] Feature: auth (#635) * Get basic auth running with errors * add login method * logging * add register endpoint * stop using forked version of form * testing error states * fix dev settings * fix up error handling and add some new methods * Add additional auth utilities * add schema to response * begin adding join route * rename CmsAuth * remove logging * begin adding some cms actions * begin adding event fetching * Add ts packages * use tsconfig # Conflicts: # tsconfig.json * remove some comments * add remix type defs * update server.js to match remix example * convert entry files to ts * use ts * allow redirect on success * add event forwarding * query all calendars * display message * move auth into membership route * use ts * add route protection back in to dashboard * return value from loader * move files into frontend route * update paths from rename * Move app files into __app * Add tailwind * remove tailwind.css from vc * ignore tailwind * add spread params to svgs * move around auth routes and add styles * rename dashboard to membership index * adjust styles * move join into auth * remove old thing * renive extra links * fix up auth * add getCalendars * move auth into pathless * move layouts into correct folder * use new singletask layout * convert to ts * use updated types * add first pass of events * add inter font * use classNames * rename method * add events calendar * add events calendar views * description * update calendar formatting * add popover for event * remove console.log * go stacked layout * adjust for stacked layout * add meta data * begin adding more components and details * Add ts versions * move files * reset dependencies * check for null data * fix some ts issues * update dependencies * update utility types * update heroicons * abstract some components * clean up styling * use sky instead of indigo * upgrade remix dependencies and scripts * add tiny-invariant * convert to ts * allow for netlify dev * use globals * open in new tab * continue debugging * auth debugging go away * ignore * convert to ts and wrap in global * split out cms functions * removing logging * check for user * move tls reject to env variable * read user via getUser * removing * clean up events * authenticate * use split versions * remove esbuild from node bundler * turn on defaults * turn off esnext for now * update login form * add note * fix this thing * remove console.log call * use custom livereload from 1.6.4 of remix see /~https://github.com/remix-run/remix/issues/2997#issuecomment-1233379949 * remove debugging help * move file * fix faker fn * tweak Button styling and add fullWidth prop * Add TextInput and FieldGroup * style reset password form * clean up auth forms * convert to ts * convert some ts * add wide option * add FieldSet, TextArea, and addons for TextInput * clean up register * fix up register form / hide form for now * use solid icons * Abstract events list and add ics link * use updated imports * add events to dashboard * turn on esnext * correct link address * remove comment * add getCalendar * add black and white buttons * Add passedRef prop * Add subscribe links * move join files * fix links * fix import * Fix path for mdx loading * abstract out not found content * Add splat page to catch non-loader 404s * do not default to empty string * remove extra h1 --- .github/workflows/monthly-challenge-issue.yml | 2 +- .gitignore | 3 +- README.md | 22 +- app/api/cms.server.ts | 400 ++++ app/api/cmsauth.server.ts | 246 +++ app/api/types.ts | 46 + app/api/util.ts | 9 + app/auth/auth.server.ts | 144 ++ app/auth/session.server.ts | 36 + app/components/Root.jsx | 67 - app/components/app/Alert.tsx | 73 + app/components/app/Button.tsx | 66 + app/components/app/Events.tsx | 147 ++ app/components/app/Forms.tsx | 182 ++ app/components/app/PageHeader.tsx | 32 + app/components/content/NotFoundCatch.jsx | 64 + app/components/layouts/AppRoot.jsx | 202 ++ app/components/layouts/Root.jsx | 47 + app/components/layouts/SingleTask.tsx | 43 + app/data/mocks/sponsors.js | 52 +- app/data/monthlyChallenges/getChallenges.js | 47 +- app/data/newsletters.js | 43 +- app/entry.client.tsx | 4 +- app/entry.server.tsx | 27 +- app/root.jsx | 133 +- app/routes/__app.jsx | 32 + app/routes/__app/membership.tsx | 5 + .../calendar-subscribe.$calendarHandle.tsx | 17 + app/routes/__app/membership/events/index.tsx | 819 ++++++++ app/routes/__app/membership/index.tsx | 175 ++ app/routes/__auth.jsx | 15 + app/routes/__auth/activate.tsx | 72 + app/routes/__auth/forgot-password.jsx | 109 ++ app/routes/__auth/join/$eventUid.tsx | 60 + app/routes/__auth/login.tsx | 146 ++ app/routes/__auth/logout.jsx | 5 + app/routes/__auth/register-success.jsx | 3 + app/routes/__auth/register.jsx | 266 +++ app/routes/__auth/resend-activation.jsx | 114 ++ app/routes/__auth/set-password.jsx | 138 ++ app/routes/__frontend.jsx | 17 + app/routes/__frontend/$.tsx | 15 + app/routes/{ => __frontend}/__simpleMdx.jsx | 2 +- .../{ => __frontend}/__simpleMdx/about.mdx | 0 .../__simpleMdx/code-of-conduct.mdx | 3 +- .../{ => __frontend}/__simpleMdx/uses.mdx | 0 app/routes/{ => __frontend}/events/index.jsx | 0 app/routes/{ => __frontend}/index.tsx | 12 +- app/routes/{ => __frontend}/join/index.jsx | 0 .../{ => __frontend}/join/thank-you.jsx | 0 .../lunch-and-learn-idea/index.jsx | 0 .../lunch-and-learn-idea/thanks.jsx | 0 app/routes/{ => __frontend}/members/index.tsx | 0 .../{ => __frontend}/monthlychallenges.jsx | 4 +- .../monthlychallenges/apr-2021.jsx | 0 .../monthlychallenges/apr-2022.jsx | 0 .../monthlychallenges/aug-2021.jsx | 0 .../monthlychallenges/aug-2022.jsx | 0 .../monthlychallenges/dec-2020/dec-2020.json | 0 .../monthlychallenges/dec-2020/index.jsx | 0 .../monthlychallenges/dec-2021.jsx | 0 .../monthlychallenges/feb-2021.jsx | 0 .../monthlychallenges/feb-2022.jsx | 0 .../monthlychallenges/index.jsx | 0 .../monthlychallenges/jan-2021/index.jsx | 0 .../monthlychallenges/jan-2021/jan-2021.json | 0 .../monthlychallenges/jan-2022.jsx | 0 .../monthlychallenges/july-2021.jsx | 0 .../monthlychallenges/july-2022.jsx | 0 .../monthlychallenges/june-2021.jsx | 0 .../monthlychallenges/june-2022.jsx | 4 +- .../monthlychallenges/mar-2021.jsx | 0 .../monthlychallenges/mar-2022.jsx | 0 .../monthlychallenges/may-2021.jsx | 0 .../monthlychallenges/may-2022.jsx | 0 .../monthlychallenges/nov-2020/index.jsx | 0 .../monthlychallenges/nov-2020/nov-2020.json | 0 .../monthlychallenges/nov-2021.jsx | 0 .../monthlychallenges/oct-2021.jsx | 0 .../monthlychallenges/sept-2021.jsx | 0 .../monthlychallenges/sept-2022.jsx | 12 +- app/routes/{ => __frontend}/newsletter.jsx | 2 +- .../{ => __frontend}/newsletter/index.jsx | 0 .../newsletter/issues/2021-01.jsx | 0 .../newsletter/issues/2021-02.jsx | 0 .../newsletter/issues/2021-03.jsx | 0 .../newsletter/issues/2021-04.jsx | 0 .../newsletter/issues/2021-05.jsx | 0 .../newsletter/issues/2021-06.jsx | 0 .../newsletter/issues/2021-07.jsx | 0 .../newsletter/issues/2021-08.jsx | 0 .../newsletter/issues/2021-09.jsx | 0 .../newsletter/issues/2021-10.jsx | 0 .../newsletter/issues/2021-11.jsx | 0 .../newsletter/issues/2021-12.jsx | 0 .../newsletter/issues/2022-01.jsx | 0 .../newsletter/issues/2022-02.jsx | 0 .../newsletter/issues/2022-03.jsx | 0 .../newsletter/issues/2022-04.jsx | 0 .../newsletter/issues/2022-05.jsx | 0 .../newsletter/issues/2022-06.jsx | 0 .../newsletter/issues/2022-07.jsx | 0 .../newsletter/issues/2022-08.jsx | 0 .../newsletter/issues/2022-09.jsx | 0 .../{ => __frontend}/podcast/$episode.tsx | 0 app/routes/{ => __frontend}/podcast/index.tsx | 0 .../report-coc-violation/index.jsx | 0 .../report-coc-violation/thanks.jsx | 0 app/routes/{ => __frontend}/resources.jsx | 6 +- .../{ => __frontend}/resources/index.mdx | 0 .../open-source/about-open-source.mdx | 0 .../open-source/contributor-guide.mdx | 0 .../resources/open-source/git-101.mdx | 0 .../resources/open-source/index.mdx | 0 .../open-source/maintainer-guide.mdx | 0 .../virtual-coffee/coding-questions-guide.mdx | 0 .../get-involved/coffee-table-groups.mdx | 0 .../virtual-coffee/get-involved/index.mdx | 0 .../get-involved/lunch-and-learns.mdx | 0 .../get-involved/paths-to-leadership.mdx | 3 +- .../resources/virtual-coffee/guide-to-vc.mdx | 0 .../resources/virtual-coffee/index.mdx | 0 .../virtual-coffee/join-virtual-coffee.mdx | 0 .../virtual-coffee/slack-channel-guide.mdx | 0 .../start-coffee-table-group/index.jsx | 0 .../start-coffee-table-group/thanks.jsx | 0 .../volunteer-at-virtual-coffee/index.jsx | 0 .../volunteer-at-virtual-coffee/thanks.jsx | 0 app/svg/VirtualCoffeeFull.jsx | 2 + app/svg/VirtualCoffeeFullBanner.jsx | 4 +- app/util/loadMdx.server.js | 3 +- app/util/remixHelpers.tsx | 64 + app/util/types.ts | 59 + members/members/JesseRWeigel.js | 2 +- members/members/SuperRoach.js | 6 +- members/members/ramonh.js | 16 +- netlify.toml | 16 +- package.json | 51 +- remix.config.js | 15 +- server.js | 6 +- tailwind.config.js | 22 + yarn.lock | 1649 ++++++++++------- 142 files changed, 5151 insertions(+), 957 deletions(-) create mode 100644 app/api/cms.server.ts create mode 100644 app/api/cmsauth.server.ts create mode 100644 app/api/types.ts create mode 100644 app/api/util.ts create mode 100644 app/auth/auth.server.ts create mode 100644 app/auth/session.server.ts delete mode 100644 app/components/Root.jsx create mode 100644 app/components/app/Alert.tsx create mode 100644 app/components/app/Button.tsx create mode 100644 app/components/app/Events.tsx create mode 100644 app/components/app/Forms.tsx create mode 100644 app/components/app/PageHeader.tsx create mode 100644 app/components/content/NotFoundCatch.jsx create mode 100644 app/components/layouts/AppRoot.jsx create mode 100644 app/components/layouts/Root.jsx create mode 100644 app/components/layouts/SingleTask.tsx create mode 100644 app/routes/__app.jsx create mode 100644 app/routes/__app/membership.tsx create mode 100644 app/routes/__app/membership/events/calendar-subscribe.$calendarHandle.tsx create mode 100644 app/routes/__app/membership/events/index.tsx create mode 100644 app/routes/__app/membership/index.tsx create mode 100644 app/routes/__auth.jsx create mode 100644 app/routes/__auth/activate.tsx create mode 100644 app/routes/__auth/forgot-password.jsx create mode 100644 app/routes/__auth/join/$eventUid.tsx create mode 100644 app/routes/__auth/login.tsx create mode 100644 app/routes/__auth/logout.jsx create mode 100644 app/routes/__auth/register-success.jsx create mode 100644 app/routes/__auth/register.jsx create mode 100644 app/routes/__auth/resend-activation.jsx create mode 100644 app/routes/__auth/set-password.jsx create mode 100644 app/routes/__frontend.jsx create mode 100644 app/routes/__frontend/$.tsx rename app/routes/{ => __frontend}/__simpleMdx.jsx (95%) rename app/routes/{ => __frontend}/__simpleMdx/about.mdx (100%) rename app/routes/{ => __frontend}/__simpleMdx/code-of-conduct.mdx (99%) rename app/routes/{ => __frontend}/__simpleMdx/uses.mdx (100%) rename app/routes/{ => __frontend}/events/index.jsx (100%) rename app/routes/{ => __frontend}/index.tsx (98%) rename app/routes/{ => __frontend}/join/index.jsx (100%) rename app/routes/{ => __frontend}/join/thank-you.jsx (100%) rename app/routes/{ => __frontend}/lunch-and-learn-idea/index.jsx (100%) rename app/routes/{ => __frontend}/lunch-and-learn-idea/thanks.jsx (100%) rename app/routes/{ => __frontend}/members/index.tsx (100%) rename app/routes/{ => __frontend}/monthlychallenges.jsx (81%) rename app/routes/{ => __frontend}/monthlychallenges/apr-2021.jsx (100%) rename app/routes/{ => __frontend}/monthlychallenges/apr-2022.jsx (100%) rename app/routes/{ => __frontend}/monthlychallenges/aug-2021.jsx (100%) rename app/routes/{ => __frontend}/monthlychallenges/aug-2022.jsx (100%) rename app/routes/{ => __frontend}/monthlychallenges/dec-2020/dec-2020.json (100%) rename app/routes/{ => __frontend}/monthlychallenges/dec-2020/index.jsx (100%) rename app/routes/{ => __frontend}/monthlychallenges/dec-2021.jsx (100%) rename app/routes/{ => __frontend}/monthlychallenges/feb-2021.jsx (100%) rename app/routes/{ => __frontend}/monthlychallenges/feb-2022.jsx (100%) rename app/routes/{ => __frontend}/monthlychallenges/index.jsx (100%) rename app/routes/{ => __frontend}/monthlychallenges/jan-2021/index.jsx (100%) rename app/routes/{ => __frontend}/monthlychallenges/jan-2021/jan-2021.json (100%) rename app/routes/{ => __frontend}/monthlychallenges/jan-2022.jsx (100%) rename app/routes/{ => __frontend}/monthlychallenges/july-2021.jsx (100%) rename app/routes/{ => __frontend}/monthlychallenges/july-2022.jsx (100%) rename app/routes/{ => __frontend}/monthlychallenges/june-2021.jsx (100%) rename app/routes/{ => __frontend}/monthlychallenges/june-2022.jsx (99%) rename app/routes/{ => __frontend}/monthlychallenges/mar-2021.jsx (100%) rename app/routes/{ => __frontend}/monthlychallenges/mar-2022.jsx (100%) rename app/routes/{ => __frontend}/monthlychallenges/may-2021.jsx (100%) rename app/routes/{ => __frontend}/monthlychallenges/may-2022.jsx (100%) rename app/routes/{ => __frontend}/monthlychallenges/nov-2020/index.jsx (100%) rename app/routes/{ => __frontend}/monthlychallenges/nov-2020/nov-2020.json (100%) rename app/routes/{ => __frontend}/monthlychallenges/nov-2021.jsx (100%) rename app/routes/{ => __frontend}/monthlychallenges/oct-2021.jsx (100%) rename app/routes/{ => __frontend}/monthlychallenges/sept-2021.jsx (100%) rename app/routes/{ => __frontend}/monthlychallenges/sept-2022.jsx (95%) rename app/routes/{ => __frontend}/newsletter.jsx (91%) rename app/routes/{ => __frontend}/newsletter/index.jsx (100%) rename app/routes/{ => __frontend}/newsletter/issues/2021-01.jsx (100%) rename app/routes/{ => __frontend}/newsletter/issues/2021-02.jsx (100%) rename app/routes/{ => __frontend}/newsletter/issues/2021-03.jsx (100%) rename app/routes/{ => __frontend}/newsletter/issues/2021-04.jsx (100%) rename app/routes/{ => __frontend}/newsletter/issues/2021-05.jsx (100%) rename app/routes/{ => __frontend}/newsletter/issues/2021-06.jsx (100%) rename app/routes/{ => __frontend}/newsletter/issues/2021-07.jsx (100%) rename app/routes/{ => __frontend}/newsletter/issues/2021-08.jsx (100%) rename app/routes/{ => __frontend}/newsletter/issues/2021-09.jsx (100%) rename app/routes/{ => __frontend}/newsletter/issues/2021-10.jsx (100%) rename app/routes/{ => __frontend}/newsletter/issues/2021-11.jsx (100%) rename app/routes/{ => __frontend}/newsletter/issues/2021-12.jsx (100%) rename app/routes/{ => __frontend}/newsletter/issues/2022-01.jsx (100%) rename app/routes/{ => __frontend}/newsletter/issues/2022-02.jsx (100%) rename app/routes/{ => __frontend}/newsletter/issues/2022-03.jsx (100%) rename app/routes/{ => __frontend}/newsletter/issues/2022-04.jsx (100%) rename app/routes/{ => __frontend}/newsletter/issues/2022-05.jsx (100%) rename app/routes/{ => __frontend}/newsletter/issues/2022-06.jsx (100%) rename app/routes/{ => __frontend}/newsletter/issues/2022-07.jsx (100%) rename app/routes/{ => __frontend}/newsletter/issues/2022-08.jsx (100%) rename app/routes/{ => __frontend}/newsletter/issues/2022-09.jsx (100%) rename app/routes/{ => __frontend}/podcast/$episode.tsx (100%) rename app/routes/{ => __frontend}/podcast/index.tsx (100%) rename app/routes/{ => __frontend}/report-coc-violation/index.jsx (100%) rename app/routes/{ => __frontend}/report-coc-violation/thanks.jsx (100%) rename app/routes/{ => __frontend}/resources.jsx (95%) rename app/routes/{ => __frontend}/resources/index.mdx (100%) rename app/routes/{ => __frontend}/resources/open-source/about-open-source.mdx (100%) rename app/routes/{ => __frontend}/resources/open-source/contributor-guide.mdx (100%) rename app/routes/{ => __frontend}/resources/open-source/git-101.mdx (100%) rename app/routes/{ => __frontend}/resources/open-source/index.mdx (100%) rename app/routes/{ => __frontend}/resources/open-source/maintainer-guide.mdx (100%) rename app/routes/{ => __frontend}/resources/virtual-coffee/coding-questions-guide.mdx (100%) rename app/routes/{ => __frontend}/resources/virtual-coffee/get-involved/coffee-table-groups.mdx (100%) rename app/routes/{ => __frontend}/resources/virtual-coffee/get-involved/index.mdx (100%) rename app/routes/{ => __frontend}/resources/virtual-coffee/get-involved/lunch-and-learns.mdx (100%) rename app/routes/{ => __frontend}/resources/virtual-coffee/get-involved/paths-to-leadership.mdx (99%) rename app/routes/{ => __frontend}/resources/virtual-coffee/guide-to-vc.mdx (100%) rename app/routes/{ => __frontend}/resources/virtual-coffee/index.mdx (100%) rename app/routes/{ => __frontend}/resources/virtual-coffee/join-virtual-coffee.mdx (100%) rename app/routes/{ => __frontend}/resources/virtual-coffee/slack-channel-guide.mdx (100%) rename app/routes/{ => __frontend}/start-coffee-table-group/index.jsx (100%) rename app/routes/{ => __frontend}/start-coffee-table-group/thanks.jsx (100%) rename app/routes/{ => __frontend}/volunteer-at-virtual-coffee/index.jsx (100%) rename app/routes/{ => __frontend}/volunteer-at-virtual-coffee/thanks.jsx (100%) create mode 100644 app/util/remixHelpers.tsx create mode 100644 app/util/types.ts create mode 100644 tailwind.config.js diff --git a/.github/workflows/monthly-challenge-issue.yml b/.github/workflows/monthly-challenge-issue.yml index 4c87013b..99700b4f 100644 --- a/.github/workflows/monthly-challenge-issue.yml +++ b/.github/workflows/monthly-challenge-issue.yml @@ -20,7 +20,7 @@ jobs: ## Issue Context *Add New Challenge to the Site* - You can use [June 2021](/~https://github.com/Virtual-Coffee/virtualcoffee.io/blob/main/app/routes/monthlychallenges/june-2021.jsx) as kind of a template + You can use [June 2021](/~https://github.com/Virtual-Coffee/virtualcoffee.io/blob/main/app/routes/__frontend/monthlychallenges/june-2021.jsx) as kind of a template ## Steps - [ ] Follow the instructions for [creating monthly challenge pages](/~https://github.com/Virtual-Coffee/virtualcoffee.io/#monthly-challenges) diff --git a/.gitignore b/.gitignore index d98cbda2..e322a104 100644 --- a/.gitignore +++ b/.gitignore @@ -10,10 +10,11 @@ node_modules -/netlify/functions/server/index.js +/netlify/functions/server /public/build # Local Netlify folder .netlify /app/styles /.tmp +/app/tailwind.css diff --git a/README.md b/README.md index 1c00f39d..0740110a 100644 --- a/README.md +++ b/README.md @@ -183,13 +183,13 @@ If you'd like to work on a feature that requires an API key, please reach out to Our [VC Resources](https://virtualcoffee.io/resources) are creating using [MDX](https://mdxjs.com/). MDX is basically a combination of Markdown and React. -Any files added to `app/routes/resources` will be automatically loaded and added to the appropriate index page. +Any files added to `app/routes/__frontend/resources` will be automatically loaded and added to the appropriate index page. A good way to start adding a new page would be to copy one of the existing pages, then edit the details and content. ### Newsletters -The newsletters (for now) are simply `jsx` files, and can be found in `app/routes/newsletter/issues`. +The newsletters (for now) are simply `jsx` files, and can be found in `app/routes/__frontend/newsletter/issues`. When you add a new issue, **make sure to add it to the index**. Here's how: @@ -197,12 +197,12 @@ When you add a new issue, **make sure to add it to the index**. Here's how: - `import` the new issue - Add the new issue to the `newsletters` array. -So, if you have created `app/routes/newsletter/issues/2022-03.jsx`: +So, if you have created `app/routes/__frontend/newsletter/issues/2022-03.jsx`: ```diff -+ import { handle as issue202203 } from '~/routes/newsletter/issues/2022-03'; -import { handle as issue202202 } from '~/routes/newsletter/issues/2022-02'; -import { handle as issue202201 } from '~/routes/newsletter/issues/2022-01'; ++ import { handle as issue202203 } from '~/routes/__frontend/newsletter/issues/2022-03'; +import { handle as issue202202 } from '~/routes/__frontend/newsletter/issues/2022-02'; +import { handle as issue202201 } from '~/routes/__frontend/newsletter/issues/2022-01'; const newsletters = [ + { handleData: issue202203, slug: '2022-03' }, @@ -213,7 +213,7 @@ const newsletters = [ ### Monthly Challenges -The monthly challenges (for now) are simply `jsx` files, and can be found in `app/routes/monthlychallenges`. +The monthly challenges (for now) are simply `jsx` files, and can be found in `app/routes/__frontend/monthlychallenges`. When you add a new challenge, **make sure to add it to the index**. Here's how: @@ -221,12 +221,12 @@ When you add a new challenge, **make sure to add it to the index**. Here's how: - `import` the new challenge - Add the new challenge to the `challenges` array. -So, if you have created `app/routes/monthlychallenges/apr-2022.jsx`: +So, if you have created `app/routes/__frontend/monthlychallenges/apr-2022.jsx`: ```diff -+ import { handle as apr2022 } from '~/routes/monthlychallenges/apr-2022'; -import { handle as mar2022 } from '~/routes/monthlychallenges/mar-2022'; -import { handle as feb2022 } from '~/routes/monthlychallenges/feb-2022'; ++ import { handle as apr2022 } from '~/routes/__frontend/monthlychallenges/apr-2022'; +import { handle as mar2022 } from '~/routes/__frontend/monthlychallenges/mar-2022'; +import { handle as feb2022 } from '~/routes/__frontend/monthlychallenges/feb-2022'; const challenges = [ + { handleData: apr2022, slug: 'apr-2022' }, diff --git a/app/api/cms.server.ts b/app/api/cms.server.ts new file mode 100644 index 00000000..907ee820 --- /dev/null +++ b/app/api/cms.server.ts @@ -0,0 +1,400 @@ +import { GraphQLClient, gql } from 'graphql-request'; +import { getUser } from '~/auth/auth.server'; +import { DateTime } from 'luxon'; +import { CmsError } from '~/api/util'; +import type { + CalendarVisibility, + Event, + SafeEvent, + Calendar, + User, + EventLoaderData, +} from './types'; +import { ics } from 'calendar-link'; +import { sanitizeHtml } from '~/util/sanitizeCmsData'; + +export function getIcsLink(event: Event) { + return ics({ + title: event.title, + start: event.startDateLocalized, + end: event.endDateLocalized, + description: event.eventCalendarDescription, + }); +} + +export class CmsActions { + client: GraphQLClient; + constructor() { + if (!process.env.CMS_URL || !process.env.CMS_TOKEN) { + throw new CmsError('Missing API credentials'); + } else { + this.client = new GraphQLClient(`${process.env.CMS_URL}/api`, { + headers: { + Authorization: `bearer ${process.env.CMS_TOKEN}`, + }, + }); + } + } + + async enhanceEvent(event: Event): Promise { + event.eventCalendarDescription = await sanitizeHtml( + event.eventCalendarDescription, + ); + + return { ...event, eventIcsLink: getIcsLink(event) }; + } + + async authenticate(request: Request) { + let user = await getUser(request); + if (!user) { + throw new CmsError('User not found'); + } + this.client.setHeader('Authorization', `JWT ${user.jwt}`); + } + + async getCalendarEntryByCalendarId({ id }: { id: string | number }): Promise<{ + id: number; + title: string; + calendarVisibility: CalendarVisibility; + }> { + const query = gql` + query GetUpcomingEvent($id: [QueryArgument]) { + entry(calendar: $id, section: "calendars") { + ... on calendars_default_Entry { + id + title + calendarVisibility + } + } + } + `; + + const response = await this.client.request(query, { + id, + }); + + if (!response?.entry) { + throw new CmsError( + 'There was an error fetching the calendar entry.', + response, + ); + } + + return response.entry; + } + + async getCalendars(): Promise< + { + handle: string; + name: string; + icsHash: string; + id: number | string; + }[] + > { + const query = `query getCalendars { + solspace_calendar { + calendars { + handle + name + icsHash + id + } + } + } + `; + + const response = await this.client.request(query); + + if (!response?.solspace_calendar?.calendars?.length) { + throw new CmsError('There was an error fetching calendars.', response); + } + + return response.solspace_calendar.calendars; + } + + async getCalendar(handle: string): Promise<{ + handle: string; + name: string; + icsHash: string; + id: number | string; + }> { + const query = gql` + query getCalendars($handle: [String]) { + solspace_calendar { + calendar(handle: $handle) { + handle + name + icsHash + id + } + } + } + `; + + const response = await this.client.request(query); + + if (!response?.solspace_calendar?.calendar) { + throw new CmsError('There was an error fetching the calendar.', response); + } + + return response.solspace_calendar.calendar; + } + + async getAllCalendarHandles(): Promise { + const calendars = await this.getCalendars(); + + if (!calendars?.length) { + throw new CmsError('There was an error fetching the event.'); + } + + return calendars.map((c) => c.handle); + } + + async getEventByUid({ uid }: { uid: string }): Promise { + const calendarHandles = await this.getAllCalendarHandles(); + + // event gets one event. if it is a recurring event, it will get the first one in the range. + const query = gql` + query GetUpcomingEvent( + $rangeStart: String! + $rangeEnd: String! + $uid: [String] + ) { + solspace_calendar { + event( + uid: $uid + loadOccurrences: true + rangeStart: $rangeStart + rangeEnd: $rangeEnd + ) { + id + uid + calendarId + title + rrule + startDateLocalized + endDateLocalized + ${calendarHandles.map( + (handle) => ` + ... on ${handle}_Event { + eventVisibility + eventJoinLink + eventLink + eventCalendarDescription + }`, + )} + } + } + } + `; + + // + const rangeStart = DateTime.now().minus({ hours: 12 }).toISO(); + const rangeEnd = DateTime.now().plus({ hours: 12 }).toISO(); + + const response = await this.client.request(query, { + uid, + rangeStart, + rangeEnd, + }); + + if (!response?.solspace_calendar) { + throw new CmsError('There was an error fetching the event.', response); + } + + return await this.enhanceEvent(response.solspace_calendar.event); + } + + async getEventsInRange({ + rangeStart: specifiedRangeStart, + rangeEnd: specifiedRangeEnd, + calendars, + limit, + }: { + rangeStart?: string; + rangeEnd?: string; + calendars?: string[]; + limit?: number; + }): Promise { + let calendarHandles = calendars || (await this.getAllCalendarHandles()); + + // event gets one event. if it is a recurring event, it will get the first one in the range. + const query = gql` + query GetUpcomingEvents( + $rangeStart: String! + $rangeEnd: String! + $limit:Int + ) { + solspace_calendar { + events( + loadOccurrences: true + rangeStart: $rangeStart + rangeEnd: $rangeEnd + limit: $limit + ) { + id + uid + calendarId + title + rrule + startDateLocalized + endDateLocalized + ${calendarHandles.map( + (handle) => ` + ... on ${handle}_Event { + eventVisibility + eventJoinLink + eventLink + eventCalendarDescription + }`, + )} + } + } + } + `; + + // + const rangeStart = + specifiedRangeStart || DateTime.now().set({ hour: 0 }).toISO(); + const rangeEnd = + specifiedRangeEnd || + DateTime.now().set({ hour: 0 }).plus({ days: 30 }).toISO(); + + const response = await this.client.request(query, { + limit, + rangeStart, + rangeEnd, + }); + + if (!response?.solspace_calendar) { + throw new CmsError('There was an error fetching events.', response); + } + + return await Promise.all( + response.solspace_calendar.events.map(this.enhanceEvent), + ); + } + + async getEventJoinLink(event: Event, request: Request) { + let returnJson: EventLoaderData | null = null; + + if (!event) { + returnJson = { + type: 'error', + message: 'Event not found. Please check your event link and try again.', + }; + return returnJson; + } + + const safeEvent: SafeEvent = { + id: event.id, + uid: event.uid, + title: event.title, + rrule: event.rrule, + calendarId: event.calendarId, + startDateLocalized: event.startDateLocalized, + endDateLocalized: event.endDateLocalized, + eventVisibility: event.eventVisibility, + eventCalendarDescription: event.eventCalendarDescription, + eventLink: event.eventLink, + }; + + // check timing + const now = DateTime.now(); + const startTime = DateTime.fromISO(event.startDateLocalized).setZone( + 'America/New_York', + ); + const endTime = DateTime.fromISO(event.endDateLocalized).setZone( + 'America/New_York', + ); + + if (now < startTime.minus({ minutes: 10 })) { + returnJson = { + type: 'timing', + message: `The event hasn't started yet!`, + event: safeEvent, + }; + return returnJson; + } + + if (now > endTime.plus({ hours: 2 })) { + returnJson = { + type: 'timing', + message: `This event has already ended.`, + event: safeEvent, + }; + return returnJson; + } + + // check visibility + + let visibility = event.eventVisibility; + + if (!visibility || visibility === 'default') { + // we need to get the parent calendar and check it's visibility + const calendar: Calendar = await this.getCalendarEntryByCalendarId({ + id: event.calendarId, + }); + + if (!calendar) { + return { + message: + 'Calendar not found. Please check your event link and try again.', + } as EventLoaderData; + } + + visibility = calendar.calendarVisibility; + } + + if (visibility === 'public') { + if (event.eventJoinLink) { + returnJson = { + type: 'success', + event: safeEvent, + }; + return returnJson; + } + + returnJson = { + type: 'noLink', + message: `This event has no link.`, + event: safeEvent, + }; + return returnJson; + } + + // if it's not public, then authenticate + let user: User = await getUser(request); + + if (user) { + if ( + visibility === 'membersOnly' && + user.schema !== 'Full Members Schema' + ) { + returnJson = { + type: 'permissions', + message: `This event is for members only.`, + event: safeEvent, + }; + return returnJson; + } + + if (event.eventJoinLink) { + returnJson = { + type: 'success', + event: safeEvent, + }; + return returnJson; + } + + returnJson = { + type: 'noLink', + message: `This event has no link.`, + event: safeEvent, + }; + return returnJson; + } + + throw new CmsError('There was an error checking this event.'); + } +} diff --git a/app/api/cmsauth.server.ts b/app/api/cmsauth.server.ts new file mode 100644 index 00000000..662b3234 --- /dev/null +++ b/app/api/cmsauth.server.ts @@ -0,0 +1,246 @@ +import { GraphQLClient, gql } from 'graphql-request'; +import { CmsError } from '~/api/util'; + +export class CmsAuth { + client: GraphQLClient; + constructor() { + if (!process.env.CMS_URL || !process.env.CMS_TOKEN) { + throw new CmsError('Missing API credentials'); + } else { + this.client = new GraphQLClient(`${process.env.CMS_URL}/api`, { + headers: { + Authorization: `bearer ${process.env.CMS_TOKEN}`, + }, + }); + } + } + + handleRequestError(error: any, errorMessage: string = 'There was an error') { + console.log(error); + // @ts-ignore + const msg = + // @ts-ignore + error.response?.errors?.[0]?.message || errorMessage; + throw new CmsError(msg, { + // @ts-ignore + errors: error.response?.errors, + }); + } + + activateUser = async ({ code, id }: { code: string; id: string }) => { + const query = gql` + mutation ActivateUser($code: String!, $id: String!) { + activateUser(code: $code, id: $id) + } + `; + try { + const response = await this.client.request(query, { code, id }); + return response; + } catch (error) { + this.handleRequestError(error, 'Unable to activate user.'); + } + }; + + resendActivation = async ({ email }: { email: string }) => { + const query = gql` + mutation ResendActivation($email: String!) { + resendActivation(email: $email) + } + `; + try { + const response = await this.client.request(query, { email }); + return response; + } catch (error) { + this.handleRequestError(error); + } + }; + + forgottenPassword = async ({ email }: { email: string }) => { + const query = gql` + mutation ForgottenPassword($email: String!) { + forgottenPassword(email: $email) + } + `; + try { + const response = await this.client.request(query, { email }); + return response; + } catch (error) { + this.handleRequestError(error); + } + }; + + setPassword = async ({ + password, + code, + id, + }: { + password: string; + code: string; + id: string; + }) => { + const query = gql` + mutation SetPassword($password: String!, $code: String!, $id: String!) { + setPassword(password: $password, code: $code, id: $id) + } + `; + try { + const response = await this.client.request(query, { code, id, password }); + return response; + } catch (error) { + this.handleRequestError(error, 'Unable to save password.'); + } + }; + + refreshToken = async ({ refreshToken }: { refreshToken: string }) => { + const query = gql` + mutation RefreshToken($refreshToken: String!) { + refreshToken(refreshToken: $refreshToken) { + jwt + jwtExpiresAt + refreshToken + refreshTokenExpiresAt + schema + user { + id + email + enabled + status + ... on User { + userYourName + } + } + } + } + `; + try { + const response = await this.client.request(query, { refreshToken }); + return response; + } catch (error) { + this.handleRequestError(error, 'Unable to refresh user session.'); + } + }; + + async login(email: string, password: string) { + const query = gql` + mutation Authenticate($email: String!, $password: String!) { + authenticate(email: $email, password: $password) { + jwt + jwtExpiresAt + refreshToken + refreshTokenExpiresAt + schema + user { + id + email + enabled + status + ... on User { + userYourName + } + } + } + } + `; + try { + const response = await this.client.request(query, { email, password }); + return response; + } catch (error) { + this.handleRequestError(error, 'Unable to log in user.'); + } + } + + register = async ({ + email, + password, + userYourName, + userPronouns, + userGithubusername, + userLinks, + userHowDidYouHearAboutUs, + userWhereAreYouInYourCodingJourney, + userCodeInterests, + userHopingVirtualCoffee, + }: { + email: string; + password: string; + userYourName: string; + userPronouns?: string; + userGithubusername?: string; + userLinks?: string; + userHowDidYouHearAboutUs?: string; + userWhereAreYouInYourCodingJourney?: string; + userCodeInterests?: string; + userHopingVirtualCoffee?: string; + }) => { + const query = gql` + mutation Register( + $email: String! + $password: String! + $userYourName: String! + $userPronouns: String + $userGithubusername: String + $userHowDidYouHearAboutUs: String + $userWhereAreYouInYourCodingJourney: String + $userCodeInterests: String + $userHopingVirtualCoffee: String + ) { + registerPendingMembers( + email: $email + password: $password + userYourName: $userYourName + userPronouns: $userPronouns + userGithubusername: $userGithubusername + userHowDidYouHearAboutUs: $userHowDidYouHearAboutUs + userWhereAreYouInYourCodingJourney: $userWhereAreYouInYourCodingJourney + userCodeInterests: $userCodeInterests + userHopingVirtualCoffee: $userHopingVirtualCoffee + ) { + jwt + jwtExpiresAt + refreshToken + refreshTokenExpiresAt + user { + id + email + status + enabled + ... on User { + userYourName + } + } + } + } + `; + try { + const response = await this.client.request(query, { + email, + password, + userYourName, + userPronouns, + userGithubusername, + userLinks, + userHowDidYouHearAboutUs, + userWhereAreYouInYourCodingJourney, + userCodeInterests, + userHopingVirtualCoffee, + }); + return response; + } catch (error) { + // @ts-ignore + if (error?.response?.errors && error.response.errors.length) { + throw new CmsError( + // @ts-ignore + `Unable to register user: ${error.response.errors + // @ts-ignore + .map((e) => e.message) + .join(',')}`, + { + // @ts-ignore + errors: error.response.errors, + }, + ); + } + throw new CmsError('Unable to register user.'); + } + }; +} diff --git a/app/api/types.ts b/app/api/types.ts new file mode 100644 index 00000000..4eab13b8 --- /dev/null +++ b/app/api/types.ts @@ -0,0 +1,46 @@ +export type CalendarVisibility = 'membersOnly' | 'pendingMembers' | 'public'; +export type EventVisibility = CalendarVisibility | 'default'; + +export type Event = { + id: string; + uid: string; + title: string; + rrule?: string; + calendarId: string | number; + startDateLocalized: string; + endDateLocalized: string; + eventVisibility?: EventVisibility; + eventJoinLink?: string; + eventLink?: string; + eventCalendarDescription?: string; + eventIcsLink?: string; +}; + +export type SafeEvent = Omit; + +export type Calendar = { + id: number; + title: string; + calendarVisibility: CalendarVisibility; +}; + +export type User = { + jwt: string; + jwtExpiresAt: number; + refreshToken: string; + refreshTokenExpiresAt: number; + schema: 'Pending Members Schema' | 'Full Members Schema'; + user: { + id: string | number; + email: string; + enabled: boolean; + status: string; + userYourName?: string; + }; +}; + +export type EventLoaderData = { + message?: string; + event?: SafeEvent; + type: 'error' | 'timing' | 'permissions' | 'noLink' | 'success'; +}; diff --git a/app/api/util.ts b/app/api/util.ts new file mode 100644 index 00000000..9075a422 --- /dev/null +++ b/app/api/util.ts @@ -0,0 +1,9 @@ +export class CmsError extends Error { + data: any; + constructor(message: string, data?: any) { + super(message); + this.data = data; + } +} + +export type CmsErrors = Error | InstanceType; diff --git a/app/auth/auth.server.ts b/app/auth/auth.server.ts new file mode 100644 index 00000000..0140c7a0 --- /dev/null +++ b/app/auth/auth.server.ts @@ -0,0 +1,144 @@ +import { Authenticator, AuthorizationError } from 'remix-auth'; +import { sessionStorage } from '~/auth/session.server'; +import { FormStrategy } from 'remix-auth-form'; +import { CmsAuth } from '~/api/cmsauth.server'; +import type { User } from '~/api/types'; +import { redirect } from '@remix-run/node'; +import invariant from 'tiny-invariant'; + +// Create an instance of the authenticator, pass a generic with what +// strategies will return and will store in the session + +function configAuthenticator(authenticator: Authenticator) { + // Tell the Authenticator to use the form strategy + authenticator.use( + new FormStrategy(async ({ form }) => { + const api = new CmsAuth(); + + let email = form.get('email'); + let password = form.get('password'); + + invariant(typeof email === 'string', 'email must be a string'); + invariant(email && email.length > 0, 'email must not be empty'); + + invariant(typeof password === 'string', 'password must be a string'); + invariant(password.length > 0, 'password must not be empty'); + + // try { + + const response = await api.login(email, password); + + // the type of this user must match the type you pass to the Authenticator + // the strategy will automatically inherit the type if you instantiate + // directly inside the `use` method + return response.authenticate; + }), + // each strategy has a name and can be changed to use another one + // same strategy multiple times, especially useful for the OAuth2 strategy. + 'user-pass', + ); +} + +let authenticator: InstanceType>; + +declare global { + var __authenticator: typeof authenticator | null; +} + +// this is needed because in development we don't want to restart +// the server with every change, but we want to make sure we don't +// create a new connection to the DB with every change either. +if (process.env.NODE_ENV === 'production') { + authenticator = new Authenticator(sessionStorage, { + sessionErrorKey: 'my-error-key', + // throwOnError: true, + }); + configAuthenticator(authenticator); +} else { + if (!global.__authenticator) { + global.__authenticator = new Authenticator(sessionStorage, { + sessionErrorKey: 'my-error-key', + // throwOnError: true, + }); + configAuthenticator(global.__authenticator); + } + authenticator = global.__authenticator; +} + +export { authenticator }; + +export async function getUser(request: Request) { + let session = await sessionStorage.getSession(request.headers.get('Cookie')); + let user = session.get(authenticator.sessionKey); + + if (!user || new Date(user.refreshTokenExpiresAt) < new Date()) { + return null; + } + return user; +} + +export async function authenticate( + request: Request, + { headers = new Headers(), redirectOnFail = true } = {}, +) { + let session = await sessionStorage.getSession(request.headers.get('Cookie')); + try { + // get the auth data from the session + let user = await getUser(request); + + // if not defiend or expired, redirect to login + if (!user) { + const url = new URL(request.url); + const search = url.search; + + if (redirectOnFail) { + if (url.pathname !== '/login') { + throw redirect( + `/login?redirectOnSuccess=${encodeURIComponent( + url.pathname + (search.length > 1 ? search : ''), + )}`, + ); + } + } + return null; + } + + // if expired throw an error + if (new Date(user.jwtExpiresAt) < new Date()) { + throw new AuthorizationError('Expired'); + } + + // return the user data + return user; + } catch (error) { + // check if the eror is an AuthorizationError + if (error instanceof AuthorizationError) { + const api = new CmsAuth(); + + let response; + + // refresh the token somehow using the strategy and the refresh token + response = await api.refreshToken({ + refreshToken: session.get(authenticator.sessionKey).refreshToken, + }); + + let user = response.refreshToken; + + // update the user data on the sessino + session.set(authenticator.sessionKey, user); + + // commit the session and append the Set-Cookie header + headers.append('Set-Cookie', await sessionStorage.commitSession(session)); + + // redirect to the same URL if the request was a GET + if (request.method.toLowerCase() === 'get') { + throw redirect(request.url, { headers }); + } + + // return the user so you can use it in a POST + return user; + } + // throw again any unexpected error + throw error; + } +} diff --git a/app/auth/session.server.ts b/app/auth/session.server.ts new file mode 100644 index 00000000..27eea8a1 --- /dev/null +++ b/app/auth/session.server.ts @@ -0,0 +1,36 @@ +// app/services/session.server.ts +import { createCookieSessionStorage } from '@remix-run/node'; + +function configSessionStorage() { + return createCookieSessionStorage({ + cookie: { + name: '_session', // use any name you want here + sameSite: 'lax', // this helps with CSRF + path: '/', // remember to add this so the cookie will work in all routes + httpOnly: true, // for security reasons, make this cookie http only + secrets: ['1B754E497FD4B7DDBDD846D9BEF98'], // replace this with an actual secret + secure: process.env.NODE_ENV === 'production', // enable this in prod only + }, + }); +} + +// this is needed because in development we don't want to restart +// the server with every change, but we want to make sure we don't +// create a new connection to the DB with every change either. +let sessionStorage: ReturnType; + +declare global { + var __sessionStorage: typeof sessionStorage | null; +} + +if (process.env.NODE_ENV === 'production') { + // export the whole sessionStorage object + sessionStorage = configSessionStorage(); +} else { + if (!global.__sessionStorage) { + global.__sessionStorage = configSessionStorage(); + } + sessionStorage = global.__sessionStorage; +} + +export { sessionStorage }; diff --git a/app/components/Root.jsx b/app/components/Root.jsx deleted file mode 100644 index 977e5c6d..00000000 --- a/app/components/Root.jsx +++ /dev/null @@ -1,67 +0,0 @@ -import { - Links, - LiveReload, - Meta, - Scripts, - ScrollRestoration, -} from '@remix-run/react'; -import Nav from '~/components/Nav'; -import { useLocation } from 'react-router-dom'; - -export default function Root({ children }) { - const location = useLocation(); - - return ( - - - - - - - - Skip to main content. - -