├── .dockerignore ├── .env.example ├── .eslintrc.js ├── .github └── workflows │ └── deploy.yml ├── .gitignore ├── .prettierignore ├── Dockerfile ├── README.md ├── app ├── db.server.ts ├── entry.client.tsx ├── entry.server.tsx ├── models │ └── user.server.ts ├── root.tsx ├── routes │ ├── index.tsx │ ├── login.tsx │ └── logout.tsx ├── session.server.ts └── utils.ts ├── fly.toml ├── package.json ├── prisma ├── migrations │ ├── 20220714230702_init │ │ └── migration.sql │ └── migration_lock.toml ├── schema.prisma └── seed.ts ├── public └── favicon.ico ├── remix.config.js ├── remix.env.d.ts ├── remix.init ├── gitignore ├── index.js └── package.json ├── start.sh ├── tailwind.config.js └── tsconfig.json /.dockerignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | *.log 3 | .DS_Store 4 | .env 5 | /.cache 6 | /public/build 7 | /build 8 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | DATABASE_URL="file:./data.db?connection_limit=1" 2 | SESSION_SECRET="super-duper-s3cret" 3 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @type {import('@types/eslint').Linter.BaseConfig} 3 | */ 4 | module.exports = { 5 | extends: [ 6 | "@remix-run/eslint-config", 7 | "@remix-run/eslint-config/node", 8 | "@remix-run/eslint-config/jest-testing-library", 9 | "prettier", 10 | ], 11 | // we're using vitest which has a very similar API to jest 12 | // (so the linting plugins work nicely), but it means we have to explicitly 13 | // set the jest version. 14 | settings: { 15 | jest: { 16 | version: 27, 17 | }, 18 | }, 19 | }; 20 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: 🚀 Deploy 2 | on: 3 | push: 4 | branches: 5 | - main 6 | - dev 7 | pull_request: {} 8 | permissions: 9 | actions: write 10 | contents: read 11 | 12 | jobs: 13 | build: 14 | name: 🐳 Build 15 | # only build/deploy main branch on pushes 16 | if: ${{ (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev') && github.event_name == 'push' }} 17 | runs-on: ubuntu-latest 18 | steps: 19 | - name: ⬇️ Checkout repo 20 | uses: actions/checkout@v3 21 | 22 | - name: 👀 Read app name 23 | uses: SebRollen/toml-action@v1.0.0 24 | id: app_name 25 | with: 26 | file: "fly.toml" 27 | field: "app" 28 | 29 | - name: 🐳 Set up Docker Buildx 30 | uses: docker/setup-buildx-action@v2 31 | 32 | # Setup cache 33 | - name: ⚡️ Cache Docker layers 34 | uses: actions/cache@v3 35 | with: 36 | path: /tmp/.buildx-cache 37 | key: ${{ runner.os }}-buildx-${{ github.sha }} 38 | restore-keys: | 39 | ${{ runner.os }}-buildx- 40 | 41 | - name: 🔑 Fly Registry Auth 42 | uses: docker/login-action@v2 43 | with: 44 | registry: registry.fly.io 45 | username: x 46 | password: ${{ secrets.FLY_API_TOKEN }} 47 | 48 | - name: 🐳 Docker build 49 | uses: docker/build-push-action@v3 50 | with: 51 | context: . 52 | push: true 53 | tags: registry.fly.io/${{ steps.app_name.outputs.value }}:${{ github.ref_name }}-${{ github.sha }} 54 | build-args: | 55 | COMMIT_SHA=${{ github.sha }} 56 | cache-from: type=local,src=/tmp/.buildx-cache 57 | cache-to: type=local,mode=max,dest=/tmp/.buildx-cache-new 58 | 59 | # This ugly bit is necessary if you don't want your cache to grow forever 60 | # till it hits GitHub's limit of 5GB. 61 | # Temp fix 62 | # https://github.com/docker/build-push-action/issues/252 63 | # https://github.com/moby/buildkit/issues/1896 64 | - name: 🚚 Move cache 65 | run: | 66 | rm -rf /tmp/.buildx-cache 67 | mv /tmp/.buildx-cache-new /tmp/.buildx-cache 68 | 69 | deploy: 70 | name: 🚀 Deploy 71 | runs-on: ubuntu-latest 72 | needs: [build] 73 | # only build/deploy main branch on pushes 74 | if: ${{ (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev') && github.event_name == 'push' }} 75 | 76 | steps: 77 | - name: ⬇️ Checkout repo 78 | uses: actions/checkout@v3 79 | 80 | - name: 👀 Read app name 81 | uses: SebRollen/toml-action@v1.0.0 82 | id: app_name 83 | with: 84 | file: "fly.toml" 85 | field: "app" 86 | 87 | - name: 🚀 Deploy Production 88 | if: ${{ github.ref == 'refs/heads/main' }} 89 | uses: superfly/flyctl-actions@1.3 90 | with: 91 | args: "deploy --image registry.fly.io/${{ steps.app_name.outputs.value }}:${{ github.ref_name }}-${{ github.sha }}" 92 | env: 93 | FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} 94 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # We don't want lockfiles in stacks, as people could use a different package manager 2 | # This part will be removed by `remix.init` 3 | package-lock.json 4 | yarn.lock 5 | pnpm-lock.yaml 6 | pnpm-lock.yml 7 | 8 | node_modules 9 | 10 | /build 11 | /public/build 12 | .env 13 | 14 | /cypress/screenshots 15 | /cypress/videos 16 | /prisma/data.db 17 | /prisma/data.db-journal 18 | 19 | /app/styles/tailwind.css 20 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | /build 4 | /public/build 5 | .env 6 | 7 | /app/styles/tailwind.css 8 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # base node image 2 | FROM node:16-bullseye-slim as base 3 | 4 | # set for base and all layer that inherit from it 5 | ENV NODE_ENV production 6 | 7 | # Install openssl for Prisma 8 | RUN apt-get update && apt-get install -y openssl sqlite3 9 | 10 | # Install all node_modules, including dev dependencies 11 | FROM base as deps 12 | 13 | WORKDIR /myapp 14 | 15 | ADD package.json ./ 16 | RUN npm install --production=false 17 | 18 | # Setup production node_modules 19 | FROM base as production-deps 20 | 21 | WORKDIR /myapp 22 | 23 | COPY --from=deps /myapp/node_modules /myapp/node_modules 24 | ADD package.json ./ 25 | RUN npm prune --production 26 | 27 | # Build the app 28 | FROM base as build 29 | 30 | WORKDIR /myapp 31 | 32 | COPY --from=deps /myapp/node_modules /myapp/node_modules 33 | 34 | ADD prisma . 35 | RUN npx prisma generate 36 | 37 | ADD . . 38 | RUN npm run build 39 | 40 | # Finally, build the production image with minimal footprint 41 | FROM base 42 | 43 | ENV DATABASE_URL=file:/data/sqlite.db 44 | ENV PORT="8080" 45 | ENV NODE_ENV="production" 46 | 47 | # add shortcut for connecting to database CLI 48 | RUN echo "#!/bin/sh\nset -x\nsqlite3 \$DATABASE_URL" > /usr/local/bin/database-cli && chmod +x /usr/local/bin/database-cli 49 | 50 | WORKDIR /myapp 51 | 52 | COPY --from=production-deps /myapp/node_modules /myapp/node_modules 53 | COPY --from=build /myapp/node_modules/.prisma /myapp/node_modules/.prisma 54 | 55 | COPY --from=build /myapp/build /myapp/build 56 | COPY --from=build /myapp/public /myapp/public 57 | COPY --from=build /myapp/package.json /myapp/package.json 58 | COPY --from=build /myapp/start.sh /myapp/start.sh 59 | COPY --from=build /myapp/prisma /myapp/prisma 60 | 61 | ENTRYPOINT [ "./start.sh" ] 62 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # KCD Quick Stack 2 | 3 | ![The KCD Quick Stack](https://user-images.githubusercontent.com/1500684/179116947-2130811f-8355-4682-b09b-b222eba0586e.png) 4 | 5 | The primary use of this stack is for Kent to quickly setup new Remix apps that have no more than the bare necessities. 6 | 7 | Learn more about [Remix Stacks](https://remix.run/stacks). 8 | 9 | ``` 10 | npx create-remix --template kentcdodds/quick-stack 11 | ``` 12 | 13 | ## What's in the stack 14 | 15 | - [Fly app deployment](https://fly.io) with [Docker](https://www.docker.com/) 16 | - Production-ready [SQLite Database](https://sqlite.org) 17 | - [GitHub Actions](https://github.com/features/actions) for deploy on merge to production 18 | - Email/Password Authentication with [cookie-based sessions](https://remix.run/docs/en/v1/api/remix#createcookiesessionstorage) 19 | - Database ORM with [Prisma](https://prisma.io) 20 | - Styling with [Tailwind](https://tailwindcss.com/) 21 | - Code formatting with [Prettier](https://prettier.io) 22 | - Linting with [ESLint](https://eslint.org) 23 | - Static Types with [TypeScript](https://typescriptlang.org) 24 | 25 | Not a fan of bits of the stack? Fork it, change it, and use `npx create-remix --template your/repo`! Make it your own. 26 | 27 | ## Development 28 | 29 | - This step only applies if you've opted out of having the CLI install dependencies for you: 30 | 31 | ```sh 32 | npx remix init 33 | ``` 34 | 35 | - Initial setup: _If you just generated this project, this step has been done for you._ 36 | 37 | ```sh 38 | npm run setup 39 | ``` 40 | 41 | - Start dev server: 42 | 43 | ```sh 44 | npm run dev 45 | ``` 46 | 47 | This starts your app in development mode, rebuilding assets on file changes. 48 | 49 | The database seed script creates a new user with some data you can use to get started: 50 | 51 | - Email: `rachel@remix.run` 52 | - Password: `racheliscool` 53 | 54 | ### Relevant code: 55 | 56 | This app does nothing. You can login and logout. That's it. 57 | 58 | - creating users, and logging in and out [./app/models/user.server.ts](./app/models/user.server.ts) 59 | - user sessions, and verifying them [./app/session.server.ts](./app/session.server.ts) 60 | 61 | ## Deployment 62 | 63 | This Remix Stack comes with two GitHub Actions that handle automatically deploying your app to production. 64 | 65 | Prior to your first deployment, you'll need to do a few things: 66 | 67 | - [Install Fly](https://fly.io/docs/getting-started/installing-flyctl/) 68 | 69 | - Sign up and log in to Fly 70 | 71 | ```sh 72 | fly auth signup 73 | ``` 74 | 75 | > **Note:** If you have more than one Fly account, ensure that you are signed into the same account in the Fly CLI as you are in the browser. In your terminal, run `fly auth whoami` and ensure the email matches the Fly account signed into the browser. 76 | 77 | - Create two apps on Fly, one for production: 78 | 79 | ```sh 80 | fly create remix-quick-stack-template 81 | ``` 82 | 83 | > **Note:** Make sure this name matches the `app` set in your `fly.toml` file. Otherwise, you will not be able to deploy. 84 | 85 | - Initialize Git. 86 | 87 | ```sh 88 | git init 89 | ``` 90 | 91 | - Create a new [GitHub Repository](https://repo.new), and then add it as the remote for your project. **Do not push your app yet!** 92 | 93 | ```sh 94 | git remote add origin 95 | ``` 96 | 97 | - Add a `FLY_API_TOKEN` to your GitHub repo. To do this, go to your user settings on Fly and create a new [token](https://web.fly.io/user/personal_access_tokens/new), then add it to [your repo secrets](https://docs.github.com/en/actions/security-guides/encrypted-secrets) with the name `FLY_API_TOKEN`. 98 | 99 | - Add a `SESSION_SECRET` to your fly app secrets, to do this you can run the following command: 100 | 101 | ```sh 102 | fly secrets set SESSION_SECRET=$(openssl rand -hex 32) --app remix-quick-stack-template 103 | ``` 104 | 105 | If you don't have openssl installed, you can also use [1password](https://1password.com/password-generator/) to generate a random secret, just replace `$(openssl rand -hex 32)` with the generated secret. 106 | 107 | - Create a persistent volume for the sqlite database for your production environment. Run the following: 108 | 109 | ```sh 110 | fly volumes create data --size 1 --app remix-quick-stack-template 111 | ``` 112 | 113 | Now that everything is set up you can commit and push your changes to your repo. Every commit to your `main` branch will trigger a deployment to your production environment. 114 | 115 | ### Connecting to your database 116 | 117 | The sqlite database lives at `/data/sqlite.db` in your deployed application. You can connect to the live database by running `fly ssh console -C database-cli`. 118 | 119 | ### Getting Help with Deployment 120 | 121 | If you run into any issues deploying to Fly, make sure you've followed all of the steps above and if you have, then post as many details about your deployment (including your app name) to [the Fly support community](https://community.fly.io). They're normally pretty responsive over there and hopefully can help resolve any of your deployment issues and questions. 122 | 123 | ## GitHub Actions 124 | 125 | We use GitHub Actions for continuous integration and deployment. Anything that gets into the `main` branch will be deployed to production after running the build (we do not run linting/typescript in CI... This is quick remember?). 126 | 127 | ### Type Checking 128 | 129 | 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`. 130 | 131 | ### Linting 132 | 133 | This project uses ESLint for linting. That is configured in `.eslintrc.js`. 134 | 135 | ### Formatting 136 | 137 | 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. 138 | -------------------------------------------------------------------------------- /app/db.server.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from "@prisma/client"; 2 | 3 | let prisma: PrismaClient; 4 | 5 | declare global { 6 | var __db__: PrismaClient; 7 | } 8 | 9 | // this is needed because in development we don't want to restart 10 | // the server with every change, but we want to make sure we don't 11 | // create a new connection to the DB with every change either. 12 | // in production we'll have a single connection to the DB. 13 | if (process.env.NODE_ENV === "production") { 14 | prisma = new PrismaClient(); 15 | } else { 16 | if (!global.__db__) { 17 | global.__db__ = new PrismaClient(); 18 | } 19 | prisma = global.__db__; 20 | prisma.$connect(); 21 | } 22 | 23 | export { prisma }; 24 | -------------------------------------------------------------------------------- /app/entry.client.tsx: -------------------------------------------------------------------------------- 1 | import { RemixBrowser } from "@remix-run/react"; 2 | import { hydrateRoot } from "react-dom/client"; 3 | 4 | hydrateRoot(document, ); 5 | -------------------------------------------------------------------------------- /app/entry.server.tsx: -------------------------------------------------------------------------------- 1 | import { PassThrough } from "stream"; 2 | import { renderToPipeableStream } from "react-dom/server"; 3 | import { RemixServer } from "@remix-run/react"; 4 | import { Response } from "@remix-run/node"; 5 | import type { EntryContext, Headers } from "@remix-run/node"; 6 | import isbot from "isbot"; 7 | 8 | const ABORT_DELAY = 5000; 9 | 10 | export default function handleRequest( 11 | request: Request, 12 | responseStatusCode: number, 13 | responseHeaders: Headers, 14 | remixContext: EntryContext 15 | ) { 16 | const callbackName = isbot(request.headers.get("user-agent")) 17 | ? "onAllReady" 18 | : "onShellReady"; 19 | 20 | return new Promise((resolve, reject) => { 21 | let didError = false; 22 | 23 | const { pipe, abort } = renderToPipeableStream( 24 | , 25 | { 26 | [callbackName]() { 27 | let body = new PassThrough(); 28 | 29 | responseHeaders.set("Content-Type", "text/html"); 30 | 31 | resolve( 32 | new Response(body, { 33 | status: didError ? 500 : responseStatusCode, 34 | headers: responseHeaders, 35 | }) 36 | ); 37 | pipe(body); 38 | }, 39 | onShellError(err: unknown) { 40 | reject(err); 41 | }, 42 | onError(error: unknown) { 43 | didError = true; 44 | console.error(error); 45 | }, 46 | } 47 | ); 48 | setTimeout(abort, ABORT_DELAY); 49 | }); 50 | } 51 | -------------------------------------------------------------------------------- /app/models/user.server.ts: -------------------------------------------------------------------------------- 1 | import type { Password, User } from "@prisma/client"; 2 | import bcrypt from "bcryptjs"; 3 | 4 | import { prisma } from "~/db.server"; 5 | 6 | export type { User } from "@prisma/client"; 7 | 8 | export async function getUserById(id: User["id"]) { 9 | return prisma.user.findUnique({ where: { id } }); 10 | } 11 | 12 | export async function getUserByEmail(email: User["email"]) { 13 | return prisma.user.findUnique({ where: { email } }); 14 | } 15 | 16 | export async function createUser(email: User["email"], password: string) { 17 | const hashedPassword = await bcrypt.hash(password, 10); 18 | 19 | return prisma.user.create({ 20 | data: { 21 | email, 22 | password: { 23 | create: { 24 | hash: hashedPassword, 25 | }, 26 | }, 27 | }, 28 | }); 29 | } 30 | 31 | export async function deleteUserByEmail(email: User["email"]) { 32 | return prisma.user.delete({ where: { email } }); 33 | } 34 | 35 | export async function verifyLogin( 36 | email: User["email"], 37 | password: Password["hash"] 38 | ) { 39 | const userWithPassword = await prisma.user.findUnique({ 40 | where: { email }, 41 | include: { 42 | password: true, 43 | }, 44 | }); 45 | 46 | if (!userWithPassword || !userWithPassword.password) { 47 | return null; 48 | } 49 | 50 | const isValid = await bcrypt.compare( 51 | password, 52 | userWithPassword.password.hash 53 | ); 54 | 55 | if (!isValid) { 56 | return null; 57 | } 58 | 59 | const { password: _password, ...userWithoutPassword } = userWithPassword; 60 | 61 | return userWithoutPassword; 62 | } 63 | -------------------------------------------------------------------------------- /app/root.tsx: -------------------------------------------------------------------------------- 1 | import type { LinksFunction, LoaderArgs, MetaFunction } from "@remix-run/node"; 2 | import { json } from "@remix-run/node"; 3 | import { 4 | Links, 5 | LiveReload, 6 | Meta, 7 | Outlet, 8 | Scripts, 9 | ScrollRestoration, 10 | } from "@remix-run/react"; 11 | 12 | import tailwindStylesheetUrl from "./styles/tailwind.css"; 13 | import { getUser } from "./session.server"; 14 | 15 | export const links: LinksFunction = () => { 16 | return [{ rel: "stylesheet", href: tailwindStylesheetUrl }]; 17 | }; 18 | 19 | export const meta: MetaFunction = () => ({ 20 | charset: "utf-8", 21 | title: "Remix Notes", 22 | viewport: "width=device-width,initial-scale=1", 23 | }); 24 | 25 | export async function loader({ request }: LoaderArgs) { 26 | return json({ 27 | user: await getUser(request), 28 | }); 29 | } 30 | 31 | export default function App() { 32 | return ( 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | ); 46 | } 47 | -------------------------------------------------------------------------------- /app/routes/index.tsx: -------------------------------------------------------------------------------- 1 | import { Form, Link } from "@remix-run/react"; 2 | 3 | import { useOptionalUser } from "~/utils"; 4 | 5 | export default function Index() { 6 | const user = useOptionalUser(); 7 | return ( 8 |
9 |
10 |
11 |
12 |
13 | Sonic The Hedgehog running through a ring and away from missiles 18 |
19 |
20 |
21 |

22 | 23 | KCD Quick Stack 24 | 25 |

26 |

27 | Check the README.md file for instructions on how to get this 28 | project deployed. 29 |

30 |
31 | {user ? ( 32 |
33 | 36 |
37 | ) : ( 38 | 42 | Log In 43 | 44 | )} 45 |
46 | 47 | Remix 52 | 53 |
54 |
55 |
56 | 57 |
58 |
59 | {[ 60 | { 61 | src: "https://user-images.githubusercontent.com/1500684/157764397-ccd8ea10-b8aa-4772-a99b-35de937319e1.svg", 62 | alt: "Fly.io", 63 | href: "https://fly.io", 64 | }, 65 | { 66 | src: "https://user-images.githubusercontent.com/1500684/157764395-137ec949-382c-43bd-a3c0-0cb8cb22e22d.svg", 67 | alt: "SQLite", 68 | href: "https://sqlite.org", 69 | }, 70 | { 71 | src: "https://user-images.githubusercontent.com/1500684/157764484-ad64a21a-d7fb-47e3-8669-ec046da20c1f.svg", 72 | alt: "Prisma", 73 | href: "https://prisma.io", 74 | }, 75 | { 76 | src: "https://user-images.githubusercontent.com/1500684/157764276-a516a239-e377-4a20-b44a-0ac7b65c8c14.svg", 77 | alt: "Tailwind", 78 | href: "https://tailwindcss.com", 79 | }, 80 | { 81 | src: "https://user-images.githubusercontent.com/1500684/157772934-ce0a943d-e9d0-40f8-97f3-f464c0811643.svg", 82 | alt: "Prettier", 83 | href: "https://prettier.io", 84 | }, 85 | { 86 | src: "https://user-images.githubusercontent.com/1500684/157772990-3968ff7c-b551-4c55-a25c-046a32709a8e.svg", 87 | alt: "ESLint", 88 | href: "https://eslint.org", 89 | }, 90 | { 91 | src: "https://user-images.githubusercontent.com/1500684/157773063-20a0ed64-b9f8-4e0b-9d1e-0b65a3d4a6db.svg", 92 | alt: "TypeScript", 93 | href: "https://typescriptlang.org", 94 | }, 95 | ].map((img) => ( 96 | 101 | {img.alt} 102 | 103 | ))} 104 |
105 |
106 |
107 |
108 | ); 109 | } 110 | -------------------------------------------------------------------------------- /app/routes/login.tsx: -------------------------------------------------------------------------------- 1 | import type { ActionArgs, LoaderArgs, MetaFunction } from "@remix-run/node"; 2 | import { json, redirect } from "@remix-run/node"; 3 | import { Form, useActionData, useSearchParams } from "@remix-run/react"; 4 | import * as React from "react"; 5 | import { createUserSession, getUserId } from "~/session.server"; 6 | import { createUser, getUserByEmail, verifyLogin } from "~/models/user.server"; 7 | import { safeRedirect, validateEmail } from "~/utils"; 8 | 9 | export async function loader({ request }: LoaderArgs) { 10 | const userId = await getUserId(request); 11 | if (userId) return redirect("/"); 12 | return json({}); 13 | } 14 | 15 | export async function action({ request }: ActionArgs) { 16 | const formData = await request.formData(); 17 | const email = formData.get("email"); 18 | const password = formData.get("password"); 19 | const redirectTo = safeRedirect(formData.get("redirectTo"), "/"); 20 | const remember = formData.get("remember"); 21 | 22 | if (!validateEmail(email)) { 23 | return json({ errors: { email: "Email is invalid" } }, { status: 400 }); 24 | } 25 | 26 | if (typeof password !== "string" || password.length === 0) { 27 | return json( 28 | { errors: { password: "Password is required" } }, 29 | { status: 400 } 30 | ); 31 | } 32 | 33 | if (password.length < 8) { 34 | return json( 35 | { errors: { password: "Password is too short" } }, 36 | { status: 400 } 37 | ); 38 | } 39 | 40 | const intent = formData.get("intent"); 41 | let userId: string; 42 | switch (intent) { 43 | case "login": { 44 | const user = await verifyLogin(email, password); 45 | 46 | if (!user) { 47 | return json( 48 | { errors: { email: "Invalid email or password" } }, 49 | { status: 400 } 50 | ); 51 | } 52 | userId = user.id; 53 | break; 54 | } 55 | case "signup": { 56 | const existingUser = await getUserByEmail(email); 57 | if (existingUser) { 58 | return json( 59 | { errors: { email: "A user already exists with this email" } }, 60 | { status: 400 } 61 | ); 62 | } 63 | 64 | const user = await createUser(email, password); 65 | userId = user.id; 66 | 67 | break; 68 | } 69 | default: { 70 | return json({ errors: { email: "Invalid intent" } }, { status: 400 }); 71 | } 72 | } 73 | 74 | return createUserSession({ 75 | request, 76 | userId, 77 | remember: remember === "on" ? true : false, 78 | redirectTo, 79 | }); 80 | } 81 | 82 | export const meta: MetaFunction = () => { 83 | return { 84 | title: "Login", 85 | }; 86 | }; 87 | 88 | export default function LoginPage() { 89 | const [searchParams] = useSearchParams(); 90 | const redirectTo = searchParams.get("redirectTo") || "/"; 91 | const actionData = useActionData(); 92 | const emailRef = React.useRef(null); 93 | const passwordRef = React.useRef(null); 94 | let emailError: string | null = null; 95 | let passwordError: string | null = null; 96 | if (actionData && actionData.errors) { 97 | const { errors } = actionData; 98 | emailError = "email" in errors ? errors.email : null; 99 | passwordError = "password" in errors ? errors.password : null; 100 | } 101 | 102 | React.useEffect(() => { 103 | if (emailError) { 104 | emailRef.current?.focus(); 105 | } else if (passwordError) { 106 | passwordRef.current?.focus(); 107 | } 108 | }, [emailError, passwordError]); 109 | 110 | return ( 111 |
112 |
113 |
114 |
115 | 121 |
122 | 134 | 135 | {emailError && ( 136 |
137 | {emailError} 138 |
139 | )} 140 |
141 |
142 | 143 |
144 | 150 |
151 | 161 | {passwordError && ( 162 |
163 | {passwordError} 164 |
165 | )} 166 |
167 |
168 | 169 |
170 | 176 | 182 |
183 | 184 | 185 |
186 | 194 | 202 |
203 |
204 |
205 |
206 | ); 207 | } 208 | -------------------------------------------------------------------------------- /app/routes/logout.tsx: -------------------------------------------------------------------------------- 1 | import type { ActionArgs } from "@remix-run/node"; 2 | import { redirect } from "@remix-run/node"; 3 | 4 | import { logout } from "~/session.server"; 5 | 6 | export async function action({ request }: ActionArgs) { 7 | return logout(request); 8 | } 9 | 10 | export async function loader() { 11 | return redirect("/"); 12 | } 13 | -------------------------------------------------------------------------------- /app/session.server.ts: -------------------------------------------------------------------------------- 1 | import { createCookieSessionStorage, redirect } from "@remix-run/node"; 2 | import invariant from "tiny-invariant"; 3 | 4 | import type { User } from "~/models/user.server"; 5 | import { getUserById } from "~/models/user.server"; 6 | 7 | invariant(process.env.SESSION_SECRET, "SESSION_SECRET must be set"); 8 | 9 | export const sessionStorage = createCookieSessionStorage({ 10 | cookie: { 11 | name: "__session", 12 | httpOnly: true, 13 | path: "/", 14 | sameSite: "lax", 15 | secrets: [process.env.SESSION_SECRET], 16 | secure: process.env.NODE_ENV === "production", 17 | }, 18 | }); 19 | 20 | const USER_SESSION_KEY = "userId"; 21 | 22 | export async function getSession(request: Request) { 23 | const cookie = request.headers.get("Cookie"); 24 | return sessionStorage.getSession(cookie); 25 | } 26 | 27 | export async function getUserId( 28 | request: Request 29 | ): Promise { 30 | const session = await getSession(request); 31 | const userId = session.get(USER_SESSION_KEY); 32 | return userId; 33 | } 34 | 35 | export async function getUser(request: Request) { 36 | const userId = await getUserId(request); 37 | if (userId === undefined) return null; 38 | 39 | const user = await getUserById(userId); 40 | if (user) return user; 41 | 42 | throw await logout(request); 43 | } 44 | 45 | export async function requireUserId( 46 | request: Request, 47 | redirectTo: string = new URL(request.url).pathname 48 | ) { 49 | const userId = await getUserId(request); 50 | if (!userId) { 51 | const searchParams = new URLSearchParams([["redirectTo", redirectTo]]); 52 | throw redirect(`/login?${searchParams}`); 53 | } 54 | return userId; 55 | } 56 | 57 | export async function requireUser(request: Request) { 58 | const userId = await requireUserId(request); 59 | 60 | const user = await getUserById(userId); 61 | if (user) return user; 62 | 63 | throw await logout(request); 64 | } 65 | 66 | export async function createUserSession({ 67 | request, 68 | userId, 69 | remember, 70 | redirectTo, 71 | }: { 72 | request: Request; 73 | userId: string; 74 | remember: boolean; 75 | redirectTo: string; 76 | }) { 77 | const session = await getSession(request); 78 | session.set(USER_SESSION_KEY, userId); 79 | return redirect(redirectTo, { 80 | headers: { 81 | "Set-Cookie": await sessionStorage.commitSession(session, { 82 | maxAge: remember 83 | ? 60 * 60 * 24 * 7 // 7 days 84 | : undefined, 85 | }), 86 | }, 87 | }); 88 | } 89 | 90 | export async function logout(request: Request) { 91 | const session = await getSession(request); 92 | return redirect("/", { 93 | headers: { 94 | "Set-Cookie": await sessionStorage.destroySession(session), 95 | }, 96 | }); 97 | } 98 | -------------------------------------------------------------------------------- /app/utils.ts: -------------------------------------------------------------------------------- 1 | import { useMatches } from "@remix-run/react"; 2 | import { useMemo } from "react"; 3 | 4 | import type { User } from "~/models/user.server"; 5 | 6 | const DEFAULT_REDIRECT = "/"; 7 | 8 | /** 9 | * This should be used any time the redirect path is user-provided 10 | * (Like the query string on our login/signup pages). This avoids 11 | * open-redirect vulnerabilities. 12 | * @param {string} to The redirect destination 13 | * @param {string} defaultRedirect The redirect to use if the to is unsafe. 14 | */ 15 | export function safeRedirect( 16 | to: FormDataEntryValue | string | null | undefined, 17 | defaultRedirect: string = DEFAULT_REDIRECT 18 | ) { 19 | if (!to || typeof to !== "string") { 20 | return defaultRedirect; 21 | } 22 | 23 | if (!to.startsWith("/") || to.startsWith("//")) { 24 | return defaultRedirect; 25 | } 26 | 27 | return to; 28 | } 29 | 30 | /** 31 | * This base hook is used in other hooks to quickly search for specific data 32 | * across all loader data using useMatches. 33 | * @param {string} id The route id 34 | * @returns {JSON|undefined} The router data or undefined if not found 35 | */ 36 | export function useMatchesData( 37 | id: string 38 | ): Record | undefined { 39 | const matchingRoutes = useMatches(); 40 | const route = useMemo( 41 | () => matchingRoutes.find((route) => route.id === id), 42 | [matchingRoutes, id] 43 | ); 44 | return route?.data; 45 | } 46 | 47 | function isUser(user: any): user is User { 48 | return user && typeof user === "object" && typeof user.email === "string"; 49 | } 50 | 51 | export function useOptionalUser(): User | undefined { 52 | const data = useMatchesData("root"); 53 | if (!data || !isUser(data.user)) { 54 | return undefined; 55 | } 56 | return data.user; 57 | } 58 | 59 | export function useUser(): User { 60 | const maybeUser = useOptionalUser(); 61 | if (!maybeUser) { 62 | throw new Error( 63 | "No user found in root loader, but user is required by useUser. If user is optional, try useOptionalUser instead." 64 | ); 65 | } 66 | return maybeUser; 67 | } 68 | 69 | export function validateEmail(email: unknown): email is string { 70 | return typeof email === "string" && email.length > 3 && email.includes("@"); 71 | } 72 | -------------------------------------------------------------------------------- /fly.toml: -------------------------------------------------------------------------------- 1 | app = "remix-quick-stack-template" 2 | 3 | kill_signal = "SIGINT" 4 | kill_timeout = 5 5 | processes = [] 6 | 7 | [experimental] 8 | allowed_public_ports = [] 9 | auto_rollback = true 10 | cmd = "start.sh" 11 | entrypoint = "sh" 12 | 13 | [mounts] 14 | source = "data" 15 | destination = "/data" 16 | 17 | [[services]] 18 | internal_port = 8080 19 | processes = ["app"] 20 | protocol = "tcp" 21 | script_checks = [] 22 | 23 | [services.concurrency] 24 | hard_limit = 25 25 | soft_limit = 20 26 | type = "connections" 27 | 28 | [[services.ports]] 29 | handlers = ["http"] 30 | port = 80 31 | force_https = true 32 | 33 | [[services.ports]] 34 | handlers = ["tls", "http"] 35 | port = 443 36 | 37 | [[services.tcp_checks]] 38 | grace_period = "1s" 39 | interval = "15s" 40 | restart_limit = 0 41 | timeout = "2s" 42 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "remix-quick-stack-template", 3 | "private": true, 4 | "sideEffects": false, 5 | "scripts": { 6 | "build": "run-s build:*", 7 | "build:css": "npm run generate:css -- --minify", 8 | "build:remix": "remix build", 9 | "dev": "run-p dev:*", 10 | "dev:css": "npm run generate:css -- --watch", 11 | "dev:remix": "remix dev", 12 | "format": "prettier --write .", 13 | "generate:css": "tailwindcss -o ./app/styles/tailwind.css", 14 | "lint": "eslint --cache --cache-location ./node_modules/.cache/eslint .", 15 | "setup": "prisma generate && prisma migrate deploy && prisma db seed", 16 | "start": "remix-serve build", 17 | "typecheck": "tsc -b && tsc -b cypress", 18 | "validate": "run-p \"test -- --run\" lint typecheck" 19 | }, 20 | "prettier": {}, 21 | "eslintIgnore": [ 22 | "/node_modules", 23 | "/build", 24 | "/public/build" 25 | ], 26 | "dependencies": { 27 | "@prisma/client": "^4.5.0", 28 | "@remix-run/node": "*", 29 | "@remix-run/react": "*", 30 | "@remix-run/serve": "*", 31 | "@remix-run/server-runtime": "*", 32 | "bcryptjs": "^2.4.3", 33 | "isbot": "^3.6.3", 34 | "react": "^18.2.0", 35 | "react-dom": "^18.2.0", 36 | "tiny-invariant": "^1.3.1" 37 | }, 38 | "devDependencies": { 39 | "@remix-run/dev": "*", 40 | "@remix-run/eslint-config": "*", 41 | "@types/bcryptjs": "^2.4.2", 42 | "@types/eslint": "^8.4.10", 43 | "@types/node": "^18.11.9", 44 | "@types/react": "^18.0.25", 45 | "@types/react-dom": "^18.0.8", 46 | "@vitejs/plugin-react": "^2.2.0", 47 | "autoprefixer": "^10.4.13", 48 | "c8": "^7.12.0", 49 | "eslint": "^8.26.0", 50 | "eslint-config-prettier": "^8.5.0", 51 | "npm-run-all": "^4.1.5", 52 | "prettier": "2.7.1", 53 | "prettier-plugin-tailwindcss": "^0.1.13", 54 | "prisma": "^4.5.0", 55 | "tailwindcss": "^3.2.2", 56 | "ts-node": "^10.9.1", 57 | "tsconfig-paths": "^4.1.0", 58 | "typescript": "^4.8.4" 59 | }, 60 | "engines": { 61 | "node": ">=14" 62 | }, 63 | "prisma": { 64 | "seed": "ts-node --require tsconfig-paths/register prisma/seed.ts" 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /prisma/migrations/20220714230702_init/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "User" ( 3 | "id" TEXT NOT NULL PRIMARY KEY, 4 | "email" TEXT NOT NULL, 5 | "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, 6 | "updatedAt" DATETIME NOT NULL 7 | ); 8 | 9 | -- CreateTable 10 | CREATE TABLE "Password" ( 11 | "hash" TEXT NOT NULL, 12 | "userId" TEXT NOT NULL, 13 | CONSTRAINT "Password_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE 14 | ); 15 | 16 | -- CreateIndex 17 | CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); 18 | 19 | -- CreateIndex 20 | CREATE UNIQUE INDEX "Password_userId_key" ON "Password"("userId"); 21 | -------------------------------------------------------------------------------- /prisma/migrations/migration_lock.toml: -------------------------------------------------------------------------------- 1 | # Please do not edit this file manually 2 | # It should be added in your version-control system (i.e. Git) 3 | provider = "sqlite" -------------------------------------------------------------------------------- /prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | datasource db { 2 | provider = "sqlite" 3 | url = env("DATABASE_URL") 4 | } 5 | 6 | generator client { 7 | provider = "prisma-client-js" 8 | } 9 | 10 | model User { 11 | id String @id @default(cuid()) 12 | email String @unique 13 | 14 | createdAt DateTime @default(now()) 15 | updatedAt DateTime @updatedAt 16 | 17 | password Password? 18 | } 19 | 20 | model Password { 21 | hash String 22 | 23 | user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade) 24 | userId String @unique 25 | } 26 | -------------------------------------------------------------------------------- /prisma/seed.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from "@prisma/client"; 2 | import bcrypt from "bcryptjs"; 3 | 4 | const prisma = new PrismaClient(); 5 | 6 | async function seed() { 7 | const email = "rachel@remix.run"; 8 | 9 | // cleanup the existing database 10 | await prisma.user.delete({ where: { email } }).catch(() => { 11 | // no worries if it doesn't exist yet 12 | }); 13 | 14 | const hashedPassword = await bcrypt.hash("racheliscool", 10); 15 | 16 | await prisma.user.create({ 17 | data: { 18 | email, 19 | password: { 20 | create: { 21 | hash: hashedPassword, 22 | }, 23 | }, 24 | }, 25 | }); 26 | 27 | console.log(`Database has been seeded. 🌱`); 28 | } 29 | 30 | seed() 31 | .catch((e) => { 32 | console.error(e); 33 | process.exit(1); 34 | }) 35 | .finally(async () => { 36 | await prisma.$disconnect(); 37 | }); 38 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kentcdodds/quick-stack/25d39bdb0e3a078989ad325d8edfabc0f2763a00/public/favicon.ico -------------------------------------------------------------------------------- /remix.config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @type {import('@remix-run/dev').AppConfig} 3 | */ 4 | module.exports = { 5 | cacheDirectory: "./node_modules/.cache/remix", 6 | ignoredRouteFiles: ["**/.*", "**/*.css", "**/*.test.{js,jsx,ts,tsx}"], 7 | }; 8 | -------------------------------------------------------------------------------- /remix.env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | -------------------------------------------------------------------------------- /remix.init/gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | /build 4 | /public/build 5 | .env 6 | 7 | /cypress/screenshots 8 | /cypress/videos 9 | /prisma/data.db 10 | /prisma/data.db-journal 11 | 12 | /app/styles/tailwind.css 13 | -------------------------------------------------------------------------------- /remix.init/index.js: -------------------------------------------------------------------------------- 1 | const { execSync } = require("child_process"); 2 | const crypto = require("crypto"); 3 | const fs = require("fs/promises"); 4 | const path = require("path"); 5 | 6 | const toml = require("@iarna/toml"); 7 | const sort = require("sort-package-json"); 8 | 9 | function escapeRegExp(string) { 10 | // $& means the whole matched string 11 | return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); 12 | } 13 | 14 | function getRandomString(length) { 15 | return crypto.randomBytes(length).toString("hex"); 16 | } 17 | 18 | async function main({ rootDirectory }) { 19 | const README_PATH = path.join(rootDirectory, "README.md"); 20 | const FLY_TOML_PATH = path.join(rootDirectory, "fly.toml"); 21 | const EXAMPLE_ENV_PATH = path.join(rootDirectory, ".env.example"); 22 | const ENV_PATH = path.join(rootDirectory, ".env"); 23 | const PACKAGE_JSON_PATH = path.join(rootDirectory, "package.json"); 24 | 25 | const REPLACER = "remix-quick-stack-template"; 26 | 27 | const DIR_NAME = path.basename(rootDirectory); 28 | const SUFFIX = getRandomString(2); 29 | 30 | const APP_NAME = (DIR_NAME + "-" + SUFFIX) 31 | // get rid of anything that's not allowed in an app name 32 | .replace(/[^a-zA-Z0-9-_]/g, "-"); 33 | 34 | const [prodContent, readme, env, packageJson] = await Promise.all([ 35 | fs.readFile(FLY_TOML_PATH, "utf-8"), 36 | fs.readFile(README_PATH, "utf-8"), 37 | fs.readFile(EXAMPLE_ENV_PATH, "utf-8"), 38 | fs.readFile(PACKAGE_JSON_PATH, "utf-8"), 39 | ]); 40 | 41 | const newEnv = env.replace( 42 | /^SESSION_SECRET=.*$/m, 43 | `SESSION_SECRET="${getRandomString(16)}"` 44 | ); 45 | 46 | const prodToml = toml.parse(prodContent); 47 | prodToml.app = prodToml.app.replace(REPLACER, APP_NAME); 48 | 49 | const newReadme = readme.replace( 50 | new RegExp(escapeRegExp(REPLACER), "g"), 51 | APP_NAME 52 | ); 53 | 54 | const newPackageJson = 55 | JSON.stringify( 56 | sort({ ...JSON.parse(packageJson), name: APP_NAME }), 57 | null, 58 | 2 59 | ) + "\n"; 60 | 61 | await Promise.all([ 62 | fs.writeFile(FLY_TOML_PATH, toml.stringify(prodToml)), 63 | fs.writeFile(README_PATH, newReadme), 64 | fs.writeFile(ENV_PATH, newEnv), 65 | fs.writeFile(PACKAGE_JSON_PATH, newPackageJson), 66 | fs.copyFile( 67 | path.join(rootDirectory, "remix.init", "gitignore"), 68 | path.join(rootDirectory, ".gitignore") 69 | ), 70 | ]); 71 | 72 | execSync(`npm run setup`, { stdio: "inherit", cwd: rootDirectory }); 73 | 74 | console.log( 75 | `Setup is complete. You're now ready to rock and roll 🤘 76 | 77 | Start development with \`npm run dev\` 78 | `.trim() 79 | ); 80 | } 81 | 82 | module.exports = main; 83 | -------------------------------------------------------------------------------- /remix.init/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "remix.init", 3 | "private": true, 4 | "main": "index.js", 5 | "license": "MIT", 6 | "dependencies": { 7 | "@iarna/toml": "^2.2.5", 8 | "sort-package-json": "^1.57.0" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /start.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # This file is how Fly starts the server (configured in fly.toml). Before starting 4 | # the server though, we need to run any prisma migrations that haven't yet been 5 | # run, which is why this file exists in the first place. 6 | # Learn more: https://community.fly.io/t/sqlite-not-getting-setup-properly/4386 7 | 8 | set -ex 9 | npx prisma migrate deploy 10 | npm run start 11 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: ["./app/**/*.{ts,tsx,jsx,js}"], 4 | theme: { 5 | extend: {}, 6 | }, 7 | plugins: [], 8 | }; 9 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "exclude": ["./cypress", "./cypress.config.ts"], 3 | "include": ["remix.env.d.ts", "**/*.ts", "**/*.tsx"], 4 | "compilerOptions": { 5 | "lib": ["DOM", "DOM.Iterable", "ES2019"], 6 | "isolatedModules": true, 7 | "esModuleInterop": true, 8 | "jsx": "react-jsx", 9 | "module": "CommonJS", 10 | "moduleResolution": "node", 11 | "resolveJsonModule": true, 12 | "target": "ES2019", 13 | "strict": true, 14 | "allowJs": true, 15 | "forceConsistentCasingInFileNames": true, 16 | "baseUrl": ".", 17 | "paths": { 18 | "~/*": ["./app/*"] 19 | }, 20 | "skipLibCheck": true, 21 | 22 | // Remix takes care of building everything in `remix build`. 23 | "noEmit": true 24 | } 25 | } 26 | --------------------------------------------------------------------------------