From 4a763dfbc57be11bf96ccd863f555dce01c4c504 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Moreau?= Date: Thu, 21 Dec 2023 19:04:29 +0100 Subject: [PATCH] =?UTF-8?q?=F0=9F=9A=80=20RELEASE:=20support=20for=20Remix?= =?UTF-8?q?=202.4.0=20(#80)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 🚀 RELEASE: support for Remix 2.4.0 --- app/entry.server.tsx | 10 +- app/integrations/i18n/i18next.server.tsx | 1 - .../components/continue-with-email-form.tsx | 1 - .../user/service.server.test-disabled.ts | 236 ++++++++++++++++++ app/modules/user/service.server.test.ts | 228 ----------------- app/root.tsx | 14 +- app/routes/_index.tsx | 4 +- app/routes/forgot-password.tsx | 7 +- app/routes/healthcheck.tsx | 4 +- app/routes/join.tsx | 15 +- app/routes/login.tsx | 14 +- app/routes/logout.tsx | 4 +- app/routes/notes.$noteId.tsx | 22 +- app/routes/notes._index.tsx | 4 +- app/routes/notes.new.tsx | 4 +- app/routes/notes.tsx | 4 +- app/routes/oauth.callback.tsx | 8 +- app/routes/reset-password.tsx | 6 +- app/routes/send-magic-link.tsx | 4 +- package.json | 87 ++++--- postcss.config.mjs | 6 + remix.config.js | 10 - supabase/.gitignore | 4 + supabase/config.toml | 149 +++++++++++ supabase/readme.md | 34 +++ supabase/seed.sql | 0 vitest.config.ts | 2 +- 27 files changed, 533 insertions(+), 349 deletions(-) create mode 100644 app/modules/user/service.server.test-disabled.ts delete mode 100644 app/modules/user/service.server.test.ts create mode 100644 postcss.config.mjs create mode 100644 supabase/.gitignore create mode 100644 supabase/config.toml create mode 100644 supabase/readme.md create mode 100644 supabase/seed.sql diff --git a/app/entry.server.tsx b/app/entry.server.tsx index dcaf358..916cb80 100644 --- a/app/entry.server.tsx +++ b/app/entry.server.tsx @@ -1,8 +1,9 @@ import { PassThrough } from "stream"; -import { Response } from "@remix-run/node"; -import type { EntryContext } from "@remix-run/node"; -import { RemixServer } from "@remix-run/react"; +import { + createReadableStreamFromReadable, + type EntryContext, +} from "@remix-run/node";import { RemixServer } from "@remix-run/react"; import isbot from "isbot"; import { renderToPipeableStream } from "react-dom/server"; import { I18nextProvider } from "react-i18next"; @@ -38,11 +39,12 @@ export default async function handleRequest( { [callbackName]() { const body = new PassThrough(); + const stream = createReadableStreamFromReadable(body); responseHeaders.set("Content-Type", "text/html"); res( - new Response(body, { + new Response(stream, { status: didError ? 500 : responseStatusCode, headers: responseHeaders, }), diff --git a/app/integrations/i18n/i18next.server.tsx b/app/integrations/i18n/i18next.server.tsx index 0f592aa..bd304c3 100644 --- a/app/integrations/i18n/i18next.server.tsx +++ b/app/integrations/i18n/i18next.server.tsx @@ -24,7 +24,6 @@ export const i18nextServer = new RemixI18Next({ // 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, }); diff --git a/app/modules/auth/components/continue-with-email-form.tsx b/app/modules/auth/components/continue-with-email-form.tsx index c0d535f..49c0900 100644 --- a/app/modules/auth/components/continue-with-email-form.tsx +++ b/app/modules/auth/components/continue-with-email-form.tsx @@ -26,7 +26,6 @@ export function ContinueWithEmailForm() { ({ +// 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); +// }); +// }); diff --git a/app/modules/user/service.server.test.ts b/app/modules/user/service.server.test.ts deleted file mode 100644 index 295b318..0000000 --- a/app/modules/user/service.server.test.ts +++ /dev/null @@ -1,228 +0,0 @@ -import { matchRequestUrl, rest } from "msw"; - -import { server } from "mocks"; -import { - 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"; - -import { createUserAccount } from "./service.server"; - -// @vitest-environment node -// 👋 see https://vitest.dev/guide/environment.html#environments-for-specific-files - -// mock db -vitest.mock("~/database", () => ({ - 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); - }); -}); diff --git a/app/root.tsx b/app/root.tsx index c329990..92bd57d 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -1,8 +1,7 @@ -import { cssBundleHref } from "@remix-run/css-bundle"; import type { LinksFunction, LoaderFunction, - V2_MetaFunction as MetaFunction, + MetaFunction, } from "@remix-run/node"; import { json } from "@remix-run/node"; import { @@ -23,14 +22,7 @@ import tailwindStylesheetUrl from "./styles/tailwind.css"; import { getBrowserEnv } from "./utils/env"; export const links: LinksFunction = () => [ - { rel: "preload", href: tailwindStylesheetUrl, as: "style" }, - { rel: "stylesheet", href: tailwindStylesheetUrl, as: "style" }, - ...(cssBundleHref - ? [ - { rel: "preload", href: cssBundleHref, as: "style" }, - { rel: "stylesheet", href: cssBundleHref }, - ] - : []), + { rel: "stylesheet preload prefetch", href: tailwindStylesheetUrl, as: "style" }, ]; export const meta: MetaFunction = () => [ @@ -55,12 +47,12 @@ export default function App() { return ( - + diff --git a/app/routes/_index.tsx b/app/routes/_index.tsx index 6ae47a9..2123c54 100644 --- a/app/routes/_index.tsx +++ b/app/routes/_index.tsx @@ -1,11 +1,11 @@ -import type { LoaderArgs } from "@remix-run/node"; +import type { LoaderFunctionArgs } from "@remix-run/node"; import { json } from "@remix-run/node"; import { Link, useLoaderData } from "@remix-run/react"; import { useTranslation } from "react-i18next"; import { getAuthSession } from "~/modules/auth"; -export async function loader({ request }: LoaderArgs) { +export async function loader({ request }: LoaderFunctionArgs) { const { email } = (await getAuthSession(request)) || {}; return json({ email }); diff --git a/app/routes/forgot-password.tsx b/app/routes/forgot-password.tsx index 8832242..c901e83 100644 --- a/app/routes/forgot-password.tsx +++ b/app/routes/forgot-password.tsx @@ -1,4 +1,5 @@ -import type { ActionArgs, LoaderArgs } from "@remix-run/node"; +import type { ActionFunctionArgs, + LoaderFunctionArgs, } from "@remix-run/node"; import { json, redirect } from "@remix-run/node"; import { Form, useActionData, useNavigation } from "@remix-run/react"; import { useTranslation } from "react-i18next"; @@ -9,7 +10,7 @@ import { i18nextServer } from "~/integrations/i18n"; import { getAuthSession, sendResetPasswordLink } from "~/modules/auth"; import { assertIsPost, isFormProcessing, tw } from "~/utils"; -export async function loader({ request }: LoaderArgs) { +export async function loader({ request }: LoaderFunctionArgs) { const authSession = await getAuthSession(request); const t = await i18nextServer.getFixedT(request, "auth"); const title = t("login.forgotPassword"); @@ -26,7 +27,7 @@ const ForgotPasswordSchema = z.object({ .transform((email) => email.toLowerCase()), }); -export async function action({ request }: ActionArgs) { +export async function action({ request }: ActionFunctionArgs) { assertIsPost(request); const formData = await request.formData(); diff --git a/app/routes/healthcheck.tsx b/app/routes/healthcheck.tsx index 0bad4a6..2c825c7 100644 --- a/app/routes/healthcheck.tsx +++ b/app/routes/healthcheck.tsx @@ -1,9 +1,9 @@ // learn more: https://fly.io/docs/reference/configuration/#services-http_checks -import type { LoaderArgs } from "@remix-run/node"; +import type { LoaderFunctionArgs } from "@remix-run/node"; import { db } from "~/database"; -export async function loader({ request }: LoaderArgs) { +export async function loader({ request }: LoaderFunctionArgs) { const host = request.headers.get("X-Forwarded-Host") ?? request.headers.get("host"); diff --git a/app/routes/join.tsx b/app/routes/join.tsx index 6b71942..5eec777 100644 --- a/app/routes/join.tsx +++ b/app/routes/join.tsx @@ -1,9 +1,9 @@ import * as React from "react"; import type { - ActionArgs, - LoaderArgs, - V2_MetaFunction as MetaFunction, + ActionFunctionArgs, + LoaderFunctionArgs, + MetaFunction, } from "@remix-run/node"; import { redirect, json } from "@remix-run/node"; import { Form, Link, useNavigation, useSearchParams } from "@remix-run/react"; @@ -20,7 +20,7 @@ import { import { getUserByEmail, createUserAccount } from "~/modules/user"; import { assertIsPost, isFormProcessing } from "~/utils"; -export async function loader({ request }: LoaderArgs) { +export async function loader({ request }: LoaderFunctionArgs) { const authSession = await getAuthSession(request); const t = await i18nextServer.getFixedT(request, "auth"); const title = t("register.title"); @@ -39,7 +39,7 @@ const JoinFormSchema = z.object({ redirectTo: z.string().optional(), }); -export async function action({ request }: ActionArgs) { +export async function action({ request }: ActionFunctionArgs) { assertIsPost(request); const formData = await request.formData(); const result = await JoinFormSchema.safeParseAsync(parseFormAny(formData)); @@ -80,9 +80,10 @@ export async function action({ request }: ActionArgs) { }); } -export const meta: MetaFunction = ({ data }) => [ + +export const meta: MetaFunction = ({ data }) => [ { - title: data.title, + title: data?.title, }, ]; diff --git a/app/routes/login.tsx b/app/routes/login.tsx index e28532e..b1c7f9d 100644 --- a/app/routes/login.tsx +++ b/app/routes/login.tsx @@ -1,9 +1,9 @@ import * as React from "react"; import type { - ActionArgs, - LoaderArgs, - V2_MetaFunction as MetaFunction, + ActionFunctionArgs, + LoaderFunctionArgs, + MetaFunction, } from "@remix-run/node"; import { json, redirect } from "@remix-run/node"; import { Form, Link, useNavigation, useSearchParams } from "@remix-run/react"; @@ -20,7 +20,7 @@ import { } from "~/modules/auth"; import { assertIsPost, isFormProcessing } from "~/utils"; -export async function loader({ request }: LoaderArgs) { +export async function loader({ request }: LoaderFunctionArgs) { const authSession = await getAuthSession(request); const t = await i18nextServer.getFixedT(request, "auth"); const title = t("login.title"); @@ -39,7 +39,7 @@ const LoginFormSchema = z.object({ redirectTo: z.string().optional(), }); -export async function action({ request }: ActionArgs) { +export async function action({ request }: ActionFunctionArgs) { assertIsPost(request); const formData = await request.formData(); const result = await LoginFormSchema.safeParseAsync(parseFormAny(formData)); @@ -71,9 +71,9 @@ export async function action({ request }: ActionArgs) { }); } -export const meta: MetaFunction = ({ data }) => [ +export const meta: MetaFunction = ({ data }) => [ { - title: data.title, + title: data?.title, }, ]; diff --git a/app/routes/logout.tsx b/app/routes/logout.tsx index a3aa4aa..36d706f 100644 --- a/app/routes/logout.tsx +++ b/app/routes/logout.tsx @@ -1,10 +1,10 @@ -import type { ActionArgs } from "@remix-run/node"; +import type { ActionFunctionArgs } from "@remix-run/node"; import { redirect } from "@remix-run/node"; import { destroyAuthSession } from "~/modules/auth"; import { assertIsPost } from "~/utils"; -export async function action({ request }: ActionArgs) { +export async function action({ request }: ActionFunctionArgs) { assertIsPost(request); return destroyAuthSession(request); diff --git a/app/routes/notes.$noteId.tsx b/app/routes/notes.$noteId.tsx index bc8dd93..e4f5975 100644 --- a/app/routes/notes.$noteId.tsx +++ b/app/routes/notes.$noteId.tsx @@ -1,12 +1,12 @@ -import type { ActionArgs, LoaderArgs } from "@remix-run/node"; +import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node"; import { redirect, json } from "@remix-run/node"; -import { Form, useCatch, useLoaderData } from "@remix-run/react"; +import { Form, useLoaderData, useRouteError } from "@remix-run/react"; import { requireAuthSession, commitAuthSession } from "~/modules/auth"; import { deleteNote, getNote } from "~/modules/note"; import { assertIsDelete, getRequiredParam } from "~/utils"; -export async function loader({ request, params }: LoaderArgs) { +export async function loader({ request, params }: LoaderFunctionArgs) { const { userId } = await requireAuthSession(request); const id = getRequiredParam(params, "noteId"); @@ -18,7 +18,7 @@ export async function loader({ request, params }: LoaderArgs) { return json({ note }); } -export async function action({ request, params }: ActionArgs) { +export async function action({ request, params }: ActionFunctionArgs) { assertIsDelete(request); const id = getRequiredParam(params, "noteId"); const authSession = await requireAuthSession(request); @@ -52,16 +52,10 @@ export default function NoteDetailsPage() { ); } -export function ErrorBoundary({ error }: { error: Error }) { - return
An unexpected error occurred: {error.message}
; -} +export function ErrorBoundary() { + const error = useRouteError(); -export function CatchBoundary() { - const caught = useCatch(); + return
{JSON.stringify(error, null, 2)}
; +} - if (caught.status === 404) { - return
Note not found
; - } - throw new Error(`Unexpected caught response with status: ${caught.status}`); -} diff --git a/app/routes/notes._index.tsx b/app/routes/notes._index.tsx index 5bd933f..b7e294c 100644 --- a/app/routes/notes._index.tsx +++ b/app/routes/notes._index.tsx @@ -1,9 +1,9 @@ -import type { LoaderArgs } from "@remix-run/node"; +import type { LoaderFunctionArgs } from "@remix-run/node"; import { Link } from "@remix-run/react"; import { requireAuthSession } from "~/modules/auth"; -export async function loader({ request }: LoaderArgs) { +export async function loader({ request }: LoaderFunctionArgs) { await requireAuthSession(request); return null; diff --git a/app/routes/notes.new.tsx b/app/routes/notes.new.tsx index f894ed6..0c672bd 100644 --- a/app/routes/notes.new.tsx +++ b/app/routes/notes.new.tsx @@ -1,6 +1,6 @@ import * as React from "react"; -import type { LoaderArgs } from "@remix-run/node"; +import type { LoaderFunctionArgs } from "@remix-run/node"; import { json, redirect } from "@remix-run/node"; import { Form, useNavigation } from "@remix-run/react"; import { parseFormAny, useZorm } from "react-zorm"; @@ -15,7 +15,7 @@ export const NewNoteFormSchema = z.object({ body: z.string().min(1, "require-body"), }); -export async function action({ request }: LoaderArgs) { +export async function action({ request }: LoaderFunctionArgs) { assertIsPost(request); const authSession = await requireAuthSession(request); const formData = await request.formData(); diff --git a/app/routes/notes.tsx b/app/routes/notes.tsx index 0889d8b..7cc0e1f 100644 --- a/app/routes/notes.tsx +++ b/app/routes/notes.tsx @@ -1,4 +1,4 @@ -import type { LoaderArgs } from "@remix-run/node"; +import type { LoaderFunctionArgs } from "@remix-run/node"; import { json } from "@remix-run/node"; import { useLoaderData, Outlet, Link, NavLink } from "@remix-run/react"; @@ -6,7 +6,7 @@ import { LogoutButton, requireAuthSession } from "~/modules/auth"; import { getNotes } from "~/modules/note"; import { notFound } from "~/utils/http.server"; -export async function loader({ request }: LoaderArgs) { +export async function loader({ request }: LoaderFunctionArgs) { const { userId, email } = await requireAuthSession(request); const notes = await getNotes({ userId }); diff --git a/app/routes/oauth.callback.tsx b/app/routes/oauth.callback.tsx index fd960db..ad80a5c 100644 --- a/app/routes/oauth.callback.tsx +++ b/app/routes/oauth.callback.tsx @@ -1,7 +1,7 @@ import { useEffect } from "react"; import { json, redirect } from "@remix-run/node"; -import type { LoaderArgs, ActionArgs } from "@remix-run/node"; +import type { LoaderFunctionArgs, ActionFunctionArgs } from "@remix-run/node"; import { useActionData, useFetcher, useSearchParams } from "@remix-run/react"; import { parseFormAny } from "react-zorm"; import { z } from "zod"; @@ -17,7 +17,7 @@ import { assertIsPost, safeRedirect } from "~/utils"; // imagine a user go back after OAuth login success or type this URL // we don't want him to fall in a black hole 👽 -export async function loader({ request }: LoaderArgs) { +export async function loader({ request }: LoaderFunctionArgs) { const authSession = await getAuthSession(request); if (authSession) return redirect("/notes"); @@ -25,7 +25,7 @@ export async function loader({ request }: LoaderArgs) { return json({}); } -export async function action({ request }: ActionArgs) { +export async function action({ request }: ActionFunctionArgs) { assertIsPost(request); const formData = await request.formData(); @@ -120,7 +120,7 @@ export default function LoginCallback() { formData.append("refreshToken", refreshToken); formData.append("redirectTo", redirectTo); - fetcher.submit(formData, { method: "post", replace: true }); + fetcher.submit(formData, { method: "post", }); } }); diff --git a/app/routes/reset-password.tsx b/app/routes/reset-password.tsx index f3f2e60..5f45bff 100644 --- a/app/routes/reset-password.tsx +++ b/app/routes/reset-password.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from "react"; -import type { ActionArgs, LoaderArgs } from "@remix-run/node"; +import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node"; import { json, redirect } from "@remix-run/node"; import { Form, Link, useActionData, useNavigation } from "@remix-run/react"; import { useTranslation } from "react-i18next"; @@ -17,7 +17,7 @@ import { } from "~/modules/auth"; import { assertIsPost, isFormProcessing, tw } from "~/utils"; -export async function loader({ request }: LoaderArgs) { +export async function loader({ request }: LoaderFunctionArgs) { const authSession = await getAuthSession(request); const t = await i18nextServer.getFixedT(request, "auth"); const title = t("register.changePassword"); @@ -45,7 +45,7 @@ const ResetPasswordSchema = z return { password, confirmPassword, refreshToken }; }); -export async function action({ request }: ActionArgs) { +export async function action({ request }: ActionFunctionArgs) { assertIsPost(request); const formData = await request.formData(); diff --git a/app/routes/send-magic-link.tsx b/app/routes/send-magic-link.tsx index 457df0b..6c3d6be 100644 --- a/app/routes/send-magic-link.tsx +++ b/app/routes/send-magic-link.tsx @@ -1,4 +1,4 @@ -import type { ActionArgs } from "@remix-run/node"; +import type { ActionFunctionArgs } from "@remix-run/node"; import { json } from "@remix-run/node"; import { parseFormAny } from "react-zorm"; import { z } from "zod"; @@ -6,7 +6,7 @@ import { z } from "zod"; import { sendMagicLink } from "~/modules/auth"; import { assertIsPost } from "~/utils/http.server"; -export async function action({ request }: ActionArgs) { +export async function action({ request }: ActionFunctionArgs) { assertIsPost(request); const formData = await request.formData(); diff --git a/package.json b/package.json index 3259ce0..6f94a39 100644 --- a/package.json +++ b/package.json @@ -25,64 +25,69 @@ "postinstall": "prisma generate" }, "dependencies": { - "@prisma/client": "^5.2.0", - "@remix-run/css-bundle": "^1.19.3", + "@prisma/client": "^5.7.1", "@remix-run/node": "*", "@remix-run/react": "*", "@remix-run/serve": "*", - "@supabase/supabase-js": "^2.33.1", - "cookie": "^0.5.0", - "i18next": "^21.9.1", - "i18next-browser-languagedetector": "^6.1.5", - "i18next-fs-backend": "^1.1.5", - "i18next-http-backend": "^1.4.1", - "isbot": "^3.6.13", + "@supabase/supabase-js": "^2.39.1", + "autoprefixer": "^10.4.16", + "cookie": "^0.6.0", + "i18next": "^23.7.11", + "i18next-browser-languagedetector": "^7.2.0", + "i18next-fs-backend": "^2.3.1", + "i18next-http-backend": "^2.4.2", + "isbot": "^3.7.1", "react": "^18.2.0", "react-dom": "^18.2.0", - "react-i18next": "^11.18.5", + "react-i18next": "^13.5.0", "react-zorm": "^0.9.0", - "remix-i18next": "^4.1.1", - "tailwind-merge": "^1.14.0", - "zod": "^3.22.2" + "remix-i18next": "^5.4.0", + "tailwind-merge": "^2.1.0", + "zod": "^3.22.4" }, "devDependencies": { - "@faker-js/faker": "^8.0.2", + "@faker-js/faker": "^8.3.1", "@remix-run/dev": "*", - "@remix-run/eslint-config": "1.18.1", - "@tailwindcss/forms": "^0.5.6", - "@tailwindcss/typography": "^0.5.9", - "@testing-library/cypress": "^9.0.0", - "@testing-library/jest-dom": "^6.0.1", - "@testing-library/react": "^14.0.0", - "@testing-library/user-event": "^14.4.3", - "@types/i18next-fs-backend": "^1.1.2", - "@types/react": "^18.2.21", - "@types/react-dom": "^18.2.7", - "@vitejs/plugin-react": "^4.0.4", - "@vitest/coverage-v8": "^0.34.3", + "@remix-run/eslint-config": "*", + "@tailwindcss/forms": "^0.5.7", + "@tailwindcss/typography": "^0.5.10", + "@testing-library/cypress": "^10.0.1", + "@testing-library/jest-dom": "^6.1.5", + "@testing-library/react": "^14.1.2", + "@testing-library/user-event": "^14.5.1", + "@types/i18next-fs-backend": "^1.1.5", + "@types/react": "^18.2.45", + "@types/react-dom": "^18.2.18", + "@vitejs/plugin-react": "^4.2.1", + "@vitest/coverage-v8": "^1.1.0", "cross-env": "^7.0.3", - "cypress": "^13.0.0", + "cypress": "^13.6.1", "dotenv-cli": "^7.3.0", - "eslint": "^8.48.0", - "eslint-config-prettier": "^9.0.0", + "eslint": "^8.56.0", + "eslint-config-prettier": "^9.1.0", "eslint-plugin-tailwindcss": "^3.13.0", - "happy-dom": "^10.11.2", - "msw": "^1.2.5", + "happy-dom": "^12.10.3", + "msw": "^2.0.11", "npm-run-all": "^4.1.5", - "prettier": "3.0.3", - "prisma": "^5.2.0", - "start-server-and-test": "^2.0.0", + "prettier": "3.1.1", + "prisma": "^5.7.1", + "start-server-and-test": "^2.0.3", "tailwind-scrollbar": "^3.0.5", - "tailwindcss": "^3.3.2", - "ts-node": "^10.9.1", + "tailwindcss": "^3.4.0", + "ts-node": "^10.9.2", "tsconfig-paths": "^4.2.0", - "typescript": "^5.2.2", - "vite": "^4.4.9", - "vite-tsconfig-paths": "^4.2.0", - "vitest": "^0.34.3" + "typescript": "^5.3.3", + "vite": "^5.0.10", + "vite-tsconfig-paths": "^4.2.2", + "vitest": "^1.1.0" + }, + "overrides": { + "msw": { + "typescript": "$typescript" + } }, "engines": { - "node": ">=18" + "node": ">=20" }, "prisma": { "schema": "app/database/schema.prisma", diff --git a/postcss.config.mjs b/postcss.config.mjs new file mode 100644 index 0000000..7b75c83 --- /dev/null +++ b/postcss.config.mjs @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/remix.config.js b/remix.config.js index a4f2404..fffc7ab 100644 --- a/remix.config.js +++ b/remix.config.js @@ -4,16 +4,6 @@ module.exports = { ignoredRouteFiles: ["**/.*"], serverModuleFormat: "cjs", - future: { - v2_dev: true, - v2_errorBoundary: true, - v2_headers: true, - v2_meta: true, - v2_normalizeFormMethod: true, - v2_routeConvention: true, - }, - tailwind: true, - postcss: true, watchPaths: ["./tailwind.config.ts"], serverPlatform: "node", }; diff --git a/supabase/.gitignore b/supabase/.gitignore new file mode 100644 index 0000000..a3ad880 --- /dev/null +++ b/supabase/.gitignore @@ -0,0 +1,4 @@ +# Supabase +.branches +.temp +.env diff --git a/supabase/config.toml b/supabase/config.toml new file mode 100644 index 0000000..8ea2238 --- /dev/null +++ b/supabase/config.toml @@ -0,0 +1,149 @@ +# A string used to distinguish different Supabase projects on the same host. Defaults to the +# working directory name when running `supabase init`. +project_id = "supa-fly-stack" + +[api] +enabled = true +# Port to use for the API URL. +port = 54321 +# Schemas to expose in your API. Tables, views and stored procedures in this schema will get API +# endpoints. public and storage are always included. +schemas = ["public", "storage", "graphql_public"] +# Extra schemas to add to the search_path of every request. public is always included. +extra_search_path = ["public", "extensions"] +# The maximum number of rows returns from a view, table, or stored procedure. Limits payload size +# for accidental or malicious requests. +max_rows = 1000 + +[db] +# Port to use for the local database URL. +port = 54322 +# Port used by db diff command to initialize the shadow database. +shadow_port = 54320 +# The database major version to use. This has to be the same as your remote database's. Run `SHOW +# server_version;` on the remote database to check. +major_version = 15 + +[db.pooler] +enabled = false +# Port to use for the local connection pooler. +port = 54329 +# Specifies when a server connection can be reused by other clients. +# Configure one of the supported pooler modes: `transaction`, `session`. +pool_mode = "transaction" +# How many server connections to allow per user/database pair. +default_pool_size = 20 +# Maximum number of client connections allowed. +max_client_conn = 100 + +[realtime] +enabled = true +# Bind realtime via either IPv4 or IPv6. (default: IPv6) +# ip_version = "IPv6" + +[studio] +enabled = true +# Port to use for Supabase Studio. +port = 54323 +# External URL of the API server that frontend connects to. +api_url = "http://127.0.0.1" + +# Email testing server. Emails sent with the local dev setup are not actually sent - rather, they +# are monitored, and you can view the emails that would have been sent from the web interface. +[inbucket] +enabled = true +# Port to use for the email testing server web interface. +port = 54324 +# Uncomment to expose additional ports for testing user applications that send emails. +# smtp_port = 54325 +# pop3_port = 54326 + +[storage] +enabled = true +# The maximum file size allowed (e.g. "5MB", "500KB"). +file_size_limit = "50MiB" + +[auth] +enabled = true +# The base URL of your website. Used as an allow-list for redirects and for constructing URLs used +# in emails. +site_url = "http://127.0.0.1:3000" +# A list of *exact* URLs that auth providers are permitted to redirect to post authentication. +additional_redirect_urls = ["https://127.0.0.1:3000"] +# How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 (1 week). +jwt_expiry = 3600 +# If disabled, the refresh token will never expire. +enable_refresh_token_rotation = true +# Allows refresh tokens to be reused after expiry, up to the specified interval in seconds. +# Requires enable_refresh_token_rotation = true. +refresh_token_reuse_interval = 10 +# Allow/disallow new user signups to your project. +enable_signup = true + +[auth.email] +# Allow/disallow new user signups via email to your project. +enable_signup = true +# If enabled, a user will be required to confirm any email change on both the old, and new email +# addresses. If disabled, only the new email is required to confirm. +double_confirm_changes = true +# If enabled, users need to confirm their email address before signing in. +enable_confirmations = false + +# Uncomment to customize email template +# [auth.email.template.invite] +# subject = "You have been invited" +# content_path = "./supabase/templates/invite.html" + +[auth.sms] +# Allow/disallow new user signups via SMS to your project. +enable_signup = true +# If enabled, users need to confirm their phone number before signing in. +enable_confirmations = false +# Template for sending OTP to users +template = "Your code is {{ .Code }} ." + +# Use pre-defined map of phone number to OTP for testing. +[auth.sms.test_otp] +# 4152127777 = "123456" + +# Configure one of the supported SMS providers: `twilio`, `twilio_verify`, `messagebird`, `textlocal`, `vonage`. +[auth.sms.twilio] +enabled = false +account_sid = "" +message_service_sid = "" +# DO NOT commit your Twilio auth token to git. Use environment variable substitution instead: +auth_token = "env(SUPABASE_AUTH_SMS_TWILIO_AUTH_TOKEN)" + +# Use an external OAuth provider. The full list of providers are: `apple`, `azure`, `bitbucket`, +# `discord`, `facebook`, `github`, `gitlab`, `google`, `keycloak`, `linkedin`, `notion`, `twitch`, +# `twitter`, `slack`, `spotify`, `workos`, `zoom`. +[auth.external.apple] +enabled = false +client_id = "" +# DO NOT commit your OAuth provider secret to git. Use environment variable substitution instead: +secret = "env(SUPABASE_AUTH_EXTERNAL_APPLE_SECRET)" +# Overrides the default auth redirectUrl. +redirect_uri = "" +# Overrides the default auth provider URL. Used to support self-hosted gitlab, single-tenant Azure, +# or any other third-party OIDC providers. +url = "" + +[analytics] +enabled = false +port = 54327 +vector_port = 54328 +# Configure one of the supported backends: `postgres`, `bigquery`. +backend = "postgres" + +# Experimental features may be deprecated any time +[experimental] +# Configures Postgres storage engine to use OrioleDB (S3) +orioledb_version = "" +# Configures S3 bucket URL, eg. .s3-accelerate.amazonaws.com +s3_host = "env(S3_HOST)" +# Configures S3 bucket region, eg. us-east-1 +s3_region = "env(S3_REGION)" +# Configures AWS_ACCESS_KEY_ID for S3 bucket +s3_access_key = "env(S3_ACCESS_KEY)" +# Configures AWS_SECRET_ACCESS_KEY for S3 bucket +s3_secret_key = "env(S3_SECRET_KEY)" diff --git a/supabase/readme.md b/supabase/readme.md new file mode 100644 index 0000000..2cd8556 --- /dev/null +++ b/supabase/readme.md @@ -0,0 +1,34 @@ +This is to run Supabase locally. + +> Doc: https://supabase.com/docs/guides/cli/local-development + +### Start Supabase services +```bash +supabase start +``` +Once all of the Supabase services are running, you'll see output containing your local Supabase credentials. +It should look like this, with urls and keys that you'll use in the project: + +```txt +Started supabase local development setup. + + API URL: http://127.0.0.1:54321 + GraphQL URL: http://127.0.0.1:54321/graphql/v1 + DB URL: postgresql://postgres:postgres@127.0.0.1:54322/postgres + Studio URL: http://127.0.0.1:54323 + Inbucket URL: http://127.0.0.1:54324 + JWT secret: super-secret-jwt-token-with-at-least-32-characters-long + anon key: eyJhbGci..... +service_role key: eyJhbGci.... +``` + +### Copy the `.env.example` file to `.env` + +In your `.env` file, set the following environment variables (from the output above): +- `DATABASE_URL` ➡️ `DB URL` +- `SUPABASE_ANON_PUBLIC` ➡️ `anon key` + > [This public sharable key is used in combination with RLS.](https://supabase.com/docs/guides/api/api-keys#the-anon-key) +- `SUPABASE_SERVICE_ROLE` ➡️ `service_role key` + > [This private secret key bypass RLS](https://supabase.com/docs/guides/api/api-keys#the-servicerole-key) +- `SUPABASE_URL` ➡️ `API URL` + > Used by Supabase SDK. diff --git a/supabase/seed.sql b/supabase/seed.sql new file mode 100644 index 0000000..e69de29 diff --git a/vitest.config.ts b/vitest.config.ts index 3cb46a5..36d4e79 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -9,7 +9,7 @@ export default defineConfig({ test: { globals: true, environment: "happy-dom", - setupFiles: ["./test/setup-test-env.ts"], + // setupFiles: ["./test/setup-test-env.ts"], DISABLED because I have not had the time to upgrade MSW mocks includeSource: ["app/**/*.{js,ts}"], exclude: ["node_modules", "mocks/**/*.{js,ts}"], coverage: {