From ec66b3060fac83eec2389eb0c96aad6d8ea4aed1 Mon Sep 17 00:00:00 2001 From: Christopher Pickering Date: Mon, 16 Jan 2023 13:46:52 -0600 Subject: [PATCH 01/30] added remix saml auth example --- remix-auth-saml/.dockerignore | 7 + remix-auth-saml/.env.example | 20 ++ remix-auth-saml/.eslintrc.js | 21 ++ remix-auth-saml/.github/workflows/deploy.yml | 220 ++++++++++++++++++ remix-auth-saml/.gitignore | 12 + remix-auth-saml/.gitpod.Dockerfile | 9 + remix-auth-saml/.gitpod.yml | 56 +++++ remix-auth-saml/.npmrc | 1 + remix-auth-saml/.prettierignore | 11 + remix-auth-saml/Dockerfile | 52 +++++ remix-auth-saml/README.md | 176 ++++++++++++++ remix-auth-saml/app/db.server.ts | 62 +++++ remix-auth-saml/app/entry.client.tsx | 22 ++ remix-auth-saml/app/entry.server.tsx | 53 +++++ remix-auth-saml/app/models/note.server.ts | 54 +++++ remix-auth-saml/app/models/user.server.ts | 26 +++ remix-auth-saml/app/root.tsx | 46 ++++ remix-auth-saml/app/routes/auth/asc.tsx | 40 ++++ remix-auth-saml/app/routes/auth/slo.tsx | 10 + remix-auth-saml/app/routes/healthcheck.tsx | 24 ++ remix-auth-saml/app/routes/index.tsx | 143 ++++++++++++ remix-auth-saml/app/routes/join.tsx | 171 ++++++++++++++ remix-auth-saml/app/routes/login.tsx | 28 +++ remix-auth-saml/app/routes/logout.tsx | 12 + remix-auth-saml/app/routes/metadata[.]xml.tsx | 11 + remix-auth-saml/app/routes/notes.tsx | 69 ++++++ remix-auth-saml/app/routes/notes/$noteId.tsx | 63 +++++ remix-auth-saml/app/routes/notes/index.tsx | 12 + remix-auth-saml/app/routes/notes/new.tsx | 109 +++++++++ remix-auth-saml/app/saml.server.ts | 63 +++++ remix-auth-saml/app/session.server.ts | 100 ++++++++ remix-auth-saml/app/utils.test.ts | 13 ++ remix-auth-saml/app/utils.ts | 71 ++++++ remix-auth-saml/cypress.config.ts | 27 +++ remix-auth-saml/cypress/.eslintrc.js | 6 + remix-auth-saml/cypress/e2e/smoke.cy.ts | 48 ++++ remix-auth-saml/cypress/fixtures/example.json | 5 + remix-auth-saml/cypress/support/commands.ts | 95 ++++++++ .../cypress/support/create-user.ts | 48 ++++ .../cypress/support/delete-user.ts | 37 +++ remix-auth-saml/cypress/support/e2e.ts | 15 ++ remix-auth-saml/cypress/tsconfig.json | 29 +++ remix-auth-saml/docker-compose.yml | 13 ++ remix-auth-saml/fly.toml | 55 +++++ remix-auth-saml/mocks/README.md | 7 + remix-auth-saml/mocks/index.js | 9 + remix-auth-saml/package.json | 106 +++++++++ .../20220224172159_init/migration.sql | 39 ++++ .../prisma/migrations/migration_lock.toml | 3 + remix-auth-saml/prisma/schema.prisma | 38 +++ remix-auth-saml/prisma/seed.ts | 53 +++++ remix-auth-saml/public/favicon.ico | Bin 0 -> 16958 bytes remix-auth-saml/remix.config.js | 7 + remix-auth-saml/remix.env.d.ts | 2 + remix-auth-saml/server.ts | 120 ++++++++++ remix-auth-saml/tailwind.config.js | 8 + remix-auth-saml/test/setup-test-env.ts | 4 + remix-auth-saml/tsconfig.json | 26 +++ remix-auth-saml/vitest.config.ts | 21 ++ 59 files changed, 2608 insertions(+) create mode 100644 remix-auth-saml/.dockerignore create mode 100644 remix-auth-saml/.env.example create mode 100644 remix-auth-saml/.eslintrc.js create mode 100644 remix-auth-saml/.github/workflows/deploy.yml create mode 100644 remix-auth-saml/.gitignore create mode 100644 remix-auth-saml/.gitpod.Dockerfile create mode 100644 remix-auth-saml/.gitpod.yml create mode 100644 remix-auth-saml/.npmrc create mode 100644 remix-auth-saml/.prettierignore create mode 100644 remix-auth-saml/Dockerfile create mode 100644 remix-auth-saml/README.md create mode 100644 remix-auth-saml/app/db.server.ts create mode 100644 remix-auth-saml/app/entry.client.tsx create mode 100644 remix-auth-saml/app/entry.server.tsx create mode 100644 remix-auth-saml/app/models/note.server.ts create mode 100644 remix-auth-saml/app/models/user.server.ts create mode 100644 remix-auth-saml/app/root.tsx create mode 100644 remix-auth-saml/app/routes/auth/asc.tsx create mode 100644 remix-auth-saml/app/routes/auth/slo.tsx create mode 100644 remix-auth-saml/app/routes/healthcheck.tsx create mode 100644 remix-auth-saml/app/routes/index.tsx create mode 100644 remix-auth-saml/app/routes/join.tsx create mode 100644 remix-auth-saml/app/routes/login.tsx create mode 100644 remix-auth-saml/app/routes/logout.tsx create mode 100644 remix-auth-saml/app/routes/metadata[.]xml.tsx create mode 100644 remix-auth-saml/app/routes/notes.tsx create mode 100644 remix-auth-saml/app/routes/notes/$noteId.tsx create mode 100644 remix-auth-saml/app/routes/notes/index.tsx create mode 100644 remix-auth-saml/app/routes/notes/new.tsx create mode 100644 remix-auth-saml/app/saml.server.ts create mode 100644 remix-auth-saml/app/session.server.ts create mode 100644 remix-auth-saml/app/utils.test.ts create mode 100644 remix-auth-saml/app/utils.ts create mode 100644 remix-auth-saml/cypress.config.ts create mode 100644 remix-auth-saml/cypress/.eslintrc.js create mode 100644 remix-auth-saml/cypress/e2e/smoke.cy.ts create mode 100644 remix-auth-saml/cypress/fixtures/example.json create mode 100644 remix-auth-saml/cypress/support/commands.ts create mode 100644 remix-auth-saml/cypress/support/create-user.ts create mode 100644 remix-auth-saml/cypress/support/delete-user.ts create mode 100644 remix-auth-saml/cypress/support/e2e.ts create mode 100644 remix-auth-saml/cypress/tsconfig.json create mode 100644 remix-auth-saml/docker-compose.yml create mode 100644 remix-auth-saml/fly.toml create mode 100644 remix-auth-saml/mocks/README.md create mode 100644 remix-auth-saml/mocks/index.js create mode 100644 remix-auth-saml/package.json create mode 100644 remix-auth-saml/prisma/migrations/20220224172159_init/migration.sql create mode 100644 remix-auth-saml/prisma/migrations/migration_lock.toml create mode 100644 remix-auth-saml/prisma/schema.prisma create mode 100644 remix-auth-saml/prisma/seed.ts create mode 100644 remix-auth-saml/public/favicon.ico create mode 100644 remix-auth-saml/remix.config.js create mode 100644 remix-auth-saml/remix.env.d.ts create mode 100644 remix-auth-saml/server.ts create mode 100644 remix-auth-saml/tailwind.config.js create mode 100644 remix-auth-saml/test/setup-test-env.ts create mode 100644 remix-auth-saml/tsconfig.json create mode 100644 remix-auth-saml/vitest.config.ts diff --git a/remix-auth-saml/.dockerignore b/remix-auth-saml/.dockerignore new file mode 100644 index 00000000..91077d06 --- /dev/null +++ b/remix-auth-saml/.dockerignore @@ -0,0 +1,7 @@ +/node_modules +*.log +.DS_Store +.env +/.cache +/public/build +/build diff --git a/remix-auth-saml/.env.example b/remix-auth-saml/.env.example new file mode 100644 index 00000000..c3183559 --- /dev/null +++ b/remix-auth-saml/.env.example @@ -0,0 +1,20 @@ +DATABASE_URL="postgresql://postgres:postgres@localhost:5432/postgres" +SESSION_SECRET="super-duper-s3cret" + +SESSION_SECRET="03e0f8241c4fc7bb9b97528f59dfb5ce" + +SAML_IDP_METADATA="http://localhost:7000/metadata" + +SAML_SP_AUTHNREQUESTSSIGNED=false +SAML_SP_WANTMESSAGESIGNED=false +SAML_SP_WANTASSERTIONSIGNED=false +SAML_SP_WANTLOGOUTREQUESTSIGNED=false + +SAML_PRIVATE_KEY="/path/to/saml-idp/idp-private-key.pem" +SAML_PRIVATE_KEY_PASS="" + +SAML_ENC_PRIVATE_KEY="/path/to/saml-idp/idp-private-key.pem" + +SAML_SP_ISASSERTIONENCRYPTED=false + +HOSTNAME="http://localhost:3000" \ No newline at end of file diff --git a/remix-auth-saml/.eslintrc.js b/remix-auth-saml/.eslintrc.js new file mode 100644 index 00000000..3c3134ac --- /dev/null +++ b/remix-auth-saml/.eslintrc.js @@ -0,0 +1,21 @@ +/** @type {import('@types/eslint').Linter.BaseConfig} */ +module.exports = { + extends: [ + "@remix-run/eslint-config", + "@remix-run/eslint-config/node", + "@remix-run/eslint-config/jest-testing-library", + "prettier", + ], + env: { + "cypress/globals": true, + }, + plugins: ["cypress"], + // We're using vitest which has a very similar API to jest + // (so the linting plugins work nicely), but we have to + // set the jest version explicitly. + settings: { + jest: { + version: 28, + }, + }, +}; diff --git a/remix-auth-saml/.github/workflows/deploy.yml b/remix-auth-saml/.github/workflows/deploy.yml new file mode 100644 index 00000000..8da37c4b --- /dev/null +++ b/remix-auth-saml/.github/workflows/deploy.yml @@ -0,0 +1,220 @@ +name: 🚀 Deploy +on: + push: + branches: + - main + - dev + pull_request: {} + +permissions: + actions: write + contents: read + +jobs: + lint: + name: ⬣ ESLint + runs-on: ubuntu-latest + steps: + - name: 🛑 Cancel Previous Runs + uses: styfle/cancel-workflow-action@0.11.0 + + - name: ⬇️ Checkout repo + uses: actions/checkout@v3 + + - name: ⎔ Setup node + uses: actions/setup-node@v3 + with: + node-version: 16 + + - name: 📥 Download deps + uses: bahmutov/npm-install@v1 + with: + useLockFile: false + + - name: 🔬 Lint + run: npm run lint + + typecheck: + name: ʦ TypeScript + runs-on: ubuntu-latest + steps: + - name: 🛑 Cancel Previous Runs + uses: styfle/cancel-workflow-action@0.11.0 + + - name: ⬇️ Checkout repo + uses: actions/checkout@v3 + + - name: ⎔ Setup node + uses: actions/setup-node@v3 + with: + node-version: 16 + + - name: 📥 Download deps + uses: bahmutov/npm-install@v1 + with: + useLockFile: false + + - name: 🔎 Type check + run: npm run typecheck --if-present + + vitest: + name: ⚡ Vitest + runs-on: ubuntu-latest + steps: + - name: 🛑 Cancel Previous Runs + uses: styfle/cancel-workflow-action@0.11.0 + + - name: ⬇️ Checkout repo + uses: actions/checkout@v3 + + - name: ⎔ Setup node + uses: actions/setup-node@v3 + with: + node-version: 16 + + - name: 📥 Download deps + uses: bahmutov/npm-install@v1 + with: + useLockFile: false + + - name: ⚡ Run vitest + run: npm run test -- --coverage + + cypress: + name: ⚫️ Cypress + runs-on: ubuntu-latest + steps: + - name: 🛑 Cancel Previous Runs + uses: styfle/cancel-workflow-action@0.11.0 + + - name: ⬇️ Checkout repo + uses: actions/checkout@v3 + + - name: 🏄 Copy test env vars + run: cp .env.example .env + + - name: ⎔ Setup node + uses: actions/setup-node@v3 + with: + node-version: 16 + + - name: 📥 Download deps + uses: bahmutov/npm-install@v1 + with: + useLockFile: false + + - name: 🐳 Docker compose + # the sleep is just there to give time for postgres to get started + run: docker-compose up -d && sleep 3 + env: + DATABASE_URL: "postgresql://postgres:postgres@localhost:5432/postgres" + + - name: 🛠 Setup Database + run: npx prisma migrate reset --force + + - name: ⚙️ Build + run: npm run build + + - name: 🌳 Cypress run + uses: cypress-io/github-action@v5 + with: + start: npm run start:mocks + wait-on: "http://localhost:8811" + env: + PORT: "8811" + + build: + name: 🐳 Build + # only build/deploy main branch on pushes + if: ${{ (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev') && github.event_name == 'push' }} + runs-on: ubuntu-latest + steps: + - name: 🛑 Cancel Previous Runs + uses: styfle/cancel-workflow-action@0.11.0 + + - name: ⬇️ Checkout repo + uses: actions/checkout@v3 + + - name: 👀 Read app name + uses: SebRollen/toml-action@v1.0.2 + id: app_name + with: + file: "fly.toml" + field: "app" + + - name: 🐳 Set up Docker Buildx + uses: docker/setup-buildx-action@v2 + + # Setup cache + - name: ⚡️ Cache Docker layers + uses: actions/cache@v3 + with: + path: /tmp/.buildx-cache + key: ${{ runner.os }}-buildx-${{ github.sha }} + restore-keys: | + ${{ runner.os }}-buildx- + + - name: 🔑 Fly Registry Auth + uses: docker/login-action@v2 + with: + registry: registry.fly.io + username: x + password: ${{ secrets.FLY_API_TOKEN }} + + - name: 🐳 Docker build + uses: docker/build-push-action@v3 + with: + context: . + push: true + tags: registry.fly.io/${{ steps.app_name.outputs.value }}:${{ github.ref_name }}-${{ github.sha }} + build-args: | + COMMIT_SHA=${{ github.sha }} + cache-from: type=local,src=/tmp/.buildx-cache + cache-to: type=local,mode=max,dest=/tmp/.buildx-cache-new + + # This ugly bit is necessary if you don't want your cache to grow forever + # till it hits GitHub's limit of 5GB. + # Temp fix + # https://github.com/docker/build-push-action/issues/252 + # https://github.com/moby/buildkit/issues/1896 + - name: 🚚 Move cache + run: | + rm -rf /tmp/.buildx-cache + mv /tmp/.buildx-cache-new /tmp/.buildx-cache + + deploy: + name: 🚀 Deploy + runs-on: ubuntu-latest + needs: [lint, typecheck, vitest, cypress, build] + # only build/deploy main branch on pushes + if: ${{ (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev') && github.event_name == 'push' }} + + steps: + - name: 🛑 Cancel Previous Runs + uses: styfle/cancel-workflow-action@0.11.0 + + - name: ⬇️ Checkout repo + uses: actions/checkout@v3 + + - name: 👀 Read app name + uses: SebRollen/toml-action@v1.0.2 + id: app_name + with: + file: "fly.toml" + field: "app" + + - name: 🚀 Deploy Staging + if: ${{ github.ref == 'refs/heads/dev' }} + uses: superfly/flyctl-actions@1.3 + with: + args: "deploy --app ${{ steps.app_name.outputs.value }}-staging --image registry.fly.io/${{ steps.app_name.outputs.value }}:${{ github.ref_name }}-${{ github.sha }}" + env: + FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} + + - name: 🚀 Deploy Production + if: ${{ github.ref == 'refs/heads/main' }} + uses: superfly/flyctl-actions@1.3 + with: + args: "deploy --image registry.fly.io/${{ steps.app_name.outputs.value }}:${{ github.ref_name }}-${{ github.sha }}" + env: + FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} diff --git a/remix-auth-saml/.gitignore b/remix-auth-saml/.gitignore new file mode 100644 index 00000000..3717c6ee --- /dev/null +++ b/remix-auth-saml/.gitignore @@ -0,0 +1,12 @@ +.DS_Store +node_modules + +/build +/public/build +.env + +/cypress/screenshots +/cypress/videos +/postgres-data + +/app/styles/tailwind.css diff --git a/remix-auth-saml/.gitpod.Dockerfile b/remix-auth-saml/.gitpod.Dockerfile new file mode 100644 index 00000000..e52ca2d6 --- /dev/null +++ b/remix-auth-saml/.gitpod.Dockerfile @@ -0,0 +1,9 @@ +FROM gitpod/workspace-full + +# Install Fly +RUN curl -L https://fly.io/install.sh | sh +ENV FLYCTL_INSTALL="/home/gitpod/.fly" +ENV PATH="$FLYCTL_INSTALL/bin:$PATH" + +# Install GitHub CLI +RUN brew install gh diff --git a/remix-auth-saml/.gitpod.yml b/remix-auth-saml/.gitpod.yml new file mode 100644 index 00000000..79155796 --- /dev/null +++ b/remix-auth-saml/.gitpod.yml @@ -0,0 +1,56 @@ +# https://www.gitpod.io/docs/config-gitpod-file + +image: + file: .gitpod.Dockerfile + +ports: + - port: 3000 + onOpen: notify + +tasks: + - name: Restore .env file + command: | + if [ -f .env ]; then + # If this workspace already has a .env, don't override it + # Local changes survive a workspace being opened and closed + # but they will not persist between separate workspaces for the same repo + + echo "Found .env in workspace" + else + # There is no .env + if [ ! -n "${ENV}" ]; then + # There is no $ENV from a previous workspace + # Default to the example .env + echo "Setting example .env" + + cp .env.example .env + else + # After making changes to .env, run this line to persist it to $ENV + # eval $(gp env -e ENV="$(base64 .env | tr -d '\n')") + # + # Environment variables set this way are shared between all your workspaces for this repo + # The lines below will read $ENV and print a .env file + + echo "Restoring .env from Gitpod" + + echo "${ENV}" | base64 -d | tee .env > /dev/null + fi + fi + + - name: Docker + init: docker-compose pull + command: docker-compose up + + - init: npm install + command: | + gp await-port 5432 + npm run setup + npm run build + npm run dev + +vscode: + extensions: + - ms-azuretools.vscode-docker + - esbenp.prettier-vscode + - dbaeumer.vscode-eslint + - bradlc.vscode-tailwindcss diff --git a/remix-auth-saml/.npmrc b/remix-auth-saml/.npmrc new file mode 100644 index 00000000..521a9f7c --- /dev/null +++ b/remix-auth-saml/.npmrc @@ -0,0 +1 @@ +legacy-peer-deps=true diff --git a/remix-auth-saml/.prettierignore b/remix-auth-saml/.prettierignore new file mode 100644 index 00000000..72bfc1fa --- /dev/null +++ b/remix-auth-saml/.prettierignore @@ -0,0 +1,11 @@ +node_modules + +/build +/public/build +.env + +/cypress/screenshots +/cypress/videos +/postgres-data + +/app/styles/tailwind.css diff --git a/remix-auth-saml/Dockerfile b/remix-auth-saml/Dockerfile new file mode 100644 index 00000000..1ca1abb5 --- /dev/null +++ b/remix-auth-saml/Dockerfile @@ -0,0 +1,52 @@ +# base node image +FROM node:16-bullseye-slim as base + +# set for base and all layer that inherit from it +ENV NODE_ENV production + +# Install openssl for Prisma +RUN apt-get update && apt-get install -y openssl + +# Install all node_modules, including dev dependencies +FROM base as deps + +WORKDIR /myapp + +ADD package.json package-lock.json .npmrc ./ +RUN npm install --production=false + +# Setup production node_modules +FROM base as production-deps + +WORKDIR /myapp + +COPY --from=deps /myapp/node_modules /myapp/node_modules +ADD package.json package-lock.json .npmrc ./ +RUN npm prune --production + +# Build the app +FROM base as build + +WORKDIR /myapp + +COPY --from=deps /myapp/node_modules /myapp/node_modules + +ADD prisma . +RUN npx prisma generate + +ADD . . +RUN npm run build + +# Finally, build the production image with minimal footprint +FROM base + +WORKDIR /myapp + +COPY --from=production-deps /myapp/node_modules /myapp/node_modules +COPY --from=build /myapp/node_modules/.prisma /myapp/node_modules/.prisma + +COPY --from=build /myapp/build /myapp/build +COPY --from=build /myapp/public /myapp/public +ADD . . + +CMD ["npm", "start"] diff --git a/remix-auth-saml/README.md b/remix-auth-saml/README.md new file mode 100644 index 00000000..a7da1593 --- /dev/null +++ b/remix-auth-saml/README.md @@ -0,0 +1,176 @@ +# Remix with SAML Authentication + +This is an example Remix website using the default `Blues Stack` with enough tweaks to make Single Sign On with SAML2 work nicely. + +This site is using [Samlify](https://samlify.js.org/#/). + +(note) I also removed some of the references to fly. + +## Basic configuration + +### Start a Saml IDP + +For development/demoing you can start up a simple SAML IPD server from https://github.com/mcguinness/saml-idp. + +1. clone [the repo](https://github.com/mcguinness/saml-idp) somewhere. +2. cd to the repo. +3. generate a cert using their sample code `openssl req -x509 -new -newkey rsa:2048 -nodes -subj '/C=US/ST=California/L=San Francisco/O=JankyCo/CN=Test Identity Provider' -keyout idp-private-key.pem -out idp-public-cert.pem -days 7300 ` +4. Start up the IDP server `node ./bin/run.js --acsUrl http://localhost:3000/auth/asc --audience http://localhost:3000/login` + +🎉 Nice! + +### Create a .env file + +Next, copy the `.env.example` file into `.env`. + +Update `SAML_PRIVATE_KEY` and `SAML_ENC_PRIVATE_KEY` to wherever you saved your `.pem` generated in the previous step. Easiest to copy the `.pem` into this folder.. but whatever floats your boat. + +Consider changing the database url as well. + +### Run like Remix! + +Next startup the app like remix recommends. + +```sh + npm run setup # create the database + npm run build # initial build + npm run dev # run the website! +``` + +## Using the site + +The `/` route is not secured with login. + +Go to http://localhost:3000/notes to see the SSO process in action. You will be redirected to the IDP for login. Click the login button at the bottom of the screen. You will not be sent back to the `/notes` route. + +You can fine tune user access, etc, or add additional functions to protect routes based on user groups, addresses, etc if you wish. + +Users are automatically added to the database on their first signin attempt. + +--- + +Remix stuff..... + +# Remix Blues Stack + +![The Remix Blues Stack](https://repository-images.githubusercontent.com/461012689/37d5bd8b-fa9c-4ab0-893c-f0a199d5012d) + +Learn more about [Remix Stacks](https://remix.run/stacks). + +``` +npx create-remix@latest --template remix-run/blues-stack +``` + +## What's in the stack + +- [GitHub Actions](https://github.com/features/actions) for deploy on merge to production and staging environments +- Email/Password Authentication with [cookie-based sessions](https://remix.run/docs/en/v1/api/remix#createcookiesessionstorage) +- Database ORM with [Prisma](https://prisma.io) +- Styling with [Tailwind](https://tailwindcss.com/) +- End-to-end testing with [Cypress](https://cypress.io) +- Local third party request mocking with [MSW](https://mswjs.io) +- Unit testing with [Vitest](https://vitest.dev) and [Testing Library](https://testing-library.com) +- Code formatting with [Prettier](https://prettier.io) +- Linting with [ESLint](https://eslint.org) +- Static Types with [TypeScript](https://typescriptlang.org) + +Not a fan of bits of the stack? Fork it, change it, and use `npx create-remix --template your/repo`! Make it your own. + +## Development + +- This step only applies if you've opted out of having the CLI install dependencies for you: + + ```sh + npx remix init + ``` + +- Initial setup: + + ```sh + npm run setup + ``` + +- Run the first build: + + ```sh + npm run build + ``` + +- Start dev server: + + ```sh + npm run dev + ``` + +This starts your app in development mode, rebuilding assets on file changes. + +### Relevant code: + +This is a pretty simple note-taking app, but it's a good example of how you can build a full stack app with Prisma and Remix. The main functionality is creating users, logging in and out, and creating and deleting notes. + +- creating users, and logging in and out [./app/models/user.server.ts](./app/models/user.server.ts) +- user sessions, and verifying them [./app/session.server.ts](./app/session.server.ts) +- creating, and deleting notes [./app/models/note.server.ts](./app/models/note.server.ts) + +## Deployment + +This Remix Stack comes with two GitHub Actions that handle automatically deploying your app to production and staging environments. + +- Initialize Git. + + ```sh + git init + ``` + +- Create a new [GitHub Repository](https://repo.new), and then add it as the remote for your project. **Do not push your app yet!** + + ```sh + git remote add origin + ``` + +## GitHub Actions + +We use GitHub Actions for continuous integration and deployment. Anything that gets into the `main` branch will be deployed to production after running tests/build/etc. Anything in the `dev` branch will be deployed to staging. + +## Testing + +### Cypress + +We use Cypress for our End-to-End tests in this project. You'll find those in the `cypress` directory. As you make changes, add to an existing file or create a new file in the `cypress/e2e` directory to test your changes. + +We use [`@testing-library/cypress`](https://testing-library.com/cypress) for selecting elements on the page semantically. + +To run these tests in development, run `npm run test:e2e:dev` which will start the dev server for the app as well as the Cypress client. Make sure the database is running in docker as described above. + +We have a utility for testing authenticated features without having to go through the login flow: + +```ts +cy.login(); +// you are now logged in as a new user +``` + +We also have a utility to auto-delete the user at the end of your test. Just make sure to add this in each test file: + +```ts +afterEach(() => { + cy.cleanupUser(); +}); +``` + +That way, we can keep your local db clean and keep your tests isolated from one another. + +### Vitest + +For lower level tests of utilities and individual components, we use `vitest`. We have DOM-specific assertion helpers via [`@testing-library/jest-dom`](https://testing-library.com/jest-dom). + +### Type Checking + +This project uses TypeScript. It's recommended to get TypeScript set up for your editor to get a really great in-editor experience with type checking and auto-complete. To run type checking across the whole project, run `npm run typecheck`. + +### Linting + +This project uses ESLint for linting. That is configured in `.eslintrc.js`. + +### Formatting + +We use [Prettier](https://prettier.io/) for auto-formatting in this project. It's recommended to install an editor plugin (like the [VSCode Prettier plugin](https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode)) to get auto-formatting on save. There's also a `npm run format` script you can run to format all files in the project. diff --git a/remix-auth-saml/app/db.server.ts b/remix-auth-saml/app/db.server.ts new file mode 100644 index 00000000..990a05f6 --- /dev/null +++ b/remix-auth-saml/app/db.server.ts @@ -0,0 +1,62 @@ +import { PrismaClient } from "@prisma/client"; +import invariant from "tiny-invariant"; + +let prisma: PrismaClient; + +declare global { + var __db__: PrismaClient; +} + +// 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. +// in production we'll have a single connection to the DB. +if (process.env.NODE_ENV === "production") { + prisma = getClient(); +} else { + if (!global.__db__) { + global.__db__ = getClient(); + } + prisma = global.__db__; +} + +function getClient() { + const { DATABASE_URL } = process.env; + invariant(typeof DATABASE_URL === "string", "DATABASE_URL env var not set"); + + const databaseUrl = new URL(DATABASE_URL); + + const isLocalHost = databaseUrl.hostname === "localhost"; + + const PRIMARY_REGION = isLocalHost ? null : process.env.PRIMARY_REGION; + const FLY_REGION = isLocalHost ? null : process.env.FLY_REGION; + + const isReadReplicaRegion = !PRIMARY_REGION || PRIMARY_REGION === FLY_REGION; + + if (!isLocalHost) { + databaseUrl.host = `${FLY_REGION}.${databaseUrl.host}`; + if (!isReadReplicaRegion) { + // 5433 is the read-replica port + databaseUrl.port = "5433"; + } + } + + console.log(`🔌 setting up prisma client to ${databaseUrl.host}`); + // NOTE: during development if you change anything in this function, remember + // that this only runs once per server restart and won't automatically be + // re-run per request like everything else is. So if you need to change + // something in this file, you'll need to manually restart the server. + const client = new PrismaClient({ + datasources: { + db: { + url: databaseUrl.toString(), + }, + }, + }); + // connect eagerly + client.$connect(); + + return client; +} + +export { prisma }; diff --git a/remix-auth-saml/app/entry.client.tsx b/remix-auth-saml/app/entry.client.tsx new file mode 100644 index 00000000..1d4ba68d --- /dev/null +++ b/remix-auth-saml/app/entry.client.tsx @@ -0,0 +1,22 @@ +import { RemixBrowser } from "@remix-run/react"; +import { startTransition, StrictMode } from "react"; +import { hydrateRoot } from "react-dom/client"; + +const hydrate = () => { + startTransition(() => { + hydrateRoot( + document, + + + + ); + }); +}; + +if (window.requestIdleCallback) { + window.requestIdleCallback(hydrate); +} else { + // Safari doesn't support requestIdleCallback + // https://caniuse.com/requestidlecallback + window.setTimeout(hydrate, 1); +} diff --git a/remix-auth-saml/app/entry.server.tsx b/remix-auth-saml/app/entry.server.tsx new file mode 100644 index 00000000..7b9fd913 --- /dev/null +++ b/remix-auth-saml/app/entry.server.tsx @@ -0,0 +1,53 @@ +import { PassThrough } from "stream"; +import type { EntryContext } from "@remix-run/node"; +import { Response } from "@remix-run/node"; +import { RemixServer } from "@remix-run/react"; +import isbot from "isbot"; +import { renderToPipeableStream } from "react-dom/server"; + +const ABORT_DELAY = 5000; + +export default function handleRequest( + request: Request, + responseStatusCode: number, + responseHeaders: Headers, + remixContext: EntryContext +) { + const callbackName = isbot(request.headers.get("user-agent")) + ? "onAllReady" + : "onShellReady"; + + return new Promise((resolve, reject) => { + let didError = false; + + const { pipe, abort } = renderToPipeableStream( + , + { + [callbackName]: () => { + const body = new PassThrough(); + + responseHeaders.set("Content-Type", "text/html"); + + resolve( + new Response(body, { + headers: responseHeaders, + status: didError ? 500 : responseStatusCode, + }) + ); + + pipe(body); + }, + onShellError: (err: unknown) => { + reject(err); + }, + onError: (error: unknown) => { + didError = true; + + console.error(error); + }, + } + ); + + setTimeout(abort, ABORT_DELAY); + }); +} diff --git a/remix-auth-saml/app/models/note.server.ts b/remix-auth-saml/app/models/note.server.ts new file mode 100644 index 00000000..c266dfc0 --- /dev/null +++ b/remix-auth-saml/app/models/note.server.ts @@ -0,0 +1,54 @@ +import type { User, Note } from "@prisma/client"; + +import { prisma } from "~/db.server"; + +export type { Note } from "@prisma/client"; + +export function getNote({ + id, + userId, +}: Pick & { + userId: User["id"]; +}) { + return prisma.note.findFirst({ + select: { id: true, body: true, title: true }, + where: { id, userId }, + }); +} + +export function getNoteListItems({ userId }: { userId: User["id"] }) { + return prisma.note.findMany({ + where: { userId }, + select: { id: true, title: true }, + orderBy: { updatedAt: "desc" }, + }); +} + +export function createNote({ + body, + title, + userId, +}: Pick & { + userId: User["id"]; +}) { + return prisma.note.create({ + data: { + title, + body, + user: { + connect: { + id: userId, + }, + }, + }, + }); +} + +export function deleteNote({ + id, + userId, +}: Pick & { userId: User["id"] }) { + return prisma.note.deleteMany({ + where: { id, userId }, + }); +} diff --git a/remix-auth-saml/app/models/user.server.ts b/remix-auth-saml/app/models/user.server.ts new file mode 100644 index 00000000..03cd4075 --- /dev/null +++ b/remix-auth-saml/app/models/user.server.ts @@ -0,0 +1,26 @@ +import type { Password, User } from "@prisma/client"; +import bcrypt from "bcryptjs"; + +import { prisma } from "~/db.server"; + +export type { User } from "@prisma/client"; + +export async function getUserById(id: User["id"]) { + return prisma.user.findUnique({ where: { id } }); +} + +export async function getUserByEmail(email: User["email"]) { + return prisma.user.findUnique({ where: { email } }); +} + +export async function createUser(email: User["email"]) { + return prisma.user.create({ + data: { + email, + }, + }); +} + +export async function deleteUserByEmail(email: User["email"]) { + return prisma.user.delete({ where: { email } }); +} diff --git a/remix-auth-saml/app/root.tsx b/remix-auth-saml/app/root.tsx new file mode 100644 index 00000000..55162c16 --- /dev/null +++ b/remix-auth-saml/app/root.tsx @@ -0,0 +1,46 @@ +import type { LinksFunction, LoaderArgs, MetaFunction } from "@remix-run/node"; +import { json } from "@remix-run/node"; +import { + Links, + LiveReload, + Meta, + Outlet, + Scripts, + ScrollRestoration, +} from "@remix-run/react"; + +import { getUser } from "./session.server"; +import tailwindStylesheetUrl from "./styles/tailwind.css"; + +export const links: LinksFunction = () => { + return [{ rel: "stylesheet", href: tailwindStylesheetUrl }]; +}; + +export const meta: MetaFunction = () => ({ + charset: "utf-8", + title: "Remix Notes", + viewport: "width=device-width,initial-scale=1", +}); + +export async function loader({ request }: LoaderArgs) { + return json({ + user: await getUser(request), + }); +} + +export default function App() { + return ( + + + + + + + + + + + + + ); +} diff --git a/remix-auth-saml/app/routes/auth/asc.tsx b/remix-auth-saml/app/routes/auth/asc.tsx new file mode 100644 index 00000000..dc4e6593 --- /dev/null +++ b/remix-auth-saml/app/routes/auth/asc.tsx @@ -0,0 +1,40 @@ +import type { ActionArgs, LoaderArgs, MetaFunction } from "@remix-run/node"; +import { createUserSession } from "~/session.server"; +import { sp, getIdp } from "~/saml.server"; +import { redirect } from "@remix-run/node"; +import { createUser, getUserByEmail } from "~/models/user.server"; + +export const action: ActionFunction = async ({ request }) => { + const formData = await request.formData(); + + if (request.method == "POST") { + const body = Object.fromEntries(formData); + const idp = await getIdp(); + const { samlContent, extract } = await sp.parseLoginResponse(idp, "post", { + body: body, + }); + if (extract.nameID) { + const next = body.RelayState ? body.RelayState : "/"; + const email = extract.nameID; + + const expiration = extract.conditions?.notOnOrAfter; + + // get or create user + let user = await getUserByEmail(email); + + if (!user) user = await createUser(email); + + return createUserSession({ + request: request, + userId: user.id, + expiration: expiration, + redirectTo: next, + }); + } + + // return to next url + return redirect("/access_denied"); + } else { + return redirect("/"); + } +}; diff --git a/remix-auth-saml/app/routes/auth/slo.tsx b/remix-auth-saml/app/routes/auth/slo.tsx new file mode 100644 index 00000000..9121f7bc --- /dev/null +++ b/remix-auth-saml/app/routes/auth/slo.tsx @@ -0,0 +1,10 @@ +import type { ActionArgs, LoaderArgs, MetaFunction } from "@remix-run/node"; +import { logout } from "~/session.server"; +import { redirect } from "@remix-run/node"; + +/* can't do idp initiated logout w/ cookie sessions, but can still use + this point to logout if we wanna +*/ +export const action: ActionFunction = async ({ request }) => { + return await logout(request); +}; diff --git a/remix-auth-saml/app/routes/healthcheck.tsx b/remix-auth-saml/app/routes/healthcheck.tsx new file mode 100644 index 00000000..8cdea4d0 --- /dev/null +++ b/remix-auth-saml/app/routes/healthcheck.tsx @@ -0,0 +1,24 @@ +// learn more: https://fly.io/docs/reference/configuration/#services-http_checks +import type { LoaderArgs } from "@remix-run/server-runtime"; +import { prisma } from "~/db.server"; + +export async function loader({ request }: LoaderArgs) { + const host = + request.headers.get("X-Forwarded-Host") ?? request.headers.get("host"); + + try { + const url = new URL("/", `http://${host}`); + // if we can connect to the database and make a simple query + // and make a HEAD request to ourselves, then we're good. + await Promise.all([ + prisma.user.count(), + fetch(url.toString(), { method: "HEAD" }).then((r) => { + if (!r.ok) return Promise.reject(r); + }), + ]); + return new Response("OK"); + } catch (error: unknown) { + console.log("healthcheck ❌", { error }); + return new Response("ERROR", { status: 500 }); + } +} diff --git a/remix-auth-saml/app/routes/index.tsx b/remix-auth-saml/app/routes/index.tsx new file mode 100644 index 00000000..76fe65d7 --- /dev/null +++ b/remix-auth-saml/app/routes/index.tsx @@ -0,0 +1,143 @@ +import { Link } from "@remix-run/react"; + +import { useOptionalUser } from "~/utils"; + +export default function Index() { + const user = useOptionalUser(); + return ( +
+
+
+
+
+ BB King playing blues on his Les Paul guitar +
+
+
+

+ + Blues Stack + +

+

+ Check the README.md file for instructions on how to get this + project deployed. +

+
+ {user ? ( + + View Notes for {user.email} + + ) : ( +
+ + Sign up + + + Log In + +
+ )} +
+ + Remix + +
+
+
+ +
+
+ {[ + { + src: + "https://user-images.githubusercontent.com/1500684/158238105-e7279a0c-1640-40db-86b0-3d3a10aab824.svg", + alt: "PostgreSQL", + href: "https://www.postgresql.org/", + }, + { + src: + "https://user-images.githubusercontent.com/1500684/157764484-ad64a21a-d7fb-47e3-8669-ec046da20c1f.svg", + alt: "Prisma", + href: "https://prisma.io", + }, + { + src: + "https://user-images.githubusercontent.com/1500684/157764276-a516a239-e377-4a20-b44a-0ac7b65c8c14.svg", + alt: "Tailwind", + href: "https://tailwindcss.com", + }, + { + src: + "https://user-images.githubusercontent.com/1500684/157764454-48ac8c71-a2a9-4b5e-b19c-edef8b8953d6.svg", + alt: "Cypress", + href: "https://www.cypress.io", + }, + { + src: + "https://user-images.githubusercontent.com/1500684/157772386-75444196-0604-4340-af28-53b236faa182.svg", + alt: "MSW", + href: "https://mswjs.io", + }, + { + src: + "https://user-images.githubusercontent.com/1500684/157772447-00fccdce-9d12-46a3-8bb4-fac612cdc949.svg", + alt: "Vitest", + href: "https://vitest.dev", + }, + { + src: + "https://user-images.githubusercontent.com/1500684/157772662-92b0dd3a-453f-4d18-b8be-9fa6efde52cf.png", + alt: "Testing Library", + href: "https://testing-library.com", + }, + { + src: + "https://user-images.githubusercontent.com/1500684/157772934-ce0a943d-e9d0-40f8-97f3-f464c0811643.svg", + alt: "Prettier", + href: "https://prettier.io", + }, + { + src: + "https://user-images.githubusercontent.com/1500684/157772990-3968ff7c-b551-4c55-a25c-046a32709a8e.svg", + alt: "ESLint", + href: "https://eslint.org", + }, + { + src: + "https://user-images.githubusercontent.com/1500684/157773063-20a0ed64-b9f8-4e0b-9d1e-0b65a3d4a6db.svg", + alt: "TypeScript", + href: "https://typescriptlang.org", + }, + ].map((img) => ( + + {img.alt} + + ))} +
+
+
+
+ ); +} diff --git a/remix-auth-saml/app/routes/join.tsx b/remix-auth-saml/app/routes/join.tsx new file mode 100644 index 00000000..d3bc3b31 --- /dev/null +++ b/remix-auth-saml/app/routes/join.tsx @@ -0,0 +1,171 @@ +import type { ActionArgs, LoaderArgs, MetaFunction } from "@remix-run/node"; +import { json, redirect } from "@remix-run/node"; +import { Form, Link, useActionData, useSearchParams } from "@remix-run/react"; +import * as React from "react"; + +import { createUserSession, getUserId } from "~/session.server"; + +import { createUser, getUserByEmail } from "~/models/user.server"; +import { safeRedirect, validateEmail } from "~/utils"; + +export async function loader({ request }: LoaderArgs) { + const userId = await getUserId(request); + if (userId) return redirect("/"); + return json({}); +} + +export async function action({ request }: ActionArgs) { + const formData = await request.formData(); + const email = formData.get("email"); + const password = formData.get("password"); + const redirectTo = safeRedirect(formData.get("redirectTo"), "/"); + + if (!validateEmail(email)) { + return json( + { errors: { email: "Email is invalid", password: null } }, + { status: 400 } + ); + } + + if (typeof password !== "string" || password.length === 0) { + return json( + { errors: { email: null, password: "Password is required" } }, + { status: 400 } + ); + } + + if (password.length < 8) { + return json( + { errors: { email: null, password: "Password is too short" } }, + { status: 400 } + ); + } + + const existingUser = await getUserByEmail(email); + if (existingUser) { + return json( + { + errors: { + email: "A user already exists with this email", + password: null, + }, + }, + { status: 400 } + ); + } + + const user = await createUser(email, password); + + return createUserSession({ + request, + userId: user.id, + remember: false, + redirectTo, + }); +} + +export const meta: MetaFunction = () => { + return { + title: "Sign Up", + }; +}; + +export default function Join() { + const [searchParams] = useSearchParams(); + const redirectTo = searchParams.get("redirectTo") ?? undefined; + const actionData = useActionData(); + const emailRef = React.useRef(null); + const passwordRef = React.useRef(null); + + React.useEffect(() => { + if (actionData?.errors?.email) { + emailRef.current?.focus(); + } else if (actionData?.errors?.password) { + passwordRef.current?.focus(); + } + }, [actionData]); + + return ( +
+
+
+
+ +
+ + {actionData?.errors?.email && ( +
+ {actionData.errors.email} +
+ )} +
+
+ +
+ +
+ + {actionData?.errors?.password && ( +
+ {actionData.errors.password} +
+ )} +
+
+ + + +
+
+ Already have an account?{" "} + + Log in + +
+
+
+
+
+ ); +} diff --git a/remix-auth-saml/app/routes/login.tsx b/remix-auth-saml/app/routes/login.tsx new file mode 100644 index 00000000..53a8791e --- /dev/null +++ b/remix-auth-saml/app/routes/login.tsx @@ -0,0 +1,28 @@ +/* +login page would never be direcly accessed by users clicking a link. +just send them to a page that needs to be authenticated and they will +automatically be logged in :) +*/ + +import type { ActionArgs, LoaderArgs, MetaFunction } from "@remix-run/node"; +import { json, redirect } from "@remix-run/node"; +import { Form, Link, useActionData, useSearchParams } from "@remix-run/react"; +import * as React from "react"; + +import { createUserSession, getUserId } from "~/session.server"; +import { getIdp, sp } from "~/saml.server"; +import { safeRedirect, validateEmail } from "~/utils"; + +export async function loader({ request }: LoaderArgs) { + const userId = await getUserId(request); + + if (!userId) { + const idp = await getIdp(); + const { id, context } = sp.createLoginRequest(idp, "redirect"); + const url = new URL(request.url); + const pathname = url.searchParams.get("redirectTo") || "/"; + return redirect(context + "&RelayState=" + pathname); + } + // fallback if someone accidentally landed here and was already logged in. + if (userId) return redirect("/"); +} diff --git a/remix-auth-saml/app/routes/logout.tsx b/remix-auth-saml/app/routes/logout.tsx new file mode 100644 index 00000000..5def3ddf --- /dev/null +++ b/remix-auth-saml/app/routes/logout.tsx @@ -0,0 +1,12 @@ +import type { ActionArgs } from "@remix-run/node"; +import { redirect } from "@remix-run/node"; + +import { logout } from "~/session.server"; + +export async function action({ request }: ActionArgs) { + return logout(request); +} + +export async function loader() { + return redirect("/"); +} diff --git a/remix-auth-saml/app/routes/metadata[.]xml.tsx b/remix-auth-saml/app/routes/metadata[.]xml.tsx new file mode 100644 index 00000000..085ec4e0 --- /dev/null +++ b/remix-auth-saml/app/routes/metadata[.]xml.tsx @@ -0,0 +1,11 @@ +import { metadata } from "~/saml.server"; + +export async function loader({ params }: LoaderArgs) { + const meta = metadata(); + return new Response(meta, { + status: 200, + headers: { + "Content-Type": "text/xml", + }, + }); +} diff --git a/remix-auth-saml/app/routes/notes.tsx b/remix-auth-saml/app/routes/notes.tsx new file mode 100644 index 00000000..34537bc3 --- /dev/null +++ b/remix-auth-saml/app/routes/notes.tsx @@ -0,0 +1,69 @@ +import type { LoaderArgs } from "@remix-run/node"; +import { json } from "@remix-run/node"; +import { Form, Link, NavLink, Outlet, useLoaderData } from "@remix-run/react"; + +import { getNoteListItems } from "~/models/note.server"; +import { requireUserId } from "~/session.server"; +import { useUser } from "~/utils"; + +export async function loader({ request }: LoaderArgs) { + const userId = await requireUserId(request); + const noteListItems = await getNoteListItems({ userId }); + return json({ noteListItems }); +} + +export default function NotesPage() { + const data = useLoaderData(); + const user = useUser(); + return ( +
+
+

+ Notes +

+

{user.email}

+
+ +
+
+ +
+
+ + + New Note + + +
+ + {data.noteListItems.length === 0 ? ( +

No notes yet

+ ) : ( +
    + {data.noteListItems.map((note) => ( +
  1. + + `block border-b p-4 text-xl ${isActive ? "bg-white" : ""}` + } + to={note.id} + > + 📝 {note.title} + +
  2. + ))} +
+ )} +
+ +
+ +
+
+
+ ); +} diff --git a/remix-auth-saml/app/routes/notes/$noteId.tsx b/remix-auth-saml/app/routes/notes/$noteId.tsx new file mode 100644 index 00000000..5b58da67 --- /dev/null +++ b/remix-auth-saml/app/routes/notes/$noteId.tsx @@ -0,0 +1,63 @@ +import type { ActionArgs, LoaderArgs } from "@remix-run/node"; +import { json, redirect } from "@remix-run/node"; +import { Form, useCatch, useLoaderData } from "@remix-run/react"; +import invariant from "tiny-invariant"; + +import { deleteNote, getNote } from "~/models/note.server"; +import { requireUserId } from "~/session.server"; + +export async function loader({ request, params }: LoaderArgs) { + const userId = await requireUserId(request); + invariant(params.noteId, "noteId not found"); + + const note = await getNote({ userId, id: params.noteId }); + if (!note) { + throw new Response("Not Found", { status: 404 }); + } + return json({ note }); +} + +export async function action({ request, params }: ActionArgs) { + const userId = await requireUserId(request); + invariant(params.noteId, "noteId not found"); + + await deleteNote({ userId, id: params.noteId }); + + return redirect("/notes"); +} + +export default function NoteDetailsPage() { + const data = useLoaderData(); + + return ( +
+

{data.note.title}

+

{data.note.body}

+
+
+ +
+
+ ); +} + +export function ErrorBoundary({ error }: { error: Error }) { + console.error(error); + + return
An unexpected error occurred: {error.message}
; +} + +export function CatchBoundary() { + const caught = useCatch(); + + if (caught.status === 404) { + return
Note not found
; + } + + throw new Error(`Unexpected caught response with status: ${caught.status}`); +} diff --git a/remix-auth-saml/app/routes/notes/index.tsx b/remix-auth-saml/app/routes/notes/index.tsx new file mode 100644 index 00000000..aa858a99 --- /dev/null +++ b/remix-auth-saml/app/routes/notes/index.tsx @@ -0,0 +1,12 @@ +import { Link } from "@remix-run/react"; + +export default function NoteIndexPage() { + return ( +

+ No note selected. Select a note on the left, or{" "} + + create a new note. + +

+ ); +} diff --git a/remix-auth-saml/app/routes/notes/new.tsx b/remix-auth-saml/app/routes/notes/new.tsx new file mode 100644 index 00000000..90cb8c22 --- /dev/null +++ b/remix-auth-saml/app/routes/notes/new.tsx @@ -0,0 +1,109 @@ +import type { ActionArgs } from "@remix-run/node"; +import { json, redirect } from "@remix-run/node"; +import { Form, useActionData } from "@remix-run/react"; +import * as React from "react"; + +import { createNote } from "~/models/note.server"; +import { requireUserId } from "~/session.server"; + +export async function action({ request }: ActionArgs) { + const userId = await requireUserId(request); + + const formData = await request.formData(); + const title = formData.get("title"); + const body = formData.get("body"); + + if (typeof title !== "string" || title.length === 0) { + return json( + { errors: { title: "Title is required", body: null } }, + { status: 400 } + ); + } + + if (typeof body !== "string" || body.length === 0) { + return json( + { errors: { body: "Body is required", title: null } }, + { status: 400 } + ); + } + + const note = await createNote({ title, body, userId }); + + return redirect(`/notes/${note.id}`); +} + +export default function NewNotePage() { + const actionData = useActionData(); + const titleRef = React.useRef(null); + const bodyRef = React.useRef(null); + + React.useEffect(() => { + if (actionData?.errors?.title) { + titleRef.current?.focus(); + } else if (actionData?.errors?.body) { + bodyRef.current?.focus(); + } + }, [actionData]); + + return ( +
+
+ + {actionData?.errors?.title && ( +
+ {actionData.errors.title} +
+ )} +
+ +
+