From 77ba4e290115d75a15d7fa562cd66100c91e2cb4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Moreau?= Date: Thu, 31 Aug 2023 16:13:36 +0200 Subject: [PATCH] prepare remix v2 (#75) --- .eslintrc | 144 +++--- .github/workflows/deploy.yml | 438 +++++++++--------- .../workflows/for-this-stack-repo-only.yml | 240 +++++----- .prettierrc.cjs | 5 + Dockerfile | 2 +- README.md | 201 ++++---- app/database/db.server.ts | 16 +- app/database/seed.server.ts | 138 +++--- app/entry.client.tsx | 20 +- app/entry.server.tsx | 96 ++-- app/hooks/use-fetcher.ts | 10 - app/hooks/use-interval.ts | 30 +- app/hooks/use-matches-data.ts | 12 +- app/integrations/i18n/config.ts | 18 +- app/integrations/i18n/i18next.client.tsx | 62 +-- app/integrations/i18n/i18next.server.tsx | 68 +-- app/integrations/supabase/client.ts | 48 +- .../components/continue-with-email-form.tsx | 90 ++-- app/modules/auth/components/logout-button.tsx | 27 +- app/modules/auth/index.ts | 24 +- app/modules/auth/mappers.ts | 28 +- app/modules/auth/service.server.ts | 90 ++-- app/modules/auth/session.server.ts | 274 +++++------ app/modules/auth/types.ts | 12 +- app/modules/note/service.server.ts | 64 +-- app/modules/user/service.server.test.ts | 411 ++++++++-------- app/modules/user/service.server.ts | 86 ++-- app/root.tsx | 102 ++-- app/routes/_index.tsx | 167 +++++++ app/routes/forgot-password.tsx | 188 ++++---- app/routes/healthcheck.tsx | 36 +- app/routes/index.tsx | 166 ------- app/routes/join.tsx | 340 +++++++------- app/routes/login.tsx | 345 +++++++------- app/routes/logout.tsx | 6 +- app/routes/notes.$noteId.tsx | 67 +++ app/routes/notes._index.tsx | 23 + app/routes/notes.new.tsx | 113 +++++ app/routes/notes.tsx | 99 ++-- app/routes/notes/$noteId.tsx | 67 --- app/routes/notes/index.tsx | 26 -- app/routes/notes/new.tsx | 109 ----- app/routes/oauth.callback.tsx | 216 ++++----- app/routes/reset-password.tsx | 373 +++++++-------- app/routes/send-magic-link.tsx | 57 +-- app/utils/env.ts | 66 +-- app/utils/form.ts | 2 +- app/utils/http.server.ts | 72 +-- app/utils/http.test.ts | 136 +++--- app/utils/is-browser.ts | 2 +- app/utils/tw-classes.ts | 2 +- app/utils/zod.ts | 12 +- cypress.config.ts | 42 +- cypress/.eslintrc.js | 8 +- cypress/e2e/smoke.cy.ts | 96 ++-- cypress/support/commands.ts | 102 ++-- cypress/support/create-user.ts | 50 +- cypress/support/delete-user.ts | 52 +-- cypress/support/e2e.ts | 20 +- cypress/tsconfig.json | 51 +- .../0001-split-tailwind-classes-with-tw.md | 70 +-- mocks/handlers.js | 116 ++--- mocks/index.js | 2 +- mocks/user.js | 6 +- package.json | 179 ++++--- public/locales/en/auth.json | 56 +-- public/locales/fr/auth.json | 56 +-- public/locales/ru/auth.json | 56 +-- remix.config.js | 18 +- remix.init/index.js | 178 +++---- remix.init/package.json | 16 +- tailwind.config.js | 12 - tailwind.config.ts | 13 + test/setup-test-env.ts | 10 +- tsconfig.json | 46 +- vitest.config.ts | 27 +- 76 files changed, 3402 insertions(+), 3326 deletions(-) create mode 100644 .prettierrc.cjs delete mode 100644 app/hooks/use-fetcher.ts create mode 100644 app/routes/_index.tsx delete mode 100644 app/routes/index.tsx create mode 100644 app/routes/notes.$noteId.tsx create mode 100644 app/routes/notes._index.tsx create mode 100644 app/routes/notes.new.tsx delete mode 100644 app/routes/notes/$noteId.tsx delete mode 100644 app/routes/notes/index.tsx delete mode 100644 app/routes/notes/new.tsx delete mode 100644 tailwind.config.js create mode 100644 tailwind.config.ts diff --git a/.eslintrc b/.eslintrc index 00e74b1..5e9d6d8 100644 --- a/.eslintrc +++ b/.eslintrc @@ -1,74 +1,74 @@ { - "plugins": ["tailwindcss"], - "extends": [ - "@remix-run/eslint-config", - "@remix-run/eslint-config/node", - "plugin:tailwindcss/recommended", - "prettier" - ], - "parserOptions": { - "project": ["./tsconfig.json"] - }, - "settings": { - // Help eslint-plugin-tailwindcss to parse Tailwind classes outside of className - "tailwindcss": { - "callees": ["tw"] - }, - "jest": { - "version": 27 - } - }, - "rules": { - "no-console": "warn", - "arrow-body-style": ["warn", "as-needed"], - // @typescript-eslint - "@typescript-eslint/no-duplicate-imports": "error", - "@typescript-eslint/consistent-type-imports": "error", - "@typescript-eslint/no-unused-vars": [ - "warn", - { - "vars": "all", - "args": "all", - "argsIgnorePattern": "^_", - "destructuredArrayIgnorePattern": "^_", - "ignoreRestSiblings": false - } - ], - //import - "import/no-cycle": "error", - "import/no-unresolved": "error", - "import/no-default-export": "warn", - "import/order": [ - "error", - { - "groups": ["builtin", "external", "internal"], - "pathGroups": [ - { - "pattern": "react", - "group": "external", - "position": "before" - } - ], - "pathGroupsExcludedImportTypes": ["react"], - "newlines-between": "always", - "alphabetize": { - "order": "asc", - "caseInsensitive": true - } - } - ] - }, - "overrides": [ - { - "files": [ - "./app/root.tsx", - "./app/entry.client.tsx", - "./app/entry.server.tsx", - "./app/routes/**/*.tsx" - ], - "rules": { - "import/no-default-export": "off" - } - } - ] + "plugins": ["tailwindcss"], + "extends": [ + "@remix-run/eslint-config", + "@remix-run/eslint-config/node", + "plugin:tailwindcss/recommended", + "prettier" + ], + "parserOptions": { + "project": ["./tsconfig.json"] + }, + "settings": { + // Help eslint-plugin-tailwindcss to parse Tailwind classes outside of className + "tailwindcss": { + "callees": ["tw"] + }, + "jest": { + "version": 27 + } + }, + "rules": { + "no-console": "warn", + "arrow-body-style": ["warn", "as-needed"], + // @typescript-eslint + "@typescript-eslint/no-duplicate-imports": "error", + "@typescript-eslint/consistent-type-imports": "error", + "@typescript-eslint/no-unused-vars": [ + "warn", + { + "vars": "all", + "args": "all", + "argsIgnorePattern": "^_", + "destructuredArrayIgnorePattern": "^_", + "ignoreRestSiblings": false + } + ], + //import + "import/no-cycle": "error", + "import/no-unresolved": "error", + "import/no-default-export": "warn", + "import/order": [ + "error", + { + "groups": ["builtin", "external", "internal"], + "pathGroups": [ + { + "pattern": "react", + "group": "external", + "position": "before" + } + ], + "pathGroupsExcludedImportTypes": ["react"], + "newlines-between": "always", + "alphabetize": { + "order": "asc", + "caseInsensitive": true + } + } + ] + }, + "overrides": [ + { + "files": [ + "./app/root.tsx", + "./app/entry.client.tsx", + "./app/entry.server.tsx", + "./app/routes/**/*.tsx" + ], + "rules": { + "import/no-default-export": "off" + } + } + ] } diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 8e0543f..e46ad48 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -1,224 +1,224 @@ name: 🚀 Deploy on: - push: - branches: - - main - - dev - pull_request: {} + push: + branches: + - main + - dev + pull_request: {} permissions: - actions: write - contents: read + 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: 🔑 Make envfile - uses: SpicyPizza/create-envfile@v1.3 - with: - envkey_SESSION_SECRET: ${{ secrets.SESSION_SECRET }} - envkey_SUPABASE_ANON_PUBLIC: ${{ secrets.SUPABASE_ANON_PUBLIC }} - envkey_SUPABASE_SERVICE_ROLE: ${{ secrets.SUPABASE_SERVICE_ROLE }} - envkey_SUPABASE_URL: ${{ secrets.SUPABASE_URL }} - envkey_SERVER_URL: ${{ secrets.SERVER_URL }} - envkey_DATABASE_URL: ${{ secrets.DATABASE_URL }} - file_name: .env - - - name: ⎔ Setup node - uses: actions/setup-node@v3 - with: - node-version: 16 - - - name: 📥 Download deps - uses: bahmutov/npm-install@v1 - with: - useLockFile: false - - # uncomment if you really want to reset your testing database - # - name: 🛠 Setup Database - # run: npx prisma migrate reset --force - - - name: ⚙️ Build - run: npm run build - - - name: 🌳 Cypress run - uses: cypress-io/github-action@v4 - with: - start: npm run start:ci - wait-on: "http://localhost:8811" - browser: chrome - headed: true - 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.0 - 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.0 - 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 }} + 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: 🔑 Make envfile + uses: SpicyPizza/create-envfile@v1.3 + with: + envkey_SESSION_SECRET: ${{ secrets.SESSION_SECRET }} + envkey_SUPABASE_ANON_PUBLIC: ${{ secrets.SUPABASE_ANON_PUBLIC }} + envkey_SUPABASE_SERVICE_ROLE: ${{ secrets.SUPABASE_SERVICE_ROLE }} + envkey_SUPABASE_URL: ${{ secrets.SUPABASE_URL }} + envkey_SERVER_URL: ${{ secrets.SERVER_URL }} + envkey_DATABASE_URL: ${{ secrets.DATABASE_URL }} + file_name: .env + + - name: ⎔ Setup node + uses: actions/setup-node@v3 + with: + node-version: 16 + + - name: 📥 Download deps + uses: bahmutov/npm-install@v1 + with: + useLockFile: false + + # uncomment if you really want to reset your testing database + # - name: 🛠 Setup Database + # run: npx prisma migrate reset --force + + - name: ⚙️ Build + run: npm run build + + - name: 🌳 Cypress run + uses: cypress-io/github-action@v4 + with: + start: npm run start:ci + wait-on: "http://localhost:8811" + browser: chrome + headed: true + 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.0 + 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.0 + 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/.github/workflows/for-this-stack-repo-only.yml b/.github/workflows/for-this-stack-repo-only.yml index 5ec5f82..34e9002 100644 --- a/.github/workflows/for-this-stack-repo-only.yml +++ b/.github/workflows/for-this-stack-repo-only.yml @@ -1,125 +1,125 @@ name: 🚀 Check Stack on: - push: - branches: - - main - - dev - pull_request: {} + push: + branches: + - main + - dev + pull_request: {} permissions: - actions: write - contents: read + 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 - needs: [lint, typecheck, vitest] - steps: - - name: 🛑 Cancel Previous Runs - uses: styfle/cancel-workflow-action@0.11.0 - - - name: ⬇️ Checkout repo - uses: actions/checkout@v3 - - - name: 🔑 Make envfile - uses: SpicyPizza/create-envfile@v1.3 - with: - envkey_SESSION_SECRET: ${{ secrets.SESSION_SECRET }} - envkey_SUPABASE_ANON_PUBLIC: ${{ secrets.SUPABASE_ANON_PUBLIC }} - envkey_SUPABASE_SERVICE_ROLE: ${{ secrets.SUPABASE_SERVICE_ROLE }} - envkey_SUPABASE_URL: ${{ secrets.SUPABASE_URL }} - envkey_SERVER_URL: ${{ secrets.SERVER_URL }} - envkey_DATABASE_URL: ${{ secrets.DATABASE_URL }} - file_name: .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: ⚙️ Build - run: npm run build - - - name: 🌳 Cypress run - uses: cypress-io/github-action@v4 - with: - start: npm run start:ci - wait-on: "http://localhost:8811" - browser: chrome - headed: true - env: - PORT: "8811" + 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 + needs: [lint, typecheck, vitest] + steps: + - name: 🛑 Cancel Previous Runs + uses: styfle/cancel-workflow-action@0.11.0 + + - name: ⬇️ Checkout repo + uses: actions/checkout@v3 + + - name: 🔑 Make envfile + uses: SpicyPizza/create-envfile@v1.3 + with: + envkey_SESSION_SECRET: ${{ secrets.SESSION_SECRET }} + envkey_SUPABASE_ANON_PUBLIC: ${{ secrets.SUPABASE_ANON_PUBLIC }} + envkey_SUPABASE_SERVICE_ROLE: ${{ secrets.SUPABASE_SERVICE_ROLE }} + envkey_SUPABASE_URL: ${{ secrets.SUPABASE_URL }} + envkey_SERVER_URL: ${{ secrets.SERVER_URL }} + envkey_DATABASE_URL: ${{ secrets.DATABASE_URL }} + file_name: .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: ⚙️ Build + run: npm run build + + - name: 🌳 Cypress run + uses: cypress-io/github-action@v4 + with: + start: npm run start:ci + wait-on: "http://localhost:8811" + browser: chrome + headed: true + env: + PORT: "8811" diff --git a/.prettierrc.cjs b/.prettierrc.cjs new file mode 100644 index 0000000..9d4eec2 --- /dev/null +++ b/.prettierrc.cjs @@ -0,0 +1,5 @@ +/** @type {import("prettier").Options} */ +module.exports = { + tabWidth: 4, + useTabs: true, +}; diff --git a/Dockerfile b/Dockerfile index 99f6e15..1623377 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # base node image -FROM node:16-bullseye-slim as base +FROM node:18-bookworm-slim as base # set for base and all layer that inherit from it ENV NODE_ENV production diff --git a/README.md b/README.md index 5c6197d..cdf3ef1 100644 --- a/README.md +++ b/README.md @@ -12,40 +12,39 @@ npx create-remix --template rphlmr/supa-fly-stack ## What's in the stack -- [Fly app deployment](https://fly.io) with [Docker](https://www.docker.com/products/docker-desktop/) -- Production-ready [Supabase Database](https://supabase.com/) -- Healthcheck endpoint for [Fly backups region fallbacks](https://fly.io/docs/reference/configuration/#services-http_checks) -- [GitHub Actions](https://github.com/features/actions) to deploy on merge to production and staging environments -- Email/Password Authentication / Magic Link, with [cookie-based sessions](https://remix.run/docs/en/v1/api/remix#createcookiesessionstorage) -- Database ORM with [Prisma](https://prisma.io) -- Forms Schema (client and server sides !) validation with [Remix Params Helper](https://github.com/kiliman/remix-params-helper) -- 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) +- [Fly app deployment](https://fly.io) with [Docker](https://www.docker.com/products/docker-desktop/) +- Production-ready [Supabase Database](https://supabase.com/) +- Healthcheck endpoint for [Fly backups region fallbacks](https://fly.io/docs/reference/configuration/#services-http_checks) +- [GitHub Actions](https://github.com/features/actions) to deploy on merge to production and staging environments +- Email/Password Authentication / Magic Link, with [cookie-based sessions](https://remix.run/docs/en/v1/api/remix#createcookiesessionstorage) +- Database ORM with [Prisma](https://prisma.io) +- Forms Schema (client and server sides !) validation with [Remix Params Helper](https://github.com/kiliman/remix-params-helper) +- 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 -- Create a [Supabase Database](https://supabase.com/) (free tier gives you 2 databases) +- Create a [Supabase Database](https://supabase.com/) (free tier gives you 2 databases) - > **Note:** Only one for playing around with Supabase or 2 for `staging` and `production` + > **Note:** Only one for playing around with Supabase or 2 for `staging` and `production` - > **Note:** Used all your free tiers ? Also works with [Supabase CLI](https://github.com/supabase/cli) and local self-hosting + > **Note:** Used all your free tiers ? Also works with [Supabase CLI](https://github.com/supabase/cli) and local self-hosting - > **Note:** Create a strong database password, but prefer a passphrase, it'll be more easy to use in connection string (no need to escape special char) - > - > _example : my_strong_passphrase_ - -- Go to https://app.supabase.io/project/{PROJECT}/settings/api to find your secrets -- "Project API keys" -- Add your `SUPABASE_URL`, `SERVER_URL`, `SUPABASE_SERVICE_ROLE` (aka `service_role` `secret`), `SUPABASE_ANON_PUBLIC` (aka `anon` `public`) and `DATABASE_URL` in the `.env` file - > **Note:** `SERVER_URL` is your localhost on dev. It'll work for magic link login + > **Note:** Create a strong database password, but prefer a passphrase, it'll be more easy to use in connection string (no need to escape special char) + > + > _example : my_strong_passphrase_ +- Go to https://app.supabase.io/project/{PROJECT}/settings/api to find your secrets +- "Project API keys" +- Add your `SUPABASE_URL`, `SERVER_URL`, `SUPABASE_SERVICE_ROLE` (aka `service_role` `secret`), `SUPABASE_ANON_PUBLIC` (aka `anon` `public`) and `DATABASE_URL` in the `.env` file + > **Note:** `SERVER_URL` is your localhost on dev. It'll work for magic link login ```en DATABASE_URL="postgres://postgres:{STAGING_POSTGRES_PASSWORD}@db.{STAGING_YOUR_INSTANCE_NAME}.supabase.co:5432/postgres" @@ -56,37 +55,37 @@ SESSION_SECRET="super-duper-s3cret" SERVER_URL="http://localhost:3000" ``` -- This step only applies if you've opted out of having the CLI install dependencies for you: +- This step only applies if you've opted out of having the CLI install dependencies for you: - ```sh - npx remix init - ``` + ```sh + npx remix init + ``` -- Initial setup: +- Initial setup: - ```sh - npm run setup - ``` + ```sh + npm run setup + ``` -- Start dev server: +- Start dev server: - ```sh - npm run dev - ``` + ```sh + npm run dev + ``` This starts your app in development mode, rebuilding assets on file changes. The database seed script creates a new user with some data you can use to get started: -- Email: `hello@supabase.com` -- Password: `supabase` +- Email: `hello@supabase.com` +- Password: `supabase` ### 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, Supabase, and Remix. The main functionality is creating users, logging in and out (handling access and refresh tokens + refresh on expiration), and creating and deleting notes. -- auth / session [./app/modules/auth](./app/modules/auth) -- creating, and deleting notes [./app/modules/note](./app/modules/note) +- auth / session [./app/modules/auth](./app/modules/auth) +- creating, and deleting notes [./app/modules/note](./app/modules/note) ## Deployment @@ -96,65 +95,65 @@ This Remix Stack comes with two GitHub Actions that handle automatically deployi Prior to your first deployment, you'll need to do a few things: -- [Install Fly](https://fly.io/docs/getting-started/installing-flyctl/) +- [Install Fly](https://fly.io/docs/getting-started/installing-flyctl/) -- Sign up and log in to Fly +- Sign up and log in to Fly - ```sh - fly auth signup - ``` + ```sh + fly auth signup + ``` - > **Note:** If you have more than one Fly account, ensure that you are signed into the same account in the Fly CLI as you are in the browser. In your terminal, run `fly auth whoami` and ensure the email matches the Fly account signed into the browser. + > **Note:** If you have more than one Fly account, ensure that you are signed into the same account in the Fly CLI as you are in the browser. In your terminal, run `fly auth whoami` and ensure the email matches the Fly account signed into the browser. -- Create two apps on Fly, one for staging and one for production: +- Create two apps on Fly, one for staging and one for production: - ```sh - fly apps create supa-fly-stack-template - fly apps create supa-fly-stack-template-staging # ** not mandatory if you don't want a staging environnement ** - ``` + ```sh + fly apps create supa-fly-stack-template + fly apps create supa-fly-stack-template-staging # ** not mandatory if you don't want a staging environnement ** + ``` - > **Note:** For production app, make sure this name matches the `app` set in your `fly.toml` file. Otherwise, you will not be able to deploy. + > **Note:** For production app, make sure this name matches the `app` set in your `fly.toml` file. Otherwise, you will not be able to deploy. - - Initialize Git. + - Initialize Git. - ```sh - git init - ``` + ```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!** +- 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 - ``` + ```sh + git remote add origin + ``` -- Add a `FLY_API_TOKEN` to your GitHub repo. To do this, go to your user settings on Fly and create a new [token](https://web.fly.io/user/personal_access_tokens/new), then add it to [your repo secrets](https://docs.github.com/en/actions/security-guides/encrypted-secrets) with the name `FLY_API_TOKEN`. +- Add a `FLY_API_TOKEN` to your GitHub repo. To do this, go to your user settings on Fly and create a new [token](https://web.fly.io/user/personal_access_tokens/new), then add it to [your repo secrets](https://docs.github.com/en/actions/security-guides/encrypted-secrets) with the name `FLY_API_TOKEN`. -- Add a `SESSION_SECRET`, `SUPABASE_URL`, `SUPABASE_SERVICE_ROLE`,`SUPABASE_ANON_PUBLIC`, `SERVER_URL` and `DATABASE_URL` to your fly app secrets +- Add a `SESSION_SECRET`, `SUPABASE_URL`, `SUPABASE_SERVICE_ROLE`,`SUPABASE_ANON_PUBLIC`, `SERVER_URL` and `DATABASE_URL` to your fly app secrets - > **Note:** To find your `SERVER_URL`, go to [your fly.io dashboard](https://fly.io/apps/supa-fly-stack-template-3a36) + > **Note:** To find your `SERVER_URL`, go to [your fly.io dashboard](https://fly.io/apps/supa-fly-stack-template-3a36) - To do this you can run the following commands: + To do this you can run the following commands: - ```sh - # production (--app name is resolved from fly.toml) - fly secrets set SESSION_SECRET=$(openssl rand -hex 32) - fly secrets set SUPABASE_URL="https://{YOUR_INSTANCE_NAME}.supabase.co" - fly secrets set SUPABASE_SERVICE_ROLE="{SUPABASE_SERVICE_ROLE}" - fly secrets set SUPABASE_ANON_PUBLIC="{SUPABASE_ANON_PUBLIC}" - fly secrets set DATABASE_URL="postgres://postgres:{POSTGRES_PASSWORD}@db.{YOUR_INSTANCE_NAME}.supabase.co:5432/postgres" - fly secrets set SERVER_URL="https://{YOUR_STAGING_SERVEUR_URL}" + ```sh + # production (--app name is resolved from fly.toml) + fly secrets set SESSION_SECRET=$(openssl rand -hex 32) + fly secrets set SUPABASE_URL="https://{YOUR_INSTANCE_NAME}.supabase.co" + fly secrets set SUPABASE_SERVICE_ROLE="{SUPABASE_SERVICE_ROLE}" + fly secrets set SUPABASE_ANON_PUBLIC="{SUPABASE_ANON_PUBLIC}" + fly secrets set DATABASE_URL="postgres://postgres:{POSTGRES_PASSWORD}@db.{YOUR_INSTANCE_NAME}.supabase.co:5432/postgres" + fly secrets set SERVER_URL="https://{YOUR_STAGING_SERVEUR_URL}" - # staging (specify --app name) ** not mandatory if you don't want a staging environnement ** - fly secrets set SESSION_SECRET=$(openssl rand -hex 32) --app supa-fly-stack-template-staging - fly secrets set SUPABASE_URL="https://{YOUR_STAGING_INSTANCE_NAME}.supabase.co" --app supa-fly-stack-template-staging - fly secrets set SUPABASE_SERVICE_ROLE="{STAGING_SUPABASE_SERVICE_ROLE}" --app supa-fly-stack-template-staging - fly secrets set SUPABASE_ANON_PUBLIC="{STAGING_SUPABASE_ANON_PUBLIC}" --app supa-fly-stack-template-staging - fly secrets set DATABASE_URL="postgres://postgres:{STAGING_POSTGRES_PASSWORD}@db.{STAGING_YOUR_INSTANCE_NAME}.supabase.co:5432/postgres" --app supa-fly-stack-template-staging - fly secrets set SERVER_URL="https://{YOUR_STAGING_SERVEUR_URL}" --app supa-fly-stack-template-staging + # staging (specify --app name) ** not mandatory if you don't want a staging environnement ** + fly secrets set SESSION_SECRET=$(openssl rand -hex 32) --app supa-fly-stack-template-staging + fly secrets set SUPABASE_URL="https://{YOUR_STAGING_INSTANCE_NAME}.supabase.co" --app supa-fly-stack-template-staging + fly secrets set SUPABASE_SERVICE_ROLE="{STAGING_SUPABASE_SERVICE_ROLE}" --app supa-fly-stack-template-staging + fly secrets set SUPABASE_ANON_PUBLIC="{STAGING_SUPABASE_ANON_PUBLIC}" --app supa-fly-stack-template-staging + fly secrets set DATABASE_URL="postgres://postgres:{STAGING_POSTGRES_PASSWORD}@db.{STAGING_YOUR_INSTANCE_NAME}.supabase.co:5432/postgres" --app supa-fly-stack-template-staging + fly secrets set SERVER_URL="https://{YOUR_STAGING_SERVEUR_URL}" --app supa-fly-stack-template-staging - ``` + ``` - If you don't have openssl installed, you can also use [1password](https://1password.com/generate-password) to generate a random secret, just replace `$(openssl rand -hex 32)` with the generated secret. + If you don't have openssl installed, you can also use [1password](https://1password.com/generate-password) to generate a random secret, just replace `$(openssl rand -hex 32)` with the generated secret. Now that everything is set up you can commit and push your changes to your repo. Every commit to your `main` branch will trigger a deployment to your production environment, and every commit to your `dev` branch will trigger a deployment to your staging environment. @@ -184,7 +183,7 @@ We also have a utility to auto-delete the user at the end of your test. Just mak ```ts afterEach(() => { - cy.cleanupUser(); + cy.cleanupUser(); }); ``` @@ -211,23 +210,25 @@ We use [Prettier](https://prettier.io/) for auto-formatting in this project. It' You are now ready to go further, congrats! To extend your Prisma schema and apply changes on your supabase database : -- Make your changes in [./app/database/schema.prisma](./app/database/schema.prisma) -- Prepare your schema migration - ```sh - npm run db:prepare-migration - ``` -- Check your migration in [./app/database/migrations](./app/database) -- Apply this migration to production - - ```sh - npm run db:deploy-migration - ``` + +- Make your changes in [./app/database/schema.prisma](./app/database/schema.prisma) +- Prepare your schema migration + ```sh + npm run db:prepare-migration + ``` +- Check your migration in [./app/database/migrations](./app/database) +- Apply this migration to production + + ```sh + npm run db:deploy-migration + ``` ## If your token expires in less than 1 hour (3600 seconds in Supabase Dashboard) If you have a lower token lifetime than me (1 hour), you should take a look at `REFRESH_ACCESS_TOKEN_THRESHOLD` in [./app/modules/auth/session.server.ts](./app/modules/auth/session.server.ts) and set what you think is the best value for your use case. ## Supabase RLS + You may ask "can I use RLS with Remix". The answer is "Yes" but It has a cost. @@ -242,9 +243,9 @@ In order to make the register/login with magic link work, you will need to add s You need to add the site url as well as the redirect urls of your local, test and live app that will be used for oauth To do that navigate to Authentication > URL configiration and add the folowing values: -- https://localhost:3000/oauth/callback -- https://localhost:3000/reset-password -- https://staging-domain.com/oauth/callback -- https://staging-domain.com/reset-password -- https://live-domain.com/oauth/callback -- https://live-domain.com/reset-password \ No newline at end of file +- https://localhost:3000/oauth/callback +- https://localhost:3000/reset-password +- https://staging-domain.com/oauth/callback +- https://staging-domain.com/reset-password +- https://live-domain.com/oauth/callback +- https://live-domain.com/reset-password diff --git a/app/database/db.server.ts b/app/database/db.server.ts index a9b89ec..fee08cf 100644 --- a/app/database/db.server.ts +++ b/app/database/db.server.ts @@ -6,8 +6,8 @@ export type { Note, User } from "@prisma/client"; let db: PrismaClient; declare global { - // eslint-disable-next-line no-var - var __db__: PrismaClient; + // eslint-disable-next-line no-var + var __db__: PrismaClient; } // this is needed because in development we don't want to restart @@ -15,13 +15,13 @@ declare global { // create a new connection to the DB with every change either. // in production, we'll have a single connection to the DB. if (NODE_ENV === "production") { - db = new PrismaClient(); + db = new PrismaClient(); } else { - if (!global.__db__) { - global.__db__ = new PrismaClient(); - } - db = global.__db__; - db.$connect(); + if (!global.__db__) { + global.__db__ = new PrismaClient(); + } + db = global.__db__; + db.$connect(); } export { db }; diff --git a/app/database/seed.server.ts b/app/database/seed.server.ts index 37e7a8f..c9e3fbd 100644 --- a/app/database/seed.server.ts +++ b/app/database/seed.server.ts @@ -5,10 +5,10 @@ import { createClient } from "@supabase/supabase-js"; import { SUPABASE_SERVICE_ROLE, SUPABASE_URL } from "../utils/env"; const supabaseAdmin = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE, { - auth: { - autoRefreshToken: false, - persistSession: false, - }, + auth: { + autoRefreshToken: false, + persistSession: false, + }, }); const prisma = new PrismaClient(); @@ -16,80 +16,80 @@ const prisma = new PrismaClient(); const email = "hello@supabase.com"; const getUserId = async (): Promise => { - const userList = await supabaseAdmin.auth.admin.listUsers(); + const userList = await supabaseAdmin.auth.admin.listUsers(); - if (userList.error) { - throw userList.error; - } + if (userList.error) { + throw userList.error; + } - const existingUserId = userList.data.users.find( - (user) => user.email === email - )?.id; + const existingUserId = userList.data.users.find( + (user) => user.email === email, + )?.id; - if (existingUserId) { - return existingUserId; - } + if (existingUserId) { + return existingUserId; + } - const newUser = await supabaseAdmin.auth.admin.createUser({ - email, - password: "supabase", - email_confirm: true, - }); + const newUser = await supabaseAdmin.auth.admin.createUser({ + email, + password: "supabase", + email_confirm: true, + }); - if (newUser.error) { - throw newUser.error; - } + if (newUser.error) { + throw newUser.error; + } - return newUser.data.user.id; + return newUser.data.user.id; }; async function seed() { - try { - const id = await getUserId(); - - // cleanup the existing database - await prisma.user.delete({ where: { email } }).catch(() => { - // no worries if it doesn't exist yet - }); - - const user = await prisma.user.create({ - data: { - email, - id, - }, - }); - - await prisma.note.create({ - data: { - title: "My first note", - body: "Hello, world!", - userId: user.id, - }, - }); - - await prisma.note.create({ - data: { - title: "My second note", - body: "Hello, world!", - userId: user.id, - }, - }); - - console.log(`Database has been seeded. 🌱\n`); - console.log( - `User added to your database 👇 \n🆔: ${user.id}\n📧: ${user.email}\n🔑: supabase` - ); - } catch (cause) { - console.error(cause); - throw new Error("Seed failed 🥲"); - } + try { + const id = await getUserId(); + + // cleanup the existing database + await prisma.user.delete({ where: { email } }).catch(() => { + // no worries if it doesn't exist yet + }); + + const user = await prisma.user.create({ + data: { + email, + id, + }, + }); + + await prisma.note.create({ + data: { + title: "My first note", + body: "Hello, world!", + userId: user.id, + }, + }); + + await prisma.note.create({ + data: { + title: "My second note", + body: "Hello, world!", + userId: user.id, + }, + }); + + console.log(`Database has been seeded. 🌱\n`); + console.log( + `User added to your database 👇 \n🆔: ${user.id}\n📧: ${user.email}\n🔑: supabase`, + ); + } catch (cause) { + console.error(cause); + throw new Error("Seed failed 🥲"); + } } seed() - .catch((e) => { - console.error(e); - process.exit(1); - }) - .finally(async () => { - await prisma.$disconnect(); - }); + .catch((e) => { + console.error(e); + process.exit(1); + }) + .finally(async () => { + await prisma.$disconnect(); + }); diff --git a/app/entry.client.tsx b/app/entry.client.tsx index 93ef8b9..c2ca900 100644 --- a/app/entry.client.tsx +++ b/app/entry.client.tsx @@ -6,16 +6,16 @@ import { hydrateRoot } from "react-dom/client"; import { I18nClientProvider, initI18nextClient } from "./integrations/i18n"; // your i18n configuration file function hydrate() { - React.startTransition(() => { - hydrateRoot( - document, - - - - - - ); - }); + React.startTransition(() => { + hydrateRoot( + document, + + + + + , + ); + }); } initI18nextClient(hydrate); diff --git a/app/entry.server.tsx b/app/entry.server.tsx index 606015d..dcaf358 100644 --- a/app/entry.server.tsx +++ b/app/entry.server.tsx @@ -1,7 +1,7 @@ import { PassThrough } from "stream"; import { Response } from "@remix-run/node"; -import type { EntryContext, Headers } from "@remix-run/node"; +import type { EntryContext } from "@remix-run/node"; import { RemixServer } from "@remix-run/react"; import isbot from "isbot"; import { renderToPipeableStream } from "react-dom/server"; @@ -12,52 +12,52 @@ import { createI18nextServerInstance } from "./integrations/i18n"; const ABORT_DELAY = 5000; export default async function handleRequest( - request: Request, - responseStatusCode: number, - responseHeaders: Headers, - remixContext: EntryContext + request: Request, + responseStatusCode: number, + responseHeaders: Headers, + remixContext: EntryContext, ) { - const callbackName = isbot(request.headers.get("user-agent")) - ? "onAllReady" - : "onShellReady"; - - return new Promise(async (res, reject) => { - let didError = false; - - // First, we create a new instance of i18next so every request will have a - // completely unique instance and not share any state - const instance = await createI18nextServerInstance(request, remixContext); - - const { pipe, abort } = renderToPipeableStream( - - - , - { - [callbackName]() { - const body = new PassThrough(); - - responseHeaders.set("Content-Type", "text/html"); - - res( - new Response(body, { - status: didError ? 500 : responseStatusCode, - headers: responseHeaders, - }) - ); - pipe(body); - }, - onShellError(err: unknown) { - reject(err); - }, - onError(error: unknown) { - didError = true; - console.error(error); - }, - } - ); - setTimeout(abort, ABORT_DELAY); - }); + const callbackName = isbot(request.headers.get("user-agent")) + ? "onAllReady" + : "onShellReady"; + + return new Promise(async (res, reject) => { + let didError = false; + + // First, we create a new instance of i18next so every request will have a + // completely unique instance and not share any state + const instance = await createI18nextServerInstance( + request, + remixContext, + ); + + const { pipe, abort } = renderToPipeableStream( + + + , + { + [callbackName]() { + const body = new PassThrough(); + + responseHeaders.set("Content-Type", "text/html"); + + res( + new Response(body, { + status: didError ? 500 : responseStatusCode, + headers: responseHeaders, + }), + ); + pipe(body); + }, + onShellError(err: unknown) { + reject(err); + }, + onError(error: unknown) { + didError = true; + console.error(error); + }, + }, + ); + setTimeout(abort, ABORT_DELAY); + }); } diff --git a/app/hooks/use-fetcher.ts b/app/hooks/use-fetcher.ts deleted file mode 100644 index d5d00b8..0000000 --- a/app/hooks/use-fetcher.ts +++ /dev/null @@ -1,10 +0,0 @@ -import type { SerializeFrom } from "@remix-run/node"; -import { useFetcher } from "@remix-run/react"; -import type { FetcherWithComponents } from "@remix-run/react"; - -type TypedFetcherWithComponents = Omit, "data"> & { - data: SerializeFrom | null; -}; -export function useTypedFetcher(): TypedFetcherWithComponents { - return useFetcher() as TypedFetcherWithComponents; -} diff --git a/app/hooks/use-interval.ts b/app/hooks/use-interval.ts index 01024a5..c3072d4 100644 --- a/app/hooks/use-interval.ts +++ b/app/hooks/use-interval.ts @@ -8,21 +8,21 @@ import { useEffect, useRef } from "react"; * @param delay - in seconds */ export function useInterval(callback: () => void, delay?: number) { - const savedCallback = useRef<() => void>(); + const savedCallback = useRef<() => void>(); - // Remember the latest callback. - useEffect(() => { - savedCallback.current = callback; - }, [callback]); + // Remember the latest callback. + useEffect(() => { + savedCallback.current = callback; + }, [callback]); - // Set up the interval. - useEffect(() => { - function tick() { - savedCallback.current?.(); - } - if (delay) { - const id = setInterval(tick, delay * 1_000); - return () => clearInterval(id); - } - }, [delay]); + // Set up the interval. + useEffect(() => { + function tick() { + savedCallback.current?.(); + } + if (delay) { + const id = setInterval(tick, delay * 1_000); + return () => clearInterval(id); + } + }, [delay]); } diff --git a/app/hooks/use-matches-data.ts b/app/hooks/use-matches-data.ts index 113ef4b..c303a82 100644 --- a/app/hooks/use-matches-data.ts +++ b/app/hooks/use-matches-data.ts @@ -9,10 +9,10 @@ import { useMatches } from "@remix-run/react"; * @returns {JSON|undefined} The router data or undefined if not found */ export function useMatchesData(id: string): T | undefined { - const matchingRoutes = useMatches(); - const route = useMemo( - () => matchingRoutes.find((route) => route.id === id), - [matchingRoutes, id] - ); - return (route?.data as T) || undefined; + const matchingRoutes = useMatches(); + const route = useMemo( + () => matchingRoutes.find((route) => route.id === id), + [matchingRoutes, id], + ); + return (route?.data as T) || undefined; } diff --git a/app/integrations/i18n/config.ts b/app/integrations/i18n/config.ts index 4754512..9d83191 100644 --- a/app/integrations/i18n/config.ts +++ b/app/integrations/i18n/config.ts @@ -1,11 +1,11 @@ export const config = { - // This is the list of languages your application supports - supportedLngs: ["en", "fr", "ru"], - // This is the language you want to use in case - // if the user language is not in the supportedLngs - fallbackLng: "en", - // The default namespace of i18next is "translation", but you can customize it here - defaultNS: "common", - // Disabling suspense is recommended - react: { useSuspense: false }, + // This is the list of languages your application supports + supportedLngs: ["en", "fr", "ru"], + // This is the language you want to use in case + // if the user language is not in the supportedLngs + fallbackLng: "en", + // The default namespace of i18next is "translation", but you can customize it here + defaultNS: "common", + // Disabling suspense is recommended + react: { useSuspense: false }, }; diff --git a/app/integrations/i18n/i18next.client.tsx b/app/integrations/i18n/i18next.client.tsx index 99e3428..e55f437 100644 --- a/app/integrations/i18n/i18next.client.tsx +++ b/app/integrations/i18n/i18next.client.tsx @@ -7,40 +7,40 @@ import { getInitialNamespaces } from "remix-i18next"; import { config } from "./config"; export function initI18nextClient(hydrate: IdleRequestCallback) { - i18next - .use(initReactI18next) // Tell i18next to use the react-i18next plugin - .use(LanguageDetector) // Setup a client-side language detector - .use(Backend) // Setup your backend - .init({ - ...config, // spread the configuration - // This function detects the namespaces your routes rendered while SSR use - ns: getInitialNamespaces(), - backend: { - loadPath: "/locales/{{lng}}/{{ns}}.json", - }, - detection: { - // Here only enable htmlTag detection, we'll detect the language only - // server-side with remix-i18next, by using the `` attribute - // we can communicate to the client the language detected server-side - order: ["htmlTag"], - // Because we only use htmlTag, there's no reason to cache the language - // on the browser, so we disable it - caches: [], - }, - }) - .then(() => { - if (window.requestIdleCallback) { - window.requestIdleCallback(hydrate); - } else { - window.setTimeout(hydrate, 1); - } - }); + i18next + .use(initReactI18next) // Tell i18next to use the react-i18next plugin + .use(LanguageDetector) // Setup a client-side language detector + .use(Backend) // Setup your backend + .init({ + ...config, // spread the configuration + // This function detects the namespaces your routes rendered while SSR use + ns: getInitialNamespaces(), + backend: { + loadPath: "/locales/{{lng}}/{{ns}}.json", + }, + detection: { + // Here only enable htmlTag detection, we'll detect the language only + // server-side with remix-i18next, by using the `` attribute + // we can communicate to the client the language detected server-side + order: ["htmlTag"], + // Because we only use htmlTag, there's no reason to cache the language + // on the browser, so we disable it + caches: [], + }, + }) + .then(() => { + if (window.requestIdleCallback) { + window.requestIdleCallback(hydrate); + } else { + window.setTimeout(hydrate, 1); + } + }); } export function I18nClientProvider({ - children, + children, }: { - children: React.ReactNode; + children: React.ReactNode; }) { - return {children}; + return {children}; } diff --git a/app/integrations/i18n/i18next.server.tsx b/app/integrations/i18n/i18next.server.tsx index c0c46ce..0f592aa 100644 --- a/app/integrations/i18n/i18next.server.tsx +++ b/app/integrations/i18n/i18next.server.tsx @@ -9,44 +9,44 @@ import { RemixI18Next } from "remix-i18next"; import { config } from "./config"; // your i18n configuration file export const i18nextServer = new RemixI18Next({ - detection: { - supportedLanguages: config.supportedLngs, - fallbackLanguage: config.fallbackLng, - }, - // This is the configuration for i18next used - // when translating messages server-side only - i18next: { - ...config, - backend: { - loadPath: resolve("./public/locales/{{lng}}/{{ns}}.json"), - }, - }, - // The backend you want to use to load the translations - // Tip: You could pass `resources` to the `i18next` configuration and avoid - // a backend here - // @ts-expect-error - `i18next-fs-backend` is not typed - backend: Backend, + detection: { + supportedLanguages: config.supportedLngs, + fallbackLanguage: config.fallbackLng, + }, + // This is the configuration for i18next used + // when translating messages server-side only + i18next: { + ...config, + backend: { + loadPath: resolve("./public/locales/{{lng}}/{{ns}}.json"), + }, + }, + // The backend you want to use to load the translations + // Tip: You could pass `resources` to the `i18next` configuration and avoid + // a backend here + // @ts-expect-error - `i18next-fs-backend` is not typed + backend: Backend, }); export async function createI18nextServerInstance( - request: Request, - remixContext: EntryContext, + request: Request, + remixContext: EntryContext, ) { - // Create a new instance of i18next so every request will have a - // completely unique instance and not share any state - const instance = createInstance(); + // Create a new instance of i18next so every request will have a + // completely unique instance and not share any state + const instance = createInstance(); - await instance - .use(initReactI18next) // Tell our instance to use react-i18next - .use(Backend) // Setup our backend - .init({ - ...config, // spread the configuration - lng: await i18nextServer.getLocale(request), // detect locale from the request - ns: i18nextServer.getRouteNamespaces(remixContext), // detect what namespaces the routes about to render want to use - backend: { - loadPath: resolve("./public/locales/{{lng}}/{{ns}}.json"), - }, - }); + await instance + .use(initReactI18next) // Tell our instance to use react-i18next + .use(Backend) // Setup our backend + .init({ + ...config, // spread the configuration + lng: await i18nextServer.getLocale(request), // detect locale from the request + ns: i18nextServer.getRouteNamespaces(remixContext), // detect what namespaces the routes about to render want to use + backend: { + loadPath: resolve("./public/locales/{{lng}}/{{ns}}.json"), + }, + }); - return instance; + return instance; } diff --git a/app/integrations/supabase/client.ts b/app/integrations/supabase/client.ts index 8b16e69..55df16f 100644 --- a/app/integrations/supabase/client.ts +++ b/app/integrations/supabase/client.ts @@ -1,32 +1,32 @@ import { createClient } from "@supabase/supabase-js"; import { - SUPABASE_SERVICE_ROLE, - SUPABASE_URL, - SUPABASE_ANON_PUBLIC, + SUPABASE_SERVICE_ROLE, + SUPABASE_URL, + SUPABASE_ANON_PUBLIC, } from "~/utils/env"; import { isBrowser } from "~/utils/is-browser"; // ⚠️ cloudflare needs you define fetch option : https://github.com/supabase/supabase-js#custom-fetch-implementation // Use Remix fetch polyfill for node (See https://remix.run/docs/en/v1/other-api/node) function getSupabaseClient(supabaseKey: string, accessToken?: string) { - const global = accessToken - ? { - global: { - headers: { - Authorization: `Bearer ${accessToken}`, - }, - }, - } - : {}; + const global = accessToken + ? { + global: { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }, + } + : {}; - return createClient(SUPABASE_URL, supabaseKey, { - auth: { - autoRefreshToken: false, - persistSession: false, - }, - ...global, - }); + return createClient(SUPABASE_URL, supabaseKey, { + auth: { + autoRefreshToken: false, + persistSession: false, + }, + ...global, + }); } /** @@ -37,12 +37,12 @@ function getSupabaseClient(supabaseKey: string, accessToken?: string) { * Reason : https://github.com/rphlmr/supa-fly-stack/pull/43#issue-1336412790 */ function getSupabaseAdmin() { - if (isBrowser) - throw new Error( - "getSupabaseAdmin is not available in browser and should NOT be used in insecure environments" - ); + if (isBrowser) + throw new Error( + "getSupabaseAdmin is not available in browser and should NOT be used in insecure environments", + ); - return getSupabaseClient(SUPABASE_SERVICE_ROLE); + return getSupabaseClient(SUPABASE_SERVICE_ROLE); } const supabaseClient = getSupabaseClient(SUPABASE_ANON_PUBLIC); diff --git a/app/modules/auth/components/continue-with-email-form.tsx b/app/modules/auth/components/continue-with-email-form.tsx index 54a1a9a..c0d535f 100644 --- a/app/modules/auth/components/continue-with-email-form.tsx +++ b/app/modules/auth/components/continue-with-email-form.tsx @@ -1,55 +1,55 @@ import React from "react"; +import { useFetcher } from "@remix-run/react"; import { useTranslation } from "react-i18next"; -import { useTypedFetcher } from "~/hooks/use-fetcher"; import type { action } from "~/routes/send-magic-link"; export function ContinueWithEmailForm() { - const ref = React.useRef(null); - const sendMagicLink = useTypedFetcher(); - const { data, state, type } = sendMagicLink; - const isSuccessFull = type === "done" && !data?.error; - const isLoading = state === "submitting" || state === "loading"; - const { t } = useTranslation("auth"); - const buttonLabel = isLoading - ? t("register.sendingLink") - : t("register.continueWithEmail"); + const ref = React.useRef(null); + const sendMagicLink = useFetcher(); + const { data, state } = sendMagicLink; + const isSuccessFull = state === "idle" && data != null && !data.error; + const isLoading = state === "submitting" || state === "loading"; + const { t } = useTranslation("auth"); + const buttonLabel = isLoading + ? t("register.sendingLink") + : t("register.continueWithEmail"); - React.useEffect(() => { - if (isSuccessFull) { - ref.current?.reset(); - } - }, [isSuccessFull]); + React.useEffect(() => { + if (isSuccessFull) { + ref.current?.reset(); + } + }, [isSuccessFull]); - return ( - - -
- {!isSuccessFull ? data?.error : t("register.checkEmail")} -
- -
- ); + return ( + + +
+ {!isSuccessFull ? data?.error : t("register.checkEmail")} +
+ +
+ ); } diff --git a/app/modules/auth/components/logout-button.tsx b/app/modules/auth/components/logout-button.tsx index 3440708..38f95aa 100644 --- a/app/modules/auth/components/logout-button.tsx +++ b/app/modules/auth/components/logout-button.tsx @@ -2,20 +2,17 @@ import { Form } from "@remix-run/react"; import { useTranslation } from "react-i18next"; export function LogoutButton() { - const { t } = useTranslation("auth"); + const { t } = useTranslation("auth"); - return ( -
- -
- ); + return ( +
+ +
+ ); } diff --git a/app/modules/auth/index.ts b/app/modules/auth/index.ts index 4f45bab..908e18a 100644 --- a/app/modules/auth/index.ts +++ b/app/modules/auth/index.ts @@ -1,18 +1,18 @@ export { - createEmailAuthAccount, - deleteAuthAccount, - signInWithEmail, - sendMagicLink, - refreshAccessToken, - updateAccountPassword, - sendResetPasswordLink, + createEmailAuthAccount, + deleteAuthAccount, + signInWithEmail, + sendMagicLink, + refreshAccessToken, + updateAccountPassword, + sendResetPasswordLink, } from "./service.server"; export { - commitAuthSession, - createAuthSession, - destroyAuthSession, - requireAuthSession, - getAuthSession, + commitAuthSession, + createAuthSession, + destroyAuthSession, + requireAuthSession, + getAuthSession, } from "./session.server"; export * from "./types"; export * from "./components"; diff --git a/app/modules/auth/mappers.ts b/app/modules/auth/mappers.ts index e5421eb..45b119f 100644 --- a/app/modules/auth/mappers.ts +++ b/app/modules/auth/mappers.ts @@ -3,22 +3,22 @@ import type { SupabaseAuthSession } from "~/integrations/supabase"; import type { AuthSession } from "./types"; export function mapAuthSession( - supabaseAuthSession: SupabaseAuthSession | null + supabaseAuthSession: SupabaseAuthSession | null, ): AuthSession | null { - if (!supabaseAuthSession) return null; + if (!supabaseAuthSession) return null; - if (!supabaseAuthSession.refresh_token) - throw new Error("User should have a refresh token"); + if (!supabaseAuthSession.refresh_token) + throw new Error("User should have a refresh token"); - if (!supabaseAuthSession.user?.email) - throw new Error("User should have an email"); + if (!supabaseAuthSession.user?.email) + throw new Error("User should have an email"); - return { - accessToken: supabaseAuthSession.access_token, - refreshToken: supabaseAuthSession.refresh_token, - userId: supabaseAuthSession.user.id, - email: supabaseAuthSession.user.email, - expiresIn: supabaseAuthSession.expires_in ?? -1, - expiresAt: supabaseAuthSession.expires_at ?? -1, - }; + return { + accessToken: supabaseAuthSession.access_token, + refreshToken: supabaseAuthSession.refresh_token, + userId: supabaseAuthSession.user.id, + email: supabaseAuthSession.user.email, + expiresIn: supabaseAuthSession.expires_in ?? -1, + expiresAt: supabaseAuthSession.expires_at ?? -1, + }; } diff --git a/app/modules/auth/service.server.ts b/app/modules/auth/service.server.ts index 218a5f1..87e3814 100644 --- a/app/modules/auth/service.server.ts +++ b/app/modules/auth/service.server.ts @@ -5,88 +5,88 @@ import { mapAuthSession } from "./mappers"; import type { AuthSession } from "./types"; export async function createEmailAuthAccount(email: string, password: string) { - const { data, error } = await getSupabaseAdmin().auth.admin.createUser({ - email, - password, - email_confirm: true, // FIXME: demo purpose, assert that email is confirmed. For production, check email confirmation - }); + const { data, error } = await getSupabaseAdmin().auth.admin.createUser({ + email, + password, + email_confirm: true, // FIXME: demo purpose, assert that email is confirmed. For production, check email confirmation + }); - if (!data.user || error) return null; + if (!data.user || error) return null; - return data.user; + return data.user; } export async function signInWithEmail(email: string, password: string) { - const { data, error } = await getSupabaseAdmin().auth.signInWithPassword({ - email, - password, - }); + const { data, error } = await getSupabaseAdmin().auth.signInWithPassword({ + email, + password, + }); - if (!data.session || error) return null; + if (!data.session || error) return null; - return mapAuthSession(data.session); + return mapAuthSession(data.session); } export async function sendMagicLink(email: string) { - return getSupabaseAdmin().auth.signInWithOtp({ - email, - options: { - emailRedirectTo: `${SERVER_URL}/oauth/callback`, - }, - }); + return getSupabaseAdmin().auth.signInWithOtp({ + email, + options: { + emailRedirectTo: `${SERVER_URL}/oauth/callback`, + }, + }); } export async function sendResetPasswordLink(email: string) { - return getSupabaseAdmin().auth.resetPasswordForEmail(email, { - redirectTo: `${SERVER_URL}/reset-password`, - }); + return getSupabaseAdmin().auth.resetPasswordForEmail(email, { + redirectTo: `${SERVER_URL}/reset-password`, + }); } export async function updateAccountPassword(id: string, password: string) { - const { data, error } = await getSupabaseAdmin().auth.admin.updateUserById( - id, - { password } - ); + const { data, error } = await getSupabaseAdmin().auth.admin.updateUserById( + id, + { password }, + ); - if (!data.user || error) return null; + if (!data.user || error) return null; - return data.user; + return data.user; } export async function deleteAuthAccount(userId: string) { - const { error } = await getSupabaseAdmin().auth.admin.deleteUser(userId); + const { error } = await getSupabaseAdmin().auth.admin.deleteUser(userId); - if (error) return null; + if (error) return null; - return true; + return true; } export async function getAuthAccountByAccessToken(accessToken: string) { - const { data, error } = await getSupabaseAdmin().auth.getUser(accessToken); + const { data, error } = await getSupabaseAdmin().auth.getUser(accessToken); - if (!data.user || error) return null; + if (!data.user || error) return null; - return data.user; + return data.user; } export async function refreshAccessToken( - refreshToken?: string + refreshToken?: string, ): Promise { - if (!refreshToken) return null; + if (!refreshToken) return null; - const { data, error } = await getSupabaseAdmin().auth.refreshSession({ - refresh_token: refreshToken, - }); + const { data, error } = await getSupabaseAdmin().auth.refreshSession({ + refresh_token: refreshToken, + }); - if (!data.session || error) return null; + if (!data.session || error) return null; - return mapAuthSession(data.session); + return mapAuthSession(data.session); } export async function verifyAuthSession(authSession: AuthSession) { - const authAccount = await getAuthAccountByAccessToken( - authSession.accessToken - ); + const authAccount = await getAuthAccountByAccessToken( + authSession.accessToken, + ); - return Boolean(authAccount); + return Boolean(authAccount); } diff --git a/app/modules/auth/session.server.ts b/app/modules/auth/session.server.ts index 72ca757..0c1829e 100644 --- a/app/modules/auth/session.server.ts +++ b/app/modules/auth/session.server.ts @@ -1,12 +1,12 @@ import { createCookieSessionStorage, redirect } from "@remix-run/node"; import { - getCurrentPath, - isGet, - makeRedirectToFromHere, - NODE_ENV, - safeRedirect, - SESSION_SECRET, + getCurrentPath, + isGet, + makeRedirectToFromHere, + NODE_ENV, + safeRedirect, + SESSION_SECRET, } from "~/utils"; import { refreshAccessToken, verifyAuthSession } from "./service.server"; @@ -23,102 +23,104 @@ const REFRESH_ACCESS_TOKEN_THRESHOLD = 60 * 10; // 10 minutes left before token */ const sessionStorage = createCookieSessionStorage({ - cookie: { - name: "__authSession", - httpOnly: true, - path: "/", - sameSite: "lax", - secrets: [SESSION_SECRET], - secure: NODE_ENV === "production", - }, + cookie: { + name: "__authSession", + httpOnly: true, + path: "/", + sameSite: "lax", + secrets: [SESSION_SECRET], + secure: NODE_ENV === "production", + }, }); export async function createAuthSession({ - request, - authSession, - redirectTo, + request, + authSession, + redirectTo, }: { - request: Request; - authSession: AuthSession; - redirectTo: string; + request: Request; + authSession: AuthSession; + redirectTo: string; }) { - return redirect(safeRedirect(redirectTo), { - headers: { - "Set-Cookie": await commitAuthSession(request, { - authSession, - flashErrorMessage: null, - }), - }, - }); + return redirect(safeRedirect(redirectTo), { + headers: { + "Set-Cookie": await commitAuthSession(request, { + authSession, + flashErrorMessage: null, + }), + }, + }); } async function getSession(request: Request) { - const cookie = request.headers.get("Cookie"); - return sessionStorage.getSession(cookie); + const cookie = request.headers.get("Cookie"); + return sessionStorage.getSession(cookie); } export async function getAuthSession( - request: Request + request: Request, ): Promise { - const session = await getSession(request); - return session.get(SESSION_KEY); + const session = await getSession(request); + return session.get(SESSION_KEY); } export async function commitAuthSession( - request: Request, - { - authSession, - flashErrorMessage, - }: { - authSession?: AuthSession | null; - flashErrorMessage?: string | null; - } = {} + request: Request, + { + authSession, + flashErrorMessage, + }: { + authSession?: AuthSession | null; + flashErrorMessage?: string | null; + } = {}, ) { - const session = await getSession(request); + const session = await getSession(request); - // allow user session to be null. - // useful you want to clear session and display a message explaining why - if (authSession !== undefined) { - session.set(SESSION_KEY, authSession); - } + // allow user session to be null. + // useful you want to clear session and display a message explaining why + if (authSession !== undefined) { + session.set(SESSION_KEY, authSession); + } - session.flash(SESSION_ERROR_KEY, flashErrorMessage); + session.flash(SESSION_ERROR_KEY, flashErrorMessage); - return sessionStorage.commitSession(session, { maxAge: SESSION_MAX_AGE }); + return sessionStorage.commitSession(session, { maxAge: SESSION_MAX_AGE }); } export async function destroyAuthSession(request: Request) { - const session = await getSession(request); + const session = await getSession(request); - return redirect("/", { - headers: { - "Set-Cookie": await sessionStorage.destroySession(session), - }, - }); + return redirect("/", { + headers: { + "Set-Cookie": await sessionStorage.destroySession(session), + }, + }); } async function assertAuthSession( - request: Request, - { onFailRedirectTo }: { onFailRedirectTo?: string } = {} + request: Request, + { onFailRedirectTo }: { onFailRedirectTo?: string } = {}, ) { - const authSession = await getAuthSession(request); - - // If there is no user session, Fly, You Fools! 🧙‍♂️ - if (!authSession?.accessToken || !authSession?.refreshToken) { - throw redirect( - `${onFailRedirectTo || LOGIN_URL}?${makeRedirectToFromHere(request)}`, - { - headers: { - "Set-Cookie": await commitAuthSession(request, { - authSession: null, - flashErrorMessage: "no-user-session", - }), - }, - } - ); - } - - return authSession; + const authSession = await getAuthSession(request); + + // If there is no user session, Fly, You Fools! 🧙‍♂️ + if (!authSession?.accessToken || !authSession?.refreshToken) { + throw redirect( + `${onFailRedirectTo || LOGIN_URL}?${makeRedirectToFromHere( + request, + )}`, + { + headers: { + "Set-Cookie": await commitAuthSession(request, { + authSession: null, + flashErrorMessage: "no-user-session", + }), + }, + }, + ); + } + + return authSession; } /** @@ -135,72 +137,72 @@ async function assertAuthSession( * - Destroy session if refresh token is expired */ export async function requireAuthSession( - request: Request, - { - onFailRedirectTo, - verify, - }: { onFailRedirectTo?: string; verify: boolean } = { verify: false } + request: Request, + { + onFailRedirectTo, + verify, + }: { onFailRedirectTo?: string; verify: boolean } = { verify: false }, ): Promise { - // hello there - const authSession = await assertAuthSession(request, { - onFailRedirectTo, - }); - - // ok, let's challenge its access token. - // by default, we don't verify the access token from supabase auth api to save some time - const isValidSession = verify ? await verifyAuthSession(authSession) : true; - - // damn, access token is not valid or expires soon - // let's try to refresh, in case of 🧐 - if (!isValidSession || isExpiringSoon(authSession.expiresAt)) { - return refreshAuthSession(request); - } - - // finally, we have a valid session, let's return it - return authSession; + // hello there + const authSession = await assertAuthSession(request, { + onFailRedirectTo, + }); + + // ok, let's challenge its access token. + // by default, we don't verify the access token from supabase auth api to save some time + const isValidSession = verify ? await verifyAuthSession(authSession) : true; + + // damn, access token is not valid or expires soon + // let's try to refresh, in case of 🧐 + if (!isValidSession || isExpiringSoon(authSession.expiresAt)) { + return refreshAuthSession(request); + } + + // finally, we have a valid session, let's return it + return authSession; } function isExpiringSoon(expiresAt: number) { - return (expiresAt - REFRESH_ACCESS_TOKEN_THRESHOLD) * 1000 < Date.now(); + return (expiresAt - REFRESH_ACCESS_TOKEN_THRESHOLD) * 1000 < Date.now(); } async function refreshAuthSession(request: Request): Promise { - const authSession = await getAuthSession(request); - - const refreshedAuthSession = await refreshAccessToken( - authSession?.refreshToken - ); - - // 👾 game over, log in again - // yes, arbitrary, but it's a good way to don't let an illegal user here with an expired token - if (!refreshedAuthSession) { - const redirectUrl = `${LOGIN_URL}?${makeRedirectToFromHere(request)}`; - - // here we throw instead of return because this function promise a AuthSession and not a response object - // https://remix.run/docs/en/v1/guides/constraints#higher-order-functions - throw redirect(redirectUrl, { - headers: { - "Set-Cookie": await commitAuthSession(request, { - authSession: null, - flashErrorMessage: "fail-refresh-auth-session", - }), - }, - }); - } - - // refresh is ok and we can redirect - if (isGet(request)) { - // here we throw instead of return because this function promise a UserSession and not a response object - // https://remix.run/docs/en/v1/guides/constraints#higher-order-functions - throw redirect(getCurrentPath(request), { - headers: { - "Set-Cookie": await commitAuthSession(request, { - authSession: refreshedAuthSession, - }), - }, - }); - } - - // we can't redirect because we are in an action, so, deal with it and don't forget to handle session commit 👮‍♀️ - return refreshedAuthSession; + const authSession = await getAuthSession(request); + + const refreshedAuthSession = await refreshAccessToken( + authSession?.refreshToken, + ); + + // 👾 game over, log in again + // yes, arbitrary, but it's a good way to don't let an illegal user here with an expired token + if (!refreshedAuthSession) { + const redirectUrl = `${LOGIN_URL}?${makeRedirectToFromHere(request)}`; + + // here we throw instead of return because this function promise a AuthSession and not a response object + // https://remix.run/docs/en/v1/guides/constraints#higher-order-functions + throw redirect(redirectUrl, { + headers: { + "Set-Cookie": await commitAuthSession(request, { + authSession: null, + flashErrorMessage: "fail-refresh-auth-session", + }), + }, + }); + } + + // refresh is ok and we can redirect + if (isGet(request)) { + // here we throw instead of return because this function promise a UserSession and not a response object + // https://remix.run/docs/en/v1/guides/constraints#higher-order-functions + throw redirect(getCurrentPath(request), { + headers: { + "Set-Cookie": await commitAuthSession(request, { + authSession: refreshedAuthSession, + }), + }, + }); + } + + // we can't redirect because we are in an action, so, deal with it and don't forget to handle session commit 👮‍♀️ + return refreshedAuthSession; } diff --git a/app/modules/auth/types.ts b/app/modules/auth/types.ts index 6af12e8..931b1b1 100644 --- a/app/modules/auth/types.ts +++ b/app/modules/auth/types.ts @@ -1,8 +1,8 @@ export interface AuthSession { - accessToken: string; - refreshToken: string; - userId: string; - email: string; - expiresIn: number; - expiresAt: number; + accessToken: string; + refreshToken: string; + userId: string; + email: string; + expiresIn: number; + expiresAt: number; } diff --git a/app/modules/note/service.server.ts b/app/modules/note/service.server.ts index 1d0e2cb..cfc4f68 100644 --- a/app/modules/note/service.server.ts +++ b/app/modules/note/service.server.ts @@ -2,50 +2,50 @@ import type { Note, User } from "~/database"; import { db } from "~/database"; export async function getNote({ - userId, - id, + userId, + id, }: Pick & { - userId: User["id"]; + userId: User["id"]; }) { - return db.note.findFirst({ - select: { id: true, body: true, title: true }, - where: { id, userId }, - }); + return db.note.findFirst({ + select: { id: true, body: true, title: true }, + where: { id, userId }, + }); } export async function getNotes({ userId }: { userId: User["id"] }) { - return db.note.findMany({ - where: { userId }, - select: { id: true, title: true }, - orderBy: { updatedAt: "desc" }, - }); + return db.note.findMany({ + where: { userId }, + select: { id: true, title: true }, + orderBy: { updatedAt: "desc" }, + }); } export async function createNote({ - title, - body, - userId, + title, + body, + userId, }: Pick & { - userId: User["id"]; + userId: User["id"]; }) { - return db.note.create({ - data: { - title, - body, - user: { - connect: { - id: userId, - }, - }, - }, - }); + return db.note.create({ + data: { + title, + body, + user: { + connect: { + id: userId, + }, + }, + }, + }); } export async function deleteNote({ - id, - userId, + id, + userId, }: Pick & { userId: User["id"] }) { - return db.note.deleteMany({ - where: { id, userId }, - }); + return db.note.deleteMany({ + where: { id, userId }, + }); } diff --git a/app/modules/user/service.server.test.ts b/app/modules/user/service.server.test.ts index 9d8ae12..295b318 100644 --- a/app/modules/user/service.server.test.ts +++ b/app/modules/user/service.server.test.ts @@ -2,10 +2,10 @@ import { matchRequestUrl, rest } from "msw"; import { server } from "mocks"; import { - SUPABASE_URL, - SUPABASE_AUTH_TOKEN_API, - SUPABASE_AUTH_ADMIN_USER_API, - authSession, + SUPABASE_URL, + SUPABASE_AUTH_TOKEN_API, + SUPABASE_AUTH_ADMIN_USER_API, + authSession, } from "mocks/handlers"; import { USER_EMAIL, USER_ID, USER_PASSWORD } from "mocks/user"; import { db } from "~/database"; @@ -17,205 +17,212 @@ import { createUserAccount } from "./service.server"; // mock db vitest.mock("~/database", () => ({ - db: { - user: { - create: vitest.fn().mockResolvedValue({}), - }, - }, + db: { + user: { + create: vitest.fn().mockResolvedValue({}), + }, + }, })); describe(createUserAccount.name, () => { - it("should return null if no auth account created", async () => { - expect.assertions(3); - - const fetchAuthAdminUserAPI = new Map(); - - server.events.on("request:start", (req) => { - const matchesMethod = req.method === "POST"; - const matchesUrl = matchRequestUrl( - req.url, - SUPABASE_AUTH_ADMIN_USER_API, - SUPABASE_URL - ).matches; - - if (matchesMethod && matchesUrl) fetchAuthAdminUserAPI.set(req.id, req); - }); - - // https://mswjs.io/docs/api/setup-server/use#one-time-override - server.use( - rest.post( - `${SUPABASE_URL}${SUPABASE_AUTH_ADMIN_USER_API}`, - async (_req, res, ctx) => - res.once( - ctx.status(400), - ctx.json({ message: "create-account-error", status: 400 }) - ) - ) - ); - - const result = await createUserAccount(USER_EMAIL, USER_PASSWORD); - - server.events.removeAllListeners(); - - expect(result).toBeNull(); - expect(fetchAuthAdminUserAPI.size).toEqual(1); - const [request] = fetchAuthAdminUserAPI.values(); - expect(request.body).toEqual({ - email: USER_EMAIL, - password: USER_PASSWORD, - email_confirm: true, - }); - }); - - it("should return null and delete auth account if unable to sign in", async () => { - expect.assertions(5); - - const fetchAuthTokenAPI = new Map(); - const fetchAuthAdminUserAPI = new Map(); - - server.events.on("request:start", (req) => { - const matchesMethod = req.method === "POST"; - const matchesUrl = matchRequestUrl( - req.url, - SUPABASE_AUTH_TOKEN_API, - SUPABASE_URL - ).matches; - - if (matchesMethod && matchesUrl) fetchAuthTokenAPI.set(req.id, req); - }); - - server.events.on("request:start", (req) => { - const matchesMethod = req.method === "DELETE"; - const matchesUrl = matchRequestUrl( - req.url, - `${SUPABASE_AUTH_ADMIN_USER_API}/*`, - SUPABASE_URL - ).matches; - - if (matchesMethod && matchesUrl) fetchAuthAdminUserAPI.set(req.id, req); - }); - - server.use( - rest.post( - `${SUPABASE_URL}${SUPABASE_AUTH_TOKEN_API}`, - async (_req, res, ctx) => - res.once( - ctx.status(400), - ctx.json({ message: "sign-in-error", status: 400 }) - ) - ) - ); - - const result = await createUserAccount(USER_EMAIL, USER_PASSWORD); - - server.events.removeAllListeners(); - - expect(result).toBeNull(); - expect(fetchAuthTokenAPI.size).toEqual(1); - const [signInRequest] = fetchAuthTokenAPI.values(); - expect(signInRequest.body).toEqual({ - email: USER_EMAIL, - password: USER_PASSWORD, - gotrue_meta_security: {}, - }); - expect(fetchAuthAdminUserAPI.size).toEqual(1); - // expect call delete auth account with the expected user id - const [authAdminUserReq] = fetchAuthAdminUserAPI.values(); - expect(authAdminUserReq.url.pathname).toEqual( - `${SUPABASE_AUTH_ADMIN_USER_API}/${USER_ID}` - ); - }); - - it("should return null and delete auth account if unable to create user in database", async () => { - expect.assertions(4); - - const fetchAuthTokenAPI = new Map(); - const fetchAuthAdminUserAPI = new Map(); - - server.events.on("request:start", (req) => { - const matchesMethod = req.method === "POST"; - const matchesUrl = matchRequestUrl( - req.url, - SUPABASE_AUTH_TOKEN_API, - SUPABASE_URL - ).matches; - - if (matchesMethod && matchesUrl) fetchAuthTokenAPI.set(req.id, req); - }); - - server.events.on("request:start", (req) => { - const matchesMethod = req.method === "DELETE"; - const matchesUrl = matchRequestUrl( - req.url, - `${SUPABASE_AUTH_ADMIN_USER_API}/*`, - SUPABASE_URL - ).matches; - - if (matchesMethod && matchesUrl) fetchAuthAdminUserAPI.set(req.id, req); - }); - - //@ts-expect-error missing vitest type - db.user.create.mockResolvedValue(null); - - const result = await createUserAccount(USER_EMAIL, USER_PASSWORD); - - server.events.removeAllListeners(); - - expect(result).toBeNull(); - expect(fetchAuthTokenAPI.size).toEqual(1); - expect(fetchAuthAdminUserAPI.size).toEqual(1); - - // expect call delete auth account with the expected user id - const [authAdminUserReq] = fetchAuthAdminUserAPI.values(); - expect(authAdminUserReq.url.pathname).toEqual( - `${SUPABASE_AUTH_ADMIN_USER_API}/${USER_ID}` - ); - }); - - it("should create an account", async () => { - expect.assertions(4); - - const fetchAuthAdminUserAPI = new Map(); - const fetchAuthTokenAPI = new Map(); - - server.events.on("request:start", (req) => { - const matchesMethod = req.method === "POST"; - const matchesUrl = matchRequestUrl( - req.url, - SUPABASE_AUTH_ADMIN_USER_API, - SUPABASE_URL - ).matches; - - if (matchesMethod && matchesUrl) fetchAuthAdminUserAPI.set(req.id, req); - }); - - server.events.on("request:start", (req) => { - const matchesMethod = req.method === "POST"; - const matchesUrl = matchRequestUrl( - req.url, - SUPABASE_AUTH_TOKEN_API, - SUPABASE_URL - ).matches; - - if (matchesMethod && matchesUrl) fetchAuthTokenAPI.set(req.id, req); - }); - - //@ts-expect-error missing vitest type - db.user.create.mockResolvedValue({ id: USER_ID, email: USER_EMAIL }); - - const result = await createUserAccount(USER_EMAIL, USER_PASSWORD); - - // we don't want to test the implementation of the function - result!.expiresAt = -1; - - server.events.removeAllListeners(); - - expect(db.user.create).toBeCalledWith({ - data: { email: USER_EMAIL, id: USER_ID }, - }); - - expect(result).toEqual(authSession); - expect(fetchAuthAdminUserAPI.size).toEqual(1); - expect(fetchAuthTokenAPI.size).toEqual(1); - }); + it("should return null if no auth account created", async () => { + expect.assertions(3); + + const fetchAuthAdminUserAPI = new Map(); + + server.events.on("request:start", (req) => { + const matchesMethod = req.method === "POST"; + const matchesUrl = matchRequestUrl( + req.url, + SUPABASE_AUTH_ADMIN_USER_API, + SUPABASE_URL, + ).matches; + + if (matchesMethod && matchesUrl) + fetchAuthAdminUserAPI.set(req.id, req); + }); + + // https://mswjs.io/docs/api/setup-server/use#one-time-override + server.use( + rest.post( + `${SUPABASE_URL}${SUPABASE_AUTH_ADMIN_USER_API}`, + async (_req, res, ctx) => + res.once( + ctx.status(400), + ctx.json({ + message: "create-account-error", + status: 400, + }), + ), + ), + ); + + const result = await createUserAccount(USER_EMAIL, USER_PASSWORD); + + server.events.removeAllListeners(); + + expect(result).toBeNull(); + expect(fetchAuthAdminUserAPI.size).toEqual(1); + const [request] = fetchAuthAdminUserAPI.values(); + expect(request.body).toEqual({ + email: USER_EMAIL, + password: USER_PASSWORD, + email_confirm: true, + }); + }); + + it("should return null and delete auth account if unable to sign in", async () => { + expect.assertions(5); + + const fetchAuthTokenAPI = new Map(); + const fetchAuthAdminUserAPI = new Map(); + + server.events.on("request:start", (req) => { + const matchesMethod = req.method === "POST"; + const matchesUrl = matchRequestUrl( + req.url, + SUPABASE_AUTH_TOKEN_API, + SUPABASE_URL, + ).matches; + + if (matchesMethod && matchesUrl) fetchAuthTokenAPI.set(req.id, req); + }); + + server.events.on("request:start", (req) => { + const matchesMethod = req.method === "DELETE"; + const matchesUrl = matchRequestUrl( + req.url, + `${SUPABASE_AUTH_ADMIN_USER_API}/*`, + SUPABASE_URL, + ).matches; + + if (matchesMethod && matchesUrl) + fetchAuthAdminUserAPI.set(req.id, req); + }); + + server.use( + rest.post( + `${SUPABASE_URL}${SUPABASE_AUTH_TOKEN_API}`, + async (_req, res, ctx) => + res.once( + ctx.status(400), + ctx.json({ message: "sign-in-error", status: 400 }), + ), + ), + ); + + const result = await createUserAccount(USER_EMAIL, USER_PASSWORD); + + server.events.removeAllListeners(); + + expect(result).toBeNull(); + expect(fetchAuthTokenAPI.size).toEqual(1); + const [signInRequest] = fetchAuthTokenAPI.values(); + expect(signInRequest.body).toEqual({ + email: USER_EMAIL, + password: USER_PASSWORD, + gotrue_meta_security: {}, + }); + expect(fetchAuthAdminUserAPI.size).toEqual(1); + // expect call delete auth account with the expected user id + const [authAdminUserReq] = fetchAuthAdminUserAPI.values(); + expect(authAdminUserReq.url.pathname).toEqual( + `${SUPABASE_AUTH_ADMIN_USER_API}/${USER_ID}`, + ); + }); + + it("should return null and delete auth account if unable to create user in database", async () => { + expect.assertions(4); + + const fetchAuthTokenAPI = new Map(); + const fetchAuthAdminUserAPI = new Map(); + + server.events.on("request:start", (req) => { + const matchesMethod = req.method === "POST"; + const matchesUrl = matchRequestUrl( + req.url, + SUPABASE_AUTH_TOKEN_API, + SUPABASE_URL, + ).matches; + + if (matchesMethod && matchesUrl) fetchAuthTokenAPI.set(req.id, req); + }); + + server.events.on("request:start", (req) => { + const matchesMethod = req.method === "DELETE"; + const matchesUrl = matchRequestUrl( + req.url, + `${SUPABASE_AUTH_ADMIN_USER_API}/*`, + SUPABASE_URL, + ).matches; + + if (matchesMethod && matchesUrl) + fetchAuthAdminUserAPI.set(req.id, req); + }); + + //@ts-expect-error missing vitest type + db.user.create.mockResolvedValue(null); + + const result = await createUserAccount(USER_EMAIL, USER_PASSWORD); + + server.events.removeAllListeners(); + + expect(result).toBeNull(); + expect(fetchAuthTokenAPI.size).toEqual(1); + expect(fetchAuthAdminUserAPI.size).toEqual(1); + + // expect call delete auth account with the expected user id + const [authAdminUserReq] = fetchAuthAdminUserAPI.values(); + expect(authAdminUserReq.url.pathname).toEqual( + `${SUPABASE_AUTH_ADMIN_USER_API}/${USER_ID}`, + ); + }); + + it("should create an account", async () => { + expect.assertions(4); + + const fetchAuthAdminUserAPI = new Map(); + const fetchAuthTokenAPI = new Map(); + + server.events.on("request:start", (req) => { + const matchesMethod = req.method === "POST"; + const matchesUrl = matchRequestUrl( + req.url, + SUPABASE_AUTH_ADMIN_USER_API, + SUPABASE_URL, + ).matches; + + if (matchesMethod && matchesUrl) + fetchAuthAdminUserAPI.set(req.id, req); + }); + + server.events.on("request:start", (req) => { + const matchesMethod = req.method === "POST"; + const matchesUrl = matchRequestUrl( + req.url, + SUPABASE_AUTH_TOKEN_API, + SUPABASE_URL, + ).matches; + + if (matchesMethod && matchesUrl) fetchAuthTokenAPI.set(req.id, req); + }); + + //@ts-expect-error missing vitest type + db.user.create.mockResolvedValue({ id: USER_ID, email: USER_EMAIL }); + + const result = await createUserAccount(USER_EMAIL, USER_PASSWORD); + + // we don't want to test the implementation of the function + result!.expiresAt = -1; + + server.events.removeAllListeners(); + + expect(db.user.create).toBeCalledWith({ + data: { email: USER_EMAIL, id: USER_ID }, + }); + + expect(result).toEqual(authSession); + expect(fetchAuthAdminUserAPI.size).toEqual(1); + expect(fetchAuthTokenAPI.size).toEqual(1); + }); }); diff --git a/app/modules/user/service.server.ts b/app/modules/user/service.server.ts index b8c8f5e..6daa5b2 100644 --- a/app/modules/user/service.server.ts +++ b/app/modules/user/service.server.ts @@ -2,70 +2,70 @@ import type { User } from "~/database"; import { db } from "~/database"; import type { AuthSession } from "~/modules/auth"; import { - createEmailAuthAccount, - signInWithEmail, - deleteAuthAccount, + createEmailAuthAccount, + signInWithEmail, + deleteAuthAccount, } from "~/modules/auth"; export async function getUserByEmail(email: User["email"]) { - return db.user.findUnique({ where: { email: email.toLowerCase() } }); + return db.user.findUnique({ where: { email: email.toLowerCase() } }); } async function createUser({ - email, - userId, + email, + userId, }: Pick) { - return db.user - .create({ - data: { - email, - id: userId, - }, - }) - .then((user) => user) - .catch(() => null); + return db.user + .create({ + data: { + email, + id: userId, + }, + }) + .then((user) => user) + .catch(() => null); } export async function tryCreateUser({ - email, - userId, + email, + userId, }: Pick) { - const user = await createUser({ - userId, - email, - }); + const user = await createUser({ + userId, + email, + }); - // user account created and have a session but unable to store in User table - // we should delete the user account to allow retry create account again - if (!user) { - await deleteAuthAccount(userId); - return null; - } + // user account created and have a session but unable to store in User table + // we should delete the user account to allow retry create account again + if (!user) { + await deleteAuthAccount(userId); + return null; + } - return user; + return user; } export async function createUserAccount( - email: string, - password: string + email: string, + password: string, ): Promise { - const authAccount = await createEmailAuthAccount(email, password); + const authAccount = await createEmailAuthAccount(email, password); - // ok, no user account created - if (!authAccount) return null; + // ok, no user account created + if (!authAccount) return null; - const authSession = await signInWithEmail(email, password); + const authSession = await signInWithEmail(email, password); - // user account created but no session 😱 - // we should delete the user account to allow retry create account again - if (!authSession) { - await deleteAuthAccount(authAccount.id); - return null; - } + // user account created but no session 😱 + // we should delete the user account to allow retry create account again + if (!authSession) { + await deleteAuthAccount(authAccount.id); + return null; + } - const user = await tryCreateUser(authSession); + const user = await tryCreateUser(authSession); - if (!user) return null; + if (!user) return null; - return authSession; + return authSession; } diff --git a/app/root.tsx b/app/root.tsx index 205e2ba..c329990 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -1,17 +1,18 @@ +import { cssBundleHref } from "@remix-run/css-bundle"; import type { - LinksFunction, - LoaderFunction, - MetaFunction, + LinksFunction, + LoaderFunction, + V2_MetaFunction as MetaFunction, } from "@remix-run/node"; import { json } from "@remix-run/node"; import { - Links, - LiveReload, - Meta, - Outlet, - Scripts, - ScrollRestoration, - useLoaderData, + Links, + LiveReload, + Meta, + Outlet, + Scripts, + ScrollRestoration, + useLoaderData, } from "@remix-run/react"; import { useTranslation } from "react-i18next"; import { useChangeLanguage } from "remix-i18next"; @@ -22,50 +23,57 @@ import tailwindStylesheetUrl from "./styles/tailwind.css"; import { getBrowserEnv } from "./utils/env"; export const links: LinksFunction = () => [ - { rel: "stylesheet", href: tailwindStylesheetUrl }, + { rel: "preload", href: tailwindStylesheetUrl, as: "style" }, + { rel: "stylesheet", href: tailwindStylesheetUrl, as: "style" }, + ...(cssBundleHref + ? [ + { rel: "preload", href: cssBundleHref, as: "style" }, + { rel: "stylesheet", href: cssBundleHref }, + ] + : []), ]; -export const meta: MetaFunction = () => ({ - charset: "utf-8", - title: "Remix Notes", - viewport: "width=device-width,initial-scale=1", -}); +export const meta: MetaFunction = () => [ + { title: "Remix Notes" }, + { name: "description", content: "Remix Notes App" }, +]; export const loader: LoaderFunction = async ({ request }) => { - const locale = await i18nextServer.getLocale(request); - return json({ - locale, - env: getBrowserEnv(), - }); + const locale = await i18nextServer.getLocale(request); + return json({ + locale, + env: getBrowserEnv(), + }); }; export default function App() { - const { env, locale } = useLoaderData(); - const { i18n } = useTranslation(); + const { env, locale } = useLoaderData(); + const { i18n } = useTranslation(); - useChangeLanguage(locale); + useChangeLanguage(locale); - return ( - - - - - - - - -