diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..91077d06 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +/node_modules +*.log +.DS_Store +.env +/.cache +/public/build +/build diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..0d0e0d65 --- /dev/null +++ b/.env.example @@ -0,0 +1,2 @@ +DATABASE_URL="file:./data.db?connection_limit=1" +SESSION_SECRET="super-duper-s3cret" diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 00000000..b6f94a5b --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,87 @@ +const testingLibraryRules = { + "testing-library/await-async-query": "error", + "testing-library/await-async-utils": "error", + "testing-library/no-await-sync-events": "error", + "testing-library/no-await-sync-query": "error", + "testing-library/no-debugging-utils": "warn", + "testing-library/no-dom-import": ["error", "react"], + "testing-library/no-promise-in-fire-event": "error", + "testing-library/no-render-in-setup": "error", + "testing-library/no-unnecessary-act": "error", + "testing-library/no-wait-for-empty-callback": "error", + "testing-library/no-wait-for-multiple-assertions": "error", + "testing-library/no-wait-for-side-effects": "error", + "testing-library/no-wait-for-snapshot": "error", + "testing-library/prefer-find-by": "error", + "testing-library/prefer-presence-queries": "error", + "testing-library/prefer-query-by-disappearance": "error", + "testing-library/prefer-screen-queries": "warn", + "testing-library/prefer-user-event": "warn", + "testing-library/prefer-wait-for": "error", + "testing-library/render-result-naming-convention": "warn", +}; + +/** + * @type {import('@types/eslint').Linter.BaseConfig} + */ +module.exports = { + extends: ["@remix-run/eslint-config", "prettier"], + rules: { + // having a type the same name as a variable is totally fine + "@typescript-eslint/no-redeclare": "off", + "@typescript-eslint/consistent-type-imports": "error", + }, + overrides: [ + { + files: ["**/*.test.{js,jsx,ts,tsx}"], + env: { + "jest/globals": true, + }, + plugins: ["jest-dom", "jest", "testing-library"], + settings: { + jest: { + version: 27, + }, + }, + rules: { + "jest/no-conditional-expect": "error", + "jest/no-deprecated-functions": "error", + "jest/no-disabled-tests": "warn", + "jest/no-export": "error", + "jest/no-focused-tests": "error", + "jest/no-identical-title": "error", + "jest/no-if": "error", + "jest/no-interpolation-in-snapshots": "error", + "jest/no-large-snapshots": ["warn", { maxSize: 300 }], + "jest/no-mocks-import": "error", + "jest/valid-describe-callback": "error", + "jest/valid-expect": "error", + "jest/valid-expect-in-promise": "error", + "jest/valid-title": "warn", + + "jest-dom/prefer-checked": "error", + "jest-dom/prefer-empty": "error", + "jest-dom/prefer-enabled-disabled": "error", + "jest-dom/prefer-focus": "error", + "jest-dom/prefer-in-document": "error", + "jest-dom/prefer-required": "error", + "jest-dom/prefer-to-have-attribute": "error", + "jest-dom/prefer-to-have-class": "error", + "jest-dom/prefer-to-have-style": "error", + "jest-dom/prefer-to-have-text-content": "error", + "jest-dom/prefer-to-have-value": "error", + + ...testingLibraryRules, + }, + }, + { + files: ["cypress/**/*.{js,jsx,ts,tsx}"], + rules: { + ...testingLibraryRules, + // override these because they don't make sense in cypress: + "testing-library/prefer-screen-queries": "off", + "testing-library/await-async-query": "off", + }, + }, + ], +}; diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 00000000..0b2f0484 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,204 @@ +name: 🚀 Deploy +on: + push: + branches: + - main + - dev + pull_request: {} + +jobs: + lint: + name: ⬣ ESLint + runs-on: ubuntu-latest + steps: + - name: 🛑 Cancel Previous Runs + uses: styfle/cancel-workflow-action@0.9.1 + + - name: ⬇️ Checkout repo + uses: actions/checkout@v2 + + - name: ⎔ Setup node + uses: actions/setup-node@v2 + with: + node-version: 16 + + - name: 📥 Download deps + uses: bahmutov/npm-install@v1 + + - name: 🔬 Lint + run: npm run lint + + typecheck: + name: ʦ TypeScript + runs-on: ubuntu-latest + steps: + - name: 🛑 Cancel Previous Runs + uses: styfle/cancel-workflow-action@0.9.1 + + - name: ⬇️ Checkout repo + uses: actions/checkout@v2 + + - name: ⎔ Setup node + uses: actions/setup-node@v2 + with: + node-version: 16 + + - name: 📥 Download deps + uses: bahmutov/npm-install@v1 + + - name: 🔎 Type check + run: npm run typecheck + + vitest: + name: ⚡ Vitest + runs-on: ubuntu-latest + steps: + - name: 🛑 Cancel Previous Runs + uses: styfle/cancel-workflow-action@0.9.1 + + - name: ⬇️ Checkout repo + uses: actions/checkout@v2 + + - name: ⎔ Setup node + uses: actions/setup-node@v2 + with: + node-version: 16 + + - name: 📥 Download deps + uses: bahmutov/npm-install@v1 + + - name: ⚡ Run vitest + run: npm run test -- --coverage + + cypress: + name: ⚫️ Cypress + runs-on: ubuntu-latest + steps: + - name: 🛑 Cancel Previous Runs + uses: styfle/cancel-workflow-action@0.9.1 + + - name: ⬇️ Checkout repo + uses: actions/checkout@v2 + + - name: 🏄 Copy test env vars + run: cp .env.example .env + + - name: ⎔ Setup node + uses: actions/setup-node@v2 + with: + node-version: 16 + + - name: 📥 Download deps + uses: bahmutov/npm-install@v1 + + - name: ⚙️ Build + run: npm run build + + - name: 🐳 Docker compose + run: docker-compose up -d && sleep 3 && npx prisma migrate reset --force + env: + DATABASE_URL: "postgresql://postgres:postgres@localhost:5432/postgres" + + - name: 🌳 Cypress run + uses: cypress-io/github-action@v2 + with: + start: npm run start:mocks + wait-on: "http://localhost:8811" + env: + PORT: "8811" + + build: + name: 🐳 Build + # only build/deploy main branch on pushes + if: ${{ (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev') && github.event_name == 'push' }} + runs-on: ubuntu-latest + steps: + - name: 🛑 Cancel Previous Runs + uses: styfle/cancel-workflow-action@0.9.1 + + - name: ⬇️ Checkout repo + uses: actions/checkout@v2 + + - name: 👀 Read app name + uses: SebRollen/toml-action@v1.0.0 + id: app_name + with: + file: "fly.toml" + field: "app" + + - name: 🐳 Set up Docker Buildx + uses: docker/setup-buildx-action@v1 + + # Setup cache + - name: ⚡️ Cache Docker layers + uses: actions/cache@v2 + with: + path: /tmp/.buildx-cache + key: ${{ runner.os }}-buildx-${{ github.sha }} + restore-keys: | + ${{ runner.os }}-buildx- + + - name: 🔑 Fly Registry Auth + uses: docker/login-action@v1 + with: + registry: registry.fly.io + username: x + password: ${{ secrets.FLY_API_TOKEN }} + + - name: 🐳 Docker build + uses: docker/build-push-action@v2 + with: + context: . + push: true + tags: registry.fly.io/${{ steps.app_name.outputs.value }}:${{ github.ref_name }}-${{ github.sha }} + build-args: | + COMMIT_SHA=${{ github.sha }} + cache-from: type=local,src=/tmp/.buildx-cache + cache-to: type=local,mode=max,dest=/tmp/.buildx-cache-new + + # This ugly bit is necessary if you don't want your cache to grow forever + # till it hits GitHub's limit of 5GB. + # Temp fix + # https://github.com/docker/build-push-action/issues/252 + # https://github.com/moby/buildkit/issues/1896 + - name: 🚚 Move cache + run: | + rm -rf /tmp/.buildx-cache + mv /tmp/.buildx-cache-new /tmp/.buildx-cache + + deploy: + name: 🚀 Deploy + runs-on: ubuntu-latest + needs: [lint, typecheck, vitest, cypress, build] + # only build/deploy main branch on pushes + if: ${{ (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev') && github.event_name == 'push' }} + + steps: + - name: 🛑 Cancel Previous Runs + uses: styfle/cancel-workflow-action@0.9.1 + + - name: ⬇️ Checkout repo + uses: actions/checkout@v2 + + - name: 👀 Read app name + uses: SebRollen/toml-action@v1.0.0 + id: app_name + with: + file: "fly.toml" + field: "app" + + - name: 🚀 Deploy Staging + if: ${{ github.ref == 'refs/heads/dev' }} + uses: superfly/flyctl-actions@1.1 + with: + args: "deploy --app ${{ steps.app_name.outputs.value }}-staging --image registry.fly.io/${{ steps.app_name.outputs.value }}:${{ github.ref_name }}-${{ github.sha }}" + env: + FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} + + - name: 🚀 Deploy Production + if: ${{ github.ref == 'refs/heads/main' }} + uses: superfly/flyctl-actions@1.1 + with: + args: "deploy --image registry.fly.io/${{ steps.app_name.outputs.value }}:${{ github.ref_name }}-${{ github.sha }}" + env: + FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..4876fdfd --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +node_modules +/build +/public/build +/prisma/data.db +/prisma/data.db-journal +.env +/cypress/screenshots +/cypress/videos diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 00000000..148f8e06 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,6 @@ +/node_modules +/.cache +/build +/public/build +/postgres-data +.env diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..06a5140b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,53 @@ +# base node image +FROM node:16-bullseye-slim as base + +# set for base and all layer that inherit from it +ENV NODE_ENV production + +# Install openssl for Prisma +RUN apt-get update && apt-get install -y openssl + +# Install all node_modules, including dev dependencies +FROM base as deps + +WORKDIR /myapp + +ADD package.json package-lock.json ./ +RUN npm install --production=false + +# Setup production node_modules +FROM base as production-deps + +WORKDIR /myapp + +COPY --from=deps /myapp/node_modules /myapp/node_modules +ADD package.json package-lock.json ./ +RUN npm prune --production + +# Build the app +FROM base as build + +WORKDIR /myapp + +COPY --from=deps /myapp/node_modules /myapp/node_modules + +ADD prisma . +RUN npx prisma generate + +ADD . . +RUN npm run postinstall +RUN npm run build + +# Finally, build the production image with minimal footprint +FROM base + +WORKDIR /myapp + +COPY --from=production-deps /myapp/node_modules /myapp/node_modules +COPY --from=build /myapp/node_modules/.prisma /myapp/node_modules/.prisma + +COPY --from=build /myapp/build /myapp/build +COPY --from=build /myapp/public /myapp/public +ADD . . + +CMD ["npm", "start"] diff --git a/README.md b/README.md new file mode 100644 index 00000000..382921f8 --- /dev/null +++ b/README.md @@ -0,0 +1,148 @@ +# Remix Indie Stack + +Learn more about [Remix Stacks](https://remix.run/stacks). + +## What's in the stack + +- [Fly app deployment](https://fly.io) with [Docker](https://www.docker.com/) +- Production-ready [SQLite Database](https://sqlite.org) +- 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) +- Database ORM with [Prisma](https://prisma.io) +- End-to-end testing with [Cypress](https://cypress.io) +- Local third party request mocking with [MSW](https://mswjs.io) +- Unit testing with [Vitest](https://vitest.dev) +- Code formatting with [prettier](https://prettier.io) +- Linting with [ESLint](https://eslint.org) +- Static Types with [TypeScript](https://typescriptlang.org) + +## Fly Setup + +1. [Install Fly](https://fly.io/docs/getting-started/installing-flyctl/) + +2. Sign up and log in to Fly + +```sh +fly auth signup +``` + +## The Database + +To get the database started, run this: + +```sh +npx prisma migrate deploy +``` + +When this finishes successfully, it will say: + +> All migrations have been successfully applied. + +## Build + +To run the production build for the app, run the following script: + +```sh +npm run build +``` + +This should take less than a second ⚡ + +## Development + +With your sqlite database setup with tables for your data model via prisma, you're ready to start the dev server. Run this in a new tab in your terminal: + +```sh +npm run dev +``` + +This starts your app in development mode, rebuilding assets on file changes. + +This is a pretty simple note-taking app, but it's a good example of how you can build a full stack app with Prisma and Remix. The main functionality is creating users, logging in and out, and creating and deleting notes. + +### Relevant code: + +- creating users, and logging in and out [./app/models/user.server.ts](./app/models/user.server.ts) +- user sessions, and verifying them [./app/session.server.ts](./app/session.server.ts) +- creating, and deleting notes [./app/models/note.server.ts](./app/models/note.server.ts) + +## Deployment + +This Remix Stack comes with two GitHub actions that handle automatically deploying your app to production and staging environments. + +Prior to your first deployment, you'll need to do a few thing: + +- Create a new [GitHub Repository](https://repo.new) + +- Create two apps on Fly, one for staging and one for production: + + ```sh + fly create indie-stack-template + fly create indie-stack-template-staging + ``` + +- Make sure you have a `FLY_API_TOKEN` added to your GitHub repo, to do this, go to your user settings on Fly and create a new [token](https://web.fly.io/user/personal_access_tokens/new), then add it to [your repo secrets](https://docs.github.com/en/actions/security-guides/encrypted-secrets) with the name `FLY_API_TOKEN`. Finally you'll need to add a `SESSION_SECRET` to your fly app secrets, to do this you can run the following commands: + + ```sh + fly secrets set SESSION_SECRET=$(openssl rand -hex 32) --app indie-stack-template + fly secrets set SESSION_SECRET=$(openssl rand -hex 32) --app indie-stack-template-staging + ``` + + If you don't have openssl installed, you can also use [1password](https://1password.com/generate-password) to generate a random secret, just replace `$(openssl rand -hex 32)` with the generated secret. + +- Create a persistent volume for the sqlite database for both your staging and production environments. Run the following: + + ```sh + fly volumes create data --size 1 --app indie-stack-template + fly volumes create data --size 1 --app indie-stack-template-staging + ``` + +Now that every is set up you can commit and push your changes to your repo. Every commit to your `main` branch will trigger a deployment to your production environment, and every commit to your `dev` branch will trigger a deployment to your staging environment. + +## GitHub Actions + +We use GitHub Actions for continuous integration and deployment. Anything that gets into the `main` branch will be deployed to production after running tests/build/etc. Anything in the `dev` branch will be deployed to staging. + +## Testing + +### Cypress + +We use Cypress for our End-to-End tests in this project. You'll find those in the `cypress` directory. As you make changes, add to an existing file or create a new file in the `cypress/e2e` directory to test your changes. + +We use [`@testing-library/cypress`](https://testing-library.com/cypress) for selecting elements on the page semantically. + +To run these tests in development, run `npm run test:e2e:dev` which will start the dev server for the app as well as the Cypress client. Make sure the database is running in docker as described above. + +We have a utility for testing authenticated features without having to go through the login flow: + +```ts +cy.login(); +// you are now logged in as a new user +``` + +We also have a utility to auto-delete the user at the end of your test. Just make sure to add this in each test file: + +```ts +afterEach(() => { + cy.cleanupUser(); +}); +``` + +That way, we can keep your local db clean and keep your tests isolated from one another. + +### Vitest + +For lower level tests of utilities and individual components, we use `vitest`. We have DOM-specific assertion helpers via [`@testing-library/jest-dom`](https://testing-library.com/jest-dom). + +### Type Checking + +This project uses TypeScript. It's recommended to get TypeScript set up for your editor to get a really great in-editor experience with type checking and auto-complete. To run type checking across the whole project, run `npm run typecheck`. + +### Linting + +This project uses ESLint for linting. That is configured in `.eslintrc.js`. + +### Formatting + +We use [prettier](https://prettier.io/) for auto-formatting in this project. It's recommended to install an editor plugin (like the [VSCode prettier plugin](https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode)) to get auto-formatting on save. There's also a `npm run format` script you can run to format all files in the project. diff --git a/app/db.server.ts b/app/db.server.ts new file mode 100644 index 00000000..843cc97e --- /dev/null +++ b/app/db.server.ts @@ -0,0 +1,23 @@ +import { PrismaClient } from "@prisma/client"; + +let prisma: PrismaClient; + +declare global { + var __db__: PrismaClient; +} + +// this is needed because in development we don't want to restart +// the server with every change, but we want to make sure we don't +// create a new connection to the DB with every change either. +// in production we'll have a single connection to the DB. +if (process.env.NODE_ENV === "production") { + prisma = new PrismaClient(); +} else { + if (!global.__db__) { + global.__db__ = new PrismaClient(); + } + prisma = global.__db__; + prisma.$connect(); +} + +export { prisma }; diff --git a/app/entry.client.tsx b/app/entry.client.tsx new file mode 100644 index 00000000..a19979b2 --- /dev/null +++ b/app/entry.client.tsx @@ -0,0 +1,4 @@ +import { hydrate } from "react-dom"; +import { RemixBrowser } from "remix"; + +hydrate(, document); diff --git a/app/entry.server.tsx b/app/entry.server.tsx new file mode 100644 index 00000000..cae20674 --- /dev/null +++ b/app/entry.server.tsx @@ -0,0 +1,21 @@ +import { renderToString } from "react-dom/server"; +import { RemixServer } from "remix"; +import type { EntryContext } from "remix"; + +export default function handleRequest( + request: Request, + responseStatusCode: number, + responseHeaders: Headers, + remixContext: EntryContext +) { + const markup = renderToString( + + ); + + responseHeaders.set("Content-Type", "text/html"); + + return new Response("" + markup, { + status: responseStatusCode, + headers: responseHeaders, + }); +} diff --git a/app/models/note.server.ts b/app/models/note.server.ts new file mode 100644 index 00000000..b24c52b7 --- /dev/null +++ b/app/models/note.server.ts @@ -0,0 +1,37 @@ +import { prisma } from "~/db.server"; + +export function getNote(userId: string, id: string) { + return prisma.note.findFirst({ + where: { id, userId }, + }); +} + +export function getNoteListItems(userId: string) { + return prisma.note.findMany({ + where: { userId: userId }, + select: { id: true, title: true }, + orderBy: { updatedAt: "desc" }, + }); +} + +export function createNote(title: string, body: string, userId: string) { + return prisma.note.create({ + data: { + title, + body, + user: { + connect: { + id: userId, + }, + }, + }, + }); +} + +export function deleteNote(userId: string, id: string) { + return prisma.note.deleteMany({ + where: { id, userId }, + }); +} + +export type { Note } from "@prisma/client"; diff --git a/app/models/user.server.ts b/app/models/user.server.ts new file mode 100644 index 00000000..794fac82 --- /dev/null +++ b/app/models/user.server.ts @@ -0,0 +1,53 @@ +import bcrypt from "@node-rs/bcrypt"; +import { prisma } from "~/db.server"; + +export async function getUserById(id: string) { + return prisma.user.findUnique({ where: { id } }); +} + +export async function getUserByEmail(email: string) { + return prisma.user.findUnique({ where: { email } }); +} + +export async function createUser(email: string, password: string) { + const hashedPassword = await bcrypt.hash(password, 10); + const user = await prisma.user.create({ + data: { + email, + password: { + create: { + hash: hashedPassword, + }, + }, + }, + }); + + return user; +} + +export async function deleteUserByEmail(email: string) { + return prisma.user.delete({ where: { email } }); +} + +export async function verifyLogin(email: string, password: string) { + const userWithPassword = await prisma.user.findUnique({ + where: { email }, + include: { + password: true, + }, + }); + + if (!userWithPassword || !userWithPassword.password) { + return null; + } + + const isValid = await bcrypt.verify(password, userWithPassword.password.hash); + + if (!isValid) { + return null; + } + + const { password: _password, ...userWithoutPassword } = userWithPassword; + + return userWithoutPassword; +} diff --git a/app/root.tsx b/app/root.tsx new file mode 100644 index 00000000..241d9246 --- /dev/null +++ b/app/root.tsx @@ -0,0 +1,54 @@ +import { + json, + Links, + LiveReload, + Meta, + Outlet, + Scripts, + ScrollRestoration, +} from "remix"; +import type { LinksFunction, MetaFunction, LoaderFunction } from "remix"; + +import appStyles from "./styles/app.css"; +import { getUser } from "./session.server"; + +export const links: LinksFunction = () => { + return [{ rel: "stylesheet", href: appStyles }]; +}; + +export const meta: MetaFunction = () => { + return { + title: "Remix Notes", + viewport: "width=device-width,initial-scale=1", + }; +}; + +type LoaderData = { + user: Awaited>; +}; + +export let loader: LoaderFunction = async ({ request }) => { + return json({ + user: await getUser(request), + }); +}; + +export default function App() { + return ( + + + + + + + +
+ +
+ + + + + + ); +} diff --git a/app/routes/healthcheck.tsx b/app/routes/healthcheck.tsx new file mode 100644 index 00000000..8ca82e9b --- /dev/null +++ b/app/routes/healthcheck.tsx @@ -0,0 +1,23 @@ +// learn more: https://fly.io/docs/reference/configuration/#services-http_checks +import type { LoaderFunction } from "remix"; +import { prisma } from "~/db.server"; + +export const loader: LoaderFunction = async ({ request }) => { + const host = + request.headers.get("X-Forwarded-Host") ?? request.headers.get("host"); + + try { + // if we can connect to the database and make a simple query + // and make a HEAD request to ourselves, then we're good. + await Promise.all([ + prisma.user.count(), + fetch(`http://${host}`, { method: "HEAD" }).then((r) => { + if (!r.ok) return Promise.reject(r); + }), + ]); + return new Response("OK"); + } catch (error: unknown) { + console.log("healthcheck ❌", { error }); + return new Response("ERROR", { status: 500 }); + } +}; diff --git a/app/routes/index.tsx b/app/routes/index.tsx new file mode 100644 index 00000000..6adac69e --- /dev/null +++ b/app/routes/index.tsx @@ -0,0 +1,54 @@ +import { Link } from "remix"; +import { useOptionalUser } from "~/utils"; + +export default function Index() { + const user = useOptionalUser(); + return ( +
+
+

