├── app ├── styles │ └── tailwind.css ├── entry.client.tsx ├── routes │ ├── logout.tsx │ ├── app │ │ ├── README.md │ │ ├── __authenticated │ │ │ └── home.tsx │ │ └── __authenticated.tsx │ ├── health.ts │ ├── __anonymous.tsx │ └── __anonymous │ │ ├── index.tsx │ │ ├── login.tsx │ │ └── register.tsx ├── utils │ ├── auth.ts │ ├── db.server.ts │ ├── anonymous.tsx │ └── session.server.ts ├── entry.server.tsx └── root.tsx ├── public └── favicon.ico ├── .gitignore ├── remix.env.d.ts ├── postcss.config.js ├── prisma ├── migrations │ ├── migration_lock.toml │ └── 20211128203245_user │ │ └── migration.sql ├── seed.ts └── schema.prisma ├── .env.example ├── remix.config.js ├── docker-compose.yaml ├── tsconfig.json ├── tailwind.config.js ├── render.yaml ├── package.json └── README.md /app/styles/tailwind.css: -------------------------------------------------------------------------------- 1 | 2 | @tailwind base; 3 | @tailwind components; 4 | @tailwind utilities; -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sjones6/remix-render-starter/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | /.cache 4 | /build 5 | /public/build 6 | 7 | app/styles/app.css 8 | .env -------------------------------------------------------------------------------- /remix.env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {} 5 | } 6 | }; -------------------------------------------------------------------------------- /app/entry.client.tsx: -------------------------------------------------------------------------------- 1 | import { hydrate } from "react-dom"; 2 | import { RemixBrowser } from "remix"; 3 | 4 | hydrate(, document); 5 | -------------------------------------------------------------------------------- /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 = "postgresql" -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # Env 2 | NODE_ENV=development 3 | HOST=http://localhost:9000 4 | PORT=9000 5 | 6 | # Secrets 7 | SESSION_SECRET=keyboardcat 8 | 9 | # DB 10 | DATABASE_URL=postgresql://admin:test@127.0.0.1:5432/remix -------------------------------------------------------------------------------- /app/routes/logout.tsx: -------------------------------------------------------------------------------- 1 | import type { LoaderFunction } from "remix"; 2 | import { logout } from "~/utils/session.server"; 3 | 4 | export let loader: LoaderFunction = async ({ request }) => { 5 | return logout(request); 6 | }; -------------------------------------------------------------------------------- /app/routes/app/README.md: -------------------------------------------------------------------------------- 1 | # Authenticated Routes 2 | 3 | Place all authenticated routes inside of the `__authenticated` directory. 4 | 5 | The template `__authenticated.tsx` ensures that there is an authenticated user before rendering the outlet. -------------------------------------------------------------------------------- /remix.config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @type {import('@remix-run/dev/config').AppConfig} 3 | */ 4 | module.exports = { 5 | appDirectory: "app", 6 | browserBuildDirectory: "public/build", 7 | publicPath: "/build/", 8 | serverBuildDirectory: "build", 9 | devServerPort: 8002 10 | }; 11 | -------------------------------------------------------------------------------- /app/routes/health.ts: -------------------------------------------------------------------------------- 1 | import { json } from "remix"; 2 | 3 | // the health endpoint tells render's auto-deployment that the service is ready 4 | // to receive traffic 5 | // optionally add in checks here to dependencies like pinging a database 6 | export function loader() { 7 | return json({ ok: true }); 8 | } -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | 3 | postgres: 4 | image: postgres 5 | container_name: remix_db 6 | expose: 7 | - 5432 8 | ports: 9 | - "5432:5432" 10 | environment: 11 | POSTGRES_PASSWORD: test 12 | POSTGRES_USER: admin 13 | POSTGRES_DB: ${DB:-remix} 14 | volumes: 15 | - remix-db:/var/lib/backup/data 16 | 17 | volumes: 18 | remix-db: 19 | driver: local -------------------------------------------------------------------------------- /prisma/seed.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from "@prisma/client"; 2 | let prisma = new PrismaClient(); 3 | 4 | async function seed() { 5 | await prisma.user.create({ 6 | data: { 7 | username: "kody", 8 | // this is a hashed version of "twixrox" 9 | passwordHash: 10 | "$2b$10$K7L1OJ45/4Y2nIvhRVpCe.FSmhDdWoXehVzJptJ/op0lSsvqNu/1u" 11 | } 12 | }); 13 | } 14 | 15 | (async () => { 16 | await seed(); 17 | })(); 18 | -------------------------------------------------------------------------------- /prisma/migrations/20211128203245_user/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "User" ( 3 | "id" TEXT NOT NULL, 4 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 5 | "updatedAt" TIMESTAMP(3) NOT NULL, 6 | "username" TEXT NOT NULL, 7 | "passwordHash" TEXT NOT NULL, 8 | 9 | CONSTRAINT "User_pkey" PRIMARY KEY ("id") 10 | ); 11 | 12 | -- CreateIndex 13 | CREATE UNIQUE INDEX "User_username_key" ON "User"("username"); 14 | -------------------------------------------------------------------------------- /prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | // This is your Prisma schema file, 2 | // learn more about it in the docs: https://pris.ly/d/prisma-schema 3 | 4 | generator client { 5 | provider = "prisma-client-js" 6 | } 7 | 8 | datasource db { 9 | provider = "postgresql" 10 | url = env("DATABASE_URL") 11 | } 12 | 13 | model User { 14 | id String @id @default(uuid()) 15 | createdAt DateTime @default(now()) 16 | updatedAt DateTime @updatedAt 17 | username String @unique 18 | passwordHash String 19 | } 20 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["remix.env.d.ts", "**/*.ts", "**/*.tsx"], 3 | "compilerOptions": { 4 | "lib": ["DOM", "DOM.Iterable", "ES2019"], 5 | "esModuleInterop": true, 6 | "jsx": "react-jsx", 7 | "moduleResolution": "node", 8 | "resolveJsonModule": true, 9 | "noUnusedLocals": true, 10 | "noUnusedParameters": true, 11 | "target": "ES2019", 12 | "strict": true, 13 | "paths": { 14 | "~/*": ["./app/*"] 15 | }, 16 | 17 | // Remix takes care of building everything in `remix build`. 18 | "noEmit": true 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /app/utils/auth.ts: -------------------------------------------------------------------------------- 1 | import { User } from ".prisma/client"; 2 | import { createContext, useContext } from "react"; 3 | 4 | export type SafeUser = Omit; 5 | 6 | const UserContext = createContext<{ 7 | user: SafeUser | null 8 | }>({ 9 | user: null 10 | }); 11 | UserContext.displayName = 'UserContext'; 12 | 13 | export { UserContext } 14 | 15 | export const useUser = (): SafeUser | null => { 16 | const { user } = useContext(UserContext); 17 | return user; 18 | } 19 | 20 | export const useIsAuthenticated = (): boolean => { 21 | const user = useUser(); 22 | return !!user; 23 | } -------------------------------------------------------------------------------- /app/entry.server.tsx: -------------------------------------------------------------------------------- 1 | import { renderToString } from "react-dom/server"; 2 | import { RemixServer } from "remix"; 3 | import type { EntryContext } from "remix"; 4 | 5 | export default function handleRequest( 6 | request: Request, 7 | responseStatusCode: number, 8 | responseHeaders: Headers, 9 | remixContext: EntryContext 10 | ) { 11 | let markup = renderToString( 12 | 13 | ); 14 | 15 | responseHeaders.set("Content-Type", "text/html"); 16 | 17 | return new Response("" + markup, { 18 | status: responseStatusCode, 19 | headers: responseHeaders 20 | }); 21 | } 22 | -------------------------------------------------------------------------------- /app/utils/db.server.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from "@prisma/client"; 2 | 3 | let db: PrismaClient; 4 | 5 | declare global { 6 | var __db: PrismaClient | undefined; 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 | if (process.env.NODE_ENV === "production") { 13 | db = new PrismaClient(); 14 | db.$connect(); 15 | } else { 16 | if (!global.__db) { 17 | global.__db = new PrismaClient(); 18 | global.__db.$connect(); 19 | } 20 | db = global.__db; 21 | } 22 | 23 | export { db }; 24 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | 2 | const defaultTheme = require('tailwindcss/defaultTheme') 3 | 4 | module.exports = { 5 | mode: 'jit', 6 | purge: [ 7 | "./app/**/*.tsx", 8 | "./app/**/*.jsx", 9 | "./app/**/*.js", 10 | "./app/**/*.ts" 11 | ], 12 | darkMode: false, // or 'media' or 'class' 13 | theme: { 14 | extend: { 15 | fontFamily: { 16 | sans: ['Work Sans', ...defaultTheme.fontFamily.sans], 17 | }, 18 | } 19 | }, 20 | variants: {}, 21 | plugins: [ 22 | require('@tailwindcss/typography'), 23 | require('@tailwindcss/line-clamp'), 24 | require('@tailwindcss/aspect-ratio'), 25 | require('daisyui') 26 | ] 27 | }; -------------------------------------------------------------------------------- /app/routes/app/__authenticated/home.tsx: -------------------------------------------------------------------------------- 1 | import { MetaFunction } from "remix"; 2 | import { useUser } from "../../../utils/auth"; 3 | 4 | export let meta: MetaFunction = () => { 5 | return { 6 | title: "Remix/Render Starter", 7 | description: "Welcome to remix-render starter!" 8 | }; 9 | }; 10 | 11 | export default function Index() { 12 | const user = useUser()!; 13 | 14 | return ( 15 |
16 |
17 |
18 |

