diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..6546236 --- /dev/null +++ b/.eslintignore @@ -0,0 +1,4 @@ +/build +/public/build +*.config* +/test/ \ No newline at end of file diff --git a/.eslintrc b/.eslintrc new file mode 100644 index 0000000..00e74b1 --- /dev/null +++ b/.eslintrc @@ -0,0 +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" + } + } + ] +} diff --git a/.eslintrc.js b/.eslintrc.js deleted file mode 100644 index c0cb157..0000000 --- a/.eslintrc.js +++ /dev/null @@ -1,110 +0,0 @@ -/** - * @type {import('@types/eslint').Linter.BaseConfig} - */ -module.exports = { - root: true, - extends: [ - "@remix-run/eslint-config", - "@remix-run/eslint-config/node", - "@remix-run/eslint-config/jest-testing-library", - "plugin:import/recommended", - "plugin:import/typescript", - "plugin:@typescript-eslint/eslint-recommended", - "plugin:@typescript-eslint/recommended", - "prettier", - ], - plugins: ["@typescript-eslint", "import"], - parser: "@typescript-eslint/parser", - parserOptions: { - project: ["./tsconfig.json"], - }, - ignorePatterns: [ - "node_modules", - "coverage", - "server-build", - "build", - "public/build", - "*.ignored/", - "*.ignored.*", - "remix.config.js", - ".cache", - ".history", - "tailwind.config.js", - ".eslintrc.js", - "vitest.config.ts", - "cypress", - "test", - "mocks", - "remix.init", - "seed.server.ts", - "wait-shadow-db-setup.js", - "cypress.config.ts", - ], - // we're using vitest which has a very similar API to jest - // (so the linting plugins work nicely), but it we have to explicitly - // set the jest version. - settings: { - "import/extensions": [".ts", ".tsx"], - "import/parsers": { - "@typescript-eslint/parser": [".ts", ".tsx"], - }, - "import/resolver": { - typescript: { - alwaysTryTypes: true, - project: "./tsconfig.json", - }, - }, - jest: { - version: 27, - }, - }, - rules: { - "no-console": "warn", - "arrow-body-style": ["warn", "as-needed"], - "react/jsx-filename-extension": "off", - // @typescript-eslint - "@typescript-eslint/consistent-type-imports": "error", - "@typescript-eslint/no-non-null-assertion": "off", - "@typescript-eslint/sort-type-union-intersection-members": "off", - "@typescript-eslint/no-namespace": "off", - "@typescript-eslint/no-unsafe-call": "off", - "@typescript-eslint/no-unsafe-assignment": "off", - "@typescript-eslint/no-unsafe-member-access": "off", - "@typescript-eslint/no-unsafe-argument": "off", - "@typescript-eslint/no-throw-literal": "off", // for CatchBoundaries - //import - "import/no-default-export": "error", - "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 9d375ca..6051c71 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -15,7 +15,7 @@ jobs: runs-on: ubuntu-latest steps: - name: 🛑 Cancel Previous Runs - uses: styfle/cancel-workflow-action@0.9.1 + uses: styfle/cancel-workflow-action@0.11.0 - name: ⬇️ Checkout repo uses: actions/checkout@v3 @@ -41,7 +41,7 @@ jobs: runs-on: ubuntu-latest steps: - name: 🛑 Cancel Previous Runs - uses: styfle/cancel-workflow-action@0.9.1 + uses: styfle/cancel-workflow-action@0.11.0 - name: ⬇️ Checkout repo uses: actions/checkout@v3 @@ -64,7 +64,7 @@ jobs: runs-on: ubuntu-latest steps: - name: 🛑 Cancel Previous Runs - uses: styfle/cancel-workflow-action@0.9.1 + uses: styfle/cancel-workflow-action@0.11.0 - name: ⬇️ Checkout repo uses: actions/checkout@v3 @@ -87,7 +87,7 @@ jobs: runs-on: ubuntu-latest steps: - name: 🛑 Cancel Previous Runs - uses: styfle/cancel-workflow-action@0.9.1 + uses: styfle/cancel-workflow-action@0.11.0 - name: ⬇️ Checkout repo uses: actions/checkout@v3 @@ -137,7 +137,7 @@ jobs: runs-on: ubuntu-latest steps: - name: 🛑 Cancel Previous Runs - uses: styfle/cancel-workflow-action@0.9.1 + uses: styfle/cancel-workflow-action@0.11.0 - name: ⬇️ Checkout repo uses: actions/checkout@v3 @@ -198,7 +198,7 @@ jobs: steps: - name: 🛑 Cancel Previous Runs - uses: styfle/cancel-workflow-action@0.9.1 + uses: styfle/cancel-workflow-action@0.11.0 - name: ⬇️ Checkout repo uses: actions/checkout@v3 diff --git a/.github/workflows/for-this-stack-repo-only.yml b/.github/workflows/for-this-stack-repo-only.yml index dcdff7f..f665d9a 100644 --- a/.github/workflows/for-this-stack-repo-only.yml +++ b/.github/workflows/for-this-stack-repo-only.yml @@ -15,7 +15,7 @@ jobs: runs-on: ubuntu-latest steps: - name: 🛑 Cancel Previous Runs - uses: styfle/cancel-workflow-action@0.9.1 + uses: styfle/cancel-workflow-action@0.11.0 - name: ⬇️ Checkout repo uses: actions/checkout@v3 @@ -41,7 +41,7 @@ jobs: runs-on: ubuntu-latest steps: - name: 🛑 Cancel Previous Runs - uses: styfle/cancel-workflow-action@0.9.1 + uses: styfle/cancel-workflow-action@0.11.0 - name: ⬇️ Checkout repo uses: actions/checkout@v3 @@ -64,7 +64,7 @@ jobs: runs-on: ubuntu-latest steps: - name: 🛑 Cancel Previous Runs - uses: styfle/cancel-workflow-action@0.9.1 + uses: styfle/cancel-workflow-action@0.11.0 - name: ⬇️ Checkout repo uses: actions/checkout@v3 @@ -88,7 +88,7 @@ jobs: needs: [lint, typecheck, vitest] steps: - name: 🛑 Cancel Previous Runs - uses: styfle/cancel-workflow-action@0.9.1 + uses: styfle/cancel-workflow-action@0.11.0 - name: ⬇️ Checkout repo uses: actions/checkout@v3 diff --git a/.gitignore b/.gitignore index d37c382..2c7fb66 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ pnpm-lock.yml node_modules +/.cache /build /public/build .env diff --git a/README.md b/README.md index 9ce6613..94fe7d6 100644 --- a/README.md +++ b/README.md @@ -15,9 +15,8 @@ npx create-remix --template rphlmr/supa-fly-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) for deploy on merge to production and staging environments -- Email/Password Authentication with [cookie-based sessions](https://remix.run/docs/en/v1/api/remix#createcookiesessionstorage) - - **NEW** : Magic Link login 🥳 +- [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/) @@ -32,7 +31,7 @@ Not a fan of bits of the stack? Fork it, change it, and use `npx create-remix -- ## Development -- Create a [Supabase Database](https://supabase.com/) (Free tiers 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` @@ -84,7 +83,7 @@ The database seed script creates a new user with some data you can use to get st ### 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 expire), and creating and deleting notes. +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) @@ -197,7 +196,7 @@ For lower level tests of utilities and individual components, we use `vitest`. W ### Type Checking -This project uses TypeScript. It's recommended to get TypeScript set up for your editor to get a really great in-editor experience with type checking and auto-complete. To run type checking across the whole project, run `npm run typecheck`. +This project uses TypeScript. It's recommended to get TypeScript set up for your editor to get a great in-editor experience with type checking and auto-complete. To run type checking across the whole project, run `npm run typecheck`. ### Linting @@ -209,48 +208,30 @@ We use [Prettier](https://prettier.io/) for auto-formatting in this project. It' ## Start working with Supabase -Your are now ready to go further, congrats ! - -To extend your prisma schema and apply changes on your supabase database : - -- Download and run [Docker Desktop](https://www.docker.com/products/docker-desktop/) - - > **Note:** Needed to create a [shadow database for prisma](https://www.prisma.io/docs/concepts/components/prisma-migrate/shadow-database) - - > **Note:** Shadow database is local and run by `docker-compose.yml` +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 - - > **Note:** First time take a long moment 😅 - ```sh npm run db:prepare-migration ``` - -- Check your migration in [./app/database/migrations](./app/database/migrations) -- Apply this migration in production +- Check your migration in [./app/database/migrations](./app/database) +- Apply this migration to production ```sh npm run db:deploy-migration ``` -## Use with Supabase RLS - -> To test this stack with RLS, you can find a demo in [./app/routes/rls](./app/routes/rls) - -> **Before playing, add some RLS rules for "Note" table** - -| Policy name | Target roles | WITH CHECK expression | -| ---------------------------------- | :-----------: | -------------------------: | -| Creator can see their own notes | authenticated | ((uid())::text = "userId") | -| Authenticated user can add notes | authenticated | true | -| Creator can delete their own posts | authenticated | ((uid())::text = "userId") | +## If your token expires in less than 1 hour (3600 seconds in Supabase Dashboard) -> Then, go to [http://localhost:3000/rls/notes](http://localhost:3000/rls/notes) +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. -## Your token expires in less than 1 hour (3600 seconds in Supabase Dashboard) +## Supabase RLS +You may ask "can I use RLS with Remix". -If you have a lower token lifetime than me (1 hour), you should take a look at `REFRESH_THRESHOLD` in [./app/modules/auth/const.ts](./app/modules/auth/const.ts) and set what you think is the best value for your use case. +The answer is "Yes" but It has a cost. +Using Supabase SDK server side to query your database (for those using RLS features) adds an extra delay due to calling a Gotrue rest API instead of directly calling the Postgres database (and this is fine because at first Supabase SDK is for those who don't have/want backend). +In my benchmark, it makes my pages twice slower. (~+200ms compared to a direct query with Prisma) diff --git a/app/database/seed.server.ts b/app/database/seed.server.ts index bedb4e6..90c4a85 100644 --- a/app/database/seed.server.ts +++ b/app/database/seed.server.ts @@ -1,10 +1,14 @@ +/* eslint-disable no-console */ import { PrismaClient } from "@prisma/client"; import { createClient } from "@supabase/supabase-js"; + import { SUPABASE_SERVICE_ROLE, SUPABASE_URL } from "../utils/env"; const supabaseAdmin = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE, { - autoRefreshToken: false, - persistSession: false, + auth: { + autoRefreshToken: false, + persistSession: false, + }, }); const prisma = new PrismaClient(); @@ -12,19 +16,19 @@ const prisma = new PrismaClient(); const email = "hello@supabase.com"; const getUserId = async (): Promise => { - const existingUserId = await supabaseAdmin.auth.api + const existingUserId = await supabaseAdmin.auth.admin .listUsers() - .then(({ data }) => data?.find((user) => user.email === email)?.id); + .then(({ data }) => data.users.find((user) => user.email === email)?.id); if (existingUserId) return existingUserId; - const newUserId = await supabaseAdmin.auth.api + const newUserId = await supabaseAdmin.auth.admin .createUser({ email, password: "supabase", email_confirm: true, }) - .then(({ user }) => user?.id); + .then(({ data }) => data.user?.id); if (newUserId) return newUserId; diff --git a/app/entry.client.tsx b/app/entry.client.tsx index 18127a8..93ef8b9 100644 --- a/app/entry.client.tsx +++ b/app/entry.client.tsx @@ -1,53 +1,21 @@ -import * as React from "react"; +import React from "react"; import { RemixBrowser } from "@remix-run/react"; -import i18next from "i18next"; -import LanguageDetector from "i18next-browser-languagedetector"; -import Backend from "i18next-http-backend"; import { hydrateRoot } from "react-dom/client"; -import { I18nextProvider, initReactI18next } from "react-i18next"; -import { getInitialNamespaces } from "remix-i18next"; -import i18n from "./i18n"; // your i18n configuration file +import { I18nClientProvider, initI18nextClient } from "./integrations/i18n"; // your i18n configuration file function hydrate() { React.startTransition(() => { hydrateRoot( document, - + - + ); }); } -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({ - ...i18n, // 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); - } - }); +initI18nextClient(hydrate); diff --git a/app/entry.server.tsx b/app/entry.server.tsx index c5b91a8..606015d 100644 --- a/app/entry.server.tsx +++ b/app/entry.server.tsx @@ -1,17 +1,13 @@ -import { resolve } from "node:path"; import { PassThrough } from "stream"; import { Response } from "@remix-run/node"; import type { EntryContext, Headers } from "@remix-run/node"; import { RemixServer } from "@remix-run/react"; -import { createInstance } from "i18next"; -import Backend from "i18next-fs-backend"; import isbot from "isbot"; import { renderToPipeableStream } from "react-dom/server"; -import { I18nextProvider, initReactI18next } from "react-i18next"; +import { I18nextProvider } from "react-i18next"; -import i18n from "./i18n"; // your i18n configuration file -import i18next from "./i18next.server"; +import { createI18nextServerInstance } from "./integrations/i18n"; const ABORT_DELAY = 5000; @@ -30,24 +26,7 @@ export default async function handleRequest( // First, we create a new instance of i18next so every request will have a // completely unique instance and not share any state - const instance = createInstance(); - - // Then we could detect locale from the request - const lng = await i18next.getLocale(request); - // And here we detect what namespaces the routes about to render want to use - const ns = i18next.getRouteNamespaces(remixContext); - - await instance - .use(initReactI18next) // Tell our instance to use react-i18next - .use(Backend) // Setup our backend - .init({ - ...i18n, // spread the configuration - lng, // The locale we detected above - ns, // The namespaces the routes about to render wants to use - backend: { - loadPath: resolve("./public/locales/{{lng}}/{{ns}}.json"), - }, - }); + const instance = await createI18nextServerInstance(request, remixContext); const { pipe, abort } = renderToPipeableStream( diff --git a/app/i18next.server.ts b/app/i18next.server.ts deleted file mode 100644 index 6145c0e..0000000 --- a/app/i18next.server.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { resolve } from "node:path"; - -import Backend from "i18next-fs-backend"; -import { RemixI18Next } from "remix-i18next"; - -import i18n from "~/i18n"; // your i18n configuration file - -const i18next = new RemixI18Next({ - detection: { - supportedLanguages: i18n.supportedLngs, - fallbackLanguage: i18n.fallbackLng, - }, - // This is the configuration for i18next used - // when translating messages server-side only - i18next: { - ...i18n, - 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 - backend: Backend, -}); - -// eslint-disable-next-line import/no-default-export -export default i18next; diff --git a/app/i18n.ts b/app/integrations/i18n/config.ts similarity index 85% rename from app/i18n.ts rename to app/integrations/i18n/config.ts index 6af13df..4754512 100644 --- a/app/i18n.ts +++ b/app/integrations/i18n/config.ts @@ -1,5 +1,4 @@ -// eslint-disable-next-line import/no-default-export -export default { +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 diff --git a/app/integrations/i18n/i18next.client.tsx b/app/integrations/i18n/i18next.client.tsx new file mode 100644 index 0000000..99e3428 --- /dev/null +++ b/app/integrations/i18n/i18next.client.tsx @@ -0,0 +1,46 @@ +import i18next from "i18next"; +import LanguageDetector from "i18next-browser-languagedetector"; +import Backend from "i18next-http-backend"; +import { I18nextProvider, initReactI18next } from "react-i18next"; +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); + } + }); +} + +export function I18nClientProvider({ + children, +}: { + children: React.ReactNode; +}) { + return {children}; +} diff --git a/app/integrations/i18n/i18next.server.tsx b/app/integrations/i18n/i18next.server.tsx new file mode 100644 index 0000000..688f6fa --- /dev/null +++ b/app/integrations/i18n/i18next.server.tsx @@ -0,0 +1,51 @@ +import { resolve } from "node:path"; + +import type { EntryContext } from "@remix-run/node"; +import { createInstance } from "i18next"; +import Backend from "i18next-fs-backend"; +import { initReactI18next } from "react-i18next"; +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 + backend: Backend, +}); + +export async function createI18nextServerInstance( + 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(); + + 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; +} diff --git a/app/integrations/i18n/index.ts b/app/integrations/i18n/index.ts new file mode 100644 index 0000000..7046297 --- /dev/null +++ b/app/integrations/i18n/index.ts @@ -0,0 +1,3 @@ +export * from "./config"; +export * from "./i18next.client"; +export * from "./i18next.server"; diff --git a/app/integrations/supabase/client.ts b/app/integrations/supabase/client.ts new file mode 100644 index 0000000..87a8c85 --- /dev/null +++ b/app/integrations/supabase/client.ts @@ -0,0 +1,59 @@ +import { createClient } from "@supabase/supabase-js"; + +import { + 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}`, + }, + }, + } + : {}; + + return createClient(SUPABASE_URL, supabaseKey, { + auth: { + autoRefreshToken: false, + persistSession: false, + }, + ...global, + }); +} + +/** + * Provides a Supabase Client for the logged in user or get back a public and safe client without admin privileges + * + * It's a per request scoped client to prevent access token leaking over multiple concurrent requests and from different users. + * + * Reason : https://github.com/rphlmr/supa-fly-stack/pull/43#issue-1336412790 + */ +function getSupabase(accessToken?: string) { + return getSupabaseClient(SUPABASE_ANON_PUBLIC, accessToken); +} + +/** + * Provides a Supabase Admin Client with full admin privileges + * + * It's a per request scoped client, to prevent access token leaking if you don't use it like `getSupabaseAdmin().auth.api`. + * + * 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" + ); + + return getSupabaseClient(SUPABASE_SERVICE_ROLE); +} + +export { getSupabaseAdmin, getSupabase }; diff --git a/app/integrations/supabase/index.ts b/app/integrations/supabase/index.ts index 131ada6..d2ec230 100644 --- a/app/integrations/supabase/index.ts +++ b/app/integrations/supabase/index.ts @@ -1,50 +1,2 @@ -import { createClient } from "@supabase/supabase-js"; - -import { - 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) { - return createClient(SUPABASE_URL, supabaseKey, { - autoRefreshToken: false, - persistSession: false, - }); -} - -/** - * Provides a Supabase Client for the logged in user or get back a public and safe client without admin privileges - * - * It's a per request scoped client to prevent access token leaking over multiple concurrent requests and from different users. - * - * Reason : https://github.com/rphlmr/supa-fly-stack/pull/43#issue-1336412790 - */ -function getSupabase(accessToken?: string) { - const supabase = getSupabaseClient(SUPABASE_ANON_PUBLIC); - - if (accessToken) supabase.auth.setAuth(accessToken); - - return supabase; -} - -/** - * Provides a Supabase Admin Client with full admin privileges - * - * It's a per request scoped client, to prevent access token leaking if you don't use it like `getSupabaseAdmin().auth.api`. - * - * 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" - ); - - return getSupabaseClient(SUPABASE_SERVICE_ROLE); -} - -export { getSupabaseAdmin, getSupabase }; +export * from "./client"; +export * from "./types"; diff --git a/app/integrations/supabase/realtime-context.tsx b/app/integrations/supabase/realtime-context.tsx deleted file mode 100644 index bcdac71..0000000 --- a/app/integrations/supabase/realtime-context.tsx +++ /dev/null @@ -1,82 +0,0 @@ -import type { ReactElement } from "react"; -import { createContext, useContext, useState } from "react"; - -import { useFetcher } from "@remix-run/react"; - -import { useInterval, useMatchesData } from "~/hooks"; -import type { RealtimeAuthSession } from "~/modules/auth/session.server"; -import { isBrowser } from "~/utils/is-browser"; - -import { getSupabase } from "."; -import type { SupabaseClient } from "./types"; - -// Remix feature here, we can "watch" root loader data -function useOptionalRealtimeSession(): Partial { - const data = useMatchesData<{ realtimeSession: RealtimeAuthSession }>("root"); - - return data?.realtimeSession || {}; -} - -const SupabaseRealtimeContext = createContext( - undefined -); - -// in root.tsx, wrap with to use realtime features -export const SupabaseRealtimeProvider = ({ - children, -}: { - children: ReactElement; -}) => { - // what root loader data returns - const { accessToken, expiresIn, expiresAt } = useOptionalRealtimeSession(); - const [currentExpiresAt, setCurrentExpiresAt] = useState< - number | undefined - >(); - const [supabaseRealtimeClient, setSupabaseRealtimeClient] = useState< - SupabaseClient | undefined - >(() => { - // prevents server side initial state - if (isBrowser) return getSupabase(); // init a default client in browser. Needed for oauth callback - }); - const refresh = useFetcher(); - - // auto refresh session at expire time - useInterval(() => { - // refreshes only if expiresIn is defined - // prevents refresh when user is not logged in - if (expiresIn) - refresh.submit(null, { - method: "post", - action: "/refresh-session", - }); - }, expiresIn); - - // when client side - // after root loader fetch, if user session is refresh, it's time to create a new supabase client - if (isBrowser && expiresAt !== currentExpiresAt) { - // recreate a supabase client to force provider's consumer to rerender - const client = getSupabase(accessToken); - - // refresh provider's state - setCurrentExpiresAt(expiresAt); - setSupabaseRealtimeClient(client); - } - - return ( - - {children} - - ); -}; - -export const useSupabaseRealtime = () => { - const context = useContext(SupabaseRealtimeContext); - - if (isBrowser && context === undefined) { - throw new Error( - `useSupabaseRealtime must be used within a SupabaseClientProvider.` - ); - } - - return context as SupabaseClient; -}; diff --git a/app/integrations/supabase/types.ts b/app/integrations/supabase/types.ts index 6314c49..d8bca3b 100644 --- a/app/integrations/supabase/types.ts +++ b/app/integrations/supabase/types.ts @@ -1,6 +1 @@ -export type { - AuthSession as SupabaseAuthSession, - SupabaseClient, - User as SupabaseAuthAccount, - ApiError as SupabaseError, -} from "@supabase/supabase-js"; +export type { AuthSession as SupabaseAuthSession } from "@supabase/supabase-js"; diff --git a/app/modules/auth/components/logout-button.tsx b/app/modules/auth/components/logout-button.tsx index 40d5320..3440708 100644 --- a/app/modules/auth/components/logout-button.tsx +++ b/app/modules/auth/components/logout-button.tsx @@ -2,7 +2,8 @@ import { Form } from "@remix-run/react"; import { useTranslation } from "react-i18next"; export function LogoutButton() { - const { t } = useTranslation(); + const { t } = useTranslation("auth"); + return (
{ - // hello there - const authSession = await assertAuthSession(request, { - onFailRedirectTo, - }); - - // ok, let's challenge its access token - const isValidSession = await verifyAuthSession(authSession); - - // 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; -} diff --git a/app/modules/auth/index.ts b/app/modules/auth/index.ts new file mode 100644 index 0000000..1626ce9 --- /dev/null +++ b/app/modules/auth/index.ts @@ -0,0 +1,16 @@ +export { + createEmailAuthAccount, + deleteAuthAccount, + signInWithEmail, + sendMagicLink, + refreshAccessToken, +} from "./service.server"; +export { + commitAuthSession, + createAuthSession, + destroyAuthSession, + requireAuthSession, + getAuthSession, +} from "./session.server"; +export * from "./types"; +export * from "./components"; diff --git a/app/modules/auth/utils/map-auth-session.server.ts b/app/modules/auth/mappers.ts similarity index 50% rename from app/modules/auth/utils/map-auth-session.server.ts rename to app/modules/auth/mappers.ts index 1e6925c..e5421eb 100644 --- a/app/modules/auth/utils/map-auth-session.server.ts +++ b/app/modules/auth/mappers.ts @@ -1,19 +1,24 @@ -import type { SupabaseAuthSession } from "~/integrations/supabase/types"; +import type { SupabaseAuthSession } from "~/integrations/supabase"; -import type { AuthSession } from "../session.server"; +import type { AuthSession } from "./types"; export function mapAuthSession( supabaseAuthSession: SupabaseAuthSession | null ): AuthSession | null { if (!supabaseAuthSession) return null; + 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"); + return { accessToken: supabaseAuthSession.access_token, - refreshToken: supabaseAuthSession.refresh_token ?? "", - userId: supabaseAuthSession.user?.id ?? "", - email: supabaseAuthSession.user?.email ?? "", + refreshToken: supabaseAuthSession.refresh_token, + userId: supabaseAuthSession.user.id, + email: supabaseAuthSession.user.email, expiresIn: supabaseAuthSession.expires_in ?? -1, expiresAt: supabaseAuthSession.expires_at ?? -1, - providerToken: supabaseAuthSession.provider_token, }; } diff --git a/app/modules/auth/mutations/create-auth-account.server.ts b/app/modules/auth/mutations/create-auth-account.server.ts deleted file mode 100644 index bb9e3e4..0000000 --- a/app/modules/auth/mutations/create-auth-account.server.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { getSupabaseAdmin } from "~/integrations/supabase"; - -export async function createAuthAccount(email: string, password: string) { - const { data, error } = await getSupabaseAdmin().auth.api.createUser({ - email, - password, - email_confirm: true, // demo purpose, assert that email is confirmed. For production, check email confirmation - }); - - if (!data || error) return null; - - return data; -} diff --git a/app/modules/auth/mutations/delete-auth-account.server.ts b/app/modules/auth/mutations/delete-auth-account.server.ts deleted file mode 100644 index 1ba7d69..0000000 --- a/app/modules/auth/mutations/delete-auth-account.server.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { getSupabaseAdmin } from "~/integrations/supabase"; - -export async function deleteAuthAccount(userId: string) { - return getSupabaseAdmin().auth.api.deleteUser(userId); -} diff --git a/app/modules/auth/mutations/index.ts b/app/modules/auth/mutations/index.ts deleted file mode 100644 index bc09c5b..0000000 --- a/app/modules/auth/mutations/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export * from "./create-auth-account.server"; -export * from "./delete-auth-account.server"; -export * from "./sign-in.server"; -export * from "./refresh-auth-session.server"; diff --git a/app/modules/auth/mutations/refresh-auth-session.server.ts b/app/modules/auth/mutations/refresh-auth-session.server.ts deleted file mode 100644 index 4be00ef..0000000 --- a/app/modules/auth/mutations/refresh-auth-session.server.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { redirect } from "@remix-run/node"; - -import { getSupabaseAdmin } from "~/integrations/supabase"; -import { - getCurrentPath, - isGet, - makeRedirectToFromHere, -} from "~/utils/http.server"; - -import { LOGIN_URL } from "../const"; -import type { AuthSession } from "../session.server"; -import { getAuthSession, commitAuthSession } from "../session.server"; -import { mapAuthSession } from "../utils/map-auth-session.server"; - -export async function refreshAccessToken(refreshToken?: string) { - if (!refreshToken) return null; - - const { data, error } = await getSupabaseAdmin().auth.api.refreshAccessToken( - refreshToken - ); - - if (!data || error) return null; - - return mapAuthSession(data); -} - -export 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; -} diff --git a/app/modules/auth/mutations/sign-in.server.test.ts b/app/modules/auth/mutations/sign-in.server.test.ts deleted file mode 100644 index 258f16b..0000000 --- a/app/modules/auth/mutations/sign-in.server.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { matchRequestUrl } from "msw"; - -import { server } from "mocks"; -import { - SUPABASE_URL, - SUPABASE_AUTH_TOKEN_API, - authSession, -} from "mocks/handlers"; -import { USER_EMAIL, USER_PASSWORD } from "mocks/user"; -import { signInWithEmail } from "~/modules/auth/mutations/sign-in.server"; - -vitest.mock("../models/user.server", () => ({ - createUser: vitest.fn().mockResolvedValue({}), -})); - -describe(signInWithEmail.name, () => { - it("should fetch supabase sign in auth api", async () => { - expect.assertions(3); - - const fetchAuthTokenAPI = 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); - }); - - const result = await signInWithEmail(USER_EMAIL, USER_PASSWORD); - - server.events.removeAllListeners(); - - expect(result).toEqual(authSession); - expect(fetchAuthTokenAPI.size).toEqual(1); - const [signInRequest] = fetchAuthTokenAPI.values(); - expect(signInRequest.body).toEqual( - JSON.stringify({ - email: USER_EMAIL, - password: USER_PASSWORD, - gotrue_meta_security: {}, - }) - ); - }); -}); diff --git a/app/modules/auth/mutations/sign-in.server.ts b/app/modules/auth/mutations/sign-in.server.ts deleted file mode 100644 index 50ea1ac..0000000 --- a/app/modules/auth/mutations/sign-in.server.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { getSupabaseAdmin } from "~/integrations/supabase"; -import { SERVER_URL } from "~/utils/env"; - -import { mapAuthSession } from "../utils/map-auth-session.server"; - -export async function signInWithEmail(email: string, password: string) { - const { data, error } = await getSupabaseAdmin().auth.api.signInWithEmail( - email, - password - ); - - if (!data || error) return null; - - return mapAuthSession(data); -} - -export async function sendMagicLink(email: string) { - return getSupabaseAdmin().auth.api.sendMagicLinkEmail(email, { - redirectTo: `${SERVER_URL}/oauth/callback`, - }); -} diff --git a/app/modules/auth/queries/get-auth-account.server.ts b/app/modules/auth/queries/get-auth-account.server.ts deleted file mode 100644 index 423cfd1..0000000 --- a/app/modules/auth/queries/get-auth-account.server.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { getSupabaseAdmin } from "~/integrations/supabase"; -import type { SupabaseAuthSession } from "~/integrations/supabase/types"; - -export async function getAuthAccountByAccessToken( - accessToken: SupabaseAuthSession["access_token"] -) { - const { data, error } = await getSupabaseAdmin().auth.api.getUser( - accessToken - ); - - if (!data || error) return null; - - return data; -} diff --git a/app/modules/auth/queries/get-auth-account.test.ts b/app/modules/auth/queries/get-auth-account.test.ts deleted file mode 100644 index 210c1d3..0000000 --- a/app/modules/auth/queries/get-auth-account.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { matchRequestUrl } from "msw"; - -import { server } from "mocks"; -import { SUPABASE_AUTH_USER_API, SUPABASE_URL } from "mocks/handlers"; -import { USER_ID } from "mocks/user"; - -import { getAuthAccountByAccessToken } from "./get-auth-account.server"; - -describe(getAuthAccountByAccessToken.name, () => { - it("should fetch supabase getUser auth api", async () => { - expect.assertions(2); - - const fetchAuthUserAPI = new Map(); - - server.events.on("request:start", (req) => { - const matchesMethod = req.method === "GET"; - const matchesUrl = matchRequestUrl( - req.url, - SUPABASE_AUTH_USER_API, - SUPABASE_URL - ).matches; - - if (matchesMethod && matchesUrl) fetchAuthUserAPI.set(req.id, req); - }); - - const authAccount = await getAuthAccountByAccessToken("valid"); - - server.events.removeAllListeners(); - - expect(authAccount).toEqual({ id: USER_ID }); - expect(fetchAuthUserAPI.size).toEqual(1); - }); -}); diff --git a/app/modules/auth/service.server.ts b/app/modules/auth/service.server.ts new file mode 100644 index 0000000..8cc330a --- /dev/null +++ b/app/modules/auth/service.server.ts @@ -0,0 +1,76 @@ +import { getSupabaseAdmin } from "~/integrations/supabase"; +import { SERVER_URL } from "~/utils/env"; + +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 + }); + + if (!data.user || error) return null; + + return data.user; +} + +export async function signInWithEmail(email: string, password: string) { + const { data, error } = await getSupabaseAdmin().auth.signInWithPassword({ + email, + password, + }); + + if (!data.session || error) return null; + + return mapAuthSession(data.session); +} + +export async function sendMagicLink(email: string) { + return getSupabaseAdmin().auth.signInWithOtp({ + email, + options: { + emailRedirectTo: `${SERVER_URL}/oauth/callback`, + }, + }); +} + +export async function deleteAuthAccount(userId: string) { + const { error } = await getSupabaseAdmin().auth.admin.deleteUser(userId); + + if (error) return null; + + return true; +} + +export async function getAuthAccountByAccessToken(accessToken: string) { + const { data, error } = await getSupabaseAdmin().auth.getUser(accessToken); + + if (!data.user || error) return null; + + return data.user; +} + +export async function refreshAccessToken( + refreshToken?: string +): Promise { + if (!refreshToken) return null; + + const { data, error } = await getSupabaseAdmin().auth.setSession({ + access_token: "", + refresh_token: refreshToken, + }); + + if (!data.session || error) return null; + + return mapAuthSession(data.session); +} + +export async function verifyAuthSession(authSession: AuthSession) { + const authAccount = await getAuthAccountByAccessToken( + authSession.accessToken + ); + + return Boolean(authAccount); +} diff --git a/app/modules/auth/session.server.ts b/app/modules/auth/session.server.ts index 02b21c1..72ca757 100644 --- a/app/modules/auth/session.server.ts +++ b/app/modules/auth/session.server.ts @@ -1,23 +1,22 @@ import { createCookieSessionStorage, redirect } from "@remix-run/node"; -import { NODE_ENV, SESSION_SECRET } from "../../utils/env"; -import { safeRedirect } from "../../utils/http.server"; -import { SESSION_ERROR_KEY, SESSION_KEY, SESSION_MAX_AGE } from "./const"; - -export interface AuthSession { - accessToken: string; - refreshToken: string; - userId: string; - email: string; - expiresIn: number; - expiresAt: number; - providerToken?: string | null; -} +import { + getCurrentPath, + isGet, + makeRedirectToFromHere, + NODE_ENV, + safeRedirect, + SESSION_SECRET, +} from "~/utils"; + +import { refreshAccessToken, verifyAuthSession } from "./service.server"; +import type { AuthSession } from "./types"; -export type RealtimeAuthSession = Pick< - AuthSession, - "accessToken" | "expiresIn" | "expiresAt" ->; +const SESSION_KEY = "authenticated"; +const SESSION_ERROR_KEY = "error"; +const SESSION_MAX_AGE = 60 * 60 * 24 * 7; // 7 days; +const LOGIN_URL = "/login"; +const REFRESH_ACCESS_TOKEN_THRESHOLD = 60 * 10; // 10 minutes left before token expires /** * Session storage CRUD @@ -25,7 +24,7 @@ export type RealtimeAuthSession = Pick< const sessionStorage = createCookieSessionStorage({ cookie: { - name: "__session", + name: "__authSession", httpOnly: true, path: "/", sameSite: "lax", @@ -53,7 +52,7 @@ export async function createAuthSession({ }); } -export async function getSession(request: Request) { +async function getSession(request: Request) { const cookie = request.headers.get("Cookie"); return sessionStorage.getSession(cookie); } @@ -78,7 +77,7 @@ export async function commitAuthSession( const session = await getSession(request); // allow user session to be null. - // useful if you want to clear session and display a message explaining why + // useful you want to clear session and display a message explaining why if (authSession !== undefined) { session.set(SESSION_KEY, authSession); } @@ -97,3 +96,111 @@ export async function destroyAuthSession(request: Request) { }, }); } + +async function assertAuthSession( + 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; +} + +/** + * Assert auth session is present and verified from supabase auth api + * + * If used in loader (GET method) + * - Refresh tokens if session is expired + * - Return auth session if not expired + * - Destroy session if refresh token is expired + * + * If used in action (POST method) + * - Try to refresh session if expired and return this new session (it's your job to handle session commit) + * - Return auth session if not expired + * - Destroy session if refresh token is expired + */ +export async function requireAuthSession( + 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; +} + +function isExpiringSoon(expiresAt: number) { + 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; +} diff --git a/app/modules/auth/types.ts b/app/modules/auth/types.ts new file mode 100644 index 0000000..6af12e8 --- /dev/null +++ b/app/modules/auth/types.ts @@ -0,0 +1,8 @@ +export interface AuthSession { + accessToken: string; + refreshToken: string; + userId: string; + email: string; + expiresIn: number; + expiresAt: number; +} diff --git a/app/modules/note/components/.gitKeep b/app/modules/note/components/.gitKeep deleted file mode 100644 index e69de29..0000000 diff --git a/app/modules/note/hooks/index.ts b/app/modules/note/hooks/index.ts deleted file mode 100644 index 5ac4c7b..0000000 --- a/app/modules/note/hooks/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./use-watch-notes"; diff --git a/app/modules/note/hooks/use-watch-notes.ts b/app/modules/note/hooks/use-watch-notes.ts deleted file mode 100644 index 2f3a1b7..0000000 --- a/app/modules/note/hooks/use-watch-notes.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { useEffect } from "react"; - -import { useSubmit } from "@remix-run/react"; - -import { useSupabaseRealtime } from "~/integrations/supabase/realtime-context"; - -export function useWatchNotes() { - const supabase = useSupabaseRealtime(); - const submit = useSubmit(); - - useEffect(() => { - const subscription = supabase - .from("Note") - .on("INSERT", () => { - submit(null, { replace: true }); - }) - .on("DELETE", () => { - submit(null, { replace: true }); - }) - .subscribe(); - - return () => { - subscription?.unsubscribe(); - }; - }, [supabase, submit]); -} diff --git a/app/modules/note/index.ts b/app/modules/note/index.ts new file mode 100644 index 0000000..6b66ae1 --- /dev/null +++ b/app/modules/note/index.ts @@ -0,0 +1 @@ +export * from "./service.server"; diff --git a/app/modules/note/mutations/create-note.server.ts b/app/modules/note/mutations/create-note.server.ts deleted file mode 100644 index 95fd5a1..0000000 --- a/app/modules/note/mutations/create-note.server.ts +++ /dev/null @@ -1,22 +0,0 @@ -import type { Note, User } from "~/database"; -import { db } from "~/database"; - -export async function createNote({ - title, - body, - userId, -}: Pick & { - userId: User["id"]; -}) { - return db.note.create({ - data: { - title, - body, - user: { - connect: { - id: userId, - }, - }, - }, - }); -} diff --git a/app/modules/note/mutations/delete-note.server.ts b/app/modules/note/mutations/delete-note.server.ts deleted file mode 100644 index 9fad466..0000000 --- a/app/modules/note/mutations/delete-note.server.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { Note, User } from "~/database"; -import { db } from "~/database"; - -export async function deleteNote({ - id, - userId, -}: Pick & { userId: User["id"] }) { - return db.note.deleteMany({ - where: { id, userId }, - }); -} diff --git a/app/modules/note/mutations/index.ts b/app/modules/note/mutations/index.ts deleted file mode 100644 index 3fd07cc..0000000 --- a/app/modules/note/mutations/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from "./create-note.server"; -export * from "./delete-note.server"; diff --git a/app/modules/note/queries/get-note-count.server.ts b/app/modules/note/queries/get-note-count.server.ts deleted file mode 100644 index 96d9c4c..0000000 --- a/app/modules/note/queries/get-note-count.server.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { db } from "~/database"; - -export async function getNoteCount() { - return db.note.count(); -} diff --git a/app/modules/note/queries/get-note.server.ts b/app/modules/note/queries/get-note.server.ts deleted file mode 100644 index 317719f..0000000 --- a/app/modules/note/queries/get-note.server.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { Note, User } from "~/database"; -import { db } from "~/database"; - -export async function getNote({ - userId, - id, -}: Pick & { - userId: User["id"]; -}) { - return db.note.findFirst({ - select: { id: true, body: true, title: true }, - where: { id, userId }, - }); -} diff --git a/app/modules/note/queries/get-notes.server.ts b/app/modules/note/queries/get-notes.server.ts deleted file mode 100644 index dda497c..0000000 --- a/app/modules/note/queries/get-notes.server.ts +++ /dev/null @@ -1,10 +0,0 @@ -import type { User } from "~/database"; -import { db } from "~/database"; - -export async function getNotes({ userId }: { userId: User["id"] }) { - return db.note.findMany({ - where: { userId }, - select: { id: true, title: true }, - orderBy: { updatedAt: "desc" }, - }); -} diff --git a/app/modules/note/queries/index.ts b/app/modules/note/queries/index.ts deleted file mode 100644 index e0f59e0..0000000 --- a/app/modules/note/queries/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from "./get-notes.server"; -export * from "./get-note.server"; -export * from "./get-note-count.server"; diff --git a/app/modules/note/service.server.ts b/app/modules/note/service.server.ts new file mode 100644 index 0000000..1d0e2cb --- /dev/null +++ b/app/modules/note/service.server.ts @@ -0,0 +1,51 @@ +import type { Note, User } from "~/database"; +import { db } from "~/database"; + +export async function getNote({ + userId, + id, +}: Pick & { + userId: User["id"]; +}) { + 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" }, + }); +} + +export async function createNote({ + title, + body, + userId, +}: Pick & { + userId: User["id"]; +}) { + return db.note.create({ + data: { + title, + body, + user: { + connect: { + id: userId, + }, + }, + }, + }); +} + +export async function deleteNote({ + id, + userId, +}: Pick & { userId: User["id"] }) { + return db.note.deleteMany({ + where: { id, userId }, + }); +} diff --git a/app/modules/user/index.ts b/app/modules/user/index.ts new file mode 100644 index 0000000..6b66ae1 --- /dev/null +++ b/app/modules/user/index.ts @@ -0,0 +1 @@ +export * from "./service.server"; diff --git a/app/modules/user/mutations/index.ts b/app/modules/user/mutations/index.ts deleted file mode 100644 index e3650ac..0000000 --- a/app/modules/user/mutations/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./create-user-account.server"; diff --git a/app/modules/user/queries/get-user.server.ts b/app/modules/user/queries/get-user.server.ts deleted file mode 100644 index 5dcb7da..0000000 --- a/app/modules/user/queries/get-user.server.ts +++ /dev/null @@ -1,6 +0,0 @@ -import type { User } from "~/database"; -import { db } from "~/database"; - -export async function getUserByEmail(email: User["email"]) { - return db.user.findUnique({ where: { email: email.toLowerCase() } }); -} diff --git a/app/modules/user/queries/index.ts b/app/modules/user/queries/index.ts deleted file mode 100644 index c5bda74..0000000 --- a/app/modules/user/queries/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./get-user.server"; diff --git a/app/modules/user/mutations/create-user-account.test.ts b/app/modules/user/service.server.test.ts similarity index 91% rename from app/modules/user/mutations/create-user-account.test.ts rename to app/modules/user/service.server.test.ts index 8bfd3ec..3c849b5 100644 --- a/app/modules/user/mutations/create-user-account.test.ts +++ b/app/modules/user/service.server.test.ts @@ -10,10 +10,10 @@ import { import { USER_EMAIL, USER_ID, USER_PASSWORD } from "mocks/user"; import { db } from "~/database"; -import { createUserAccount } from "./create-user-account.server"; +import { createUserAccount } from "./service.server"; // mock db -vitest.mock("~/database/db.server", () => ({ +vitest.mock("~/database", () => ({ db: { user: { create: vitest.fn().mockResolvedValue({}), @@ -42,7 +42,7 @@ describe(createUserAccount.name, () => { server.use( rest.post( `${SUPABASE_URL}${SUPABASE_AUTH_ADMIN_USER_API}`, - async (req, res, ctx) => + async (_req, res, ctx) => res.once( ctx.status(400), ctx.json({ message: "create-account-error", status: 400 }) @@ -57,13 +57,11 @@ describe(createUserAccount.name, () => { expect(result).toBeNull(); expect(fetchAuthAdminUserAPI.size).toEqual(1); const [request] = fetchAuthAdminUserAPI.values(); - expect(request.body).toEqual( - JSON.stringify({ - email: USER_EMAIL, - password: USER_PASSWORD, - email_confirm: true, - }) - ); + 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 () => { @@ -97,7 +95,7 @@ describe(createUserAccount.name, () => { server.use( rest.post( `${SUPABASE_URL}${SUPABASE_AUTH_TOKEN_API}`, - async (req, res, ctx) => + async (_req, res, ctx) => res.once( ctx.status(400), ctx.json({ message: "sign-in-error", status: 400 }) @@ -112,13 +110,12 @@ describe(createUserAccount.name, () => { expect(result).toBeNull(); expect(fetchAuthTokenAPI.size).toEqual(1); const [signInRequest] = fetchAuthTokenAPI.values(); - expect(signInRequest.body).toEqual( - JSON.stringify({ - email: USER_EMAIL, - password: USER_PASSWORD, - gotrue_meta_security: {}, - }) - ); + expect(signInRequest.body).toEqual({ + email: USER_EMAIL, + password: USER_PASSWORD, + data: {}, + gotrue_meta_security: {}, + }); expect(fetchAuthAdminUserAPI.size).toEqual(1); // expect call delete auth account with the expected user id const [authAdminUserReq] = fetchAuthAdminUserAPI.values(); @@ -206,11 +203,15 @@ describe(createUserAccount.name, () => { 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/mutations/create-user-account.server.ts b/app/modules/user/service.server.ts similarity index 78% rename from app/modules/user/mutations/create-user-account.server.ts rename to app/modules/user/service.server.ts index 68b8e0b..b8c8f5e 100644 --- a/app/modules/user/mutations/create-user-account.server.ts +++ b/app/modules/user/service.server.ts @@ -1,10 +1,15 @@ +import type { User } from "~/database"; import { db } from "~/database"; +import type { AuthSession } from "~/modules/auth"; import { - createAuthAccount, - deleteAuthAccount, + createEmailAuthAccount, signInWithEmail, -} from "~/modules/auth/mutations"; -import type { AuthSession } from "~/modules/auth/session.server"; + deleteAuthAccount, +} from "~/modules/auth"; + +export async function getUserByEmail(email: User["email"]) { + return db.user.findUnique({ where: { email: email.toLowerCase() } }); +} async function createUser({ email, @@ -44,7 +49,7 @@ export async function createUserAccount( email: string, password: string ): Promise { - const authAccount = await createAuthAccount(email, password); + const authAccount = await createEmailAuthAccount(email, password); // ok, no user account created if (!authAccount) return null; diff --git a/app/root.tsx b/app/root.tsx index c1f1c92..205e2ba 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -16,7 +16,7 @@ import { import { useTranslation } from "react-i18next"; import { useChangeLanguage } from "remix-i18next"; -import i18next from "~/i18next.server"; +import { i18nextServer } from "~/integrations/i18n"; import tailwindStylesheetUrl from "./styles/tailwind.css"; import { getBrowserEnv } from "./utils/env"; @@ -32,21 +32,13 @@ export const meta: MetaFunction = () => ({ }); export const loader: LoaderFunction = async ({ request }) => { - const locale = await i18next.getLocale(request); + const locale = await i18nextServer.getLocale(request); return json({ locale, env: getBrowserEnv(), }); }; -export const handle = { - // In the handle export, we can add a i18n key with namespaces our route - // will need to load. This key can be a single string or an array of strings. - // TIP: In most cases, you should set this to your defaultNS from your i18n config - // or if you did not set one, set it to the i18next default namespace "translation" - i18n: "common", -}; - export default function App() { const { env, locale } = useLoaderData(); const { i18n } = useTranslation(); diff --git a/app/routes/index.tsx b/app/routes/index.tsx index 8fa761e..7adb8ba 100644 --- a/app/routes/index.tsx +++ b/app/routes/index.tsx @@ -3,9 +3,7 @@ import { json } from "@remix-run/node"; import { Link, useLoaderData } from "@remix-run/react"; import { useTranslation } from "react-i18next"; -import { getAuthSession } from "~/modules/auth/session.server"; - -export const handle = { i18n: ["common", "auth"] }; +import { getAuthSession } from "~/modules/auth"; export async function loader({ request }: LoaderArgs) { const { email } = (await getAuthSession(request)) || {}; @@ -152,7 +150,7 @@ export default function Index() { {img.alt} ({ }); export default function Join() { + const zo = useZorm("NewQuestionWizardScreen", JoinFormSchema); const [searchParams] = useSearchParams(); const redirectTo = searchParams.get("redirectTo") ?? undefined; - const actionData = useActionData(); - const emailRef = React.useRef(null); - const passwordRef = React.useRef(null); - const inputProps = useFormInputProps(JoinFormSchema); const transition = useTransition(); + const disabled = isFormProcessing(transition.state); const { t } = useTranslation("auth"); - const disabled = - transition.state === "submitting" || transition.state === "loading"; - - React.useEffect(() => { - if (actionData?.errors?.email) { - emailRef.current?.focus(); - } else if (actionData?.errors?.password) { - passwordRef.current?.focus(); - } - }, [actionData]); return (
- +
- {actionData?.errors?.email && ( -
- {actionData.errors.email} + {zo.errors.email()?.message && ( +
+ {zo.errors.email()?.message}
)}
@@ -156,31 +120,23 @@ export default function Join() {
- {actionData?.errors?.password && ( -
- {actionData.errors.password} + {zo.errors.password()?.message && ( +
+ {zo.errors.password()?.message}
)}
@@ -188,13 +144,13 @@ export default function Join() { diff --git a/app/routes/notes/index.tsx b/app/routes/notes/index.tsx index 689148c..f7fa22e 100644 --- a/app/routes/notes/index.tsx +++ b/app/routes/notes/index.tsx @@ -1,25 +1,15 @@ import type { LoaderArgs } from "@remix-run/node"; -import { json } from "@remix-run/node"; -import { Link, useLoaderData } from "@remix-run/react"; +import { Link } from "@remix-run/react"; -import { requireAuthSession } from "~/modules/auth/guards"; -// import { useWatchNotes } from "~/modules/note/hooks"; -import { getNoteCount } from "~/modules/note/queries"; +import { requireAuthSession } from "~/modules/auth"; export async function loader({ request }: LoaderArgs) { await requireAuthSession(request); - const nbOfNotes = await getNoteCount(); - - return json({ - nbOfNotes, - }); + return null; } export default function NoteIndexPage() { - const { nbOfNotes } = useLoaderData(); - // useWatchNotes(); - return ( <>

@@ -31,11 +21,6 @@ export default function NoteIndexPage() { create a new note.

-
-
-

Total number of notes on database:

- {nbOfNotes} -
); } diff --git a/app/routes/notes/new.tsx b/app/routes/notes/new.tsx index b02686d..5fbc6a1 100644 --- a/app/routes/notes/new.tsx +++ b/app/routes/notes/new.tsx @@ -2,14 +2,13 @@ import * as React from "react"; import type { LoaderArgs } from "@remix-run/node"; import { json, redirect } from "@remix-run/node"; -import { Form, useActionData, useTransition } from "@remix-run/react"; -import { getFormData, useFormInputProps } from "remix-params-helper"; +import { Form, useTransition } from "@remix-run/react"; +import { parseFormAny, useZorm } from "react-zorm"; import { z } from "zod"; -import { requireAuthSession } from "~/modules/auth/guards"; -import { commitAuthSession } from "~/modules/auth/session.server"; -import { createNote } from "~/modules/note/mutations"; -import { assertIsPost } from "~/utils/http.server"; +import { requireAuthSession, commitAuthSession } from "~/modules/auth"; +import { createNote } from "~/modules/note"; +import { assertIsPost, isFormProcessing } from "~/utils"; export const NewNoteFormSchema = z.object({ title: z.string().min(2, "require-title"), @@ -18,17 +17,14 @@ export const NewNoteFormSchema = z.object({ export async function action({ request }: LoaderArgs) { assertIsPost(request); - const authSession = await requireAuthSession(request); - const formValidation = await getFormData(request, NewNoteFormSchema); + const formData = await request.formData(); + const result = await NewNoteFormSchema.safeParseAsync(parseFormAny(formData)); - if (!formValidation.success) { + if (!result.success) { return json( { - errors: { - title: formValidation.errors.title, - body: formValidation.errors.body, - }, + errors: result.error, }, { status: 400, @@ -39,7 +35,7 @@ export async function action({ request }: LoaderArgs) { ); } - const { title, body } = formValidation.data; + const { title, body } = result.data; const note = await createNote({ title, body, userId: authSession.userId }); @@ -51,24 +47,13 @@ export async function action({ request }: LoaderArgs) { } export default function NewNotePage() { - const actionData = useActionData(); - const titleRef = React.useRef(null); - const bodyRef = React.useRef(null); - const inputProps = useFormInputProps(NewNoteFormSchema); + const zo = useZorm("NewQuestionWizardScreen", NewNoteFormSchema); const transition = useTransition(); - const disabled = - transition.state === "submitting" || transition.state === "loading"; - - React.useEffect(() => { - if (actionData?.errors?.title) { - titleRef.current?.focus(); - } else if (actionData?.errors?.body) { - bodyRef.current?.focus(); - } - }, [actionData]); + const disabled = isFormProcessing(transition.state); return ( Title: - {actionData?.errors?.title && ( -
- {actionData.errors.title} + {zo.errors.title()?.message && ( +
+ {zo.errors.title()?.message}
)}
@@ -106,24 +82,15 @@ export default function NewNotePage() {