Remix Notes App

+

+ This app will get you started with Remix in no time. +
+ {user ? ( + <> + Hello {user.email} 👋 + + ) : ( + `Feel free to login!` + )} +

+ {user ? ( + Check your notes + ) : ( + Login + )} +
+
+

+ Welcome to the Remix Fly Stack! +
+ Please check the README.md file for instructions on how to get this + project deployed. +

+

Learn more about Remix:

+ +
+
+ ); +} diff --git a/app/routes/join.tsx b/app/routes/join.tsx new file mode 100644 index 00000000..126a66a3 --- /dev/null +++ b/app/routes/join.tsx @@ -0,0 +1,185 @@ +import * as React from "react"; +import type { ActionFunction, LoaderFunction, MetaFunction } from "remix"; +import { + Form, + Link, + redirect, + useSearchParams, + json, + useActionData, +} from "remix"; +import Alert from "@reach/alert"; + +import { getUserId, createUserSession } from "~/session.server"; + +import { createUser, getUserByEmail } from "~/models/user.server"; +import { validateEmail } from "~/utils"; + +export const loader: LoaderFunction = async ({ request }) => { + const userId = await getUserId(request); + if (userId) return redirect("/"); + return {}; +}; + +interface ActionData { + errors: { + email?: string; + password?: string; + }; +} + +export const action: ActionFunction = async ({ request }) => { + const formData = await request.formData(); + const email = formData.get("email"); + const password = formData.get("password"); + const redirectTo = formData.get("redirectTo"); + + if (!validateEmail(email)) { + return json( + { errors: { email: "Email is invalid" } }, + { status: 400 } + ); + } + + if (typeof password !== "string" || password.length === 0) { + return json( + { errors: { password: "Password is required" } }, + { status: 400 } + ); + } + + const existingUser = await getUserByEmail(email); + if (existingUser) { + return json( + { errors: { email: "A user already exists with this email" } }, + { status: 400 } + ); + } + + const user = await createUser(email, password); + + return createUserSession( + request, + user.id, + typeof redirectTo === "string" ? redirectTo : "/" + ); +}; + +export const meta: MetaFunction = () => { + return { + title: "Join", + }; +}; + +export default function JoinPage() { + const [searchParams] = useSearchParams(); + const redirectTo = searchParams.get("redirectTo") ?? undefined; + const actionData = useActionData(); + const emailRef = React.useRef(null); + const passwordRef = React.useRef(null); + + React.useEffect(() => { + if (actionData?.errors?.email) { + emailRef.current?.focus(); + } else if (actionData?.errors?.password) { + passwordRef.current?.focus(); + } + }, [actionData]); + + return ( +
+

Join Remix Notes

+
+ +
+ + {actionData?.errors?.email && ( + + {actionData.errors.email} + + )} +
+ +
+ + {actionData?.errors?.password && ( + + {actionData.errors.password} + + )} +
+ +
+ +
+
+ +
+ Already have an account?{" "} + + Log in + +
+
+ ); +} diff --git a/app/routes/login.tsx b/app/routes/login.tsx new file mode 100644 index 00000000..2c1ace96 --- /dev/null +++ b/app/routes/login.tsx @@ -0,0 +1,182 @@ +import * as React from "react"; +import type { ActionFunction, LoaderFunction, MetaFunction } from "remix"; +import { + Form, + json, + Link, + useActionData, + redirect, + useSearchParams, +} from "remix"; +import Alert from "@reach/alert"; + +import { createUserSession, getUserId } from "~/session.server"; +import { verifyLogin } from "~/models/user.server"; +import { validateEmail } from "~/utils"; + +export const loader: LoaderFunction = async ({ request }) => { + const userId = await getUserId(request); + if (userId) return redirect("/"); + return {}; +}; + +interface ActionData { + errors?: { + email?: string; + password?: string; + }; +} + +export const action: ActionFunction = async ({ request }) => { + const formData = await request.formData(); + const email = formData.get("email"); + const password = formData.get("password"); + const redirectTo = formData.get("redirectTo"); + + if (!validateEmail(email)) { + return json( + { errors: { email: "Email is invalid" } }, + { status: 400 } + ); + } + + if (typeof password !== "string" || password.length === 0) { + return json( + { errors: { password: "Password is required" } }, + { status: 400 } + ); + } + + const user = await verifyLogin(email, password); + + if (!user) { + return json( + { errors: { email: "Invalid email or password" } }, + { status: 400 } + ); + } + + return createUserSession( + request, + user.id, + typeof redirectTo === "string" ? redirectTo : "/" + ); +}; + +export const meta: MetaFunction = () => { + return { + title: "Login", + }; +}; + +export default function LoginPage() { + const [searchParams] = useSearchParams(); + const redirectTo = searchParams.get("redirectTo") ?? undefined; + const actionData = useActionData(); + const emailRef = React.useRef(null); + const passwordRef = React.useRef(null); + + React.useEffect(() => { + if (actionData?.errors?.email) { + emailRef.current?.focus(); + } else if (actionData?.errors?.password) { + passwordRef.current?.focus(); + } + }, [actionData]); + + return ( +
+

Sign in to Remix Notes

+
+ +
+ + {actionData?.errors?.email && ( + + {actionData.errors.email} + + )} +
+ +
+ + {actionData?.errors?.password && ( + + {actionData.errors.password} + + )} +
+
+ +
+
+ +
+ Don't have an account?{" "} + + Sign up + +
+
+ ); +} diff --git a/app/routes/logout.tsx b/app/routes/logout.tsx new file mode 100644 index 00000000..17be85ff --- /dev/null +++ b/app/routes/logout.tsx @@ -0,0 +1,11 @@ +import type { ActionFunction, LoaderFunction } from "remix"; +import { redirect } from "remix"; +import { logout } from "~/session.server"; + +export const action: ActionFunction = async ({ request }) => { + return logout(request); +}; + +export const loader: LoaderFunction = async () => { + return redirect("/"); +}; diff --git a/app/routes/notes.tsx b/app/routes/notes.tsx new file mode 100644 index 00000000..3f97b0b4 --- /dev/null +++ b/app/routes/notes.tsx @@ -0,0 +1,58 @@ +import { Form, json, useLoaderData, Outlet, Link } from "remix"; +import type { LoaderFunction } from "remix"; + +import { requireUserId } from "~/session.server"; +import { useUser } from "~/utils"; +import { getNoteListItems } from "~/models/note.server"; + +type LoaderData = { + noteListItems: Awaited>; +}; + +export const loader: LoaderFunction = async ({ request }) => { + const userId = await requireUserId(request); + const noteListItems = await getNoteListItems(userId); + return json({ noteListItems }); +}; + +export default function NotesPage() { + const data = useLoaderData(); + const user = useUser(); + + return ( +
+
+

Your Notes

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

No notes yet

+ ) : ( +
    + {data.noteListItems.map((note) => ( +
  • + {note.title} +
  • + ))} +
+ )} +
+ Create new note +
+
+ +
+
+
+ ); +} diff --git a/app/routes/notes/$noteId.tsx b/app/routes/notes/$noteId.tsx new file mode 100644 index 00000000..bd6a2ff1 --- /dev/null +++ b/app/routes/notes/$noteId.tsx @@ -0,0 +1,62 @@ +import type { LoaderFunction, ActionFunction } from "remix"; +import { redirect } from "remix"; +import { json, useLoaderData, useCatch, Form } from "remix"; +import invariant from "tiny-invariant"; +import type { Note } from "~/models/note.server"; +import { deleteNote } from "~/models/note.server"; +import { getNote } from "~/models/note.server"; +import { requireUserId } from "~/session.server"; + +type LoaderData = { + note: Note; +}; + +export const loader: LoaderFunction = async ({ request, params }) => { + const userId = await requireUserId(request); + invariant(params.noteId, "noteId not found"); + + const note = await getNote(userId, params.noteId); + if (!note) { + throw new Response("Not Found", { status: 404 }); + } + return json({ note }); +}; + +export const action: ActionFunction = async ({ request, params }) => { + const userId = await requireUserId(request); + invariant(params.noteId, "noteId not found"); + + await deleteNote(userId, params.noteId); + + return redirect("/notes"); +}; + +export default function NoteDetailsPage() { + const data = useLoaderData(); + + return ( +
+

{data.note.title}

+

{data.note.body}

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

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

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