19 | Welcome, {user.username}! 20 |

21 |

22 | You made it! 23 |

24 |
25 |
26 |
27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /app/utils/anonymous.tsx: -------------------------------------------------------------------------------- 1 | import { json, useLoaderData, Outlet, LoaderFunction, useNavigate } from "remix"; 2 | import { SafeUser } from "~/utils/auth"; 3 | import { getUser } from "~/utils/session.server"; 4 | 5 | type SessionData = { 6 | user: SafeUser | null 7 | }; 8 | 9 | export const loader: LoaderFunction = async ({ request }) => { 10 | const user = await getUser(request); 11 | return json({ user }); 12 | }; 13 | 14 | export default function Anonymous() { 15 | const navigate = useNavigate(); 16 | const { user } = useLoaderData(); 17 | 18 | if (user) { 19 | navigate('/home'); 20 | return null; 21 | } 22 | 23 | return <> 24 |
25 | 26 | 27 | } 28 | 29 | function Header(): JSX.Element { 30 | return
31 |
32 | 33 | Remix/Render 34 | 35 |
36 |
37 | 42 |
43 |
44 | } -------------------------------------------------------------------------------- /render.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | - type: web 3 | env: node 4 | 5 | # update this with the name of your project 6 | name: remix-render-starter 7 | 8 | # see service tiers: https://render.com/pricing/#services 9 | plan: free 10 | 11 | # auto-scaling is only available on paid tiers 12 | # uncomment and tune if you want 13 | # scaling: 14 | # minInstances: 1 15 | # maxInstances: 3 16 | # targetMemoryPercent: 60 17 | # targetCPUPercent: 60 18 | buildCommand: npm i --production=false && npm run build 19 | startCommand: npm start 20 | healthCheckPath: /health 21 | envVars: 22 | - key: DATABASE_URL 23 | fromDatabase: 24 | name: elephant 25 | property: connectionString 26 | - key: APP_NAME 27 | value: remix-render 28 | - key: NODE_ENV 29 | value: production 30 | - key: NODE_VERSION 31 | value: 16.13.0 32 | - key: PORT 33 | value: 9000 34 | - key: NODE_ENV 35 | value: production 36 | - key: NODE_VERSION 37 | value: 16.13.0 38 | - key: PORT 39 | value: 9000 40 | - key: SESSION_SECRET 41 | generateValue: true 42 | 43 | databases: 44 | - name: elephant 45 | plan: free 46 | -------------------------------------------------------------------------------- /app/routes/app/__authenticated.tsx: -------------------------------------------------------------------------------- 1 | import { json, useLoaderData, Outlet, LoaderFunction, useNavigate } from "remix"; 2 | import { SafeUser, UserContext } from "~/utils/auth"; 3 | import { getUser } from "~/utils/session.server"; 4 | 5 | type SessionData = { 6 | user: SafeUser | null 7 | }; 8 | 9 | export let loader: LoaderFunction = async ({ request }) => { 10 | const user = await getUser(request); 11 | return json({ user }); 12 | }; 13 | 14 | // https://remix.run/docs/en/v1/api/conventions#route-filenames 15 | export default function Authenticated() { 16 | const navigate = useNavigate(); 17 | const { user } = useLoaderData(); 18 | 19 | if (!user) { 20 | navigate('/login'); 21 | return null; 22 | } 23 | 24 | return 25 |
26 | 27 | 28 | } 29 | 30 | function Header({ user}: { user: SafeUser}): JSX.Element { 31 | return
32 |
33 | 34 | Remix/Render 35 | 36 |
37 |
38 |
39 | {user && 40 | Logout 41 | } 42 |
43 |
44 |
45 | } -------------------------------------------------------------------------------- /app/routes/__anonymous.tsx: -------------------------------------------------------------------------------- 1 | import { json, useLoaderData, Outlet, LoaderFunction, useNavigate, Link } from "remix"; 2 | import { SafeUser } from "~/utils/auth"; 3 | import { getUser } from "~/utils/session.server"; 4 | 5 | type SessionData = { 6 | user: SafeUser | null 7 | }; 8 | 9 | export let loader: LoaderFunction = async ({ request }) => { 10 | const user = await getUser(request); 11 | return json({ user }); 12 | }; 13 | 14 | // https://remix.run/docs/en/v1/api/conventions#route-filenames 15 | export default function Anonymous() { 16 | const navigate = useNavigate(); 17 | const { user } = useLoaderData(); 18 | 19 | if (user) { 20 | navigate('/app/home'); 21 | return null; 22 | } 23 | 24 | return <> 25 |
26 |
27 | 28 |
29 | 30 | } 31 | 32 | function Header(): JSX.Element { 33 | return
34 |
35 | 36 | Remix/Render 37 | 38 |
39 |
40 |
41 | 42 | Login 43 | 44 | 45 | Sign up 46 | 47 |
48 |
49 |
50 | } -------------------------------------------------------------------------------- /app/routes/__anonymous/index.tsx: -------------------------------------------------------------------------------- 1 | import { MetaFunction, LoaderFunction, Link } from "remix"; 2 | import { redirect, useLoaderData, json, useNavigate } from "remix"; 3 | import { User } from "@prisma/client"; 4 | import { getUser } from "../../utils/session.server"; 5 | 6 | type IndexData = { 7 | user: User | null 8 | }; 9 | 10 | // Loaders provide data to components and are only ever called on the server, so 11 | // you can connect to a database or run any server side code you want right next 12 | // to the component that renders it. 13 | // https://remix.run/api/conventions#loader 14 | export let loader: LoaderFunction = async ({ request }) => { 15 | let user = await getUser(request); 16 | 17 | if (user) { 18 | return redirect('/app/home'); 19 | } 20 | 21 | const data: IndexData = { 22 | user 23 | }; 24 | return json(data); 25 | }; 26 | 27 | // https://remix.run/api/conventions#meta 28 | export let meta: MetaFunction = () => { 29 | return { 30 | title: "Remix/Render Starter", 31 | description: "Welcome to remix-render starter!" 32 | }; 33 | }; 34 | 35 | // https://remix.run/guides/routing#index-routes 36 | export default function Index() { 37 | const navigate = useNavigate(); 38 | let data = useLoaderData(); 39 | 40 | if (data.user) { 41 | navigate('/app/home'); 42 | return null; 43 | } 44 | 45 | return ( 46 |
47 |
48 |
49 |

50 | Hello there 51 |

52 |

53 | This is a starter app using Remix, deployed to Render. 54 |

55 | Sign up 56 |
57 |
58 |
59 | ); 60 | } 61 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "name": "remix-app-template", 4 | "description": "", 5 | "license": "", 6 | "scripts": { 7 | "build:css": "cross-env NODE_ENV=production tailwindcss -i ./app/styles/tailwind.css -o ./app/styles/app.css --minify", 8 | "build:remix": "remix build", 9 | "build": "npm run build:css && npm run build:remix", 10 | "dev:db": "docker-compose up postgres", 11 | "dev:css": "tailwindcss -i ./app/styles/tailwind.css -o ./app/styles/app.css --watch", 12 | "dev:remix": "node -r dotenv/config node_modules/.bin/remix dev", 13 | "dev": "concurrently \"npm:dev:*\"", 14 | "migrate:create": "prisma migrate dev --create-only", 15 | "migrate": "prisma migrate dev", 16 | "postinstall": "remix setup node", 17 | "prestart": "prisma migrate deploy", 18 | "prettier": "prettier --single-quote --trailing-comma all --write \"src/**/*.{js,ts,tsx}\" --loglevel error", 19 | "prisma:generate": "prisma generate", 20 | "prisma:seed": "ts-node prisma/seed.ts", 21 | "start": "remix-serve build" 22 | }, 23 | "dependencies": { 24 | "@prisma/client": "^3.5.0", 25 | "@remix-run/react": "^1.0.6", 26 | "@remix-run/serve": "^1.0.6", 27 | "@tailwindcss/aspect-ratio": "^0.3.0", 28 | "@tailwindcss/line-clamp": "^0.2.2", 29 | "@tailwindcss/typography": "^0.4.1", 30 | "bcrypt": "^5.0.1", 31 | "daisyui": "^1.16.2", 32 | "dotenv": "^10.0.0", 33 | "react": "^17.0.2", 34 | "react-dom": "^17.0.2", 35 | "remix": "^1.0.6", 36 | "tailwindcss": "^2.2.19" 37 | }, 38 | "devDependencies": { 39 | "@remix-run/dev": "^1.0.6", 40 | "@types/bcrypt": "^5.0.0", 41 | "@types/node": "^16.11.10", 42 | "@types/react": "^17.0.24", 43 | "@types/react-dom": "^17.0.9", 44 | "autoprefixer": "^10.4.0", 45 | "concurrently": "^6.4.0", 46 | "cross-env": "^7.0.3", 47 | "postcss": "^8.4.4", 48 | "prettier": "^2.5.0", 49 | "prisma": "^3.5.0", 50 | "ts-node": "^10.4.0", 51 | "typescript": "^4.5.2" 52 | }, 53 | "engines": { 54 | "node": ">=14" 55 | }, 56 | "sideEffects": false 57 | } 58 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Welcome to Remix + Render starter kit! 2 | 3 | This repository contains a remix starter kit + deployment to [render](render.com). 4 | 5 | - [Remix Docs](https://remix.run/docs) 6 | - [Render Docs](https://render.com/docs) 7 | 8 | This starter kit includes a full DB, user-registration, and authentication. 9 | 10 | ## Requirement 11 | 12 | - node >=14 13 | - docker and docker-compose 14 | 15 | ## Tech Choices 16 | 17 | - framework: [remix](https://remix.run) 18 | - deployment/hosting: [render](https://render.com/) 19 | - DB: [postgres](https://www.postgresql.org/) 20 | - ORM and migrations: [prisma](https://www.prisma.io/) 21 | - styles: [tailwindcss](https://tailwindcss.com/) 22 | - UI components: [daisy ui](https://daisyui.com) 23 | - environment variables: [dotenv](https://github.com/motdotla/dotenv) 24 | - local dependencies: [docker](https://www.docker.com/) + `docker-compose` 25 | 26 | ## Initial setup 27 | 28 | Run `npm install`. 29 | 30 | Copy `.env.example` to `.env`: 31 | 32 | ``` 33 | cp .env.example .env 34 | ``` 35 | 36 | ## Development 37 | 38 | From your terminal: 39 | 40 | ```sh 41 | npm run dev 42 | ``` 43 | 44 | This starts your app in development mode, rebuilding assets on file changes. 45 | 46 | ## Deployment 47 | 48 | This project uses an "Infrastructure as Code" approach, and the deployment configuration is defined in the `render.yaml` file at the root of the project. 49 | 50 | See [Render's IaC documentation](https://render.com/docs/infrastructure-as-code) for more. 51 | 52 | To deploy this app, follow the directions to connect your `render.yaml` to your render account: 53 | 54 | 1. Fork this repository 55 | 2. Optionally update the `name` of your service in the `render.yaml` file 56 | 3. Open the render dasbhoard, Click Blueprints in the navigation sidebar. 57 | 4. Click the New Blueprint Instance button. 58 | 5. Select your forked repository 59 | 60 | Once selected, you’ll see a list of the changes that will be applied based on the contents of render.yaml. If there’s an issue with the file you’ll see an error message. If everything looks good, click Apply to create the resources defined in your file. 61 | 62 | Both an application server and a database should be created. 63 | 64 | Once the deployment is complete, you should be able to open up your application in the public URL provided by render. 65 | -------------------------------------------------------------------------------- /app/utils/session.server.ts: -------------------------------------------------------------------------------- 1 | import { User } from ".prisma/client"; 2 | import bcrypt from "bcrypt"; 3 | import { 4 | createCookieSessionStorage, 5 | redirect 6 | } from "remix"; 7 | import { db } from "./db.server"; 8 | 9 | type LoginForm = { 10 | username: string; 11 | password: string; 12 | }; 13 | 14 | export async function register({ 15 | username, 16 | password 17 | }: LoginForm) { 18 | const passwordHash = await bcrypt.hash(password, 10); 19 | return db.user.create({ 20 | data: { username, passwordHash } 21 | }); 22 | } 23 | 24 | export async function login({ 25 | username, 26 | password 27 | }: LoginForm) { 28 | const user = await db.user.findUnique({ 29 | where: { username } 30 | }); 31 | if (!user) return null; 32 | const isCorrectPassword = await bcrypt.compare( 33 | password, 34 | user.passwordHash 35 | ); 36 | if (!isCorrectPassword) return null; 37 | return user; 38 | } 39 | 40 | const sessionSecret = process.env.SESSION_SECRET; 41 | if (!sessionSecret) { 42 | throw new Error("SESSION_SECRET must be set"); 43 | } 44 | 45 | const storage = createCookieSessionStorage({ 46 | cookie: { 47 | name: "RJ_session", 48 | secure: true, 49 | secrets: [sessionSecret], 50 | sameSite: "lax", 51 | path: "/", 52 | maxAge: 60 * 60 * 24 * 30, 53 | httpOnly: true 54 | } 55 | }); 56 | 57 | 58 | export function getUserSession(request: Request) { 59 | return storage.getSession(request.headers.get("Cookie")); 60 | } 61 | 62 | export async function getUserId(request: Request) { 63 | const session = await getUserSession(request); 64 | const userId = session.get("userId"); 65 | if (!userId || typeof userId !== "string") return null; 66 | return userId; 67 | } 68 | 69 | export async function requireUserId( 70 | request: Request, 71 | redirectTo: string = new URL(request.url).pathname 72 | ) { 73 | const session = await getUserSession(request); 74 | const userId = session.get("userId"); 75 | if (!userId || typeof userId !== "string") { 76 | const searchParams = new URLSearchParams([ 77 | ["redirectTo", redirectTo] 78 | ]); 79 | throw redirect(`/login?${searchParams}`); 80 | } 81 | return userId; 82 | } 83 | 84 | export async function getUser(request: Request): Promise> { 85 | const userId = await getUserId(request); 86 | if (typeof userId !== "string") { 87 | return null; 88 | } 89 | 90 | try { 91 | const user = await db.user.findUnique({ 92 | where: { id: userId } 93 | }); 94 | if (user) { 95 | const { 96 | passwordHash, 97 | ...safeUser 98 | } = user; 99 | return safeUser; 100 | } 101 | return user; 102 | } catch { 103 | throw logout(request); 104 | } 105 | } 106 | 107 | export async function logout(request: Request) { 108 | const session = await storage.getSession( 109 | request.headers.get("Cookie") 110 | ); 111 | return redirect("/login", { 112 | headers: { 113 | "Set-Cookie": await storage.destroySession(session) 114 | } 115 | }); 116 | } 117 | 118 | export async function createUserSession( 119 | userId: string, 120 | redirectTo: string 121 | ) { 122 | const session = await storage.getSession(); 123 | session.set("userId", userId); 124 | return redirect(redirectTo, { 125 | headers: { 126 | "Set-Cookie": await storage.commitSession(session) 127 | } 128 | }); 129 | } -------------------------------------------------------------------------------- /app/root.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | json, 3 | Links, 4 | LiveReload, 5 | LoaderFunction, 6 | Meta, 7 | Outlet, 8 | Scripts, 9 | ScrollRestoration, 10 | useCatch, 11 | useLoaderData 12 | } from "remix"; 13 | import type { LinksFunction } from "remix"; 14 | import globalStylesheet from './styles/app.css'; 15 | import { getUser } from "./utils/session.server"; 16 | 17 | export let links: LinksFunction = () => { 18 | return [ 19 | { 20 | rel: 'stylesheet', href: globalStylesheet 21 | } 22 | ]; 23 | }; 24 | 25 | 26 | // https://remix.run/api/conventions#default-export 27 | // https://remix.run/api/conventions#route-filenames 28 | export default function App() { 29 | return ( 30 | 31 | 32 | 33 | 34 | 35 | ); 36 | } 37 | 38 | // https://remix.run/docs/en/v1/api/conventions#errorboundary 39 | export function ErrorBoundary({ error }: { error: Error }) { 40 | console.error(error); 41 | return ( 42 | 43 | 44 |
45 |

There was an error

46 |

{error.message}

47 |
48 |

49 | Hey, developer, you should replace this with what you want your 50 | users to see. 51 |

52 |
53 |
54 |
55 | ); 56 | } 57 | 58 | // https://remix.run/docs/en/v1/api/conventions#catchboundary 59 | export function CatchBoundary() { 60 | let caught = useCatch(); 61 | 62 | let message; 63 | switch (caught.status) { 64 | case 401: 65 | message = ( 66 |

67 | Oops! Looks like you tried to visit a page that you do not have access 68 | to. 69 |

70 | ); 71 | break; 72 | case 404: 73 | message = ( 74 |

Oops! Looks like you tried to visit a page that does not exist.

75 | ); 76 | break; 77 | 78 | default: 79 | throw new Error(caught.data || caught.statusText); 80 | } 81 | 82 | return ( 83 | 84 | 85 |

86 | {caught.status}: {caught.statusText} 87 |

88 | {message} 89 |
90 |
91 | ); 92 | } 93 | 94 | function Document({ 95 | children, 96 | title 97 | }: { 98 | children: React.ReactNode; 99 | title?: string; 100 | }) { 101 | return ( 102 | 103 | 104 | 105 | 106 | {title ? {title} : null} 107 | 108 | 109 | 110 | 111 | {children} 112 | 113 | 114 | {process.env.NODE_ENV === "development" && } 115 | 116 | 117 | ); 118 | } 119 | 120 | function Layout({ children }: { children: React.ReactNode }) { 121 | return (<> 122 |
123 | {children} 124 |
125 |